BARON-SSO 로그인 연동
This commit is contained in:
31
.agents/AGENTS.md
Normal file
31
.agents/AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Development Rules (개발 및 관리 규칙)
|
||||||
|
|
||||||
|
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
|
||||||
|
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
|
||||||
|
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
|
||||||
|
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
|
||||||
|
3. **개선 작업 절차 (Test-First Approach)**:
|
||||||
|
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
|
||||||
|
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
||||||
|
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
||||||
|
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
||||||
|
5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**:
|
||||||
|
- 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다.
|
||||||
|
- 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다.
|
||||||
|
6. **RED–GREEN–Refactor 개발 원칙**:
|
||||||
|
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
|
||||||
|
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
|
||||||
|
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
|
||||||
|
- **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다.
|
||||||
|
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
|
||||||
|
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
|
||||||
|
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인** 및 **외과 수술식 수정** 규칙을 준수한다.
|
||||||
|
|
||||||
|
# Server Run & External Access (서버 구동 및 외부 접속 규칙)
|
||||||
|
|
||||||
|
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
|
||||||
|
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
|
||||||
|
3. **구동 명령어**: `npm run dev`
|
||||||
|
- 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
|
||||||
|
4. **IP 확인 방법**:
|
||||||
|
- Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
|
|
||||||
scp .env.deploy "${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_PATH}/.env"
|
scp .env.deploy "${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_PATH}/.env"
|
||||||
|
|
||||||
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build"
|
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build && docker compose -f docker-compose.prod.yaml restart nginx"
|
||||||
|
|
||||||
- name: Post-deploy status check
|
- name: Post-deploy status check
|
||||||
env:
|
env:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
backups/
|
backups/
|
||||||
mysql_data/
|
mysql_data/
|
||||||
|
/docs/grafana_integration_proposal.md
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- ./map_config.json:/app/map_config.json:ro
|
- ./map_config.json:/app/map_config.json
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ server {
|
|||||||
|
|
||||||
# Serve static files with SPA fallback
|
# Serve static files with SPA fallback
|
||||||
location / {
|
location / {
|
||||||
|
rewrite ^/mobile$ /mobile.html last;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
mobile.html
22
mobile.html
@@ -294,6 +294,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Custom Confirmation Modal -->
|
||||||
|
<div id="scan-confirm-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.75); z-index: 9999; justify-content: center; align-items: center; padding: 1.5rem; backdrop-filter: blur(4px);">
|
||||||
|
<div style="background-color: var(--card); border: 1px solid var(--card-border); border-radius: 16px; padding: 1.5rem; width: 100%; max-width: 320px; display: flex; flex-direction: column; gap: 1.25rem; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4); animation: modalFadeIn 0.25s ease-out;">
|
||||||
|
<h3 style="font-size: 1.1rem; font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: var(--primary);"></span>
|
||||||
|
실사 등록 확인
|
||||||
|
</h3>
|
||||||
|
<p id="confirm-modal-msg" style="font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; word-break: keep-all;"></p>
|
||||||
|
<div style="display: flex; gap: 0.50rem; margin-top: 0.25rem;">
|
||||||
|
<button id="btn-confirm-cancel" class="btn-action btn-danger" style="flex: 1; padding: 0.75rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem;">취소</button>
|
||||||
|
<button id="btn-confirm-ok" class="btn-action" style="flex: 1; padding: 0.75rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem;">등록</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script type="module" src="/src/mobile-main.ts"></script>
|
<script type="module" src="/src/mobile-main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class DomainAssetModal extends BaseModal {
|
|||||||
|
|
||||||
revertBtn.addEventListener('click', () => {
|
revertBtn.addEventListener('click', () => {
|
||||||
this.setEditLockMode('view');
|
this.setEditLockMode('view');
|
||||||
|
this.isEditMode = false;
|
||||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +203,57 @@ class DomainAssetModal extends BaseModal {
|
|||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
|
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||||
|
|
||||||
|
const grouped: Record<string, typeof logs> = {};
|
||||||
|
logs.forEach(l => {
|
||||||
|
const date = l.log_date || '날짜 미지정';
|
||||||
|
if (!grouped[date]) grouped[date] = [];
|
||||||
|
grouped[date].push(l);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||||
|
const entriesHtml = dateLogs.map((l, idx) => {
|
||||||
|
const isLast = idx === dateLogs.length - 1;
|
||||||
|
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||||
|
|
||||||
|
let displayDetails = l.details;
|
||||||
|
if (l.details && l.details.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(l.details);
|
||||||
|
if (data.type === 'checkout') {
|
||||||
|
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'return') {
|
||||||
|
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'move') {
|
||||||
|
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-entry" style="${borderStyle}">
|
||||||
|
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||||
|
${l.log_user || '시스템'}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const isInitialReg = date === createdDate;
|
||||||
|
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||||
|
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
${entriesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ class HwAssetModal extends BaseModal {
|
|||||||
|
|
||||||
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||||
<div class="form-section-title">사용자 및 조직 정보</div>
|
<div class="form-section-title">사용자 및 조직 정보</div>
|
||||||
|
<div id="hw-pc-workflow-notice" class="form-group full-width hidden" style="background-color: rgba(59, 130, 246, 0.05); border: 1px solid rgba(59, 130, 246, 0.15); padding: 8px 12px; border-radius: 6px; font-size: 11px; color: var(--primary); line-height: 1.5; margin-bottom: 12px;">
|
||||||
|
💡 PC 자산은 데이터 정합성을 위해 '사용자 및 조직 정보'만 수정이 제한되며, 사양 및 기타 정보는 수정창에서 수정할 수 있습니다.
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||||
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||||
@@ -138,6 +141,10 @@ class HwAssetModal extends BaseModal {
|
|||||||
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
||||||
<input type="text" id="hw-serial_num" name="serial_num" />
|
<input type="text" id="hw-serial_num" name="serial_num" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group mainboard-only">
|
||||||
|
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||||
|
<input type="text" id="hw-mainboard" name="mainboard" />
|
||||||
|
</div>
|
||||||
<div class="form-group spec-only">
|
<div class="form-group spec-only">
|
||||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||||
<input type="text" id="hw-os" name="os" />
|
<input type="text" id="hw-os" name="os" />
|
||||||
@@ -265,6 +272,7 @@ class HwAssetModal extends BaseModal {
|
|||||||
|
|
||||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||||
const saveBtn = document.getElementById('btn-save-hw-asset')!;
|
const saveBtn = document.getElementById('btn-save-hw-asset')!;
|
||||||
|
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
|
||||||
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
|
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
|
||||||
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
||||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||||
@@ -309,6 +317,12 @@ class HwAssetModal extends BaseModal {
|
|||||||
typeSelect.addEventListener('change', () => {
|
typeSelect.addEventListener('change', () => {
|
||||||
this.applyRoleVisibility();
|
this.applyRoleVisibility();
|
||||||
this.updateHeaderIdentity(this.currentAsset);
|
this.updateHeaderIdentity(this.currentAsset);
|
||||||
|
|
||||||
|
if (typeSelect.value === '공용PC') {
|
||||||
|
setFieldValue('hw-user_current', '');
|
||||||
|
setFieldValue('hw-emp_no', '');
|
||||||
|
setFieldValue('hw-user_position', '공용PC');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||||
@@ -320,9 +334,15 @@ class HwAssetModal extends BaseModal {
|
|||||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||||
const cat = categorySelect.value;
|
const cat = categorySelect.value;
|
||||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||||
|
|
||||||
|
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||||
|
if (!purchaseDate.trim()) {
|
||||||
|
alert('구매일자를 먼저 입력해 주세요. 구매일자가 없으면 자산번호를 생성할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
||||||
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -393,6 +413,12 @@ class HwAssetModal extends BaseModal {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
revertBtn.addEventListener('click', () => {
|
||||||
|
if (this.currentAsset) {
|
||||||
|
this.open(this.currentAsset, 'view');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
saveBtn.addEventListener('click', async () => {
|
saveBtn.addEventListener('click', async () => {
|
||||||
if (!this.currentAsset) return;
|
if (!this.currentAsset) return;
|
||||||
|
|
||||||
@@ -749,7 +775,8 @@ class HwAssetModal extends BaseModal {
|
|||||||
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||||
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
||||||
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
||||||
const hasSN = !['사무가구', 'PC부품'].includes(category);
|
const hasSN = ['외부SW', '내부SW'].includes(category);
|
||||||
|
const showMainboard = category === 'PC';
|
||||||
const isParts = ['PC부품', '사무가구'].includes(category);
|
const isParts = ['PC부품', '사무가구'].includes(category);
|
||||||
const showRemote = category === '서버' || type.includes('서버');
|
const showRemote = category === '서버' || type.includes('서버');
|
||||||
const showServiceType = category === '서버' || type === '서버PC';
|
const showServiceType = category === '서버' || type === '서버PC';
|
||||||
@@ -762,9 +789,83 @@ class HwAssetModal extends BaseModal {
|
|||||||
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
||||||
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
||||||
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
||||||
|
document.querySelectorAll('.mainboard-only').forEach(el => (el as HTMLElement).style.display = showMainboard ? '' : 'none');
|
||||||
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
||||||
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
||||||
document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none');
|
document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none');
|
||||||
|
|
||||||
|
// Lock only User and Organization Information for PC category during edit mode
|
||||||
|
const isEditMode = this.currentMode === 'edit';
|
||||||
|
const isPC = category === 'PC';
|
||||||
|
|
||||||
|
const noticeEl = document.getElementById('hw-pc-workflow-notice');
|
||||||
|
if (noticeEl) {
|
||||||
|
if (isPC && isEditMode) {
|
||||||
|
noticeEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
noticeEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedUserFields = [
|
||||||
|
'hw-current_dept',
|
||||||
|
'hw-manager_primary',
|
||||||
|
'hw-manager_secondary',
|
||||||
|
'hw-user_current',
|
||||||
|
'hw-emp_no',
|
||||||
|
'hw-user_position',
|
||||||
|
'hw-previous_user'
|
||||||
|
];
|
||||||
|
|
||||||
|
const allFormControls = this.formEl ? this.formEl.querySelectorAll('input, select, textarea, button') : [];
|
||||||
|
|
||||||
|
allFormControls.forEach(control => {
|
||||||
|
const el = control as HTMLElement;
|
||||||
|
const id = el.id;
|
||||||
|
|
||||||
|
if (el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'hidden') return;
|
||||||
|
if (id === 'hw-asset_code' || id === 'btn-gen-hw-code') return;
|
||||||
|
|
||||||
|
if (isPC && isEditMode && lockedUserFields.includes(id)) {
|
||||||
|
// Lock user information fields for PC in edit mode
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
el.setAttribute('disabled', 'true');
|
||||||
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||||
|
el.setAttribute('readonly', 'true');
|
||||||
|
(el as HTMLInputElement).style.backgroundColor = '#f1f5f9';
|
||||||
|
(el as HTMLInputElement).style.cursor = 'not-allowed';
|
||||||
|
} else if (el.tagName === 'BUTTON') {
|
||||||
|
el.setAttribute('disabled', 'true');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal behavior based on modal edit/view mode (includes add mode which has this.isEditMode = true)
|
||||||
|
if (!this.isEditMode) {
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
el.setAttribute('disabled', 'true');
|
||||||
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||||
|
el.setAttribute('readonly', 'true');
|
||||||
|
(el as HTMLInputElement).style.backgroundColor = '';
|
||||||
|
(el as HTMLInputElement).style.cursor = '';
|
||||||
|
} else if (el.tagName === 'BUTTON') {
|
||||||
|
if (id !== 'btn-print-hw-qr' && id !== 'btn-close-hw-modal') {
|
||||||
|
el.setAttribute('disabled', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
el.removeAttribute('disabled');
|
||||||
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||||
|
if (id !== 'hw-emp_no') {
|
||||||
|
el.removeAttribute('readonly');
|
||||||
|
(el as HTMLInputElement).style.backgroundColor = '';
|
||||||
|
(el as HTMLInputElement).style.cursor = '';
|
||||||
|
}
|
||||||
|
} else if (el.tagName === 'BUTTON') {
|
||||||
|
el.removeAttribute('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateMapButtonVisibility() {
|
private updateMapButtonVisibility() {
|
||||||
@@ -976,6 +1077,8 @@ class HwAssetModal extends BaseModal {
|
|||||||
|
|
||||||
const showList = (filterText: string = '') => {
|
const showList = (filterText: string = '') => {
|
||||||
if (!this.isEditMode) return;
|
if (!this.isEditMode) return;
|
||||||
|
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
||||||
|
if (category === 'PC') return;
|
||||||
const users = state.masterData.users || [];
|
const users = state.masterData.users || [];
|
||||||
const query = filterText.trim().toLowerCase();
|
const query = filterText.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -1053,7 +1156,58 @@ class HwAssetModal extends BaseModal {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
|
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
|
||||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>'; return; }
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>'; return; }
|
||||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
|
||||||
|
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||||
|
|
||||||
|
const grouped: Record<string, typeof logs> = {};
|
||||||
|
logs.forEach(l => {
|
||||||
|
const date = l.log_date || '날짜 미지정';
|
||||||
|
if (!grouped[date]) grouped[date] = [];
|
||||||
|
grouped[date].push(l);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||||
|
const entriesHtml = dateLogs.map((l, idx) => {
|
||||||
|
const isLast = idx === dateLogs.length - 1;
|
||||||
|
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||||
|
|
||||||
|
let displayDetails = l.details;
|
||||||
|
if (l.details && l.details.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(l.details);
|
||||||
|
if (data.type === 'checkout') {
|
||||||
|
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'return') {
|
||||||
|
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'move') {
|
||||||
|
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-entry" style="${borderStyle}">
|
||||||
|
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||||
|
${l.log_user || '시스템'}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const isInitialReg = date === createdDate;
|
||||||
|
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||||
|
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
${entriesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCategoryKey(asset: any): string {
|
private getCategoryKey(asset: any): string {
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class JobSpecModal extends BaseModal {
|
|||||||
|
|
||||||
revertBtn.addEventListener('click', () => {
|
revertBtn.addEventListener('click', () => {
|
||||||
this.setEditLockMode('view');
|
this.setEditLockMode('view');
|
||||||
|
this.isEditMode = false;
|
||||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class PartsMasterModal extends BaseModal {
|
|||||||
|
|
||||||
revertBtn.addEventListener('click', () => {
|
revertBtn.addEventListener('click', () => {
|
||||||
this.setEditLockMode('view');
|
this.setEditLockMode('view');
|
||||||
|
this.isEditMode = false;
|
||||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ class SwAssetModal extends BaseModal {
|
|||||||
|
|
||||||
revertBtn.addEventListener('click', () => {
|
revertBtn.addEventListener('click', () => {
|
||||||
this.setEditLockMode('view');
|
this.setEditLockMode('view');
|
||||||
|
this.isEditMode = false;
|
||||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,7 +390,58 @@ class SwAssetModal extends BaseModal {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
|
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
|
||||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
|
||||||
|
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||||
|
|
||||||
|
const grouped: Record<string, typeof logs> = {};
|
||||||
|
logs.forEach(l => {
|
||||||
|
const date = l.log_date || '날짜 미지정';
|
||||||
|
if (!grouped[date]) grouped[date] = [];
|
||||||
|
grouped[date].push(l);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||||
|
const entriesHtml = dateLogs.map((l, idx) => {
|
||||||
|
const isLast = idx === dateLogs.length - 1;
|
||||||
|
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||||
|
|
||||||
|
let displayDetails = l.details;
|
||||||
|
if (l.details && l.details.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(l.details);
|
||||||
|
if (data.type === 'checkout') {
|
||||||
|
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'return') {
|
||||||
|
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
} else if (data.type === 'move') {
|
||||||
|
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-entry" style="${borderStyle}">
|
||||||
|
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||||
|
${l.log_user || '시스템'}
|
||||||
|
</div>
|
||||||
|
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const isInitialReg = date === createdDate;
|
||||||
|
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="history-item">
|
||||||
|
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||||
|
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
${entriesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ class UserModal extends BaseModal {
|
|||||||
|
|
||||||
revertBtn.addEventListener('click', () => {
|
revertBtn.addEventListener('click', () => {
|
||||||
this.setEditLockMode('view');
|
this.setEditLockMode('view');
|
||||||
|
this.isEditMode = false;
|
||||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { state } from '../core/state';
|
|||||||
const MENU_CONFIG: any = {
|
const MENU_CONFIG: any = {
|
||||||
hw: {
|
hw: {
|
||||||
label: '하드웨어',
|
label: '하드웨어',
|
||||||
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
|
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||||
},
|
},
|
||||||
sw: {
|
sw: {
|
||||||
label: '소프트웨어',
|
label: '소프트웨어',
|
||||||
@@ -65,39 +65,45 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (state.currentUserRole === 'admin' && catKey === 'hw') {
|
if (state.currentUserRole === 'admin' && catKey === 'hw') {
|
||||||
visibleTabs = ['대시보드', '실사 승인'];
|
visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정', '부품 마스터'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleTabs.length === 0) return;
|
if (visibleTabs.length === 0) return;
|
||||||
|
|
||||||
visibleTabs.forEach((tab: string) => {
|
visibleTabs.forEach((tab: string) => {
|
||||||
if (tab === '부품 마스터') return;
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
const isActive = state.activeSubTab === tab;
|
const isActive = state.activeSubTab === tab;
|
||||||
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
|
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
|
||||||
item.textContent = tab;
|
|
||||||
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
|
const isSubMenu = tab === '실사 승인' || tab === '위치지정' || tab === '부품 마스터';
|
||||||
|
if (isSubMenu) {
|
||||||
|
item.innerHTML = `<span style="opacity: 0.5; margin-right: 3px; font-family: sans-serif;">↳</span>${tab}`;
|
||||||
|
item.style.fontSize = '11px';
|
||||||
|
item.style.fontWeight = '500';
|
||||||
|
item.style.marginLeft = '6px';
|
||||||
|
if (!isActive) {
|
||||||
|
item.style.color = 'var(--mute)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.textContent = tab;
|
||||||
|
item.style.fontSize = 'var(--fs-sm)';
|
||||||
|
}
|
||||||
|
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
state.activeCategory = catKey as any;
|
state.activeCategory = catKey as any;
|
||||||
state.activeSubTab = tab;
|
if (tab === '관리도구') {
|
||||||
|
state.activeSubTab = '실사 승인';
|
||||||
|
} else {
|
||||||
|
state.activeSubTab = tab;
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
onTabChange(tab);
|
onTabChange(state.activeSubTab);
|
||||||
});
|
});
|
||||||
navList.appendChild(item);
|
navList.appendChild(item);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 관리자 전용 '관리도구'
|
|
||||||
if (state.currentUserRole === 'admin') {
|
|
||||||
const adminTrigger = document.createElement('div');
|
|
||||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
|
||||||
adminTrigger.innerHTML = '관리도구';
|
|
||||||
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
|
|
||||||
navList.appendChild(adminTrigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 이벤트 바인딩
|
// 4. 이벤트 바인딩
|
||||||
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
|
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface FilterOptions {
|
|||||||
showField?: boolean;
|
showField?: boolean;
|
||||||
showType?: boolean;
|
showType?: boolean;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
|
showPartCategory?: boolean;
|
||||||
|
showPartTier?: boolean;
|
||||||
extraHTML?: string;
|
extraHTML?: string;
|
||||||
onFilterChange: (filters: any) => void;
|
onFilterChange: (filters: any) => void;
|
||||||
initialFilters?: any;
|
initialFilters?: any;
|
||||||
@@ -37,9 +39,11 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
showField = false,
|
showField = false,
|
||||||
showType = false,
|
showType = false,
|
||||||
showStatus = false,
|
showStatus = false,
|
||||||
|
showPartCategory = false,
|
||||||
|
showPartTier = false,
|
||||||
extraHTML = '',
|
extraHTML = '',
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' },
|
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '', partCategory: '', partTier: '' },
|
||||||
fullList = []
|
fullList = []
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -104,6 +108,22 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
|
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${showPartCategory ? `
|
||||||
|
<div class="search-item">
|
||||||
|
<label>분류</label>
|
||||||
|
<select id="filter-part-category">
|
||||||
|
<option value="">전체 분류</option>
|
||||||
|
${getUnique('category').map(v => `<option value="${v}" ${initialFilters.partCategory === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>` : ''}
|
||||||
|
${showPartTier ? `
|
||||||
|
<div class="search-item">
|
||||||
|
<label>성능등급</label>
|
||||||
|
<select id="filter-part-tier">
|
||||||
|
<option value="">전체 등급</option>
|
||||||
|
${getUnique('score_tier').map(v => `<option value="${v}" ${initialFilters.partTier === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>` : ''}
|
||||||
${extraHTML}
|
${extraHTML}
|
||||||
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
|
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
|
||||||
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
||||||
@@ -126,7 +146,9 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
|
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
|
||||||
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
|
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
|
||||||
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
|
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
|
||||||
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
|
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || '',
|
||||||
|
partCategory: (container.querySelector('#filter-part-category') as HTMLSelectElement)?.value || '',
|
||||||
|
partTier: (container.querySelector('#filter-part-tier') as HTMLSelectElement)?.value || ''
|
||||||
};
|
};
|
||||||
onFilterChange(filters);
|
onFilterChange(filters);
|
||||||
};
|
};
|
||||||
@@ -138,9 +160,11 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
|
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
|
||||||
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
|
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
|
||||||
container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
|
container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
|
||||||
|
container.querySelector('#filter-part-category')?.addEventListener('change', triggerChange);
|
||||||
|
container.querySelector('#filter-part-tier')?.addEventListener('change', triggerChange);
|
||||||
|
|
||||||
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
|
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
|
||||||
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => {
|
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status', 'filter-part-category', 'filter-part-tier'].forEach(id => {
|
||||||
const el = container.querySelector(`#${id}`);
|
const el = container.querySelector(`#${id}`);
|
||||||
if (el) (el as any).value = '';
|
if (el) (el as any).value = '';
|
||||||
});
|
});
|
||||||
@@ -153,16 +177,20 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
|||||||
*/
|
*/
|
||||||
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
|
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
|
||||||
return list.filter(item => {
|
return list.filter(item => {
|
||||||
const matchKeyword = !filters.keyword || searchKeys.some(key =>
|
const matchKeyword = !filters.keyword || searchKeys.some(key => {
|
||||||
String(item[ASSET_SCHEMA[key].key] || item[ASSET_SCHEMA[key].db] || '').toLowerCase().includes(filters.keyword)
|
const schema = ASSET_SCHEMA[key];
|
||||||
);
|
const val = schema ? (item[schema.key] || item[schema.db]) : item[key];
|
||||||
|
return String(val || '').toLowerCase().includes(filters.keyword);
|
||||||
|
});
|
||||||
const matchCorp = !filters.corp || (item[ASSET_SCHEMA.PURCHASE_CORP.key] || item[ASSET_SCHEMA.PURCHASE_CORP.db]) === filters.corp;
|
const matchCorp = !filters.corp || (item[ASSET_SCHEMA.PURCHASE_CORP.key] || item[ASSET_SCHEMA.PURCHASE_CORP.db]) === filters.corp;
|
||||||
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
|
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
|
||||||
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
|
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
|
||||||
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
|
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
|
||||||
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
|
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
|
||||||
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
|
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
|
||||||
|
const matchPartCategory = !filters.partCategory || item.category === filters.partCategory;
|
||||||
|
const matchPartTier = !filters.partTier || item.score_tier === filters.partTier;
|
||||||
|
|
||||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
|
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus && matchPartCategory && matchPartTier;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/main.ts
54
src/main.ts
@@ -6,6 +6,7 @@ import { renderDashboard } from './views/DashboardView';
|
|||||||
import { renderSWTable } from './views/SW_Table';
|
import { renderSWTable } from './views/SW_Table';
|
||||||
import { renderLocationView } from './views/LocationView';
|
import { renderLocationView } from './views/LocationView';
|
||||||
import { renderAuditApprovalView } from './views/AuditApprovalView';
|
import { renderAuditApprovalView } from './views/AuditApprovalView';
|
||||||
|
import { MapEditor } from './views/MapEditor';
|
||||||
import { initBaseModal } from './components/Modal/BaseModal';
|
import { initBaseModal } from './components/Modal/BaseModal';
|
||||||
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
||||||
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
||||||
@@ -28,11 +29,19 @@ interface AuthSessionResponse {
|
|||||||
let phoneLoginPollTimer: number | undefined;
|
let phoneLoginPollTimer: number | undefined;
|
||||||
|
|
||||||
|
|
||||||
|
let activeMapEditorInstance: MapEditor | null = null;
|
||||||
|
|
||||||
// 화면 갱신 통합 핸들러
|
// 화면 갱신 통합 핸들러
|
||||||
function refreshView(tab?: string) {
|
async function refreshView(tab?: string) {
|
||||||
const mainContent = document.getElementById('main-content')!;
|
const mainContent = document.getElementById('main-content')!;
|
||||||
if (!mainContent) return;
|
if (!mainContent) return;
|
||||||
|
|
||||||
|
// Clean up any active MapEditor instance when navigating away
|
||||||
|
if (activeMapEditorInstance) {
|
||||||
|
activeMapEditorInstance.destroy();
|
||||||
|
activeMapEditorInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
const activeTab = tab || state.activeSubTab;
|
const activeTab = tab || state.activeSubTab;
|
||||||
|
|
||||||
if (activeTab === '대시보드') {
|
if (activeTab === '대시보드') {
|
||||||
@@ -41,7 +50,48 @@ function refreshView(tab?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === '실사 승인') {
|
if (activeTab === '실사 승인') {
|
||||||
renderAuditApprovalView(mainContent);
|
await renderAuditApprovalView(mainContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === '위치지정') {
|
||||||
|
// Render Map Editor directly into main content to maximize working area
|
||||||
|
mainContent.innerHTML = `
|
||||||
|
<div class="map-editor-page-wrapper" style="display: flex; flex: 1; height: calc(100vh - var(--header-height) - 48px); overflow: hidden; width: 100%;">
|
||||||
|
<!-- Left: File Selector -->
|
||||||
|
<div class="file-sidebar" id="file-sidebar"></div>
|
||||||
|
|
||||||
|
<!-- Center: Main Editor -->
|
||||||
|
<div class="editor-container" id="container">
|
||||||
|
<div class="img-wrapper" id="wrapper">
|
||||||
|
<img src="" id="target-img" alt="Map Image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Control Panel -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
|
||||||
|
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
||||||
|
<p>
|
||||||
|
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="box-list" id="box-list"></div>
|
||||||
|
|
||||||
|
<div class="actions" style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
|
||||||
|
<button id="btn-print-map-qrs" class="btn btn-outline btn-primary">이 도면 QR 일괄인쇄</button>
|
||||||
|
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
||||||
|
<div id="save-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Initialize MapEditor instance
|
||||||
|
const editor = new MapEditor();
|
||||||
|
await editor.init();
|
||||||
|
activeMapEditorInstance = editor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const manualInput = document.getElementById('manual-code-input') as HTMLInputElement;
|
const manualInput = document.getElementById('manual-code-input') as HTMLInputElement;
|
||||||
const manualSubmitBtn = document.getElementById('btn-submit-manual') as HTMLButtonElement;
|
const manualSubmitBtn = document.getElementById('btn-submit-manual') as HTMLButtonElement;
|
||||||
|
|
||||||
|
// 확인 모달 관련 셀렉터 및 상태 변수
|
||||||
|
const confirmModal = document.getElementById('scan-confirm-modal')!;
|
||||||
|
const confirmModalMsg = document.getElementById('confirm-modal-msg')!;
|
||||||
|
const btnConfirmCancel = document.getElementById('btn-confirm-cancel') as HTMLButtonElement;
|
||||||
|
const btnConfirmOk = document.getElementById('btn-confirm-ok') as HTMLButtonElement;
|
||||||
|
|
||||||
let html5QrcodeScanner: any = null;
|
let html5QrcodeScanner: any = null;
|
||||||
|
let isModalOpen = false;
|
||||||
|
let pendingAssetCode = '';
|
||||||
|
|
||||||
// Initialize UI based on current session lock
|
// Initialize UI based on current session lock
|
||||||
updateLocationUI();
|
updateLocationUI();
|
||||||
@@ -42,6 +50,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
manualInput.value = '';
|
manualInput.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 확인 모달 버튼 이벤트 바인딩
|
||||||
|
btnConfirmCancel.addEventListener('click', () => {
|
||||||
|
closeConfirmModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnConfirmOk.addEventListener('click', () => {
|
||||||
|
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
|
||||||
|
if (pendingAssetCode && lockedLoc) {
|
||||||
|
submitAssetAudit(pendingAssetCode, lockedLoc);
|
||||||
|
}
|
||||||
|
closeConfirmModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openConfirmModal(assetCode: string, locationCode: string) {
|
||||||
|
isModalOpen = true;
|
||||||
|
pendingAssetCode = assetCode;
|
||||||
|
confirmModalMsg.innerHTML = `자산 <strong>[${assetCode}]</strong>을<br>현재 위치 <strong>[${locationCode}]</strong>에 실사 등록하시겠습니까?`;
|
||||||
|
confirmModal.style.display = 'flex';
|
||||||
|
vibrateDevice(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmModal() {
|
||||||
|
confirmModal.style.display = 'none';
|
||||||
|
isModalOpen = false;
|
||||||
|
pendingAssetCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Core Scanner Functions ---
|
// --- Core Scanner Functions ---
|
||||||
|
|
||||||
function initScanner() {
|
function initScanner() {
|
||||||
@@ -80,8 +115,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processScannedCode(rawCode: string) {
|
function processScannedCode(rawCode: string) {
|
||||||
|
if (isModalOpen) return; // 모달이 이미 열려 있는 경우 추가 스캔 차단
|
||||||
|
|
||||||
// QR 코드 인쇄 폼 등으로 인한 개행 문자(\r, \n) 및 모든 공백 문자(\s)를 제거
|
// QR 코드 인쇄 폼 등으로 인한 개행 문자(\r, \n) 및 모든 공백 문자(\s)를 제거
|
||||||
const code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim();
|
let code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim();
|
||||||
|
|
||||||
|
// 만약 스캔된 텍스트가 전체 URL 주소 형식이라면 파라미터 값만 추출하여 정제
|
||||||
|
if (code.includes('http://') || code.includes('https://') || code.includes('/mobile')) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(code, window.location.origin);
|
||||||
|
const locParam = urlObj.searchParams.get('loc');
|
||||||
|
const assetParam = urlObj.searchParams.get('asset');
|
||||||
|
|
||||||
|
if (locParam) code = locParam;
|
||||||
|
else if (assetParam) code = assetParam;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("URL 파싱 에러:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check if the code is a physical location code
|
// 1. Check if the code is a physical location code
|
||||||
if (code.startsWith('LOC-')) {
|
if (code.startsWith('LOC-')) {
|
||||||
@@ -100,8 +151,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit matching info to server
|
// 바로 전송하는 대신 확인 모달 팝업을 띄움
|
||||||
submitAssetAudit(code, lockedLoc);
|
openConfirmModal(code, lockedLoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAssetAudit(assetCode: string, locationCode: string) {
|
async function submitAssetAudit(assetCode: string, locationCode: string) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { setupTableSorting, SortState } from '../../core/tableHandler';
|
|||||||
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||||
import { state } from '../../core/state';
|
import { state } from '../../core/state';
|
||||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||||
|
import { createIcons, Plus, Settings, RefreshCcw } from 'lucide';
|
||||||
import './table.css';
|
import './table.css';
|
||||||
|
|
||||||
declare var Chart: any;
|
declare var Chart: any;
|
||||||
@@ -31,6 +32,8 @@ export interface ListViewConfig {
|
|||||||
showType?: boolean;
|
showType?: boolean;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
showPosition?: boolean;
|
showPosition?: boolean;
|
||||||
|
showPartCategory?: boolean;
|
||||||
|
showPartTier?: boolean;
|
||||||
};
|
};
|
||||||
columns: ColumnDef[];
|
columns: ColumnDef[];
|
||||||
onRowClick?: (asset: any) => void;
|
onRowClick?: (asset: any) => void;
|
||||||
@@ -50,7 +53,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
}
|
}
|
||||||
const filterKey = config.title;
|
const filterKey = config.title;
|
||||||
if (!(state as any).listFilters[filterKey]) {
|
if (!(state as any).listFilters[filterKey]) {
|
||||||
(state as any).listFilters[filterKey] = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' };
|
(state as any).listFilters[filterKey] = {
|
||||||
|
keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '',
|
||||||
|
partCategory: '', partTier: ''
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let currentFilters: any = (state as any).listFilters[filterKey];
|
let currentFilters: any = (state as any).listFilters[filterKey];
|
||||||
|
|
||||||
@@ -708,7 +714,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
|
|
||||||
function makeColumnsResizable(tableElement: HTMLTableElement) {
|
function makeColumnsResizable(tableElement: HTMLTableElement) {
|
||||||
const headers = tableElement.querySelectorAll('th');
|
const headers = tableElement.querySelectorAll('th');
|
||||||
headers.forEach(th => {
|
headers.forEach((th, index) => {
|
||||||
const resizer = th.querySelector('.resizer') as HTMLElement;
|
const resizer = th.querySelector('.resizer') as HTMLElement;
|
||||||
if (!resizer) return;
|
if (!resizer) return;
|
||||||
|
|
||||||
@@ -733,28 +739,44 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
resizer.classList.remove('resizing');
|
resizer.classList.remove('resizing');
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
// Save the widths of all columns back to the config so they persist on re-render
|
||||||
|
headers.forEach((hdr, idx) => {
|
||||||
|
if (config.columns[idx]) {
|
||||||
|
config.columns[idx].width = hdr.style.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
resizer.addEventListener('mousedown', (e: MouseEvent) => {
|
resizer.addEventListener('mousedown', (e: MouseEvent) => {
|
||||||
// Prevents header click sorting trigger from firing
|
// Prevents header click sorting trigger from firing on mousedown
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Freeze all columns at their current pixel width before dragging
|
// Freeze all columns at their current precise pixel width before dragging
|
||||||
headers.forEach(header => {
|
headers.forEach(header => {
|
||||||
header.style.width = `${header.offsetWidth}px`;
|
header.style.width = `${header.getBoundingClientRect().width}px`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Freeze the table at its current precise pixel width immediately
|
||||||
|
tableElement.style.width = `${tableElement.getBoundingClientRect().width}px`;
|
||||||
|
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
startWidth = th.offsetWidth;
|
startWidth = th.getBoundingClientRect().width;
|
||||||
|
|
||||||
// Capture the initial physical width of the entire table
|
// Capture the initial physical width of the entire table
|
||||||
startTableWidth = tableElement.offsetWidth;
|
startTableWidth = tableElement.getBoundingClientRect().width;
|
||||||
|
|
||||||
resizer.classList.add('resizing');
|
resizer.classList.add('resizing');
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prevents header click sorting trigger from firing on mouseup/click
|
||||||
|
resizer.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,5 +855,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
|||||||
chkBox?.addEventListener('change', handleToggle);
|
chkBox?.addEventListener('change', handleToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createIcons({
|
||||||
|
icons: { Plus, Settings, RefreshCcw }
|
||||||
|
});
|
||||||
|
|
||||||
switchView();
|
switchView();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export function renderPartsMasterList(container: HTMLElement) {
|
|||||||
keywordLabel: '부품명 / 등급 검색',
|
keywordLabel: '부품명 / 등급 검색',
|
||||||
showLoc: false,
|
showLoc: false,
|
||||||
showDept: false,
|
showDept: false,
|
||||||
showType: false
|
showType: false,
|
||||||
|
showPartCategory: true,
|
||||||
|
showPartTier: true
|
||||||
},
|
},
|
||||||
onRowClick: (component) => openPartsMasterModal(component, 'view'),
|
onRowClick: (component) => openPartsMasterModal(component, 'view'),
|
||||||
columns: [
|
columns: [
|
||||||
@@ -72,6 +74,7 @@ export function renderPartsMasterList(container: HTMLElement) {
|
|||||||
title: '직무별 기준 사양',
|
title: '직무별 기준 사양',
|
||||||
dataSource: () => state.masterData.jobSpecs || [],
|
dataSource: () => state.masterData.jobSpecs || [],
|
||||||
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
|
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
|
||||||
|
persistentSortState: { key: 'id', direction: 'asc' },
|
||||||
filterOptions: {
|
filterOptions: {
|
||||||
keywordLabel: '직무명 / 사양 검색',
|
keywordLabel: '직무명 / 사양 검색',
|
||||||
showLoc: false,
|
showLoc: false,
|
||||||
@@ -130,9 +133,6 @@ export function renderPartsMasterList(container: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSubTabs(container: HTMLElement) {
|
function renderSubTabs(container: HTMLElement) {
|
||||||
const header = container.querySelector('.page-header');
|
|
||||||
if (!header) return;
|
|
||||||
|
|
||||||
// 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식)
|
// 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식)
|
||||||
const existingTabs = container.querySelector('.sub-tab-container');
|
const existingTabs = container.querySelector('.sub-tab-container');
|
||||||
if (existingTabs) existingTabs.remove();
|
if (existingTabs) existingTabs.remove();
|
||||||
@@ -153,7 +153,12 @@ function renderSubTabs(container: HTMLElement) {
|
|||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
|
const header = container.querySelector('.page-header');
|
||||||
|
if (header) {
|
||||||
|
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
|
||||||
|
} else {
|
||||||
|
container.insertBefore(tabContainer, container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
|
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
|
||||||
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
|
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||||
import { QRPrinter } from '../core/qr_print';
|
import { QRPrinter } from '../core/qr_print';
|
||||||
|
import './map-editor.css';
|
||||||
|
|
||||||
export class MapEditor {
|
export class MapEditor {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
@@ -114,6 +115,45 @@ export class MapEditor {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onWindowMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!this.isDrawing || !this.currentBox) return;
|
||||||
|
const rect = this.wrapper.getBoundingClientRect();
|
||||||
|
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||||
|
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
||||||
|
|
||||||
|
const width = currentX - this.startX;
|
||||||
|
const height = currentY - this.startY;
|
||||||
|
|
||||||
|
this.currentBox.style.width = Math.abs(width) + 'px';
|
||||||
|
this.currentBox.style.height = Math.abs(height) + 'px';
|
||||||
|
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
|
||||||
|
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
|
||||||
|
};
|
||||||
|
|
||||||
|
private onWindowMouseUp = () => {
|
||||||
|
if (!this.isDrawing || !this.currentBox) return;
|
||||||
|
this.isDrawing = false;
|
||||||
|
|
||||||
|
const width = parseFloat(this.currentBox.style.width);
|
||||||
|
const height = parseFloat(this.currentBox.style.height);
|
||||||
|
|
||||||
|
if (width > 3 && height > 3) {
|
||||||
|
const rect = this.wrapper.getBoundingClientRect();
|
||||||
|
const boxData = {
|
||||||
|
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
|
||||||
|
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
|
||||||
|
w: (width / rect.width * 100).toFixed(2),
|
||||||
|
h: (height / rect.height * 100).toFixed(2),
|
||||||
|
asset_id: null
|
||||||
|
};
|
||||||
|
this.boxes.push(boxData);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentBox.remove();
|
||||||
|
this.currentBox = null;
|
||||||
|
};
|
||||||
|
|
||||||
private bindEvents() {
|
private bindEvents() {
|
||||||
this.wrapper.addEventListener('mousedown', (e) => {
|
this.wrapper.addEventListener('mousedown', (e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -135,44 +175,8 @@ export class MapEditor {
|
|||||||
this.wrapper.appendChild(this.currentBox);
|
this.wrapper.appendChild(this.currentBox);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
window.addEventListener('mousemove', this.onWindowMouseMove);
|
||||||
if (!this.isDrawing || !this.currentBox) return;
|
window.addEventListener('mouseup', this.onWindowMouseUp);
|
||||||
const rect = this.wrapper.getBoundingClientRect();
|
|
||||||
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
|
||||||
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
|
||||||
|
|
||||||
const width = currentX - this.startX;
|
|
||||||
const height = currentY - this.startY;
|
|
||||||
|
|
||||||
this.currentBox.style.width = Math.abs(width) + 'px';
|
|
||||||
this.currentBox.style.height = Math.abs(height) + 'px';
|
|
||||||
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
|
|
||||||
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
|
||||||
if (!this.isDrawing || !this.currentBox) return;
|
|
||||||
this.isDrawing = false;
|
|
||||||
|
|
||||||
const width = parseFloat(this.currentBox.style.width);
|
|
||||||
const height = parseFloat(this.currentBox.style.height);
|
|
||||||
|
|
||||||
if (width > 3 && height > 3) {
|
|
||||||
const rect = this.wrapper.getBoundingClientRect();
|
|
||||||
const boxData = {
|
|
||||||
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
|
|
||||||
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
|
|
||||||
w: (width / rect.width * 100).toFixed(2),
|
|
||||||
h: (height / rect.height * 100).toFixed(2),
|
|
||||||
asset_id: null
|
|
||||||
};
|
|
||||||
this.boxes.push(boxData);
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentBox.remove();
|
|
||||||
this.currentBox = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
(window as any).removeBox = (index: number) => {
|
(window as any).removeBox = (index: number) => {
|
||||||
this.boxes.splice(index, 1);
|
this.boxes.splice(index, 1);
|
||||||
@@ -341,6 +345,13 @@ export class MapEditor {
|
|||||||
}]);
|
}]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
window.removeEventListener('mousemove', this.onWindowMouseMove);
|
||||||
|
window.removeEventListener('mouseup', this.onWindowMouseUp);
|
||||||
|
delete (window as any).removeBox;
|
||||||
|
delete (window as any).printBoxQR;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCleanMapKey(path: string) {
|
function getCleanMapKey(path: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user