feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

@@ -0,0 +1,329 @@
/**
* Workplace Management - API Client
* 작업장 관리 관련 모든 API 호출을 관리
*/
class WorkplaceAPI {
constructor() {
this.state = window.WorkplaceState;
this.utils = window.WorkplaceUtils;
console.log('[WorkplaceAPI] 초기화 완료');
}
/**
* 모든 데이터 로드
*/
async loadAllData() {
try {
await Promise.all([
this.loadCategories(),
this.loadWorkplaces()
]);
console.log('✅ 모든 데이터 로드 완료');
} catch (error) {
console.error('데이터 로딩 오류:', error);
window.showToast?.('데이터를 불러오는데 실패했습니다.', 'error');
}
}
/**
* 카테고리 목록 로드
*/
async loadCategories() {
try {
const response = await window.apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
categoryData = response.data;
} else if (Array.isArray(response)) {
categoryData = response;
}
this.state.categories = categoryData;
console.log(`✅ 카테고리 ${this.state.categories.length}개 로드 완료`);
return categoryData;
} catch (error) {
console.error('카테고리 로딩 오류:', error);
this.state.categories = [];
return [];
}
}
/**
* 카테고리 저장 (생성/수정)
*/
async saveCategory(categoryId, categoryData) {
try {
let response;
if (categoryId) {
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('카테고리 저장 오류:', error);
throw error;
}
}
/**
* 카테고리 삭제
*/
async deleteCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('카테고리 삭제 오류:', error);
throw error;
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
workplaceData = response.data;
} else if (Array.isArray(response)) {
workplaceData = response;
}
this.state.workplaces = workplaceData;
console.log(`✅ 작업장 ${this.state.workplaces.length}개 로드 완료`);
return workplaceData;
} catch (error) {
console.error('작업장 로딩 오류:', error);
this.state.workplaces = [];
return [];
}
}
/**
* 작업장 저장 (생성/수정)
*/
async saveWorkplace(workplaceId, workplaceData) {
try {
let response;
if (workplaceId) {
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
response = await window.apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
return response;
}
throw new Error(response?.message || '저장에 실패했습니다.');
} catch (error) {
console.error('작업장 저장 오류:', error);
throw error;
}
}
/**
* 작업장 삭제
*/
async deleteWorkplace(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
return response;
}
throw new Error(response?.message || '삭제에 실패했습니다.');
} catch (error) {
console.error('작업장 삭제 오류:', error);
throw error;
}
}
/**
* 카테고리의 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
let regions = [];
if (response && response.success && Array.isArray(response.data)) {
regions = response.data;
} else if (Array.isArray(response)) {
regions = response;
}
return regions;
} catch (error) {
console.error('지도 영역 로드 오류:', error);
return [];
}
}
/**
* 작업장의 지도 영역 로드
*/
async loadWorkplaceMapRegion(workplaceId) {
try {
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplaceId}`, 'GET');
return response;
} catch (error) {
console.error('작업장 지도 영역 로드 오류:', error);
return null;
}
}
/**
* 작업장의 설비 목록 로드
*/
async loadWorkplaceEquipments(workplaceId) {
try {
const response = await window.apiCall(`/equipments/workplace/${workplaceId}`, 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
// 지도 영역이 있는 설비만 workplaceEquipmentRegions에 추가
this.state.workplaceEquipmentRegions = equipments
.filter(eq => eq.map_x_percent != null && eq.map_y_percent != null)
.map(eq => ({
equipment_id: eq.equipment_id,
equipment_name: eq.equipment_name,
equipment_code: eq.equipment_code,
x_percent: parseFloat(eq.map_x_percent),
y_percent: parseFloat(eq.map_y_percent),
width_percent: parseFloat(eq.map_width_percent) || 10,
height_percent: parseFloat(eq.map_height_percent) || 10
}));
this.state.existingEquipments = equipments;
console.log(`✅ 작업장 ${workplaceId}의 설비 ${equipments.length}개 로드 완료`);
return equipments;
} catch (error) {
console.error('설비 로드 오류:', error);
this.state.workplaceEquipmentRegions = [];
this.state.existingEquipments = [];
return [];
}
}
/**
* 전체 설비 목록 로드
*/
async loadAllEquipments() {
try {
const response = await window.apiCall('/equipments', 'GET');
let equipments = [];
if (response && response.success && Array.isArray(response.data)) {
equipments = response.data;
} else if (Array.isArray(response)) {
equipments = response;
}
this.state.allEquipments = equipments;
console.log(`✅ 전체 설비 ${this.state.allEquipments.length}개 로드 완료`);
return equipments;
} catch (error) {
console.error('전체 설비 로드 오류:', error);
this.state.allEquipments = [];
return [];
}
}
/**
* 설비 지도 위치 업데이트
*/
async updateEquipmentMapPosition(equipmentId, positionData) {
try {
const response = await window.apiCall(`/equipments/${equipmentId}/map-position`, 'PATCH', positionData);
if (!response || !response.success) {
throw new Error(response?.message || '위치 저장 실패');
}
return response;
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
throw error;
}
}
/**
* 새 설비 생성
*/
async createEquipment(equipmentData) {
try {
const response = await window.apiCall('/equipments', 'POST', equipmentData);
if (!response || !response.success) {
throw new Error(response?.message || '설비 생성 실패');
}
return response;
} catch (error) {
console.error('설비 생성 오류:', error);
throw error;
}
}
/**
* 다음 관리번호 조회
*/
async getNextEquipmentCode() {
try {
const response = await window.apiCall('/equipments/next-code', 'GET');
if (response && response.success) {
return response.data.next_code;
}
return null;
} catch (error) {
console.error('다음 관리번호 조회 실패:', error);
return null;
}
}
/**
* 작업장 레이아웃 이미지 업로드
*/
async uploadWorkplaceLayout(workplaceId, formData) {
try {
const response = await fetch(
`${this.utils.getApiBaseUrl()}/workplaces/${workplaceId}/layout-image`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
}
);
return await response.json();
} catch (error) {
console.error('레이아웃 이미지 업로드 오류:', error);
throw error;
}
}
}
// 전역 인스턴스 생성
window.WorkplaceAPI = new WorkplaceAPI();
// 하위 호환성: 기존 함수들
window.loadCategories = () => window.WorkplaceAPI.loadCategories();
window.loadWorkplaces = () => window.WorkplaceAPI.loadWorkplaces();
window.loadWorkplaceEquipments = (id) => window.WorkplaceAPI.loadWorkplaceEquipments(id);
window.loadAllEquipments = () => window.WorkplaceAPI.loadAllEquipments();
console.log('[Module] workplace-management/api.js 로드 완료');

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 로드 완료');

View File

@@ -0,0 +1,284 @@
/**
* Workplace Management - State Manager
* 작업장 관리 페이지의 전역 상태 관리
*/
class WorkplaceState {
constructor() {
// 마스터 데이터
this.categories = [];
this.workplaces = [];
this.allEquipments = [];
this.existingEquipments = [];
// 현재 상태
this.currentCategoryId = '';
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
// 작업장 지도 관련
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceStartX = 0;
this.workplaceStartY = 0;
this.workplaceCurrentRect = null;
this.workplaceEquipmentRegions = [];
// 전체화면 편집기 관련
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsStartX = 0;
this.fsStartY = 0;
this.fsCurrentRect = null;
this.fsSidebarVisible = true;
// 리스너
this.listeners = new Map();
console.log('[WorkplaceState] 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[WorkplaceState] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 카테고리 변경
*/
setCurrentCategory(categoryId) {
const prevId = this.currentCategoryId;
this.currentCategoryId = categoryId === '' ? '' : categoryId;
this.notifyListeners('currentCategoryId', this.currentCategoryId, prevId);
}
/**
* 현재 카테고리 정보 가져오기
*/
getCurrentCategory() {
if (!this.currentCategoryId) return null;
return this.categories.find(c => c.category_id == this.currentCategoryId);
}
/**
* 현재 카테고리별 작업장 가져오기
*/
getFilteredWorkplaces() {
if (this.currentCategoryId === '') {
return this.workplaces;
}
return this.workplaces.filter(w => w.category_id == this.currentCategoryId);
}
/**
* 작업장 지도 상태 초기화
*/
resetWorkplaceMapState() {
this.workplaceCanvas = null;
this.workplaceCtx = null;
this.workplaceImage = null;
this.workplaceIsDrawing = false;
this.workplaceCurrentRect = null;
}
/**
* 전체화면 편집기 상태 초기화
*/
resetFullscreenState() {
this.fsCanvas = null;
this.fsCtx = null;
this.fsImage = null;
this.fsIsDrawing = false;
this.fsCurrentRect = null;
}
/**
* 통계 계산
*/
getStatistics() {
const total = this.workplaces.length;
const active = this.workplaces.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
const factoryTotal = this.categories.length;
const filtered = this.getFilteredWorkplaces();
const filteredActive = filtered.filter(w =>
w.is_active === 1 || w.is_active === true
).length;
return {
total,
active,
factoryTotal,
filteredTotal: filtered.length,
filteredActive
};
}
/**
* 상태 초기화
*/
reset() {
this.currentEditingCategory = null;
this.currentEditingWorkplace = null;
this.currentWorkplaceMapId = null;
this.resetWorkplaceMapState();
this.resetFullscreenState();
}
/**
* 디버그 출력
*/
debug() {
console.log('[WorkplaceState] 현재 상태:', {
categories: this.categories.length,
workplaces: this.workplaces.length,
currentCategoryId: this.currentCategoryId,
allEquipments: this.allEquipments.length,
workplaceEquipmentRegions: this.workplaceEquipmentRegions.length
});
}
}
// 전역 인스턴스 생성
window.WorkplaceState = new WorkplaceState();
// 하위 호환성을 위한 전역 변수 프록시
const wpStateProxy = window.WorkplaceState;
Object.defineProperties(window, {
categories: {
get: () => wpStateProxy.categories,
set: (v) => { wpStateProxy.categories = v; }
},
workplaces: {
get: () => wpStateProxy.workplaces,
set: (v) => { wpStateProxy.workplaces = v; }
},
currentCategoryId: {
get: () => wpStateProxy.currentCategoryId,
set: (v) => { wpStateProxy.currentCategoryId = v; }
},
currentEditingCategory: {
get: () => wpStateProxy.currentEditingCategory,
set: (v) => { wpStateProxy.currentEditingCategory = v; }
},
currentEditingWorkplace: {
get: () => wpStateProxy.currentEditingWorkplace,
set: (v) => { wpStateProxy.currentEditingWorkplace = v; }
},
workplaceCanvas: {
get: () => wpStateProxy.workplaceCanvas,
set: (v) => { wpStateProxy.workplaceCanvas = v; }
},
workplaceCtx: {
get: () => wpStateProxy.workplaceCtx,
set: (v) => { wpStateProxy.workplaceCtx = v; }
},
workplaceImage: {
get: () => wpStateProxy.workplaceImage,
set: (v) => { wpStateProxy.workplaceImage = v; }
},
workplaceIsDrawing: {
get: () => wpStateProxy.workplaceIsDrawing,
set: (v) => { wpStateProxy.workplaceIsDrawing = v; }
},
workplaceStartX: {
get: () => wpStateProxy.workplaceStartX,
set: (v) => { wpStateProxy.workplaceStartX = v; }
},
workplaceStartY: {
get: () => wpStateProxy.workplaceStartY,
set: (v) => { wpStateProxy.workplaceStartY = v; }
},
workplaceCurrentRect: {
get: () => wpStateProxy.workplaceCurrentRect,
set: (v) => { wpStateProxy.workplaceCurrentRect = v; }
},
workplaceEquipmentRegions: {
get: () => wpStateProxy.workplaceEquipmentRegions,
set: (v) => { wpStateProxy.workplaceEquipmentRegions = v; }
},
existingEquipments: {
get: () => wpStateProxy.existingEquipments,
set: (v) => { wpStateProxy.existingEquipments = v; }
},
allEquipments: {
get: () => wpStateProxy.allEquipments,
set: (v) => { wpStateProxy.allEquipments = v; }
},
fsCanvas: {
get: () => wpStateProxy.fsCanvas,
set: (v) => { wpStateProxy.fsCanvas = v; }
},
fsCtx: {
get: () => wpStateProxy.fsCtx,
set: (v) => { wpStateProxy.fsCtx = v; }
},
fsImage: {
get: () => wpStateProxy.fsImage,
set: (v) => { wpStateProxy.fsImage = v; }
},
fsIsDrawing: {
get: () => wpStateProxy.fsIsDrawing,
set: (v) => { wpStateProxy.fsIsDrawing = v; }
},
fsStartX: {
get: () => wpStateProxy.fsStartX,
set: (v) => { wpStateProxy.fsStartX = v; }
},
fsStartY: {
get: () => wpStateProxy.fsStartY,
set: (v) => { wpStateProxy.fsStartY = v; }
},
fsCurrentRect: {
get: () => wpStateProxy.fsCurrentRect,
set: (v) => { wpStateProxy.fsCurrentRect = v; }
},
fsSidebarVisible: {
get: () => wpStateProxy.fsSidebarVisible,
set: (v) => { wpStateProxy.fsSidebarVisible = v; }
}
});
// currentWorkplaceMapId를 window에도 설정
Object.defineProperty(window, 'currentWorkplaceMapId', {
get: () => wpStateProxy.currentWorkplaceMapId,
set: (v) => { wpStateProxy.currentWorkplaceMapId = v; }
});
console.log('[Module] workplace-management/state.js 로드 완료');

View File

@@ -0,0 +1,154 @@
/**
* Workplace Management - Utilities
* 작업장 관리 관련 유틸리티 함수들
*/
class WorkplaceUtils {
constructor() {
console.log('[WorkplaceUtils] 초기화 완료');
}
/**
* 날짜 포맷팅
*/
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
/**
* API URL 생성
*/
getApiBaseUrl() {
return window.API_BASE_URL || 'http://localhost:20005/api';
}
/**
* 이미지 URL 생성
*/
getFullImageUrl(imagePath) {
if (!imagePath) return null;
if (imagePath.startsWith('http')) return imagePath;
return `${this.getApiBaseUrl()}${imagePath}`.replace('/api/', '/');
}
/**
* 작업장 용도 아이콘 반환
*/
getPurposeIcon(purpose) {
const icons = {
'작업구역': '🔧',
'설비': '⚙️',
'휴게시설': '☕',
'회의실': '💼',
'창고': '📦',
'기타': '📍'
};
return purpose ? (icons[purpose] || '📍') : '🏗️';
}
/**
* 퍼센트를 픽셀로 변환
*/
percentToPixel(percent, canvasSize) {
return (percent / 100) * canvasSize;
}
/**
* 픽셀을 퍼센트로 변환
*/
pixelToPercent(pixel, canvasSize) {
return (pixel / canvasSize) * 100;
}
/**
* 영역 좌표 정규화 (음수 처리)
*/
normalizeRect(rect, canvasWidth, canvasHeight) {
const xPercent = this.pixelToPercent(
Math.min(rect.x, rect.x + rect.width),
canvasWidth
);
const yPercent = this.pixelToPercent(
Math.min(rect.y, rect.y + rect.height),
canvasHeight
);
const widthPercent = this.pixelToPercent(
Math.abs(rect.width),
canvasWidth
);
const heightPercent = this.pixelToPercent(
Math.abs(rect.height),
canvasHeight
);
return { xPercent, yPercent, widthPercent, heightPercent };
}
}
// 전역 인스턴스 생성
window.WorkplaceUtils = new WorkplaceUtils();
// 하위 호환성: 기존 함수들
window.formatDate = (dateString) => window.WorkplaceUtils.formatDate(dateString);
// 토스트 메시지 표시
window.showToast = function(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// 스타일 적용
Object.assign(toast.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '12px 24px',
borderRadius: '8px',
color: 'white',
fontWeight: '500',
zIndex: '1000',
transform: 'translateX(100%)',
transition: 'transform 0.3s ease'
});
// 타입별 배경색
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
toast.style.backgroundColor = colors[type] || colors.info;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// 자동 제거
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
};
console.log('[Module] workplace-management/utils.js 로드 완료');