![]()
@@ -2041,6 +2254,7 @@
+
diff --git a/user-management/web/static/css/tkuser.css b/user-management/web/static/css/tkuser.css
index abbda6f..d8ea1c5 100644
--- a/user-management/web/static/css/tkuser.css
+++ b/user-management/web/static/css/tkuser.css
@@ -6,6 +6,8 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; back
.tab-btn.active { background: #334155; color: white; }
.tab-btn:not(.active) { color: #64748b; }
.tab-btn:not(.active):hover { background: #e2e8f0; }
+.tab-group-label { font-size: 0.65rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; padding: 0 0.25rem; white-space: nowrap; flex-shrink: 0; }
+.tab-divider { width: 1px; height: 1.25rem; background: #e2e8f0; margin: 0 0.25rem; flex-shrink: 0; }
.system-section { border-left: 4px solid; }
.system-section.system1 { border-color: #3b82f6; }
.system-section.system3 { border-color: #8b5cf6; }
@@ -27,6 +29,8 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; back
}
#tabNav::-webkit-scrollbar { display: none; }
#tabNav button { white-space: nowrap; flex-shrink: 0; font-size: 0.8rem; padding: 0.375rem 0.75rem; }
+ .tab-group-label { font-size: 0.6rem; padding: 0 0.125rem; }
+ .tab-divider { margin: 0 0.125rem; }
.tab-scroll-hint {
position: absolute; right: 0; top: 0; bottom: 0; width: 2rem;
background: linear-gradient(to right, transparent, #fff);
diff --git a/user-management/web/static/js/tkuser-core.js b/user-management/web/static/js/tkuser-core.js
index e21c9d1..7feb4ff 100644
--- a/user-management/web/static/js/tkuser-core.js
+++ b/user-management/web/static/js/tkuser-core.js
@@ -194,7 +194,7 @@ async function init() {
// URL ?tab= 파라미터로 탭 자동 전환 (화이트리스트 + URL 정리)
const ALLOWED_TABS = ['users','projects','workplaces','workers','departments',
'permissions','issueTypes','tasks','vacations','partners','vendors',
- 'consumables','notificationRecipients'];
+ 'consumables','notificationRecipients','equipments'];
const urlTab = new URLSearchParams(location.search).get('tab');
if (urlTab && ALLOWED_TABS.includes(urlTab)) {
const tabBtn = document.querySelector(`.tab-btn[data-tab="${urlTab}"]`);
diff --git a/user-management/web/static/js/tkuser-equipments.js b/user-management/web/static/js/tkuser-equipments.js
new file mode 100644
index 0000000..09dd6c4
--- /dev/null
+++ b/user-management/web/static/js/tkuser-equipments.js
@@ -0,0 +1,253 @@
+/* ===== tkuser 설비(Equipment) CRUD ===== */
+let equipmentsLoaded = false;
+let equipmentsList = [];
+let selectedEquipmentIdTkuser = null;
+
+const EQUIPMENT_STATUS_MAP = {
+ active: { label: '활성', cls: 'bg-green-100 text-green-700' },
+ maintenance: { label: '정비중', cls: 'bg-yellow-100 text-yellow-700' },
+ inactive: { label: '비활성', cls: 'bg-gray-100 text-gray-400' }
+};
+
+async function loadEquipmentsTab() {
+ if (equipmentsLoaded) return;
+ equipmentsLoaded = true;
+ if (currentUser && ['admin', 'system'].includes(currentUser.role)) {
+ document.getElementById('btnAddEquipment')?.classList.remove('hidden');
+ }
+ await Promise.all([loadEquipmentFilters(), loadEquipmentsList()]);
+}
+
+async function loadEquipmentFilters() {
+ try {
+ // 작업장 필터
+ const wRes = await api('/workplaces');
+ const workplaces = wRes.data || [];
+ const wSel = document.getElementById('equipmentFilterWorkplace');
+ if (wSel) {
+ const current = wSel.value;
+ wSel.innerHTML = '
' +
+ workplaces.map(w => `
`).join('');
+ wSel.value = current;
+ }
+ // 유형 필터
+ const tRes = await api('/equipments/types');
+ const types = tRes.data || [];
+ const tSel = document.getElementById('equipmentFilterType');
+ if (tSel) {
+ const current = tSel.value;
+ tSel.innerHTML = '
' +
+ types.map(t => `
`).join('');
+ tSel.value = current;
+ }
+ // 모달 select 채우기 (작업장)
+ ['newEquipmentWorkplaceTkuser', 'editEquipmentWorkplaceTkuser'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.innerHTML = '
' +
+ workplaces.map(w => `
`).join('');
+ });
+ } catch (e) { /* 필터 로드 실패는 무시 */ }
+}
+
+async function loadEquipmentsList() {
+ try {
+ const workplaceId = document.getElementById('equipmentFilterWorkplace')?.value || '';
+ const eqType = document.getElementById('equipmentFilterType')?.value || '';
+ const status = document.getElementById('equipmentFilterStatus')?.value || '';
+ const search = document.getElementById('equipmentSearchTkuser')?.value?.trim() || '';
+ const params = new URLSearchParams();
+ if (workplaceId) params.set('workplace_id', workplaceId);
+ if (eqType) params.set('equipment_type', eqType);
+ if (status) params.set('status', status);
+ if (search) params.set('search', search);
+ const r = await api('/equipments?' + params.toString());
+ equipmentsList = r.data || [];
+ renderEquipmentsListTkuser();
+ } catch (e) {
+ document.getElementById('equipmentsListTkuser').innerHTML = `
`;
+ }
+}
+
+function renderEquipmentsListTkuser() {
+ const c = document.getElementById('equipmentsListTkuser');
+ if (!equipmentsList.length) {
+ c.innerHTML = '
등록된 설비가 없습니다.
';
+ return;
+ }
+ const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
+ c.innerHTML = equipmentsList.map(eq => {
+ const st = EQUIPMENT_STATUS_MAP[eq.status] || EQUIPMENT_STATUS_MAP.active;
+ return `
+
+
+ ${escHtml(eq.equipment_name)}
+ ${st.label}
+
+
+ ${escHtml(eq.equipment_code)}
+ ${eq.workplace_name ? `· ${escHtml(eq.workplace_name)}` : ''}
+ ${eq.equipment_type ? `· ${escHtml(eq.equipment_type)}` : ''}
+
+
+ ${isAdmin ? `
+
+
+
` : ''}
+
`;
+ }).join('');
+}
+
+async function selectEquipmentTkuser(id) {
+ selectedEquipmentIdTkuser = id;
+ renderEquipmentsListTkuser();
+ try {
+ const r = await api(`/equipments/${id}`);
+ const eq = r.data;
+ renderEquipmentDetailTkuser(eq);
+ document.getElementById('equipmentDetailTkuser').classList.remove('hidden');
+ document.getElementById('equipmentEmptyTkuser').classList.add('hidden');
+ } catch (e) {
+ showToast('상세 조회 실패: ' + e.message, 'error');
+ }
+}
+
+function renderEquipmentDetailTkuser(eq) {
+ const st = EQUIPMENT_STATUS_MAP[eq.status] || EQUIPMENT_STATUS_MAP.active;
+ const installDate = eq.installation_date ? eq.installation_date.substring(0, 10) : '-';
+ document.getElementById('equipmentDetailTkuser').innerHTML = `
+
+
+
${escHtml(eq.equipment_name)}
+ ${st.label}
+
+
+
관리번호: ${escHtml(eq.equipment_code)}
+
설비유형: ${escHtml(eq.equipment_type) || '-'}
+
작업장: ${escHtml(eq.workplace_name) || '-'}
+
제조사: ${escHtml(eq.manufacturer) || '-'}
+
모델명: ${escHtml(eq.model_name) || '-'}
+
시리얼번호: ${escHtml(eq.serial_number) || '-'}
+
설치일: ${installDate}
+
공급업체: ${escHtml(eq.supplier) || '-'}
+ ${eq.purchase_price ? `
구매가격: ${Number(eq.purchase_price).toLocaleString()}원
` : ''}
+ ${eq.specifications ? `
사양: ${escHtml(eq.specifications)}
` : ''}
+ ${eq.notes ? `
비고: ${escHtml(eq.notes)}
` : ''}
+
+
`;
+}
+
+/* ===== 설비 등록 ===== */
+function openAddEquipmentTkuser() {
+ // 다음 코드 자동 생성
+ api('/equipments/next-code').then(r => {
+ document.getElementById('newEquipmentCodeTkuser').value = r.data || '';
+ }).catch(() => {});
+ document.getElementById('addEquipmentModalTkuser').classList.remove('hidden');
+}
+function closeAddEquipmentTkuser() { document.getElementById('addEquipmentModalTkuser').classList.add('hidden'); document.getElementById('addEquipmentFormTkuser').reset(); }
+
+async function submitAddEquipmentTkuser(e) {
+ e.preventDefault();
+ const data = {
+ equipment_code: document.getElementById('newEquipmentCodeTkuser').value.trim(),
+ equipment_name: document.getElementById('newEquipmentNameTkuser').value.trim(),
+ equipment_type: document.getElementById('newEquipmentTypeTkuser').value.trim() || null,
+ workplace_id: document.getElementById('newEquipmentWorkplaceTkuser').value || null,
+ manufacturer: document.getElementById('newEquipmentManufacturerTkuser').value.trim() || null,
+ model_name: document.getElementById('newEquipmentModelTkuser').value.trim() || null,
+ serial_number: document.getElementById('newEquipmentSerialTkuser').value.trim() || null,
+ installation_date: document.getElementById('newEquipmentInstallDateTkuser').value || null,
+ supplier: document.getElementById('newEquipmentSupplierTkuser').value.trim() || null,
+ purchase_price: document.getElementById('newEquipmentPriceTkuser').value || null,
+ status: document.getElementById('newEquipmentStatusTkuser').value || 'active',
+ specifications: document.getElementById('newEquipmentSpecsTkuser').value.trim() || null,
+ notes: document.getElementById('newEquipmentNotesTkuser').value.trim() || null,
+ };
+ if (!data.equipment_code) { showToast('관리번호는 필수입니다', 'error'); return; }
+ if (!data.equipment_name) { showToast('설비명은 필수입니다', 'error'); return; }
+ try {
+ await api('/equipments', { method: 'POST', body: JSON.stringify(data) });
+ showToast('설비가 등록되었습니다');
+ closeAddEquipmentTkuser();
+ await loadEquipmentFilters();
+ await loadEquipmentsList();
+ } catch (e) { showToast(e.message, 'error'); }
+}
+
+/* ===== 설비 수정 ===== */
+function openEditEquipmentTkuser(id) {
+ const eq = equipmentsList.find(x => x.equipment_id === id);
+ if (!eq) return;
+ document.getElementById('editEquipmentIdTkuser').value = eq.equipment_id;
+ document.getElementById('editEquipmentCodeTkuser').value = eq.equipment_code;
+ document.getElementById('editEquipmentNameTkuser').value = eq.equipment_name;
+ document.getElementById('editEquipmentTypeTkuser').value = eq.equipment_type || '';
+ document.getElementById('editEquipmentWorkplaceTkuser').value = eq.workplace_id || '';
+ document.getElementById('editEquipmentManufacturerTkuser').value = eq.manufacturer || '';
+ document.getElementById('editEquipmentModelTkuser').value = eq.model_name || '';
+ document.getElementById('editEquipmentSerialTkuser').value = eq.serial_number || '';
+ document.getElementById('editEquipmentInstallDateTkuser').value = eq.installation_date ? eq.installation_date.substring(0, 10) : '';
+ document.getElementById('editEquipmentSupplierTkuser').value = eq.supplier || '';
+ document.getElementById('editEquipmentPriceTkuser').value = eq.purchase_price || '';
+ document.getElementById('editEquipmentStatusTkuser').value = eq.status || 'active';
+ document.getElementById('editEquipmentSpecsTkuser').value = eq.specifications || '';
+ document.getElementById('editEquipmentNotesTkuser').value = eq.notes || '';
+ document.getElementById('editEquipmentModalTkuser').classList.remove('hidden');
+}
+function closeEditEquipmentTkuser() { document.getElementById('editEquipmentModalTkuser').classList.add('hidden'); }
+
+async function submitEditEquipmentTkuser(e) {
+ e.preventDefault();
+ const id = document.getElementById('editEquipmentIdTkuser').value;
+ const data = {
+ equipment_code: document.getElementById('editEquipmentCodeTkuser').value.trim(),
+ equipment_name: document.getElementById('editEquipmentNameTkuser').value.trim(),
+ equipment_type: document.getElementById('editEquipmentTypeTkuser').value.trim() || null,
+ workplace_id: document.getElementById('editEquipmentWorkplaceTkuser').value || null,
+ manufacturer: document.getElementById('editEquipmentManufacturerTkuser').value.trim() || null,
+ model_name: document.getElementById('editEquipmentModelTkuser').value.trim() || null,
+ serial_number: document.getElementById('editEquipmentSerialTkuser').value.trim() || null,
+ installation_date: document.getElementById('editEquipmentInstallDateTkuser').value || null,
+ supplier: document.getElementById('editEquipmentSupplierTkuser').value.trim() || null,
+ purchase_price: document.getElementById('editEquipmentPriceTkuser').value || null,
+ status: document.getElementById('editEquipmentStatusTkuser').value || 'active',
+ specifications: document.getElementById('editEquipmentSpecsTkuser').value.trim() || null,
+ notes: document.getElementById('editEquipmentNotesTkuser').value.trim() || null,
+ };
+ try {
+ await api(`/equipments/${id}`, { method: 'PUT', body: JSON.stringify(data) });
+ showToast('수정되었습니다');
+ closeEditEquipmentTkuser();
+ await loadEquipmentFilters();
+ await loadEquipmentsList();
+ if (selectedEquipmentIdTkuser == id) selectEquipmentTkuser(id);
+ } catch (e) { showToast(e.message, 'error'); }
+}
+
+/* ===== 설비 삭제 ===== */
+async function deleteEquipmentTkuser(id, name) {
+ if (!confirm(`"${name}" 설비를 삭제하시겠습니까?`)) return;
+ try {
+ await api(`/equipments/${id}`, { method: 'DELETE' });
+ showToast('삭제 완료');
+ await loadEquipmentsList();
+ if (selectedEquipmentIdTkuser === id) {
+ document.getElementById('equipmentDetailTkuser').classList.add('hidden');
+ document.getElementById('equipmentEmptyTkuser').classList.remove('hidden');
+ selectedEquipmentIdTkuser = null;
+ }
+ } catch (e) { showToast(e.message, 'error'); }
+}
+
+/* ===== 필터 ===== */
+let equipmentSearchTimeout;
+function filterEquipmentsTkuser() {
+ clearTimeout(equipmentSearchTimeout);
+ equipmentSearchTimeout = setTimeout(loadEquipmentsList, 300);
+}
+
+// 검색/필터 이벤트 + 모달 폼 이벤트
+document.addEventListener('DOMContentLoaded', () => {
+ document.getElementById('addEquipmentFormTkuser')?.addEventListener('submit', submitAddEquipmentTkuser);
+ document.getElementById('editEquipmentFormTkuser')?.addEventListener('submit', submitEditEquipmentTkuser);
+});
diff --git a/user-management/web/static/js/tkuser-tabs.js b/user-management/web/static/js/tkuser-tabs.js
index bfb3aab..d1b3162 100644
--- a/user-management/web/static/js/tkuser-tabs.js
+++ b/user-management/web/static/js/tkuser-tabs.js
@@ -20,7 +20,7 @@ function switchTab(name, event) {
const headerInner = document.getElementById('headerInner');
const wideClass = 'max-w-[1600px]';
const defaultClass = 'max-w-7xl';
- if (name === 'workplaces' || name === 'tasks' || name === 'vacations') {
+ if (name === 'workplaces' || name === 'tasks' || name === 'vacations' || name === 'equipments') {
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(defaultClass); el.classList.add(wideClass); });
} else {
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(wideClass); el.classList.add(defaultClass); });
@@ -37,4 +37,5 @@ function switchTab(name, event) {
if (name === 'vendors' && !vendorsLoaded) loadVendorsTab();
if (name === 'consumables' && !consumablesLoaded) loadConsumablesTab();
if (name === 'notificationRecipients' && !nrLoaded) loadNotificationRecipientsTab();
+ if (name === 'equipments' && !equipmentsLoaded) loadEquipmentsTab();
}