refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화

- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 06:42:12 +09:00
parent 36f110c90a
commit 170adcc149
25 changed files with 6202 additions and 1606 deletions

View File

@@ -0,0 +1,553 @@
/**
* Workplace Management - Module Loader
* 작업장 관리 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class WorkplaceController {
constructor() {
this.state = window.WorkplaceState;
this.api = window.WorkplaceAPI;
this.utils = window.WorkplaceUtils;
this.initialized = false;
console.log('[WorkplaceController] 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[WorkplaceController] 이미 초기화됨');
return;
}
console.log('🏗️ 작업장 관리 페이지 초기화 시작');
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 모든 데이터 로드
await this.loadAllData();
this.initialized = true;
console.log('[WorkplaceController] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await this.api.loadAllData();
this.renderCategoryTabs();
this.renderWorkplaces();
this.updateStatistics();
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 탭 렌더링
*/
renderCategoryTabs() {
const tabsContainer = document.getElementById('categoryTabs');
if (!tabsContainer) return;
const categories = this.state.categories;
const workplaces = this.state.workplaces;
const currentCategoryId = this.state.currentCategoryId;
let tabsHtml = `
<button class="wp-tab-btn ${currentCategoryId === '' ? 'active' : ''}"
data-category=""
onclick="switchCategory('')">
<span class="wp-tab-icon">🏗️</span>
전체
<span class="wp-tab-count">${workplaces.length}</span>
</button>
`;
categories.forEach(category => {
const count = workplaces.filter(w => w.category_id === category.category_id).length;
const isActive = currentCategoryId === category.category_id;
tabsHtml += `
<button class="wp-tab-btn ${isActive ? 'active' : ''}"
data-category="${category.category_id}"
onclick="switchCategory(${category.category_id})">
<span class="wp-tab-icon">🏭</span>
${category.category_name}
<span class="wp-tab-count">${count}</span>
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
/**
* 카테고리 전환
*/
async switchCategory(categoryId) {
this.state.setCurrentCategory(categoryId);
this.renderCategoryTabs();
this.renderWorkplaces();
const layoutMapSection = document.getElementById('layoutMapSection');
const selectedCategoryName = document.getElementById('selectedCategoryName');
if (categoryId && layoutMapSection) {
const category = this.state.getCurrentCategory();
if (category) {
layoutMapSection.style.display = 'block';
if (selectedCategoryName) {
selectedCategoryName.textContent = category.category_name;
}
await this.updateLayoutPreview(category);
}
} else if (layoutMapSection) {
layoutMapSection.style.display = 'none';
}
}
/**
* 레이아웃 미리보기 업데이트
*/
async updateLayoutPreview(category) {
const previewDiv = document.getElementById('layoutMapPreview');
if (!previewDiv) return;
if (category.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
previewDiv.innerHTML = `
<div style="text-align: center;">
<canvas id="previewCanvas" style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: default;"></canvas>
<p style="color: #64748b; margin-top: 12px; font-size: 14px;">
클릭하여 작업장 영역을 수정하려면 "지도 설정" 버튼을 누르세요
</p>
</div>
`;
await this.loadImageWithRegions(fullImageUrl, category.category_id);
} else {
previewDiv.innerHTML = `
<div style="padding: 40px;">
<span style="font-size: 48px;">🗺️</span>
<p style="color: #94a3b8; margin-top: 16px;">
이 공장의 레이아웃 이미지가 아직 등록되지 않았습니다
</p>
<p style="color: #cbd5e1; font-size: 14px; margin-top: 8px;">
"지도 설정" 버튼을 눌러 레이아웃 이미지를 업로드하고 작업장 위치를 지정하세요
</p>
</div>
`;
}
}
/**
* 이미지와 영역을 캔버스에 로드
*/
async loadImageWithRegions(imageUrl, categoryId) {
const canvas = document.getElementById('previewCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const img = new Image();
const self = this;
img.onload = async function() {
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const regions = await self.api.loadMapRegions(categoryId);
regions.forEach(region => {
const x1 = (region.x_start / 100) * canvas.width;
const y1 = (region.y_start / 100) * canvas.height;
const x2 = (region.x_end / 100) * canvas.width;
const y2 = (region.y_end / 100) * canvas.height;
const width = x2 - x1;
const height = y2 - y1;
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x1, y1, width, height);
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
ctx.fillRect(x1, y1, width, height);
if (region.workplace_name) {
ctx.font = 'bold 14px sans-serif';
const textMetrics = ctx.measureText(region.workplace_name);
const textPadding = 4;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x1 + 5, y1 + 5, textMetrics.width + textPadding * 2, 20);
ctx.fillStyle = '#10b981';
ctx.fillText(region.workplace_name, x1 + 5 + textPadding, y1 + 20);
}
});
if (regions.length > 0) {
console.log(`✅ 레이아웃 미리보기에 ${regions.length}개 영역 표시 완료`);
}
} catch (error) {
console.error('영역 로드 오류:', error);
}
};
img.onerror = function() {
console.error('이미지 로드 실패:', imageUrl);
};
img.src = imageUrl;
}
/**
* 작업장 렌더링
*/
renderWorkplaces() {
const grid = document.getElementById('workplaceGrid');
if (!grid) return;
const filtered = this.state.getFilteredWorkplaces();
if (filtered.length === 0) {
grid.innerHTML = `
<div class="wp-empty-state">
<div class="wp-empty-icon">🏗️</div>
<h3 class="wp-empty-title">등록된 작업장이 없습니다</h3>
<p class="wp-empty-description">"작업장 추가" 버튼을 눌러 작업장을 등록해보세요</p>
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
<span class="wp-btn-icon"></span>
첫 작업장 추가하기
</button>
</div>
`;
return;
}
let gridHtml = '';
filtered.forEach(workplace => {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
const purposeIcon = this.utils.getPurposeIcon(workplace.workplace_purpose);
gridHtml += `
<div class="wp-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
<div class="wp-card-header">
<div class="wp-card-icon">${purposeIcon}</div>
<div class="wp-card-info">
<h3 class="wp-card-title">${workplace.workplace_name}</h3>
<div class="wp-card-tags">
${workplace.category_id ? `<span class="wp-card-tag factory">🏭 ${categoryName}</span>` : ''}
${workplace.workplace_purpose ? `<span class="wp-card-tag purpose">${workplace.workplace_purpose}</span>` : ''}
</div>
</div>
<div class="wp-card-actions">
<button class="wp-card-btn map" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리">
🗺️
</button>
<button class="wp-card-btn edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
<button class="wp-card-btn delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
🗑️
</button>
</div>
</div>
${workplace.description ? `<p class="wp-card-description">${workplace.description}</p>` : ''}
<div class="wp-card-map" id="workplace-map-${workplace.workplace_id}"></div>
<div class="wp-card-meta">
<span class="wp-card-date">등록: ${this.utils.formatDate(workplace.created_at)}</span>
${workplace.updated_at !== workplace.created_at ? `<span class="wp-card-date">수정: ${this.utils.formatDate(workplace.updated_at)}</span>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
filtered.forEach(workplace => {
if (workplace.category_id) {
this.loadWorkplaceMapThumbnail(workplace);
}
});
}
/**
* 작업장 카드에 지도 썸네일 로드
*/
async loadWorkplaceMapThumbnail(workplace) {
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
if (!thumbnailDiv) return;
if (workplace.layout_image) {
const fullImageUrl = this.utils.getFullImageUrl(workplace.layout_image);
let equipmentCount = 0;
try {
const eqResponse = await window.apiCall(`/equipments/workplace/${workplace.workplace_id}`, 'GET');
if (eqResponse && eqResponse.success && Array.isArray(eqResponse.data)) {
equipmentCount = eqResponse.data.filter(eq => eq.map_x_percent != null).length;
}
} catch (e) {
console.debug('설비 정보 로드 실패');
}
const canvasId = `layout-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); border-radius: 8px; border: 1px solid #bae6fd; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 12px; color: #0369a1; font-weight: 600;">📍 작업장 지도</span>
${equipmentCount > 0 ? `<span style="font-size: 11px; background: #10b981; color: white; padding: 2px 8px; border-radius: 10px;">설비 ${equipmentCount}개</span>` : ''}
</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.12);"></canvas>
<div style="font-size: 11px; color: #64748b; margin-top: 8px;">클릭하여 지도 관리</div>
</div>
`;
await this.loadWorkplaceCanvasWithEquipments(workplace.workplace_id, fullImageUrl, canvasId);
return;
}
try {
const response = await this.api.loadWorkplaceMapRegion(workplace.workplace_id);
if (!response || (!response.success && !response.region_id)) {
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 16px; background: #f9fafb; border-radius: 8px; border: 2px dashed #cbd5e1; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 24px; margin-bottom: 8px;">🗺️</div>
<div style="font-size: 12px; color: #64748b;">클릭하여 지도 설정</div>
</div>
`;
return;
}
const region = response.success ? response.data : response;
if (!region || region.x_start === undefined || region.y_start === undefined ||
region.x_end === undefined || region.y_end === undefined) {
return;
}
const category = this.state.categories.find(c => c.category_id === workplace.category_id);
if (!category || !category.layout_image) return;
const fullImageUrl = this.utils.getFullImageUrl(category.layout_image);
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 10px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb; cursor: pointer;" onclick="openWorkplaceMapModal(${workplace.workplace_id})">
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 공장 지도 내 위치</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1);"></canvas>
<div style="font-size: 11px; color: #94a3b8; margin-top: 6px;">클릭하여 상세 지도 설정</div>
</div>
`;
const img = new Image();
img.onload = function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const x1 = (region.x_start / 100) * img.width;
const y1 = (region.y_start / 100) * img.height;
const x2 = (region.x_end / 100) * img.width;
const y2 = (region.y_end / 100) * img.height;
const regionWidth = x2 - x1;
const regionHeight = y2 - y1;
const maxThumbWidth = 350;
const scale = regionWidth > maxThumbWidth ? maxThumbWidth / regionWidth : 1;
canvas.width = regionWidth * scale;
canvas.height = regionHeight * scale;
ctx.drawImage(
img,
x1, y1, regionWidth, regionHeight,
0, 0, canvas.width, canvas.height
);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
};
img.onerror = function() {
thumbnailDiv.innerHTML = '';
};
img.src = fullImageUrl;
} catch (error) {
console.debug(`작업장 ${workplace.workplace_id}의 지도 영역 없음`);
}
}
/**
* 작업장 캔버스에 설비 영역 함께 그리기
*/
async loadWorkplaceCanvasWithEquipments(workplaceId, imageUrl, canvasId) {
const img = new Image();
img.onload = async function() {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const maxThumbWidth = 400;
const scale = img.width > maxThumbWidth ? maxThumbWidth / img.width : 1;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data.filter(eq => eq.map_x_percent != null);
}
equipments.forEach(eq => {
const x = (parseFloat(eq.map_x_percent) / 100) * canvas.width;
const y = (parseFloat(eq.map_y_percent) / 100) * canvas.height;
const width = (parseFloat(eq.map_width_percent || 10) / 100) * canvas.width;
const height = (parseFloat(eq.map_height_percent || 10) / 100) * canvas.height;
ctx.fillStyle = 'rgba(16, 185, 129, 0.2)';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
if (eq.equipment_code) {
ctx.font = 'bold 10px sans-serif';
const textMetrics = ctx.measureText(eq.equipment_code);
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(x + 2, y + 2, textMetrics.width + 6, 14);
ctx.fillStyle = '#047857';
ctx.fillText(eq.equipment_code, x + 5, y + 12);
}
});
} catch (error) {
console.debug('설비 영역 로드 실패');
}
};
img.src = imageUrl;
}
/**
* 통계 업데이트
*/
async updateStatistics() {
const stats = this.state.getStatistics();
const factoryCountEl = document.getElementById('factoryCount');
const totalCountEl = document.getElementById('totalCount');
const activeCountEl = document.getElementById('activeCount');
const equipmentCountEl = document.getElementById('equipmentCount');
if (factoryCountEl) factoryCountEl.textContent = stats.factoryTotal;
if (totalCountEl) totalCountEl.textContent = stats.total;
if (activeCountEl) activeCountEl.textContent = stats.active;
if (equipmentCountEl) {
try {
const equipments = await this.api.loadAllEquipments();
equipmentCountEl.textContent = equipments.length;
} catch (e) {
equipmentCountEl.textContent = '-';
}
}
const sectionTotalEl = document.getElementById('sectionTotalCount');
const sectionActiveEl = document.getElementById('sectionActiveCount');
if (sectionTotalEl) sectionTotalEl.textContent = stats.filteredTotal;
if (sectionActiveEl) sectionActiveEl.textContent = stats.filteredActive;
}
/**
* 전체 새로고침
*/
async refreshWorkplaces() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await this.loadAllData();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await this.loadAllData();
}
window.showToast?.('데이터가 새로고침되었습니다.', 'success');
}
/**
* 디버그
*/
debug() {
console.log('[WorkplaceController] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.WorkplaceController = new WorkplaceController();
// 하위 호환성: 기존 전역 함수들
window.switchCategory = (categoryId) => window.WorkplaceController.switchCategory(categoryId);
window.renderCategoryTabs = () => window.WorkplaceController.renderCategoryTabs();
window.renderWorkplaces = () => window.WorkplaceController.renderWorkplaces();
window.updateStatistics = () => window.WorkplaceController.updateStatistics();
window.refreshWorkplaces = () => window.WorkplaceController.refreshWorkplaces();
window.loadAllData = () => window.WorkplaceController.loadAllData();
window.updateLayoutPreview = (category) => window.WorkplaceController.updateLayoutPreview(category);
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
window.WorkplaceController.init();
}, 100);
});
console.log('[Module] workplace-management/index.js 로드 완료');