From 55e9cd4cd954da774e6eede976118e332864145e Mon Sep 17 00:00:00 2001 From: JooWangi Date: Thu, 23 Apr 2026 17:22:38 +0900 Subject: [PATCH] =?UTF-8?q?Refactor:=20SW=20=EC=83=81=EC=84=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=8F=99=EC=A0=81=20=ED=95=84=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=ED=81=B4=EB=9D=BC=EC=9A=B0=EB=93=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9,=20=EC=9E=90=EC=82=B0=20=EC=9C=A0=ED=98=95?= =?UTF-8?q?=20=EB=AA=85=EC=B9=AD=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db_init.js | 7 + server.js | 46 ++-- src/components/Modal/CloudModal.ts | 317 ---------------------------- src/components/Modal/HWModal.ts | 6 +- src/components/Modal/PCModal.ts | 34 ++- src/components/Modal/SWModal.ts | 82 ++++--- src/components/Modal/SWUserModal.ts | 18 +- src/core/excelHandler.ts | 7 +- src/main.ts | 55 +++-- src/styles/modal.css | 29 ++- src/views/List/SwListView.ts | 16 +- 11 files changed, 167 insertions(+), 450 deletions(-) delete mode 100644 src/components/Modal/CloudModal.ts diff --git a/db_init.js b/db_init.js index 0aa41e8..86ac2a3 100644 --- a/db_init.js +++ b/db_init.js @@ -47,6 +47,7 @@ async function initDB() { server_id VARCHAR(100), server_pw VARCHAR(100), model_name VARCHAR(255), + mainboard VARCHAR(255) COMMENT '메인보드', os VARCHAR(100), cpu VARCHAR(255), ram VARCHAR(100), @@ -73,11 +74,14 @@ async function initDB() { id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100) COMMENT '구매법인', asset_code VARCHAR(100) COMMENT '자산번호', + category VARCHAR(100) COMMENT '분야', + dept VARCHAR(100) COMMENT '부서', product_name VARCHAR(255) COMMENT '제품명', license_type VARCHAR(100) COMMENT '라이선스 유형', quantity INT COMMENT '수량', price VARCHAR(100) COMMENT '금액', purchase_date VARCHAR(50) COMMENT '구매일', + start_date VARCHAR(50) COMMENT '시작일', expiry_date VARCHAR(50) COMMENT '만료일', vendor VARCHAR(255) COMMENT '납품업체', remarks TEXT COMMENT '비고', @@ -91,11 +95,14 @@ async function initDB() { id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100) COMMENT '구매법인', asset_code VARCHAR(100) COMMENT '자산번호', + category VARCHAR(100) COMMENT '분야', + dept VARCHAR(100) COMMENT '부서', product_name VARCHAR(255) COMMENT '제품명', license_key VARCHAR(255) COMMENT '라이선스 키', quantity INT COMMENT '수량', price VARCHAR(100) COMMENT '금액', purchase_date VARCHAR(50) COMMENT '구매일', + start_date VARCHAR(50) COMMENT '시작일', vendor VARCHAR(255) COMMENT '납품업체', remarks TEXT COMMENT '비고', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/server.js b/server.js index 7944877..dc0ca86 100644 --- a/server.js +++ b/server.js @@ -59,7 +59,7 @@ async function ensureTables() { current_org VARCHAR(100), prev_org VARCHAR(100), location VARCHAR(255), manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50), remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100), - model_name VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100), + model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100), storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); @@ -70,16 +70,18 @@ async function ensureTables() { await connection.query(` CREATE TABLE IF NOT EXISTS sw_sub_assets ( - id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), product_name VARCHAR(255), + id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), + category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255), license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), - expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT + start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); await connection.query(` CREATE TABLE IF NOT EXISTS sw_perm_assets ( - id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), product_name VARCHAR(255), + id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), + category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255), license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), - vendor VARCHAR(100), remarks TEXT + start_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); await connection.query(` @@ -120,7 +122,7 @@ const hardwareInsertSQL = (table) => ` INSERT INTO ${table} ( id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, current_org, prev_org, location, manager_main, manager_sub, ip_address, - remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu, + remote_tool, server_id, server_pw, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, remarks ) VALUES ? `; @@ -128,7 +130,7 @@ const hardwareInsertSQL = (table) => ` const getHardwareValues = (a) => [ a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'', a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'', - a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', + a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'' ]; @@ -137,7 +139,7 @@ const mapHardware = (r, defaultType) => ({ 상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org, 이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub, IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw, - 모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1, + 모델명: r.model_name, 메인보드: r.mainboard, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1, SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks }); @@ -238,9 +240,11 @@ app.get('/api/sw/sub', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM sw_sub_assets'); res.json(rows.map(r => ({ - id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, - 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, - 만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks + id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, + 분야: r.category, 부서: r.dept, 제품명: r.product_name, + 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, + 구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date, + 납품업체: r.vendor, 비고: r.remarks }))); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -248,8 +252,11 @@ app.get('/api/sw/sub', async (req, res) => { app.post('/api/sw/sub/batch', async (req, res) => { try { const result = await batchSave('sw_sub_assets', req.body, (assets) => ({ - sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, product_name, license_type, quantity, price, purchase_date, expiry_date, vendor, remarks) VALUES ?`, - values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.만료일||'', a.납품업체||'', a.비고||'']) + sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`, + values: assets.map(a => [ + a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'', + a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||'' + ]) })); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } @@ -260,8 +267,10 @@ app.get('/api/sw/perm', async (req, res) => { try { const [rows] = await pool.query('SELECT * FROM sw_perm_assets'); res.json(rows.map(r => ({ - id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, - 라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, + id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, + 분야: r.category, 부서: r.dept, 제품명: r.product_name, + 라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, + 구매일: r.purchase_date, 시작일: r.start_date, 납품업체: r.vendor, 비고: r.remarks }))); } catch (err) { res.status(500).json({ error: err.message }); } @@ -270,8 +279,11 @@ app.get('/api/sw/perm', async (req, res) => { app.post('/api/sw/perm/batch', async (req, res) => { try { const result = await batchSave('sw_perm_assets', req.body, (assets) => ({ - sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, product_name, license_key, quantity, price, purchase_date, vendor, remarks) VALUES ?`, - values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.납품업체||'', a.비고||'']) + sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, vendor, remarks) VALUES ?`, + values: assets.map(a => [ + a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'', + a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.납품업체||'', a.비고||'' + ]) })); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } diff --git a/src/components/Modal/CloudModal.ts b/src/components/Modal/CloudModal.ts deleted file mode 100644 index eba98e1..0000000 --- a/src/components/Modal/CloudModal.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { state } from '../../core/state'; -import { SoftwareAsset } from '../../core/excelHandler'; -import { openModal } from './BaseModal'; -import { createIcons, Save, X, Edit2, RotateCcw, History, Plus } from 'lucide'; - -const CLOUD_MODAL_HTML = ` - - - -`; - -export let currentCloudAsset: SoftwareAsset | null = null; -export let isCloudEditMode = false; - -export function setCloudEditMode(edit: boolean) { - isCloudEditMode = edit; - const form = document.getElementById('cloud-asset-form') as HTMLFormElement; - const btnSave = document.getElementById('btn-save-cloud-asset') as HTMLButtonElement; - const btnRevert = document.getElementById('btn-revert-cloud-edit') as HTMLButtonElement; - const btnClose = document.getElementById('btn-close-cloud-footer') as HTMLButtonElement; - - if (edit) { - form.classList.add('is-edit-mode'); - form.classList.remove('is-view-mode'); - btnSave.textContent = '저장'; - btnRevert.classList.remove('hidden'); - btnClose.classList.add('hidden'); - Array.from(form.elements).forEach((el: any) => el.disabled = false); - } else { - form.classList.add('is-view-mode'); - form.classList.remove('is-edit-mode'); - btnSave.textContent = '수정'; - btnRevert.classList.add('hidden'); - btnClose.classList.remove('hidden'); - Array.from(form.elements).forEach((el: any) => el.disabled = true); - if (currentCloudAsset) fillCloudFormData(currentCloudAsset); - } -} - -export function fillCloudFormData(asset: SoftwareAsset) { - (document.getElementById('cloud-asset-id') as HTMLInputElement).value = asset.id; - (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value = asset.플랫폼명 || ''; - (document.getElementById('cloud-법인') as HTMLSelectElement).value = asset.법인 || '한맥'; - (document.getElementById('cloud-제품명') as HTMLInputElement).value = asset.제품명 || ''; - (document.getElementById('cloud-부서') as HTMLInputElement).value = asset.부서 || ''; - (document.getElementById('cloud-계정명') as HTMLInputElement).value = asset.계정명 || ''; - (document.getElementById('cloud-결제수단') as HTMLSelectElement).value = asset.결제수단 || ''; - (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value = asset.연결카드번호 || ''; - (document.getElementById('cloud-결제일') as HTMLInputElement).value = asset.결제일 || ''; - - const billing = asset.당월청구액 ? asset.당월청구액.replace(/[^0-9]/g, '') : ''; - (document.getElementById('cloud-당월청구액') as HTMLInputElement).value = billing ? Number(billing).toLocaleString() : ''; - (document.getElementById('cloud-비고') as HTMLInputElement).value = asset.비고 || ''; - - document.getElementById('btn-open-cloud-update')!.style.display = 'flex'; - renderCloudHistory(asset.id); -} - -function renderCloudHistory(assetId: string) { - const historyList = document.getElementById('cloud-history-list'); - if (!historyList) return; - if (!state.masterData.logs) state.masterData.logs = []; - - const logs = state.masterData.logs - .filter(l => l.assetId === assetId) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - if (logs.length === 0) { - historyList.innerHTML = '
업데이트 내역이 없습니다.
'; - return; - } - - historyList.innerHTML = logs.map(log => ` -
-
${log.date}
-
작업자: ${log.user}
-
${log.details.replace(/\n/g, '
')}
-
- `).join(''); - createIcons({ icons: { X, History, Plus } }); -} - -export function initCloudModal(renderContent: () => void, closeModals: () => void) { - if (!document.getElementById('cloud-asset-modal')) { - document.body.insertAdjacentHTML('beforeend', CLOUD_MODAL_HTML); - } - - const form = document.getElementById('cloud-asset-form') as HTMLFormElement; - const btnRevert = document.getElementById('btn-revert-cloud-edit'); - const btnSave = document.getElementById('btn-save-cloud-asset'); - const btnDelete = document.getElementById('btn-delete-cloud-asset'); - - document.getElementById('btn-close-cloud-modal')?.addEventListener('click', closeModals); - document.getElementById('btn-close-cloud-footer')?.addEventListener('click', closeModals); - - btnRevert?.addEventListener('click', (e) => { - e.preventDefault(); - setCloudEditMode(false); - }); - - btnSave?.addEventListener('click', (e) => { - e.preventDefault(); - if (!isCloudEditMode) { - setCloudEditMode(true); - return; - } - if (!form.checkValidity()) { form.reportValidity(); return; } - - const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; - const billingRaw = (document.getElementById('cloud-당월청구액') as HTMLInputElement).value.replace(/[^0-9]/g, ''); - - const newAsset: SoftwareAsset = { - id: id || Math.random().toString(36).substring(2, 9), - type: '클라우드', - 플랫폼명: (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value, - 법인: (document.getElementById('cloud-법인') as HTMLSelectElement).value, - 제품명: (document.getElementById('cloud-제품명') as HTMLInputElement).value, - 부서: (document.getElementById('cloud-부서') as HTMLInputElement).value, - 계정명: (document.getElementById('cloud-계정명') as HTMLInputElement).value, - 결제수단: (document.getElementById('cloud-결제수단') as HTMLSelectElement).value, - 연결카드번호: (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value, - 결제일: (document.getElementById('cloud-결제일') as HTMLInputElement).value, - 당월청구액: billingRaw, - 비고: (document.getElementById('cloud-비고') as HTMLInputElement).value, - 구매일: '', 금액: '', 수량: 1, 납품업체: '' - }; - - if (id) { - const idx = state.masterData.sw.findIndex(a => a.id === id); - if (idx !== -1) state.masterData.sw[idx] = newAsset; - } else { - state.masterData.sw.push(newAsset); - const now = new Date(); - state.masterData.logs = state.masterData.logs || []; - state.masterData.logs.push({ - id: Math.random().toString(36).substring(2, 9), - assetId: newAsset.id, - date: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`, - user: '관리자', - details: '신규 등록' - }); - } - closeModals(); - renderContent(); - }); - - btnDelete?.addEventListener('click', (e) => { - e.preventDefault(); - const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; - if (confirm('클라우드 자산을 삭제하시겠습니까?')) { - state.masterData.sw = state.masterData.sw.filter(a => a.id !== id); - closeModals(); - renderContent(); - } - }); - - // 클라우드 업데이트 (이력) 모달 로직 - const updateModal = document.getElementById('cloud-update-modal')!; - document.getElementById('btn-open-cloud-update')?.addEventListener('click', () => { - updateModal.classList.remove('hidden'); - (document.getElementById('cloud-update-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0]; - (document.getElementById('cloud-update-cost') as HTMLInputElement).value = ''; - (document.getElementById('cloud-update-note') as HTMLInputElement).value = ''; - }); - - const closeUpdateModal = () => updateModal.classList.add('hidden'); - document.getElementById('btn-close-cloud-update')?.addEventListener('click', closeUpdateModal); - document.getElementById('btn-cancel-cloud-update')?.addEventListener('click', closeUpdateModal); - - document.getElementById('btn-save-cloud-update')?.addEventListener('click', () => { - const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value; - if (!id) return; - - const date = (document.getElementById('cloud-update-date') as HTMLInputElement).value; - const costRaw = (document.getElementById('cloud-update-cost') as HTMLInputElement).value.replace(/[^0-9]/g, ''); - const note = (document.getElementById('cloud-update-note') as HTMLInputElement).value; - - if (!date) return alert('업데이트 일자를 입력하세요.'); - - let details = '결제/상태 업데이트'; - if (costRaw) details += ` (비용: ₩ ${Number(costRaw).toLocaleString()})`; - if (note) details += `\n메모: ${note}`; - - state.masterData.logs = state.masterData.logs || []; - state.masterData.logs.push({ - id: Math.random().toString(36).substring(2, 9), - assetId: id, - date, - user: '관리자', - details - }); - - // 금액 업데이트 반영 - if (costRaw) { - const idx = state.masterData.sw.findIndex(a => a.id === id); - if (idx !== -1) { - state.masterData.sw[idx].당월청구액 = costRaw; - (document.getElementById('cloud-당월청구액') as HTMLInputElement).value = Number(costRaw).toLocaleString(); - } - } - - closeUpdateModal(); - renderCloudHistory(id); - renderContent(); - }); - - createIcons({ icons: { Save, X, Edit2, RotateCcw, History, Plus } }); -} - -export function openCloudModal(asset?: SoftwareAsset) { - currentCloudAsset = asset || null; - const form = document.getElementById('cloud-asset-form') as HTMLFormElement; - const deleteBtn = document.getElementById('btn-delete-cloud-asset')!; - - openModal('cloud-asset-modal'); - form.reset(); - - if (asset) { - document.getElementById('cloud-modal-title')!.textContent = '클라우드 서비스 상세'; - deleteBtn.style.display = 'block'; - fillCloudFormData(asset); - setCloudEditMode(false); - } else { - document.getElementById('cloud-modal-title')!.textContent = '신규 클라우드 서비스 등록'; - deleteBtn.style.display = 'none'; - (document.getElementById('cloud-asset-id') as HTMLInputElement).value = ''; - document.getElementById('btn-open-cloud-update')!.style.display = 'none'; - renderCloudHistory(''); - setCloudEditMode(true); - } - createIcons({ icons: { History, Plus } }); -} diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index db4bdd5..a2d0a23 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -449,11 +449,7 @@ export function initHwModal(onSave: () => void, closeModals: () => void) { saveHardwareAsset(updated); onSave(); - setEditLock('hw-asset-form', 'view', { - saveBtnId: 'btn-save-hw-asset', - revertBtnId: 'btn-revert-hw-edit' - }); - isEditMode = false; + closeModalAction(); }); deleteBtn.addEventListener('click', () => { diff --git a/src/components/Modal/PCModal.ts b/src/components/Modal/PCModal.ts index e492582..dac0861 100644 --- a/src/components/Modal/PCModal.ts +++ b/src/components/Modal/PCModal.ts @@ -1,7 +1,7 @@ import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state'; import { HardwareAsset } from '../../core/excelHandler'; import { openModal, closeModals } from './BaseModal'; -import { createIcons, History, X, Paperclip } from 'lucide'; +import { createIcons, History, X, Paperclip, Calendar } from 'lucide'; import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData'; import { generateOptionsHTML, @@ -9,7 +9,8 @@ import { getFieldValue, parseAndSetLocation, bindLocationEvents, - getCombinedLocation + getCombinedLocation, + applyDateMask } from './ModalUtils'; let currentAsset: HardwareAsset | null = null; @@ -67,6 +68,10 @@ const PC_MODAL_HTML = ` +
+ + +
@@ -105,7 +110,13 @@ const PC_MODAL_HTML = `
- +
+ + + +
@@ -184,7 +195,7 @@ export function openPcModal(asset: HardwareAsset, mode: 'view' | 'add' | 'edit' modal.classList.remove('hidden'); applyPcTypeSpecificUI(); - createIcons({ icons: { X, History, Paperclip } }); + createIcons({ icons: { X, History, Paperclip, Calendar } }); } function applyPcTypeSpecificUI() { @@ -199,9 +210,10 @@ function applyPcTypeSpecificUI() { const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement; const locationFields = document.querySelectorAll('.pc-location-field'); const etcGroup = document.getElementById('pc-위치-기타-group'); + const mainboardGroup = document.getElementById('pc-메인보드')?.closest('.form-group') as HTMLElement; // 초기화 (숨김) - [modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'none'; }); + [modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group, mainboardGroup].forEach(g => { if(g) g.style.display = 'none'; }); locationFields.forEach(el => (el as HTMLElement).style.display = 'none'); if (etcGroup) etcGroup.style.display = 'none'; @@ -214,7 +226,7 @@ function applyPcTypeSpecificUI() { locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); } else if (type === 'PC' || type === '노트북') { - [modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; }); + [modelGroup, mainboardGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; }); if (detailPurpose === '서버') { locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); } @@ -243,6 +255,7 @@ function fillFormData(asset: HardwareAsset) { setFieldValue('pc-현사용조직', asset.현사용조직); setFieldValue('pc-이전사용조직', asset.이전사용조직); setFieldValue('pc-상세용도', (asset as any).상세용도); + setFieldValue('pc-메인보드', (asset as any).메인보드 || ''); parseAndSetLocation(asset.위치, 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타'); @@ -278,6 +291,9 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) { bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타'); + // 날짜 마스킹 적용 + applyDateMask(document.getElementById('pc-구매일') as HTMLInputElement); + const handleClose = () => { closeModalsCb(); isEditMode = false; }; document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose); document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose); @@ -317,6 +333,7 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) { RAM: getFieldValue('pc-RAM'), SSD1: getFieldValue('pc-SSD1'), SSD2: getFieldValue('pc-SSD2'), + 메인보드: getFieldValue('pc-메인보드'), 구매일: getFieldValue('pc-구매일'), 금액: getFieldValue('pc-금액'), 납품업체: getFieldValue('pc-납품업체'), @@ -325,10 +342,7 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) { saveHardwareAsset(updated); onSave(); - isEditMode = false; - pcForm.classList.replace('is-edit-mode', 'is-view-mode'); - saveBtn.textContent = '수정'; - revertBtn?.classList.add('hidden'); + handleClose(); }); deleteBtn?.addEventListener('click', () => { diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index d4ab284..cedd895 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -27,10 +27,17 @@ const SW_MODAL_HTML = ` -
-
-

사용자 할당 현황

- -
-
+
+
@@ -250,10 +250,10 @@ function applySwTypeUI(type: string) { if (keyGroup) keyGroup.style.display = 'none'; if (typeGroup) typeGroup.style.display = 'flex'; if (expiryGroup) expiryGroup.style.display = 'flex'; - } else { + } else if (type === '영구SW') { if (keyGroup) keyGroup.style.display = 'flex'; if (typeGroup) typeGroup.style.display = 'none'; - if (expiryGroup) expiryGroup.style.display = 'flex'; + if (expiryGroup) expiryGroup.style.display = 'none'; // 영구는 유지보수 기간이 비고에 들어가는 경우가 많아 만료일 숨김 처리 } } } @@ -263,7 +263,7 @@ function fillSwFormData(asset: SoftwareAsset) { setFieldValue('sw-asset-type', asset.type); setFieldValue('sw-분야', asset.분야 || '업무공통'); setFieldValue('sw-법인', asset.법인); - setFieldValue('sw-자산번호', asset.자산번호 || ''); + setFieldValue('sw-부서', asset.부서 || ''); setFieldValue('sw-제품명', asset.제품명); setFieldValue('sw-수량', asset.수량); @@ -287,25 +287,10 @@ function fillSwFormData(asset: SoftwareAsset) { setFieldValue('sw-라이선스키', (asset as any).라이선스키 || ''); } - renderUserSummary(asset.id); renderSwHistory(asset.id); } -function renderUserSummary(swId: string) { - const container = document.getElementById('sw-assigned-users-summary'); - if (!container) return; - const userMapping = state.masterData.swUsers.find(u => u.sw_id === swId); - if (!userMapping || !userMapping.userData || userMapping.userData.length === 0) { - container.innerHTML = '
할당된 사용자가 없습니다.
'; - return; - } - container.innerHTML = userMapping.userData.map(u => ` -
- ${u[3] || '이름없음'} - ${u[1] || '부서없음'} -
- `).join(''); -} + function renderSwHistory(swId: string) { const container = document.getElementById('sw-history-list'); @@ -354,6 +339,11 @@ export function initSwModal(onSave: () => void, closeModals: () => void) { const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const userAssignBtn = document.getElementById('btn-open-sw-user')!; const btnOpenUpdate = document.getElementById('btn-open-sw-update')!; + const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement; + + typeSelect?.addEventListener('change', () => { + applySwTypeUI(typeSelect.value); + }); // 날짜 스마트 마스킹 적용 ['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => { @@ -392,7 +382,7 @@ export function initSwModal(onSave: () => void, closeModals: () => void) { 분야: getFieldValue('sw-분야'), 법인: getFieldValue('sw-법인'), 부서: getFieldValue('sw-부서'), - 자산번호: getFieldValue('sw-자산번호'), + 제품명: getFieldValue('sw-제품명'), 수량: parseInt(getFieldValue('sw-수량') || '0'), 금액: getFieldValue('sw-금액'), @@ -418,21 +408,27 @@ export function initSwModal(onSave: () => void, closeModals: () => void) { } // 데이터 저장 로직 (state 업데이트) + const oldType = currentSwAsset.type; + const newType = updated.type; + + // 유형이 변경된 경우 기존 리스트에서 삭제 + if (oldType !== newType) { + if (oldType === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== updated.id); + else if (oldType === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== updated.id); + else if (oldType === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== updated.id); + } + let targetList: SoftwareAsset[] = []; - if (type === '구독SW') targetList = state.masterData.subSw; - else if (type === '영구SW') targetList = state.masterData.permSw; - else if (type === '클라우드') targetList = state.masterData.cloud; + if (newType === '구독SW') targetList = state.masterData.subSw; + else if (newType === '영구SW') targetList = state.masterData.permSw; + else if (newType === '클라우드') targetList = state.masterData.cloud; const idx = targetList.findIndex(a => a.id === updated.id); if (idx > -1) targetList[idx] = updated; else targetList.push(updated); onSave(); - setEditLock('sw-asset-form', 'view', { - saveBtnId: 'btn-save-sw-asset', - revertBtnId: 'btn-revert-sw-edit' - }); - isEditMode = false; + closeModalAction(); }); deleteBtn.addEventListener('click', () => { diff --git a/src/components/Modal/SWUserModal.ts b/src/components/Modal/SWUserModal.ts index 834a6ef..2dee76b 100644 --- a/src/components/Modal/SWUserModal.ts +++ b/src/components/Modal/SWUserModal.ts @@ -27,8 +27,7 @@ const SW_USER_MODAL_HTML = ` - - + @@ -58,11 +57,7 @@ const SW_USER_MODAL_HTML = `
- - -
-
- +
@@ -135,14 +130,13 @@ function renderUserList() { tbody.innerHTML = ''; if (tempSwUsers.length === 0) { - tbody.innerHTML = '
'; + tbody.innerHTML = ''; return; } tempSwUsers.forEach((user, idx) => { const tr = document.createElement('tr'); tr.innerHTML = ` - @@ -187,7 +181,6 @@ function openUserEditSubModal(idx: number = -1) { if (idx > -1) { const user = tempSwUsers[idx]; - setFieldValue('new-user-법인', user.법인); setFieldValue('new-user-부서', user.부서); setFieldValue('new-user-직위', user.직위); setFieldValue('new-user-이름', user.이름); @@ -201,8 +194,6 @@ function openUserEditSubModal(idx: number = -1) { setFieldValue('new-user-시작일', ''); setFieldValue('new-user-종료일', ''); } - } else { - setFieldValue('new-user-법인', currentSwUserAsset?.법인); } subModal.classList.remove('hidden'); @@ -236,7 +227,7 @@ export function initSwUserModal(onSave: () => void, closeModals: () => void) { const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id); const newMapping = { sw_id: currentSwUserAsset!.id, - userData: tempSwUsers.map(u => [u.법인, u.부서, u.직위, u.이름, u.사용기간, u.신청서명]) + userData: tempSwUsers.map(u => ['', u.부서, u.직위, u.이름, u.사용기간, u.신청서명]) }; if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any; @@ -266,7 +257,6 @@ function saveUserDataToList() { const 신청서명 = 신청서Input.files && 신청서Input.files.length > 0 ? 신청서Input.files[0].name : (idx > -1 ? tempSwUsers[idx].신청서명 : ''); const userData: any = { - 법인: getFieldValue('new-user-법인'), 부서: getFieldValue('new-user-부서'), 직위: getFieldValue('new-user-직위'), 이름: getFieldValue('new-user-이름'), diff --git a/src/core/excelHandler.ts b/src/core/excelHandler.ts index af1439d..a7236a4 100644 --- a/src/core/excelHandler.ts +++ b/src/core/excelHandler.ts @@ -41,6 +41,7 @@ export interface HardwareAsset { 현사용조직?: string; 이전사용조직?: string; detail_purpose?: string; + 메인보드?: string; } export interface SoftwareAsset { @@ -109,7 +110,7 @@ export interface MasterAssetData { const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기']; const SW_TABS = ['구독SW', '영구SW', '클라우드']; -const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고']; +const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', '모델명', '메인보드', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고']; const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고']; const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고']; const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고']; @@ -148,7 +149,7 @@ export function downloadTemplate() { export function exportToExcel(masterData: MasterAssetData) { const wb = XLSX.utils.book_new(); const exportMap = [ - { tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a.법인, a.자산코드, a.사용자, a.위치, a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a.구매일, a.금액, a.납품업체, a.품의서명, a.비고] }, + { tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a.법인, a.자산코드, a.사용자, a.위치, a.모델명, a.메인보드, a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a.구매일, a.금액, a.납품업체, a.품의서명, a.비고] }, { tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a.법인, a.자산코드, a.구매일, a.storage유형 || '물리', a.용도, a.상세, a.현사용조직, a.이전사용조직, a.위치, a.담당자_정, a.담당자_부, a.IP주소, a.IP2, a.원격접속, a.서버ID, a.서버PW, a.모델명, a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a.모니터링, a.비고] }, { tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a.법인, a.storage유형, a.자산코드, a.명칭, a.위치, a.모델명, a.용량, a.담당자_정, a.담당자_부, a.IP주소, a.MACaddress, a.구매일, a.금액, a.납품업체, a.품의서명, a.비고] }, { tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a.법인, a.비품유형, a.자산코드, a.명칭, a.위치, a.관리자, a.IP주소, a.MACaddress, a.HW사양, a.OS, a.구매일, a.금액, a.납품업체, a.품의서명, a.비고] }, @@ -174,7 +175,7 @@ export async function parseExcel(file: File): Promise { workbook.SheetNames.forEach(sheetName => { const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[]; if (sheetName === '개인PC') { - rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', 관리자: '', MACaddress: '', OS: '', 명칭: '' })); + rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 메인보드: r['메인보드']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', 관리자: '', MACaddress: '', OS: '', 명칭: '' })); } else if (sheetName === '서버') { rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', 관리자: '', 명칭: '', MACaddress: '', HW사양: '', 금액: '', 납품업체: '', 품의서명: '' })); } else if (sheetName === '스토리지') { diff --git a/src/main.ts b/src/main.ts index 195146b..e8bae69 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,10 +20,14 @@ async function apiBatchSave(url: string, data: any[], label: string) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); - if (!response.ok) throw new Error(`${label} DB 저장 실패`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`); + } console.log(`✅ ${label} DB 저장 완료`); } catch (err) { console.error(`❌ ${label} DB 저장 오류:`, err); + alert(`${label} 저장 중 오류가 발생했습니다: ${err.message}`); } } @@ -37,15 +41,16 @@ const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/bat const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드'); const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자'); -// 모든 하드웨어 DB 동기화 -async function saveAllHardwareToDB() { - await Promise.all([ - savePcToDB(), - saveServerToDB(), - saveStorageToDB(), - saveEquipToDB(), - saveMobileToDB() - ]); +// 화면 갱신 통합 핸들러 (대시보드 vs 리스트) +function refreshView() { + const mainContent = document.getElementById('main-content')!; + if (!mainContent) return; + + if (state.activeSubTab === '대시보드') { + renderDashboard(mainContent); + } else { + renderSWTable(mainContent); + } } // 모든 소프트웨어 DB 동기화 @@ -56,6 +61,22 @@ async function saveAllSoftwareToDB() { saveCloudToDB(), saveSwUsersToDB() ]); + // 저장 후 최신 데이터 다시 로드 (정합성) + await loadMasterDataFromDB(); + refreshView(); +} + +// 모든 하드웨어 DB 동기화 +async function saveAllHardwareToDB() { + await Promise.all([ + savePcToDB(), + saveServerToDB(), + saveStorageToDB(), + saveEquipToDB(), + saveMobileToDB() + ]); + await loadMasterDataFromDB(); + refreshView(); } // --- App Initialization --- @@ -76,17 +97,15 @@ function initApp() { }); // 모달 초기화 - initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); - initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); + initPcModal(() => saveAllHardwareToDB(), closeAllModals); + initHwModal(() => saveAllHardwareToDB(), closeAllModals); - initSwModal(() => { - saveAllSoftwareToDB(); - renderSWTable(mainContent); - }, closeAllModals); + initSwModal(() => saveAllSoftwareToDB(), closeAllModals); initSwUserModal(() => { - saveSwUsersToDB(); - renderSWTable(mainContent); + saveSwUsersToDB().then(() => { + loadMasterDataFromDB().then(() => refreshView()); + }); }, closeAllModals); initDashboardDetailModal(); diff --git a/src/styles/modal.css b/src/styles/modal.css index 402d8a7..1c59335 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -53,15 +53,20 @@ border: none !important; } -.modal-header .btn-icon i, -.modal-header .btn-icon svg { - width: 20px !important; /* Original natural size */ - height: 20px !important; - stroke: #FFFFFF !important; +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--primary-color); + transition: opacity 0.2s; } -.modal-header .btn-icon:hover { - background: none !important; +.btn-icon:hover { + opacity: 0.7; } .modal-body { @@ -102,8 +107,7 @@ /* Modal Readonly/Edit Mode Interaction */ .grid-form.is-view-mode input, .grid-form.is-view-mode select, -.grid-form.is-view-mode textarea, -.grid-form.is-view-mode button { +.grid-form.is-view-mode textarea { border: none !important; background-color: transparent !important; padding-left: 0 !important; @@ -117,6 +121,13 @@ box-shadow: none !important; } +.grid-form.is-view-mode button { + pointer-events: none !important; + background: none !important; + border: none !important; + opacity: 0.8; +} + .grid-form.is-view-mode select::-ms-expand { display: none !important; } diff --git a/src/views/List/SwListView.ts b/src/views/List/SwListView.ts index 90b0ff0..6835961 100644 --- a/src/views/List/SwListView.ts +++ b/src/views/List/SwListView.ts @@ -55,7 +55,6 @@ export function renderSwList(container: HTMLElement) { - @@ -83,7 +82,7 @@ export function renderSwList(container: HTMLElement) { tbody.innerHTML = ''; if (filtered.length === 0) { - tbody.innerHTML = ``; + tbody.innerHTML = ``; return; } @@ -126,22 +125,11 @@ export function renderSwList(container: HTMLElement) { - `; tr.addEventListener('click', (e) => { - if (!(e.target as HTMLElement).closest('button')) { - openSwModal(asset, 'view'); - } + openSwModal(asset, 'view'); }); - tr.querySelector('.btn-edit')?.addEventListener('click', (e) => { - e.stopPropagation(); - openSwModal(asset, 'edit'); - }); - tr.querySelector('.btn-users')?.addEventListener('click', (e) => { e.stopPropagation(); openSwUserModal(asset); }); tbody.appendChild(tr); }); createIcons({ icons: { Edit2, Users, RefreshCcw } });
구매법인부서/팀조직 직위 이름 사용기간
할당된 사용자가 없습니다.
할당된 사용자가 없습니다.
${user.법인 || ''} ${user.부서 || ''} ${user.직위 || ''} ${user.이름 || ''}금액 수량 사용가능관리
검색 결과가 없습니다.
검색 결과가 없습니다.
${formatPrice(asset.금액)} ${qty} ${avail} - - -