diff --git a/server.js b/server.js
index 1dacc03..eaf8c23 100644
--- a/server.js
+++ b/server.js
@@ -47,7 +47,7 @@ const CATEGORY_TABLE_MAP = {
swInternal: 'sw_internal',
swExternal: 'sw_external',
cloud: 'asset_cloud',
- users: 'user_master',
+ users: 'system_users',
swUsers: 'sw_assignment',
logs: 'asset_history'
};
@@ -62,6 +62,7 @@ const ASSET_TABLES = [
// 1. Generic Batch Save (Dynamic Table Detection)
app.post('/api/:table/batch', async (req, res) => {
const { table } = req.params;
+ const dbTable = CATEGORY_TABLE_MAP[table] || table;
const data = req.body;
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
@@ -70,14 +71,14 @@ app.post('/api/:table/batch', async (req, res) => {
connection = await pool.getConnection();
await connection.beginTransaction();
- const [columns] = await connection.query(`DESCRIBE ${table}`);
+ const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
const validFields = columns.map(c => c.Field);
- await connection.query(`DELETE FROM ${table}`);
+ await connection.query(`DELETE FROM ${dbTable}`);
if (data.length > 0) {
const placeholders = validFields.map(() => '?').join(', ');
- const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`;
+ const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
for (const item of data) {
const values = validFields.map(field => {
@@ -245,6 +246,71 @@ app.post('/api/asset/:category/save', async (req, res) => {
}
});
+// 3.6 PC Flow Transaction (Checkout, Return, Move)
+app.post('/api/pc/flow', async (req, res) => {
+ const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body;
+ let connection;
+ try {
+ connection = await pool.getConnection();
+ await connection.beginTransaction();
+
+ if (action === 'checkout') {
+ await connection.query(
+ `UPDATE asset_core
+ SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
+ WHERE id = ?`,
+ [userName, empNo, dept, position, assetId]
+ );
+ await connection.query(
+ `UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
+ [assetId]
+ );
+ } else if (action === 'return') {
+ await connection.query(
+ `UPDATE asset_core
+ SET previous_user = user_current, previous_dept = current_dept,
+ user_current = '', emp_no = '', current_dept = '재고창고', user_position = ''
+ WHERE id = ?`,
+ [assetId]
+ );
+ await connection.query(
+ `UPDATE asset_spec SET hw_status = '대기' WHERE asset_id = ?`,
+ [assetId]
+ );
+ } else if (action === 'move') {
+ await connection.query(
+ `UPDATE asset_core
+ SET previous_user = user_current, previous_dept = current_dept,
+ user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
+ WHERE id = ?`,
+ [userName, empNo, dept, position, assetId]
+ );
+ await connection.query(
+ `UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
+ [assetId]
+ );
+ } else {
+ throw new Error('Invalid action type');
+ }
+
+ // Insert into asset_history
+ await connection.query(
+ `INSERT INTO asset_history (asset_id, log_date, log_user, details)
+ VALUES (?, ?, ?, ?)`,
+ [assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details]
+ );
+
+ await connection.commit();
+ console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`);
+ res.json({ success: true });
+ } catch (err) {
+ if (connection) await connection.rollback();
+ handleError(res, err, 'PC FLOW TRANSACTION');
+ } finally {
+ if (connection) connection.release();
+ }
+});
+
// 4. Asset Delete
app.delete('/api/asset/:category/:id', async (req, res) => {
const { category, id } = req.params;
diff --git a/src/components/Modal/PCFlowModal.ts b/src/components/Modal/PCFlowModal.ts
new file mode 100644
index 0000000..d304088
--- /dev/null
+++ b/src/components/Modal/PCFlowModal.ts
@@ -0,0 +1,625 @@
+import { state, loadMasterDataFromDB } from '../../core/state';
+import { createIcons, Search, Monitor, RefreshCw } from 'lucide';
+import { API_BASE_URL } from '../../core/utils';
+
+export class PCFlowModal {
+ private static instance: PCFlowModal | null = null;
+
+ private modalEl: HTMLElement | null = null;
+ private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
+
+ // Selected state
+ private selectedUser: any = null;
+ private selectedTargetUser: any = null;
+ private selectedPC: any = null;
+
+ private constructor() {}
+
+ public static getInstance(): PCFlowModal {
+ if (!PCFlowModal.instance) {
+ PCFlowModal.instance = new PCFlowModal();
+ }
+ return PCFlowModal.instance;
+ }
+
+ public init(onSave: () => void) {
+ if (document.getElementById('pc-flow-modal')) return;
+
+ // Inject HTML
+ document.body.insertAdjacentHTML('beforeend', this.renderHTML());
+
+ this.modalEl = document.getElementById('pc-flow-modal');
+ this.setupEventListeners(onSave);
+
+ // Set default date to today
+ const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
+ if (dateInput) {
+ dateInput.value = new Date().toISOString().split('T')[0];
+ }
+
+ createIcons({ icons: { Search, Monitor, RefreshCw } });
+ }
+
+ public open() {
+ this.resetState();
+ if (this.modalEl) {
+ this.modalEl.classList.remove('hidden');
+ }
+ this.updateUI();
+ }
+
+ public close() {
+ if (this.modalEl) {
+ this.modalEl.classList.add('hidden');
+ }
+ }
+
+ private resetState() {
+ this.selectedUser = null;
+ this.selectedTargetUser = null;
+ this.selectedPC = null;
+ this.currentFlowType = 'checkout';
+
+ const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
+ if (radioCheckout) radioCheckout.checked = true;
+
+ // Reset text fields
+ const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
+ if (userSearch) userSearch.value = '';
+
+ const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
+ if (targetUserSearch) targetUserSearch.value = '';
+
+ const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
+ if (stockSearch) stockSearch.value = '';
+
+ const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement;
+ if (details) details.value = '';
+ }
+
+ private setupEventListeners(onSave: () => void) {
+ const btnClose = document.getElementById('btn-close-pc-flow-modal');
+ const btnCancel = document.getElementById('btn-cancel-pc-flow-modal');
+ const btnSubmit = document.getElementById('btn-submit-pc-flow');
+
+ btnClose?.addEventListener('click', () => this.close());
+ btnCancel?.addEventListener('click', () => this.close());
+
+ // Flow Type Radio Buttons
+ const labels = document.querySelectorAll('.flow-type-label');
+ labels.forEach(label => {
+ const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement;
+ label.addEventListener('click', () => {
+ labels.forEach(l => l.classList.remove('active'));
+ label.classList.add('active');
+ radio.checked = true;
+ this.currentFlowType = radio.value as any;
+
+ // Reset selected PC when switching flow types
+ this.selectedPC = null;
+ this.updateUI();
+ });
+ });
+
+ // 1. Source User Autocomplete Search
+ const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
+ const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
+
+ userSearch?.addEventListener('input', () => {
+ const query = userSearch.value.trim().toLowerCase();
+ if (!query) {
+ userSuggestions.classList.add('hidden');
+ return;
+ }
+
+ const users = state.masterData.users || [];
+ const filtered = users.filter((u: any) =>
+ (u.user_name && u.user_name.toLowerCase().includes(query)) ||
+ (u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
+ (u.emp_no && u.emp_no.toString().includes(query))
+ );
+
+ const uniqueFiltered: any[] = [];
+ const seen = new Set();
+ filtered.forEach((u: any) => {
+ const key = u.emp_no || u.user_name;
+ if (!seen.has(key)) {
+ seen.add(key);
+ uniqueFiltered.push(u);
+ }
+ });
+
+ this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => {
+ this.selectedUser = user;
+ userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
+ userSuggestions.classList.add('hidden');
+
+ // Automatically populate details if return or move
+ if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
+ this.selectedPC = null; // Reset selection
+ }
+ this.updateUI();
+ });
+ });
+
+ // Close suggestion overlays on clicking outside
+ document.addEventListener('click', (e) => {
+ const target = e.target as HTMLElement;
+ if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) {
+ userSuggestions.classList.add('hidden');
+ }
+ if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) {
+ const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions');
+ targetSuggestions?.classList.add('hidden');
+ }
+ if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) {
+ const stockSuggestions = document.getElementById('pc-flow-stock-suggestions');
+ stockSuggestions?.classList.add('hidden');
+ }
+ });
+
+ // 2. Target User Autocomplete Search (For Moves)
+ const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
+ const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
+
+ targetUserSearch?.addEventListener('input', () => {
+ const query = targetUserSearch.value.trim().toLowerCase();
+ if (!query) {
+ targetSuggestions.classList.add('hidden');
+ return;
+ }
+
+ const users = state.masterData.users || [];
+ const filtered = users.filter((u: any) =>
+ (u.user_name && u.user_name.toLowerCase().includes(query)) ||
+ (u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
+ (u.emp_no && u.emp_no.toString().includes(query))
+ );
+
+ const uniqueFiltered: any[] = [];
+ const seen = new Set();
+ filtered.forEach((u: any) => {
+ const key = u.emp_no || u.user_name;
+ if (!seen.has(key)) {
+ seen.add(key);
+ uniqueFiltered.push(u);
+ }
+ });
+
+ this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => {
+ this.selectedTargetUser = user;
+ targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
+ targetSuggestions.classList.add('hidden');
+ this.updateUI();
+ });
+ });
+
+ // 3. Stock PC Autocomplete Search (For Checkout)
+ const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
+ const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
+
+ const showStockSuggestions = () => {
+ const query = stockSearch.value.trim().toLowerCase();
+
+ // Filter available PCs (category PC, status '대기' or '재고창고')
+ const pcs = state.masterData.pc || [];
+ const filtered = pcs.filter((p: any) => {
+ const status = (p.hw_status || '').trim();
+ const matchesQuery = !query ||
+ (p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
+ (p.model_name && p.model_name.toLowerCase().includes(query)) ||
+ (p.cpu && p.cpu.toLowerCase().includes(query));
+
+ return (status === '대기' || status === '재고창고' || status === '미할당') && matchesQuery;
+ });
+
+ this.renderPCSuggestions(filtered, stockSuggestions, (pc) => {
+ this.selectedPC = pc;
+ stockSearch.value = `${pc.asset_code} - ${pc.model_name}`;
+ stockSuggestions.classList.add('hidden');
+ this.updateUI();
+ });
+ };
+
+ stockSearch?.addEventListener('input', showStockSuggestions);
+ stockSearch?.addEventListener('focus', showStockSuggestions);
+ stockSearch?.addEventListener('click', showStockSuggestions);
+
+ // 4. Submit Transaction
+ btnSubmit?.addEventListener('click', async () => {
+ if (!this.validateInputs()) return;
+
+ const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value;
+ const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim();
+ const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자';
+
+ // Build Details Message as JSON
+ const logData = {
+ type: this.currentFlowType,
+ user: this.selectedUser ? this.selectedUser.user_name : '',
+ dept: this.selectedUser ? this.selectedUser.dept_name : '',
+ targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '',
+ targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '',
+ assetCode: this.selectedPC ? this.selectedPC.asset_code : '',
+ memo: detailsVal
+ };
+ const finalDetails = JSON.stringify(logData);
+
+ const payload: any = {
+ action: this.currentFlowType,
+ assetId: this.selectedPC.id,
+ date: dateVal,
+ details: finalDetails,
+ manager: loginUser
+ };
+
+ if (this.currentFlowType === 'checkout') {
+ payload.userName = this.selectedUser.user_name;
+ payload.dept = this.selectedUser.dept_name;
+ payload.empNo = this.selectedUser.emp_no;
+ payload.position = this.selectedUser.position || '사원';
+ } else if (this.currentFlowType === 'move') {
+ payload.userName = this.selectedTargetUser.user_name;
+ payload.dept = this.selectedTargetUser.dept_name;
+ payload.empNo = this.selectedTargetUser.emp_no;
+ payload.position = this.selectedTargetUser.position || '사원';
+ }
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/pc/flow`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ if (response.ok) {
+ alert('PC 이동/반납 처리가 완료되었습니다.');
+ this.close();
+ onSave(); // Refresh views
+ } else {
+ const errData = await response.json();
+ alert(`오류 발생: ${errData.error || '처리 실패'}`);
+ }
+ } catch (err) {
+ console.error('API Error:', err);
+ alert('서버 전송 중 오류가 발생했습니다.');
+ }
+ });
+ }
+
+ private validateInputs(): boolean {
+ if (this.currentFlowType === 'checkout') {
+ if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; }
+ if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; }
+ } else if (this.currentFlowType === 'return') {
+ if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; }
+ if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; }
+ } else if (this.currentFlowType === 'move') {
+ if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; }
+ if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; }
+ if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; }
+ if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) {
+ alert('인계자와 인수자는 동일할 수 없습니다.');
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
+ container.innerHTML = '';
+ if (users.length === 0) {
+ container.innerHTML = '
일치하는 사원이 없습니다.
';
+ container.classList.remove('hidden');
+ return;
+ }
+
+ users.forEach(u => {
+ const item = document.createElement('div');
+ item.style.padding = '8px 12px';
+ item.style.cursor = 'pointer';
+ item.style.fontSize = '13px';
+ item.style.borderBottom = '1px solid #F3F4F6';
+ item.className = 'suggestion-item';
+ item.innerHTML = `
+ ${u.user_name}
+
+ 부서: ${u.dept_name}
+ |
+ 사번: ${u.emp_no || '-'}
+
+ `;
+ item.addEventListener('click', () => onSelect(u));
+ container.appendChild(item);
+ });
+ container.classList.remove('hidden');
+ }
+
+ private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
+ container.innerHTML = '';
+ if (pcs.length === 0) {
+ container.innerHTML = '불출 가능한 대기 PC 재고가 없습니다.
';
+ container.classList.remove('hidden');
+ return;
+ }
+
+ pcs.forEach(p => {
+ const item = document.createElement('div');
+ item.style.padding = '8px 12px';
+ item.style.cursor = 'pointer';
+ item.style.fontSize = '13px';
+ item.style.borderBottom = '1px solid #F3F4F6';
+ item.className = 'suggestion-item';
+ item.innerHTML = `
+ ${p.asset_code} (${p.model_name || '모델명 없음'})
+
+ 사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
+
+ `;
+ item.addEventListener('click', () => onSelect(p));
+ container.appendChild(item);
+ });
+ container.classList.remove('hidden');
+ }
+
+ private updateUI() {
+ // 1. Hide/Show dynamic sections based on flow type
+ const stockContainer = document.getElementById('stock-pc-search-container')!;
+ const targetUserContainer = document.getElementById('target-user-search-container')!;
+ const userPcsContainer = document.getElementById('user-pcs-container')!;
+ const labelStep2 = document.getElementById('user-search-label')!;
+
+ if (this.currentFlowType === 'checkout') {
+ stockContainer.classList.remove('hidden');
+ targetUserContainer.classList.add('hidden');
+ userPcsContainer.classList.add('hidden');
+ labelStep2.textContent = '2. 불출 대상 사원 검색';
+ } else if (this.currentFlowType === 'return') {
+ stockContainer.classList.add('hidden');
+ targetUserContainer.classList.add('hidden');
+ userPcsContainer.classList.remove('hidden');
+ labelStep2.textContent = '2. 반납 대상 사원 검색';
+ } else if (this.currentFlowType === 'move') {
+ stockContainer.classList.add('hidden');
+ targetUserContainer.classList.remove('hidden');
+ userPcsContainer.classList.remove('hidden');
+ labelStep2.textContent = '2. 인계 사원 검색';
+ }
+
+ // 2. Update summary panels on the right
+ const summaryUserName = document.getElementById('summary-user-name')!;
+ const summaryUserDept = document.getElementById('summary-user-dept')!;
+ if (this.selectedUser) {
+ summaryUserName.textContent = this.selectedUser.user_name;
+ summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`;
+ } else {
+ summaryUserName.textContent = '선택된 사원 없음';
+ summaryUserDept.textContent = '-';
+ }
+
+ const summaryTargetCard = document.getElementById('summary-target-user-card')!;
+ const summaryTargetUserName = document.getElementById('summary-target-user-name')!;
+ const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!;
+ if (this.currentFlowType === 'move') {
+ summaryTargetCard.classList.remove('hidden');
+ if (this.selectedTargetUser) {
+ summaryTargetUserName.textContent = this.selectedTargetUser.user_name;
+ summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`;
+ } else {
+ summaryTargetUserName.textContent = '선택된 사원 없음';
+ summaryTargetUserDept.textContent = '-';
+ }
+ } else {
+ summaryTargetCard.classList.add('hidden');
+ }
+
+ const summaryPcCode = document.getElementById('summary-pc-code')!;
+ const summaryPcModel = document.getElementById('summary-pc-model')!;
+ if (this.selectedPC) {
+ summaryPcCode.textContent = this.selectedPC.asset_code;
+ summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`;
+ } else {
+ summaryPcCode.textContent = '선택된 PC 없음';
+ summaryPcModel.textContent = '-';
+ }
+
+ // 3. Render user's active PCs list on the right (For Return & Move)
+ const userPcsList = document.getElementById('user-pcs-list')!;
+ if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
+ const allPcs = state.masterData.pc || [];
+ const userPcs = allPcs.filter((p: any) =>
+ (p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
+ (p.user_current && p.user_current === this.selectedUser.user_name)
+ );
+
+ if (userPcs.length === 0) {
+ userPcsList.innerHTML = '이 사용자가 소유한 PC 자산이 없습니다.
';
+ } else {
+ userPcsList.innerHTML = userPcs.map(p => {
+ const isSelected = this.selectedPC && this.selectedPC.id === p.id;
+ return `
+
+
${p.asset_code}
+
+ ${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
+
+
+ `;
+ }).join('');
+
+ // Bind clicks to list items
+ userPcsList.querySelectorAll('.user-pc-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const pcId = item.getAttribute('data-id');
+ const foundPC = userPcs.find(p => p.id === pcId);
+ if (foundPC) {
+ this.selectedPC = foundPC;
+ this.updateUI();
+ }
+ });
+ });
+ }
+ } else {
+ userPcsList.innerHTML = '';
+ }
+ }
+
+ private renderHTML(): string {
+ const overlayStyle = `
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
+ z-index: 1000; transition: opacity 0.3s;
+ `;
+ const contentStyle = `
+ background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
+ overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
+ `;
+ const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
+ const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
+ const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
선택 내역 요약
+
+
+
+
대상 사원
+
선택된 사원 없음
+
-
+
+
+
+
+
새 인수 사원
+
선택된 사원 없음
+
-
+
+
+
+
+
대상 PC 자산
+
선택된 PC 없음
+
-
+
+
+
+
+
사원 보유 PC 선택 (클릭하여 매핑)
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+export const pcFlowModal = PCFlowModal.getInstance();
diff --git a/src/core/state.ts b/src/core/state.ts
index 717b270..ced6c95 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -73,7 +73,15 @@ export async function loadMasterDataFromDB() {
// 전역 상태 업데이트
state.masterData = {
...state.masterData,
- ...data
+ ...data,
+ logs: (data.logs || []).map((l: any) => ({
+ ...l,
+ assetId: l.asset_id || l.assetId,
+ date: l.log_date || l.date,
+ user: l.log_user || l.user,
+ log_date: l.log_date || l.date,
+ log_user: l.log_user || l.user
+ }))
};
// Mapping for backward compatibility
diff --git a/src/main.ts b/src/main.ts
index ba87660..900d4c6 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -9,12 +9,18 @@ import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide';
+import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
-// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
async function apiBatchSave(url: string, data: any[], label: string) {
try {
- console.log(`✅ ${label} DB 저장 완료 (Dummy Mode: ${data?.length || 0} items)`);
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ });
+ if (!response.ok) throw new Error(`${label} DB 저장 실패`);
+ console.log(`✅ ${label} DB 저장 완료`);
} catch (err) {
console.error(`❌ ${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
@@ -26,11 +32,11 @@ const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
-const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
-const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
+const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swInternal/batch`, state.masterData.swInternal, '내부SW');
+const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swExternal/batch`, state.masterData.swExternal, '외부SW');
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
-const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자');
-const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그');
+const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swUsers/batch`, state.masterData.swUsers, 'SW사용자');
+const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/logs/batch`, state.masterData.logs, '자산 로그');
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
// 화면 갱신 통합 핸들러
@@ -83,6 +89,9 @@ function initApp() {
initDashboardDetailModal();
initGuide();
+ pcFlowModal.init(() => {
+ loadMasterDataFromDB().then(() => refreshView());
+ });
loadMasterDataFromDB().then((success) => {
if (success) {
@@ -119,6 +128,12 @@ function initApp() {
}
return;
}
+
+ // PC 이동/반납 모달 열기
+ if (target.closest('#btn-pc-flow')) {
+ pcFlowModal.open();
+ return;
+ }
});
createIcons({
diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts
index 932203f..6d567dc 100644
--- a/src/views/Dashboard/HwDashboard.ts
+++ b/src/views/Dashboard/HwDashboard.ts
@@ -19,6 +19,7 @@ let corpChartInstance4p: any = null;
let totalServerMismatchByPurposeChartInstance4p: any = null;
let serverServiceChartInstance4p: any = null;
let serverStatusChartInstance4p: any = null;
+let pcFlowChartInstance: any = null;
// ─── 서버 용도별 카테고리 분류 헬퍼 ───
function categorizePurpose(purpose: string): string {
@@ -563,12 +564,82 @@ function buildServerStatusTableRows(list: any[]): string {
export function renderHwDashboard(container: HTMLElement) {
const allHw = state.masterData.hw || [];
+ // --- PC FLOW LOGS DATA PREP ---
+ const logs = state.masterData.logs || [];
+ const now = new Date();
+ const currentYearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+
+ let currentMonthCheckout = 0;
+ let currentMonthReturn = 0;
+ let currentMonthMove = 0;
+
+ let totalCheckout = 0;
+ let totalReturn = 0;
+ let totalMove = 0;
+
+ const flowLogs = logs.filter((log: any) => {
+ const details = log.details || '';
+ const isFlow = details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
+
+ if (isFlow) {
+ const logDate = log.log_date || '';
+ const isCurrentMonth = logDate.startsWith(currentYearMonth);
+
+ if (details.includes('[불출]')) {
+ totalCheckout++;
+ if (isCurrentMonth) currentMonthCheckout++;
+ } else if (details.includes('[반납]') || details.includes('[입고]')) {
+ totalReturn++;
+ if (isCurrentMonth) currentMonthReturn++;
+ } else if (details.includes('[이동]') || details.includes('[이관]')) {
+ totalMove++;
+ if (isCurrentMonth) currentMonthMove++;
+ }
+ return true;
+ }
+ return false;
+ });
+
+ const recentFlowLogs = flowLogs.slice(0, 5);
+ let recentFlowLogsHtml = '';
+ if (recentFlowLogs.length === 0) {
+ recentFlowLogsHtml = '| 최근 유동 이력이 없습니다. |
';
+ } else {
+ recentFlowLogs.forEach((log: any) => {
+ const details = log.details || '';
+ let badgeHtml = '';
+ if (details.includes('[불출]')) {
+ badgeHtml = '불출';
+ } else if (details.includes('[반납]') || details.includes('[입고]')) {
+ badgeHtml = '입고';
+ } else if (details.includes('[이동]') || details.includes('[이관]')) {
+ badgeHtml = '이동';
+ }
+
+ const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
+
+ recentFlowLogsHtml += `
+
+ | ${log.log_date || '-'} |
+ ${badgeHtml} |
+ ${log.log_user || '시스템'} |
+ ${cleanDetails} |
+
+ `;
+ });
+ }
+
// --- PC DATA PREP ---
const pcs = allHw.filter(a => {
const cat = a[ASSET_SCHEMA.CATEGORY.key] || '';
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
- return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && job !== '재고PC';
+ const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
+ const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
+ return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') &&
+ job !== '재고PC' &&
+ status === '사용중' &&
+ user.trim() !== '';
});
const jobScores: Record = {};
@@ -824,7 +895,7 @@ export function renderHwDashboard(container: HTMLElement) {
'' +
'' +
'' +
- '1 / 4' +
+ '1 / 5' +
'' +
'
' +
'' +
@@ -1082,8 +1153,6 @@ export function renderHwDashboard(container: HTMLElement) {
'' +
'' +
'' +
- '' +
- '' +
'' +
'' +
@@ -1108,7 +1177,10 @@ export function renderHwDashboard(container: HTMLElement) {
serverServiceGroups,
serverStatusGroups,
purposeServerUnders,
- purposeServerOvers
+ purposeServerOvers,
+ totalCheckout,
+ totalReturn,
+ totalMove
);
// 기획서 보기 버튼 클릭 이벤트 바인딩
@@ -1191,7 +1263,10 @@ function initCharts(
serviceGroups: any,
statusGroups: any,
purposeServerUnders?: any,
- purposeServerOvers?: any
+ purposeServerOvers?: any,
+ totalCheckout?: number,
+ totalReturn?: number,
+ totalMove?: number
) {
// 직무별 점수
const jobCtx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
@@ -1654,4 +1729,39 @@ function initCharts(
}
});
}
+
+ // PC 유동 비율 도넛 차트
+ const flowCtx = document.getElementById('chart-pc-flow-stats') as HTMLCanvasElement;
+ if (flowCtx && typeof Chart !== 'undefined') {
+ const tCheckout = totalCheckout || 0;
+ const tReturn = totalReturn || 0;
+ const tMove = totalMove || 0;
+
+ if (pcFlowChartInstance) {
+ pcFlowChartInstance.destroy();
+ pcFlowChartInstance = null;
+ }
+
+ pcFlowChartInstance = new Chart(flowCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['불출', '입고(반납)', '이동(이관)'],
+ datasets: [{
+ data: [tCheckout, tReturn, tMove],
+ backgroundColor: ['#3B82F6', '#10B981', '#F59E0B'],
+ borderWidth: 0,
+ hoverOffset: 8
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
+ },
+ cutout: '75%',
+ animation: { animateScale: true, animateRotate: true }
+ }
+ });
+ }
}
diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts
index fa6abef..4580972 100644
--- a/src/views/List/ListFactory.ts
+++ b/src/views/List/ListFactory.ts
@@ -5,6 +5,132 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { state } from '../../core/state';
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
+declare var Chart: any;
+let pcFlowChartInstance: any = null;
+
+// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
+function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
+ let score = 100;
+ if (!cpu) cpu = '';
+ if (!ram) ram = '';
+ if (!gpu) gpu = '';
+
+ const cpuUpper = cpu.toUpperCase();
+ const ramUpper = ram.toUpperCase();
+ const gpuUpper = gpu.toUpperCase();
+
+ // 1. CPU 등급 감점 (최대 -30점)
+ let cpuDeduction = 0;
+ if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
+ cpuDeduction = 0;
+ } else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
+ cpuDeduction = 5;
+ } else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
+ cpuDeduction = 15;
+ } else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
+ cpuDeduction = 25;
+ } else {
+ cpuDeduction = 30;
+ }
+ score -= cpuDeduction;
+
+ // 2. CPU 세대 노후 감점 (최대 -15점)
+ let genDeduction = 0;
+ const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
+ let gen = 0;
+ if (intelMatch && intelMatch[1]) {
+ const numStr = intelMatch[1];
+ if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
+ else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
+ }
+
+ const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
+ let amdGen = 0;
+ if (amdMatch && amdMatch[1] && !intelMatch) {
+ const numStr = amdMatch[1];
+ if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
+ }
+
+ if (intelMatch) {
+ if (gen >= 12) genDeduction = 0;
+ else if (gen >= 10) genDeduction = 5;
+ else if (gen >= 8) genDeduction = 10;
+ else genDeduction = 15;
+ } else if (amdMatch) {
+ if (amdGen >= 5) genDeduction = 0;
+ else if (amdGen >= 3) genDeduction = 5;
+ else genDeduction = 10;
+ } else {
+ genDeduction = 15;
+ }
+ score -= genDeduction;
+
+ // 3. RAM 용량 감점 (최대 -25점)
+ const ramMatch = ramUpper.match(/(\d+)\s*GB/);
+ let ramDeduction = 25;
+ if (ramMatch && ramMatch[1]) {
+ const ramVal = parseInt(ramMatch[1], 10);
+ if (ramVal >= 32) ramDeduction = 0;
+ else if (ramVal >= 16) ramDeduction = 10;
+ else if (ramVal >= 8) ramDeduction = 20;
+ else ramDeduction = 25;
+ }
+ score -= ramDeduction;
+
+ // 4. GPU 성능 감점 (최대 -25점)
+ let gpuDeduction = 25;
+ if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
+ gpuDeduction = 25;
+ } else if (
+ gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
+ gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
+ ) {
+ gpuDeduction = 0;
+ } else if (
+ gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
+ gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
+ ) {
+ gpuDeduction = 5;
+ } else if (
+ gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
+ gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
+ ) {
+ gpuDeduction = 15;
+ } else {
+ gpuDeduction = 25;
+ }
+ score -= gpuDeduction;
+
+ // 5. 연식(노후도) 감점 (최대 -15점)
+ let age = 0;
+ if (purchaseDate && purchaseDate !== '-') {
+ let normalized = purchaseDate.replace(/\./g, '-').trim();
+ if (/^\d{6}$/.test(normalized)) {
+ normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
+ }
+ const purchase = new Date(normalized);
+ if (!isNaN(purchase.getTime())) {
+ // 2026년 5월 31일 기준 경과연수 계산
+ const mockToday = new Date('2026-05-31');
+ const diffMs = mockToday.getTime() - purchase.getTime();
+ age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
+ age = Math.max(0, parseFloat(age.toFixed(1)));
+ }
+ }
+
+ let ageDeduction = 0;
+ if (age < 1) ageDeduction = 0;
+ else if (age < 2) ageDeduction = 3;
+ else if (age < 3) ageDeduction = 6;
+ else if (age < 4) ageDeduction = 9;
+ else if (age < 5) ageDeduction = 12;
+ else ageDeduction = 15;
+
+ score -= ageDeduction;
+
+ return Math.max(10, score);
+}
+
export interface ColumnDef {
header: string;
sortKey?: string;
@@ -47,15 +173,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
+
+ const showPcFlowBtn = config.title === 'PC';
toggleWrapper.innerHTML = `
-
+
+ ${showPcFlowBtn ? `
+
+ ` : ''}
+
+
`;
container.appendChild(toggleWrapper);
@@ -71,7 +206,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
container.appendChild(contentWrapper);
// --- 내부 상태 ---
- let selectedLocation: string | null = '기술개발센터';
+ let selectedLocation: string | null = null;
let selectedDetailLocation: string | null = null;
let dynamicMapConfig: Record = {};
@@ -88,6 +223,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const renderSystemStatus = () => {
const isPcView = config.title === 'PC';
+ // 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외)
+ const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정')))
+ .filter(l => {
+ const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length;
+ return count > 0;
+ })
+ .sort();
+
+ // 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신
+ if (!selectedLocation || !validLocations.includes(selectedLocation)) {
+ selectedLocation = validLocations[0] || '';
+ }
+
const locationCounts: Record = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
@@ -215,29 +363,45 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
-
+
-
자산 현황 목록
+
+ ${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
+
+ ${!isPcView ? `
위치:
상세:
+ ` : ''}
-
- | 분류 |
- 용도/자산명 |
- 관리자(정) |
- 관리자(부) |
- 상세위치 |
-
+ ${isPcView ? `
+
+ | 일자 |
+ 담당자 |
+ 구분 |
+ 사용자 |
+ 인수자 |
+ 자산번호 |
+ 상세 |
+
+ ` : `
+
+ | 분류 |
+ 용도/자산명 |
+ 관리자(정) |
+ 관리자(부) |
+ 상세위치 |
+
+ `}
@@ -245,9 +409,34 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
-
-
-
목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.
+
+
+ ${isPcView ? `
+
+
+
+ ⚠️ 사양 주의 장비 현황 (부족/오버스펙)
+
+
+
+
+
+
+ | 사용자 |
+ 부서 (직무) |
+ 상태 |
+ 자산코드 |
+
+
+
+ | 사양 주의 자산이 없습니다. |
+
+
+
+
+ ` : `
+
목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.
+ `}
@@ -266,6 +455,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
+
@@ -394,86 +586,368 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
}
}
+
+ // 이력 보기 버튼 클릭 이벤트
+ const flowLogsBtn = document.getElementById('btn-view-flow-logs');
+ if (flowLogsBtn) {
+ flowLogsBtn.onclick = () => {
+ const emptyState = document.getElementById('detail-empty-state');
+ const content = document.getElementById('detail-content');
+ if (emptyState && content) {
+ content.style.display = 'none';
+ emptyState.style.display = 'flex';
+ }
+ const tbody = document.getElementById('system-status-tbody');
+ if (tbody) {
+ tbody.querySelectorAll('.mini-row').forEach(r => {
+ const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
+ (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
+ });
+ }
+ updateFlowLogsSection();
+ };
+ }
};
const updateTableOnly = () => {
- let filtered = selectedLocation
- ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
- : fullList;
- const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
- if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
- const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
+ const now = new Date();
+ const currentYear = now.getFullYear();
+ const currentMonthNum = now.getMonth() + 1;
+ const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`;
- const titleEl = document.getElementById('list-section-title');
- if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
- const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
- if (selectEl && !selectedDetailLocation) {
- selectEl.innerHTML = `
` + currentDetailLocs.map(dl => `
`).join('');
- }
+ if (isPcView) {
+ // PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움
+ const recentTbody = document.getElementById('system-status-tbody');
+ if (!recentTbody) return;
- const tbody = document.getElementById('system-status-tbody');
- if (tbody) {
- tbody.innerHTML = finalDisplayList.length === 0
- ? `
| 조회된 자산이 없습니다. |
`
- : finalDisplayList.map(asset => {
- const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
- const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
- const serviceType = asset[serviceTypeKey] || '외부';
- const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
- const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
-
- const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
- const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
- const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
-
- // [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
- const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
- const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
- const isWarning = isLocWarning || isTypeWarning;
- const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
+ const titleEl = document.getElementById('list-section-title');
+ if (titleEl) {
+ titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`;
+ }
- let warningReason = '';
- if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
- else if (isLocWarning) warningReason = '위치 부적절';
- else if (isTypeWarning) warningReason = '형식 부적절';
-
- return `
-
- |
-
- ${serviceType}
- ${isWarning ? `${warningReason}` : ''}
-
- |
- ${purpose || '-'} |
- ${managerMain} |
- ${managerSub} |
- ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} |
-
`;
- }).join('');
- tbody.querySelectorAll('.mini-row').forEach(row => {
- row.addEventListener('click', () => {
- tbody.querySelectorAll('.mini-row').forEach(r => {
- const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
- (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
- });
- (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
- const id = (row as HTMLElement).getAttribute('data-id');
- const asset = fullList.find(a => a.id === id);
- if (asset) updateDetailPanel(asset);
- });
- row.addEventListener('mouseenter', () => {
- if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
- (row as HTMLElement).style.backgroundColor = '#F8FAFA';
- }
- });
- row.addEventListener('mouseleave', () => {
- const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
- if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
- (row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
- }
- });
+ const logs = state.masterData.logs || [];
+ const flowLogs = logs.filter((log: any) => {
+ const details = log.details || '';
+ if (details.trim().startsWith('{')) {
+ try {
+ const info = JSON.parse(details);
+ return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move');
+ } catch (e) {}
+ }
+ return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
});
+
+ // 해당월(currentYearMonth)에 발생한 로그만 필터링
+ const monthlyFlowLogs = flowLogs.filter((log: any) => {
+ const logDate = log.log_date || '';
+ return logDate.startsWith(currentYearMonth);
+ });
+
+ if (monthlyFlowLogs.length === 0) {
+ recentTbody.innerHTML = `
| ${currentMonthNum}월 유동 이력이 없습니다. |
`;
+ } else {
+ recentTbody.innerHTML = monthlyFlowLogs.map((log: any) => {
+ const details = log.details || '';
+
+ let typeDisplay = '-';
+ let userDisplay = '-';
+ let targetUserDisplay = '-';
+ let assetCodeDisplay = '-';
+ let memoDisplay = '-';
+
+ try {
+ const info = JSON.parse(details);
+ if (info.type === 'checkout') typeDisplay = 'checkout';
+ else if (info.type === 'return') typeDisplay = 'return';
+ else if (info.type === 'move') typeDisplay = 'move';
+
+ userDisplay = info.user || '-';
+ targetUserDisplay = info.targetUser || '-';
+ assetCodeDisplay = info.assetCode || '-';
+ memoDisplay = info.memo || '-';
+ } catch (e) {
+ // 하위 호환 파싱 (기존 텍스트형 로그)
+ if (details.includes('[불출]')) typeDisplay = 'checkout';
+ else if (details.includes('[반납]') || details.includes('[입고]')) typeDisplay = 'return';
+ else if (details.includes('[이동]') || details.includes('[이관]')) typeDisplay = 'move';
+
+ const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i);
+ if (codeMatch) assetCodeDisplay = codeMatch[0];
+
+ if (details.includes('[불출]')) {
+ const match1 = details.match(/\[불출\]\s*([^\s\(]+)\s*사원/);
+ if (match1) userDisplay = match1[1];
+ else {
+ const match2 = details.match(/\[불출\]\s*([a-zA-Z가-힣]+)/);
+ userDisplay = match2 ? match2[1] : '-';
+ }
+ } else if (details.includes('[반납]') || details.includes('[입고]')) {
+ const match1 = details.match(/\[(?:반납|입고)\]\s*([^\s\(]+)\s*사원/);
+ if (match1) userDisplay = match1[1];
+ else {
+ const match2 = details.match(/\[(?:반납|입고)\]\s*([a-zA-Z가-힣]+)/);
+ userDisplay = match2 ? match2[1] : '-';
+ }
+ } else if (details.includes('[이동]') || details.includes('[이관]')) {
+ const prefixWord = details.includes('[이동]') ? '\\[이동\\]' : '\\[이관\\]';
+ const parts = details.split('➡️');
+ if (parts.length === 2) {
+ const fromMatch = parts[0].match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣]+)`));
+ const toMatch = parts[1].match(/\s*([a-zA-Z가-힣]+)/);
+ if (fromMatch && toMatch) {
+ userDisplay = fromMatch[1];
+ targetUserDisplay = toMatch[1];
+ }
+ }
+ if (userDisplay === '-') {
+ const match1 = details.match(new RegExp(`${prefixWord}\\s*([^\s\(]+)\\s*사원`));
+ if (match1) {
+ userDisplay = match1[1];
+ } else {
+ const match2 = details.match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣0-9_]+)`));
+ userDisplay = match2 ? match2[1] : '-';
+ }
+ }
+ }
+
+ const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
+ const memoParts = cleanDetails.split(' - ');
+ if (memoParts.length >= 2) {
+ memoDisplay = memoParts[memoParts.length - 1];
+ } else {
+ if (cleanDetails.includes('지급') || cleanDetails.includes('반납') || cleanDetails.includes('이관')) {
+ memoDisplay = '-';
+ } else {
+ memoDisplay = cleanDetails || '-';
+ }
+ }
+ }
+
+ // 구분 뱃지 생성
+ let badgeHtml = '';
+ if (typeDisplay === 'checkout') {
+ badgeHtml = '
불출';
+ } else if (typeDisplay === 'return') {
+ badgeHtml = '
입고';
+ } else if (typeDisplay === 'move') {
+ badgeHtml = '
이동';
+ } else {
+ badgeHtml = '
기타';
+ }
+
+ return `
+
+ | ${log.log_date || '-'} |
+ ${log.log_user || '시스템'} |
+ ${badgeHtml} |
+ ${userDisplay} |
+ ${targetUserDisplay} |
+ ${assetCodeDisplay} |
+ ${memoDisplay} |
+
+ `;
+ }).join('');
+ }
+ } else {
+ // 기존의 자산 현황 목록 갱신
+ let filtered = selectedLocation
+ ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
+ : fullList;
+ const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
+ if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
+ const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
+
+ const titleEl = document.getElementById('list-section-title');
+ if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
+ const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
+ if (selectEl && !selectedDetailLocation) {
+ selectEl.innerHTML = `
` + currentDetailLocs.map(dl => `
`).join('');
+ }
+
+ const tbody = document.getElementById('system-status-tbody');
+ if (tbody) {
+ tbody.innerHTML = finalDisplayList.length === 0
+ ? `
| 조회된 자산이 없습니다. |
`
+ : finalDisplayList.map(asset => {
+ const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
+ const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
+ const serviceType = asset[serviceTypeKey] || '외부';
+ const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
+ const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
+
+ const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
+ const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
+ const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
+
+ // [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
+ const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
+ const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
+ const isWarning = isLocWarning || isTypeWarning;
+ const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
+
+ let warningReason = '';
+ if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
+ else if (isLocWarning) warningReason = '위치 부적절';
+ else if (isTypeWarning) warningReason = '형식 부적절';
+
+ return `
+
+ |
+
+ ${serviceType}
+ ${isWarning ? `${warningReason}` : ''}
+
+ |
+ ${purpose || '-'} |
+ ${managerMain} |
+ ${managerSub} |
+ ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} |
+
`;
+ }).join('');
+
+ tbody.querySelectorAll('.mini-row').forEach(row => {
+ row.addEventListener('click', () => {
+ tbody.querySelectorAll('.mini-row').forEach(r => {
+ const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
+ (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
+ });
+ (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
+ const id = (row as HTMLElement).getAttribute('data-id');
+ const asset = fullList.find(a => a.id === id);
+ if (asset) updateDetailPanel(asset);
+ });
+ row.addEventListener('mouseenter', () => {
+ if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
+ (row as HTMLElement).style.backgroundColor = '#F8FAFA';
+ }
+ });
+ row.addEventListener('mouseleave', () => {
+ const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
+ if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
+ (row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
+ }
+ });
+ });
+ }
+ }
+ };
+
+ const updateFlowLogsSection = () => {
+ if (!isPcView) return;
+
+ // 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩
+ const specMismatchTbody = document.getElementById('spec-mismatch-tbody');
+ if (specMismatchTbody) {
+ // fullList 중 개인 PC 관련 장비 필터링
+ const pcs = fullList.filter((a: any) => {
+ const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
+ const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
+ const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
+ const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
+
+ // 사용 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상
+ return job !== '재고PC' && status === '사용중' && user.trim() !== '';
+ });
+
+ // 직무별 평균 점수 산출
+ const jobScores: Record
= {};
+ pcs.forEach((pc: any) => {
+ const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
+ const cpu = pc[ASSET_SCHEMA.CPU.key] || '';
+ const ram = pc[ASSET_SCHEMA.RAM.key] || '';
+ const gpu = pc[ASSET_SCHEMA.GPU.key] || '';
+ const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || '';
+ const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate);
+ pc['_pc_score'] = score;
+
+ if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 };
+ jobScores[job].totalScore += score;
+ jobScores[job].count += 1;
+ });
+
+ Object.keys(jobScores).forEach(job => {
+ jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
+ });
+
+ // 기준 대비 사양 부족/오버스펙 분류
+ const criticalPcList: any[] = [];
+ pcs.forEach((pc: any) => {
+ const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
+ const score = pc['_pc_score'];
+ const avg = jobScores[job].avg;
+
+ if (avg > 0) {
+ if (score < avg * 0.8) {
+ pc['_spec_status'] = '사양 부족';
+ criticalPcList.push(pc);
+ } else if (score > avg * 1.3) {
+ pc['_spec_status'] = '오버스펙';
+ criticalPcList.push(pc);
+ }
+ }
+ });
+
+ // 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬
+ criticalPcList.sort((a: any, b: any) => {
+ const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
+ const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
+ const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1;
+ const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1;
+ return ratioA - ratioB;
+ });
+
+ if (criticalPcList.length === 0) {
+ specMismatchTbody.innerHTML = '| 사양 주의 자산이 없습니다. |
';
+ } else {
+ specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => {
+ const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-';
+ const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-';
+ const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-';
+ const status = pc['_spec_status'];
+ const assetCode = pc.asset_code || '-';
+
+ const badgeColor = status === '사양 부족'
+ ? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;'
+ : 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;';
+
+ return `
+
+ | ${user} |
+ ${dept} (${job}) |
+
+ ${status}
+ |
+ ${assetCode} |
+
+ `;
+ }).join('');
+
+ // 클릭 시 해당 자산 상세 페이지로 전환
+ specMismatchTbody.querySelectorAll('.spec-row').forEach(row => {
+ row.addEventListener('click', () => {
+ specMismatchTbody.querySelectorAll('.spec-row').forEach(r => {
+ (r as HTMLElement).style.backgroundColor = 'transparent';
+ });
+ (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
+
+ const id = (row as HTMLElement).getAttribute('data-id');
+ const asset = fullList.find(a => String(a.id) === String(id));
+ if (asset) {
+ updateDetailPanel(asset);
+ }
+ });
+ row.addEventListener('mouseenter', () => {
+ if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
+ (row as HTMLElement).style.backgroundColor = '#F8FAFA';
+ }
+ });
+ row.addEventListener('mouseleave', () => {
+ if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
+ (row as HTMLElement).style.backgroundColor = 'transparent';
+ }
+ });
+ });
+ }
}
};
@@ -492,12 +966,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
selectedLocation = (e.target as HTMLSelectElement).value || null;
selectedDetailLocation = null;
updateTableOnly();
+ updateFlowLogsSection();
});
selectDetailLoc?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
+ updateFlowLogsSection();
});
updateTableOnly();
+ updateFlowLogsSection();
}, 50);
};