Files
ITAM/src/components/Modal/PCFlowModal.ts
Taehoon 89d3ac2e89 style: unify UI styling & restore dashboard logic
- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
2026-06-17 12:29:26 +09:00

596 lines
25 KiB
TypeScript

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;
document.querySelectorAll('.flow-type-label').forEach(l => {
l.classList.toggle('active', l.contains(radioCheckout));
});
}
// 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 = '<div class="autocomplete-item-empty">일치하는 사원이 없습니다.</div>';
container.classList.remove('hidden');
return;
}
users.forEach(u => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div class="suggestion-name">${u.user_name}</div>
<div class="suggestion-meta">
<span>부서: ${u.dept_name}</span>
<span>|</span>
<span>사번: ${u.emp_no || '-'}</span>
</div>
`;
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 = '<div class="autocomplete-item-empty">불출 가능한 대기 PC 재고가 없습니다.</div>';
container.classList.remove('hidden');
return;
}
pcs.forEach(p => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div class="suggestion-meta">
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
</div>
`;
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 = '<div class="empty-list-message">이 사용자가 소유한 PC 자산이 없습니다.</div>';
} else {
userPcsList.innerHTML = userPcs.map(p => {
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
return `
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
<div class="pc-item-code">${p.asset_code}</div>
<div class="pc-item-meta">
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
</div>
</div>
`;
}).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 {
return `
<div id="pc-flow-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 class="modal-title">
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
</h2>
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<!-- 왼쪽 영역: 입력 폼 -->
<div class="modal-form-area">
<div class="grid-form flex-col">
<!-- 1. 처리 유형 -->
<div class="form-group">
<label>1. 처리 유형 선택</label>
<div class="view-toggle w-full flex-row">
<label class="flow-type-label toggle-btn active flex-1 text-center">
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
불출 (지급)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="return" class="hidden" />
입고 (반납)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="move" class="hidden" />
이동 (이관)
</label>
</div>
</div>
<!-- 2. 대상 사용자 검색 -->
<div class="form-group relative">
<label id="user-search-label">2. 대상 사원 검색</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." />
<i data-lucide="search" class="icon-sm"></i>
</div>
<div id="pc-flow-user-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
<div id="target-user-search-container" class="form-group hidden relative">
<label>새 인수 사원 검색</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." />
<i data-lucide="search" class="icon-sm"></i>
</div>
<div id="pc-flow-target-user-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
<div id="stock-pc-search-container" class="form-group relative">
<label>3. 불출할 재고 PC 선택</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." />
<i data-lucide="monitor" class="icon-sm"></i>
</div>
<div id="pc-flow-stock-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 5. 상세 공통 입력 -->
<div class="detail-grid-2col">
<div class="form-group">
<label>처리 일자</label>
<input type="date" id="pc-flow-date" />
</div>
<div class="form-group">
<label>상세 사유</label>
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다."></textarea>
</div>
</div>
</div>
</div>
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
<div class="modal-history-area">
<div class="history-header">
<h3>선택 내역 요약</h3>
</div>
<div class="dynamic-row-container">
<!-- 사원 요약 카드 -->
<div id="summary-user-card" class="summary-info-card">
<div class="detail-label-sm">대상 사원</div>
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
<div id="summary-user-dept" class="detail-label-sm">-</div>
</div>
<!-- 인수 사원 요약 카드 (이동 전용) -->
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
<div class="detail-label-sm">새 인수 사원</div>
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
</div>
<!-- 대상 PC 자산 요약 카드 -->
<div id="summary-pc-card" class="summary-info-card">
<div class="detail-label-sm">대상 PC 자산</div>
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
<div id="summary-pc-model" class="detail-label-sm">-</div>
</div>
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
<div id="user-pcs-container" class="form-group hidden">
<label>사원 보유 PC 선택 (클릭하여 매핑)</label>
<div id="user-pcs-list" class="user-pc-selection-list"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline">취소</button>
<button id="btn-submit-pc-flow" class="btn btn-primary">이동/반납 처리 완료</button>
</div>
</div>
</div>
</div>
`;
}
}
export const pcFlowModal = PCFlowModal.getInstance();