feat: QR 자산 스캔 점검, 모바일 웹뷰 및 관리자 승인 시스템 구현 (DB 기반 맵 좌표 저장 단일화 포함)
This commit is contained in:
427
src/views/AuditApprovalView.ts
Normal file
427
src/views/AuditApprovalView.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { state, loadMasterDataFromDB } from '../core/state';
|
||||
import { openHwModal } from '../components/Modal/HWModal';
|
||||
|
||||
/**
|
||||
* 실사 점검 승인 대시보드 뷰 (Vercel Style Clean layout)
|
||||
*/
|
||||
export async function renderAuditApprovalView(container: HTMLElement) {
|
||||
if (!container) return;
|
||||
|
||||
// 1. CSS Stylesheet Injection
|
||||
const styleId = 'audit-approval-view-style';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.innerHTML = `
|
||||
.audit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height) - 48px);
|
||||
background-color: var(--canvas);
|
||||
color: var(--text-main);
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.audit-badge {
|
||||
background-color: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.audit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--hairline);
|
||||
background-color: var(--canvas-soft);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.audit-btn:hover {
|
||||
background-color: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
.audit-btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.audit-btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.audit-btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.audit-btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Data Table Custom Vercel layout */
|
||||
.audit-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
background-color: var(--canvas-soft);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
background-color: var(--canvas-soft-2);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.audit-table td {
|
||||
padding: 0.75rem 0.8rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.audit-table tr:hover td {
|
||||
background-color: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
.audit-checkbox {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.link-asset-code {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-asset-code:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.location-badge-diff {
|
||||
background-color: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.location-badge-same {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
color: #059669;
|
||||
border: 1px solid rgba(16, 185, 129, 0.18);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Empty State Illustration Layout */
|
||||
.audit-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audit-empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--hairline);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
let pendingData: any[] = [];
|
||||
|
||||
// Function to load data and render layout
|
||||
async function loadAndRender() {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="audit-container">
|
||||
<div class="audit-header">
|
||||
<div class="audit-title-area">
|
||||
<span class="audit-title">실사 점검 승인 관리</span>
|
||||
<span id="audit-count-badge" class="audit-badge">조회 중...</span>
|
||||
</div>
|
||||
<div class="audit-actions">
|
||||
<button id="btn-audit-refresh" class="audit-btn"><i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 새로고침</button>
|
||||
<button id="btn-audit-reject" class="audit-btn audit-btn-danger" disabled>선택 반려</button>
|
||||
<button id="btn-audit-approve" class="audit-btn audit-btn-primary" disabled>선택 승인</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audit-content-area" class="audit-table-wrapper">
|
||||
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">실사 내역을 불러오고 있습니다...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindHeaderEvents();
|
||||
await fetchPendingList();
|
||||
} catch (err) {
|
||||
console.error('Failed to init audit view:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPendingList() {
|
||||
try {
|
||||
const res = await fetch('/api/audit/pending');
|
||||
pendingData = await res.json();
|
||||
renderTable();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending audits:', err);
|
||||
const contentArea = document.getElementById('audit-content-area')!;
|
||||
contentArea.innerHTML = `<div style="padding: 3rem; text-align: center; color: var(--danger); font-weight: 600;">데이터를 불러오는 중 네트워크 에러가 발생했습니다.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const badge = document.getElementById('audit-count-badge')!;
|
||||
badge.textContent = `대기 ${pendingData.length}건`;
|
||||
|
||||
const contentArea = document.getElementById('audit-content-area')!;
|
||||
if (pendingData.length === 0) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="audit-empty-state">
|
||||
<div class="audit-empty-icon">✓</div>
|
||||
<div style="font-size: 1.05rem; font-weight: 700; color: var(--text-main);">대기 중인 실사 내역이 없습니다</div>
|
||||
<div style="font-size: 0.8rem;">현장에서 스캐너로 자산을 스캔하면 실시간으로 여기에 등록됩니다.</div>
|
||||
</div>
|
||||
`;
|
||||
updateActionButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
let tbodyRows = '';
|
||||
pendingData.forEach((row, i) => {
|
||||
// Format scanned date
|
||||
const dateStr = new Date(row.scanned_at).toLocaleString('ko-KR');
|
||||
|
||||
// Check if location actually changed
|
||||
const oldLocFull = row.old_location ? `${row.old_location} ${row.old_location_detail || ''}`.trim() : '미배치';
|
||||
const newLocFull = `${row.location_name} ${row.location_detail || ''}`.trim();
|
||||
const isDiff = oldLocFull !== newLocFull;
|
||||
|
||||
tbodyRows += `
|
||||
<tr>
|
||||
<td style="width: 40px; text-align: center;">
|
||||
<input type="checkbox" class="audit-checkbox row-select" data-id="${row.id}" />
|
||||
</td>
|
||||
<td>
|
||||
<span class="link-asset-code" data-index="${i}">${row.asset_code}</span>
|
||||
</td>
|
||||
<td>${row.asset_purpose || '-'}</td>
|
||||
<td><span class="badge" style="font-size: 11px;">${row.asset_type || 'IT자산'}</span></td>
|
||||
<td><span style="color: var(--text-muted);">${oldLocFull}</span></td>
|
||||
<td>
|
||||
<span class="${isDiff ? 'location-badge-diff' : 'location-badge-same'}">${newLocFull}</span>
|
||||
</td>
|
||||
<td style="color: var(--text-muted); font-size: 11px;">${dateStr}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
contentArea.innerHTML = `
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px; text-align: center;">
|
||||
<input type="checkbox" class="audit-checkbox" id="chk-audit-all" />
|
||||
</th>
|
||||
<th>자산번호</th>
|
||||
<th>자산용도</th>
|
||||
<th>자산유형</th>
|
||||
<th>기존 위치</th>
|
||||
<th>실사 위치</th>
|
||||
<th>스캔 일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tbodyRows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
bindTableEvents();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
function bindHeaderEvents() {
|
||||
document.getElementById('btn-audit-refresh')?.addEventListener('click', () => fetchPendingList());
|
||||
|
||||
document.getElementById('btn-audit-approve')?.addEventListener('click', () => handleAction('approve'));
|
||||
document.getElementById('btn-audit-reject')?.addEventListener('click', () => handleAction('reject'));
|
||||
}
|
||||
|
||||
function bindTableEvents() {
|
||||
// Select All Checkbox
|
||||
const selectAllChk = document.getElementById('chk-audit-all') as HTMLInputElement;
|
||||
const rowCheckboxes = document.querySelectorAll('.row-select') as NodeListOf<HTMLInputElement>;
|
||||
|
||||
selectAllChk?.addEventListener('change', () => {
|
||||
rowCheckboxes.forEach(chk => {
|
||||
chk.checked = selectAllChk.checked;
|
||||
});
|
||||
updateActionButtons();
|
||||
});
|
||||
|
||||
rowCheckboxes.forEach(chk => {
|
||||
chk.addEventListener('change', () => {
|
||||
updateActionButtons();
|
||||
// Sync selectAll checkbox state
|
||||
const allChecked = Array.from(rowCheckboxes).every(c => c.checked);
|
||||
if (selectAllChk) selectAllChk.checked = allChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// Asset Detail Modal linkage
|
||||
const assetLinks = document.querySelectorAll('.link-asset-code');
|
||||
assetLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.target as HTMLElement).dataset.index!);
|
||||
const row = pendingData[idx];
|
||||
if (!row) return;
|
||||
|
||||
// Compile master array from state data to find full asset object
|
||||
const allHwAssets = [
|
||||
...(state.masterData.pc || []),
|
||||
...(state.masterData.server || []),
|
||||
...(state.masterData.storage || []),
|
||||
...(state.masterData.network || []),
|
||||
...(state.masterData.equipment || []),
|
||||
...(state.masterData.survey || []),
|
||||
...(state.masterData.officeSupplies || []),
|
||||
...(state.masterData.pcParts || [])
|
||||
];
|
||||
|
||||
const targetAsset = allHwAssets.find(a => a.asset_code === row.asset_code);
|
||||
if (targetAsset) {
|
||||
openHwModal(targetAsset, 'view');
|
||||
} else {
|
||||
alert(`자산 코드 [${row.asset_code}] 에 매칭되는 마스터 데이터가 존재하지 않습니다.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateActionButtons() {
|
||||
const selected = document.querySelectorAll('.row-select:checked');
|
||||
const approveBtn = document.getElementById('btn-audit-approve') as HTMLButtonElement;
|
||||
const rejectBtn = document.getElementById('btn-audit-reject') as HTMLButtonElement;
|
||||
|
||||
if (approveBtn && rejectBtn) {
|
||||
const isDisabled = selected.length === 0;
|
||||
approveBtn.disabled = isDisabled;
|
||||
rejectBtn.disabled = isDisabled;
|
||||
|
||||
approveBtn.textContent = `선택 승인 (${selected.length})`;
|
||||
rejectBtn.textContent = `선택 반려 (${selected.length})`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(actionType: 'approve' | 'reject') {
|
||||
const selected = document.querySelectorAll('.row-select:checked') as NodeListOf<HTMLInputElement>;
|
||||
const ids = Array.from(selected).map(chk => parseInt(chk.dataset.id!));
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const actionText = actionType === 'approve' ? '승인' : '반려';
|
||||
if (!confirm(`선택한 ${ids.length}건의 실사 내역을 최종 ${actionText} 처리할까요?`)) return;
|
||||
|
||||
const endpoint = actionType === 'approve' ? '/api/audit/approve' : '/api/audit/reject';
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pending_ids: ids,
|
||||
processed_by: 'ADMIN'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
alert(`성공적으로 ${actionText} 완료되었습니다.`);
|
||||
// Reload dashboard state to sync map_config/db coordinates changes
|
||||
await loadMasterDataFromDB();
|
||||
await fetchPendingList();
|
||||
} else {
|
||||
alert(`${actionText} 실패: ${data.error || '알 수 없는 서버 오류'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to trigger audit ${actionType}:`, err);
|
||||
alert(`네트워크 통신 오류로 ${actionText} 처리가 실패했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run initial loading
|
||||
await loadAndRender();
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
const fetchMapConfig = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
const res = await fetch('/api/maps');
|
||||
dynamicMapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||
import { QRPrinter } from '../core/qr_print';
|
||||
|
||||
export class MapEditor {
|
||||
private container: HTMLElement;
|
||||
@@ -42,7 +43,7 @@ export class MapEditor {
|
||||
|
||||
private async loadAssets() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
|
||||
const res = await fetch('/api/assets/master');
|
||||
const masterData = await res.json();
|
||||
const allHw = [
|
||||
...(masterData.pc || []),
|
||||
@@ -95,7 +96,7 @@ export class MapEditor {
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
const res = await fetch('/api/maps');
|
||||
this.allMapConfig = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err);
|
||||
@@ -185,6 +186,30 @@ export class MapEditor {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-print-map-qrs')?.addEventListener('click', () => {
|
||||
if (this.boxes.length === 0) {
|
||||
alert('인쇄할 구역이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const cleanKey = getCleanMapKey(this.currentPath);
|
||||
const locName = getLocationName(this.currentPath);
|
||||
|
||||
const items = this.boxes.map((box, index) => {
|
||||
const padIdx = String(index + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(this.currentPath, index);
|
||||
return {
|
||||
type: 'location' as const,
|
||||
code: locCode,
|
||||
title: '[ HM LOCATION ]',
|
||||
subtitle: locName,
|
||||
dept: locDetail
|
||||
};
|
||||
});
|
||||
|
||||
QRPrinter.print(items);
|
||||
});
|
||||
|
||||
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||||
}
|
||||
|
||||
@@ -195,7 +220,7 @@ export class MapEditor {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.textContent = '저장 중...';
|
||||
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
|
||||
const res = await fetch('/api/maps/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
|
||||
@@ -248,7 +273,10 @@ export class MapEditor {
|
||||
item.innerHTML = `
|
||||
<div class="box-header">
|
||||
<span class="box-index">#${i+1}</span>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button class="btn btn-outline btn-sm" onclick="printBoxQR(${i})" style="padding: 2px 6px; font-size: 11px; margin: 0; cursor: pointer;">QR</button>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-inputs margin-bottom">
|
||||
<select data-index="${i}" data-prop="asset_id">
|
||||
@@ -294,5 +322,46 @@ export class MapEditor {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(window as any).printBoxQR = (index: number) => {
|
||||
const box = this.boxes[index];
|
||||
if (!box) return;
|
||||
const cleanKey = getCleanMapKey(this.currentPath);
|
||||
const padIdx = String(index + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(this.currentPath, index);
|
||||
const locName = getLocationName(this.currentPath);
|
||||
|
||||
QRPrinter.print([{
|
||||
type: 'location',
|
||||
code: locCode,
|
||||
title: '[ HM LOCATION ]',
|
||||
subtitle: locName,
|
||||
dept: locDetail
|
||||
}]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCleanMapKey(path: string) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
clean = clean.replace('서관', 'W').replace('동관', 'E');
|
||||
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
|
||||
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
|
||||
clean = clean.replace(/\//g, '-');
|
||||
return clean;
|
||||
}
|
||||
|
||||
function getLocationName(path: string) {
|
||||
if (path.includes('IDC')) return 'IDC';
|
||||
if (path.includes('한맥빌딩')) return '한맥빌딩';
|
||||
if (path.includes('기술개발센터')) return '기술개발센터';
|
||||
return '기타';
|
||||
}
|
||||
|
||||
function getLocationDetail(path: string, idx: number) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
let parts = clean.split('/');
|
||||
let lastPart = parts[parts.length - 1];
|
||||
return `${lastPart} 구역 자리 #${idx + 1}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user