feat: 설비 관리 시스템 구축

## 주요 기능
- 설비 등록/수정/삭제 기능
- 작업장별 설비 연결
- 작업장 지도에서 설비 위치 정의
- 필터링 및 검색 기능

## 백엔드
- equipments 테이블 생성 (마이그레이션)
- 설비 API (모델, 컨트롤러, 라우트) 구현
- workplaces 테이블에 layout_image 컬럼 추가

## 프론트엔드
- 설비 관리 페이지 (equipments.html)
- 설비 관리 JavaScript (equipment-management.js)
- 작업장 지도 모달 개선

## 버그 수정
- 카테고리/작업장 이미지 보존 로직 개선 (null 처리)
- 작업장 레이아웃 이미지 업로드 경로 수정 (public/uploads → uploads)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-28 09:22:57 +09:00
parent 9c98c44d8a
commit e1227a69fe
13 changed files with 2710 additions and 22 deletions

View File

@@ -41,8 +41,9 @@ function setupRoutes(app) {
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
const workplaceRoutes = require('../routes/workplaceRoutes');
const equipmentRoutes = require('../routes/equipmentRoutes');
const taskRoutes = require('../routes/taskRoutes');
// const tbmRoutes = require('../routes/tbmRoutes'); // 임시 비활성화 - db/connection 문제
const tbmRoutes = require('../routes/tbmRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -57,10 +58,18 @@ function setupRoutes(app) {
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 100, // 최대 100
max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false
legacyHeaders: false,
// 관리자 및 시스템 계정은 rate limit 제외
skip: (req) => {
// 인증된 사용자 정보 확인
if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
return true; // rate limit 건너뛰기
}
return false;
}
});
// 모든 API 요청에 활동 로거 적용
@@ -75,9 +84,6 @@ function setupRoutes(app) {
// Health check
app.use('/api/health', healthRoutes);
// 일반 API에 속도 제한 적용
app.use('/api/', apiLimiter);
// 인증이 필요 없는 공개 경로 목록
const publicPaths = [
'/api/auth/login',
@@ -95,7 +101,7 @@ function setupRoutes(app) {
'/api/monthly-status/daily-details'
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
app.use('/api/*', (req, res, next) => {
const isPublicPath = publicPaths.some(path => {
return req.originalUrl === path ||
@@ -112,6 +118,9 @@ function setupRoutes(app) {
verifyToken(req, res, next);
});
// 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
app.use('/api/', apiLimiter);
// 인증된 사용자만 접근 가능한 라우트들
app.use('/api/issue-reports', dailyIssueReportRoutes);
app.use('/api/issue-types', issueTypeRoutes);
@@ -130,9 +139,10 @@ function setupRoutes(app) {
app.use('/api/tools', toolsRoute);
app.use('/api/users', userRoutes);
app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
// app.use('/api/tbm', tbmRoutes); // TBM 시스템 - 임시 비활성화
app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -0,0 +1,349 @@
// controllers/equipmentController.js
const EquipmentModel = require('../models/equipmentModel');
const EquipmentController = {
// CREATE - 설비 생성
createEquipment: async (req, res) => {
try {
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 코드 중복 확인
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, null, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 생성
EquipmentModel.create(equipmentData, (error, result) => {
if (error) {
console.error('설비 생성 오류:', error);
return res.status(500).json({
success: false,
message: '설비 생성 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 성공적으로 생성되었습니다.',
data: result
});
});
});
} catch (error) {
console.error('설비 생성 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ALL - 모든 설비 조회 (필터링 가능)
getAllEquipments: (req, res) => {
try {
const filters = {
workplace_id: req.query.workplace_id,
equipment_type: req.query.equipment_type,
status: req.query.status,
search: req.query.search
};
EquipmentModel.getAll(filters, (error, results) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ONE - 특정 설비 조회
getEquipmentById: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getById(equipmentId, (error, result) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!result) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: result
});
});
} catch (error) {
console.error('설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getEquipmentsByWorkplace: (req, res) => {
try {
const workplaceId = req.params.workplaceId;
EquipmentModel.getByWorkplace(workplaceId, (error, results) => {
if (error) {
console.error('작업장 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '작업장 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('작업장 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ACTIVE - 활성 설비만 조회
getActiveEquipments: (req, res) => {
try {
EquipmentModel.getActive((error, results) => {
if (error) {
console.error('활성 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '활성 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('활성 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE - 설비 수정
updateEquipment: async (req, res) => {
try {
const equipmentId = req.params.id;
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 존재 확인
EquipmentModel.getById(equipmentId, (error, existingEquipment) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!existingEquipment) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
// 설비 코드 중복 확인 (자신 제외)
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, equipmentId, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 수정
EquipmentModel.update(equipmentId, equipmentData, (error, result) => {
if (error) {
console.error('설비 수정 오류:', error);
return res.status(500).json({
success: false,
message: '설비 수정 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 수정되었습니다.',
data: result
});
});
});
});
} catch (error) {
console.error('설비 수정 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트
updateMapPosition: (req, res) => {
try {
const equipmentId = req.params.id;
const positionData = {
map_x_percent: req.body.map_x_percent,
map_y_percent: req.body.map_y_percent,
map_width_percent: req.body.map_width_percent,
map_height_percent: req.body.map_height_percent
};
EquipmentModel.updateMapPosition(equipmentId, positionData, (error, result) => {
if (error) {
console.error('설비 위치 업데이트 오류:', error);
return res.status(500).json({
success: false,
message: '설비 위치 업데이트 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비 위치가 성공적으로 업데이트되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// DELETE - 설비 삭제
deleteEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.delete(equipmentId, (error, result) => {
if (error) {
console.error('설비 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '설비 삭제 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 삭제되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: (req, res) => {
try {
EquipmentModel.getEquipmentTypes((error, results) => {
if (error) {
console.error('설비 유형 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 유형 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('설비 유형 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
}
};
module.exports = EquipmentController;

View File

@@ -115,8 +115,28 @@ exports.updateCategory = asyncHandler(async (req, res) => {
logger.info('카테고리 수정 요청', { category_id: categoryId });
// 기존 카테고리 정보 가져오기
const existingCategory = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingCategory) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...categoryData,
layout_image: (categoryData.layout_image !== undefined && categoryData.layout_image !== null)
? categoryData.layout_image
: existingCategory.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, categoryData, (err, result) => {
workplaceModel.updateCategory(categoryId, updateData, (err, result) => {
if (err) reject(new DatabaseError('카테고리 수정 중 오류가 발생했습니다'));
else resolve(result);
});
@@ -275,8 +295,28 @@ exports.updateWorkplace = asyncHandler(async (req, res) => {
logger.info('작업장 수정 요청', { workplace_id: workplaceId });
// 기존 작업장 정보 가져오기
const existingWorkplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingWorkplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...workplaceData,
layout_image: (workplaceData.layout_image !== undefined && workplaceData.layout_image !== null)
? workplaceData.layout_image
: existingWorkplace.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, workplaceData, (err, result) => {
workplaceModel.updateWorkplace(workplaceId, updateData, (err, result) => {
if (err) reject(new DatabaseError('작업장 수정 중 오류가 발생했습니다'));
else resolve(result);
});
@@ -312,3 +352,224 @@ exports.deleteWorkplace = asyncHandler(async (req, res) => {
message: '작업장이 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 지도 영역 관련 ====================
/**
* 카테고리 레이아웃 이미지 업로드
*/
exports.uploadCategoryLayoutImage = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('카테고리 레이아웃 이미지 업로드 요청', { category_id: categoryId, path: imagePath });
// 현재 카테고리 정보 가져오기
const category = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!category) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// 카테고리 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
category_name: category.category_name,
description: category.description,
display_order: category.display_order,
is_active: category.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('레이아웃 이미지 업로드 성공', { category_id: categoryId });
res.json({
success: true,
data: { image_path: imagePath },
message: '레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 작업장 레이아웃 이미지 업로드
*/
exports.uploadWorkplaceLayoutImage = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('작업장 레이아웃 이미지 업로드 요청', { workplace_id: workplaceId, path: imagePath });
// 현재 작업장 정보 가져오기
const workplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!workplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// 작업장 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
workplace_name: workplace.workplace_name,
category_id: workplace.category_id,
description: workplace.description,
workplace_purpose: workplace.workplace_purpose,
display_priority: workplace.display_priority,
is_active: workplace.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 레이아웃 이미지 업로드 성공', { workplace_id: workplaceId });
res.json({
success: true,
data: { image_path: imagePath },
message: '작업장 레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 지도 영역 생성
*/
exports.createMapRegion = asyncHandler(async (req, res) => {
const regionData = req.body;
if (!regionData.workplace_id || !regionData.category_id) {
throw new ValidationError('작업장 ID와 카테고리 ID는 필수 입력 항목입니다');
}
logger.info('지도 영역 생성 요청', { workplace_id: regionData.workplace_id });
const id = await new Promise((resolve, reject) => {
workplaceModel.createMapRegion(regionData, (err, lastID) => {
if (err) reject(new DatabaseError('지도 영역 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('지도 영역 생성 성공', { region_id: id });
res.status(201).json({
success: true,
data: { region_id: id },
message: '지도 영역이 성공적으로 생성되었습니다'
});
});
/**
* 카테고리별 지도 영역 조회 (작업장 정보 포함)
*/
exports.getMapRegionsByCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.categoryId;
const rows = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionsByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '지도 영역 조회 성공'
});
});
/**
* 작업장별 지도 영역 조회
*/
exports.getMapRegionByWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.workplaceId;
const region = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionByWorkplace(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: region,
message: '지도 영역 조회 성공'
});
});
/**
* 지도 영역 수정
*/
exports.updateMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
const regionData = req.body;
logger.info('지도 영역 수정 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.updateMapRegion(regionId, regionData, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 수정 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 수정되었습니다'
});
});
/**
* 지도 영역 삭제
*/
exports.deleteMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
logger.info('지도 영역 삭제 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.deleteMapRegion(regionId, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 삭제 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,22 @@
/**
* 작업장 테이블에 레이아웃 이미지 컬럼 추가
*
* @author TK-FB-Project
* @since 2026-01-28
*/
exports.up = async function(knex) {
await knex.schema.table('workplaces', (table) => {
table.string('layout_image', 500).nullable().comment('작업장 레이아웃 이미지 경로');
});
console.log('✅ workplaces 테이블에 layout_image 컬럼 추가 완료');
};
exports.down = async function(knex) {
await knex.schema.table('workplaces', (table) => {
table.dropColumn('layout_image');
});
console.log('✅ workplaces 테이블에서 layout_image 컬럼 제거 완료');
};

View File

@@ -0,0 +1,48 @@
/**
* 설비 관리 테이블 생성
*
* @author TK-FB-Project
* @since 2026-01-28
*/
exports.up = async function(knex) {
await knex.schema.createTable('equipments', (table) => {
table.increments('equipment_id').primary().comment('설비 ID');
table.string('equipment_code', 50).notNullable().unique().comment('설비 코드 (예: CNC-01, LATHE-A)');
table.string('equipment_name', 100).notNullable().comment('설비명');
table.string('equipment_type', 50).nullable().comment('설비 유형 (예: CNC, 선반, 밀링 등)');
table.string('model_name', 100).nullable().comment('모델명');
table.string('manufacturer', 100).nullable().comment('제조사');
table.date('installation_date').nullable().comment('설치일');
table.string('serial_number', 100).nullable().comment('시리얼 번호');
table.text('specifications').nullable().comment('사양 정보 (JSON 형태로 저장 가능)');
table.enum('status', ['active', 'maintenance', 'inactive']).defaultTo('active').comment('설비 상태');
table.text('notes').nullable().comment('비고');
// 작업장 연결
table.integer('workplace_id').unsigned().nullable().comment('연결된 작업장 ID');
table.foreign('workplace_id').references('workplace_id').inTable('workplaces').onDelete('SET NULL');
// 지도상 위치 정보 (백분율 기반)
table.decimal('map_x_percent', 5, 2).nullable().comment('지도상 X 위치 (%)');
table.decimal('map_y_percent', 5, 2).nullable().comment('지도상 Y 위치 (%)');
table.decimal('map_width_percent', 5, 2).nullable().comment('지도상 영역 너비 (%)');
table.decimal('map_height_percent', 5, 2).nullable().comment('지도상 영역 높이 (%)');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 인덱스
table.index('workplace_id');
table.index('equipment_type');
table.index('status');
});
console.log('✅ equipments 테이블 생성 완료');
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('equipments');
console.log('✅ equipments 테이블 삭제 완료');
};

View File

@@ -0,0 +1,300 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const EquipmentModel = {
// CREATE - 설비 생성
create: async (equipmentData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipments (
equipment_code, equipment_name, equipment_type, model_name,
manufacturer, installation_date, serial_number, specifications,
status, notes, workplace_id, map_x_percent, map_y_percent,
map_width_percent, map_height_percent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null
];
const [result] = await db.query(query, values);
callback(null, {
equipment_id: result.insertId,
...equipmentData
});
} catch (error) {
callback(error);
}
},
// READ ALL - 모든 설비 조회 (필터링 옵션 포함)
getAll: async (filters, callback) => {
try {
const db = await getDb();
let query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE 1=1
`;
const values = [];
// 필터링: 작업장 ID
if (filters.workplace_id) {
query += ' AND e.workplace_id = ?';
values.push(filters.workplace_id);
}
// 필터링: 설비 유형
if (filters.equipment_type) {
query += ' AND e.equipment_type = ?';
values.push(filters.equipment_type);
}
// 필터링: 상태
if (filters.status) {
query += ' AND e.status = ?';
values.push(filters.status);
}
// 필터링: 검색어 (설비명, 설비코드)
if (filters.search) {
query += ' AND (e.equipment_name LIKE ? OR e.equipment_code LIKE ?)';
const searchTerm = `%${filters.search}%`;
values.push(searchTerm, searchTerm);
}
query += ' ORDER BY e.equipment_code ASC';
const [rows] = await db.query(query, values);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ONE - 특정 설비 조회
getById: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.equipment_id = ?
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows[0]);
} catch (error) {
callback(error);
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getByWorkplace: async (workplaceId, callback) => {
try {
const db = await getDb();
const query = `
SELECT e.*
FROM equipments e
WHERE e.workplace_id = ?
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query, [workplaceId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ACTIVE - 활성 설비만 조회
getActive: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.status = 'active'
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// UPDATE - 설비 수정
update: async (equipmentId, equipmentData, callback) => {
try {
const db = await getDb();
const query = `
UPDATE equipments SET
equipment_code = ?,
equipment_name = ?,
equipment_type = ?,
model_name = ?,
manufacturer = ?,
installation_date = ?,
serial_number = ?,
specifications = ?,
status = ?,
notes = ?,
workplace_id = ?,
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...equipmentData });
} catch (error) {
callback(error);
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트
updateMapPosition: async (equipmentId, positionData, callback) => {
try {
const db = await getDb();
const query = `
UPDATE equipments SET
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = [
positionData.map_x_percent,
positionData.map_y_percent,
positionData.map_width_percent,
positionData.map_height_percent,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...positionData });
} catch (error) {
callback(error);
}
},
// DELETE - 설비 삭제
delete: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = 'DELETE FROM equipments WHERE equipment_id = ?';
const [result] = await db.query(query, [equipmentId]);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId });
} catch (error) {
callback(error);
}
},
// CHECK DUPLICATE CODE - 설비 코드 중복 확인
checkDuplicateCode: async (equipmentCode, excludeEquipmentId, callback) => {
try {
const db = await getDb();
let query = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
const values = [equipmentCode];
if (excludeEquipmentId) {
query += ' AND equipment_id != ?';
values.push(excludeEquipmentId);
}
const [rows] = await db.query(query, values);
callback(null, rows.length > 0);
} catch (error) {
callback(error);
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT DISTINCT equipment_type
FROM equipments
WHERE equipment_type IS NOT NULL
ORDER BY equipment_type ASC
`;
const [rows] = await db.query(query);
callback(null, rows.map(row => row.equipment_type));
} catch (error) {
callback(error);
}
}
};
module.exports = EquipmentModel;

View File

@@ -0,0 +1,36 @@
// routes/equipmentRoutes.js
const express = require('express');
const router = express.Router();
const equipmentController = require('../controllers/equipmentController');
// ==================== 설비 관리 ====================
// CREATE 설비
router.post('/', equipmentController.createEquipment);
// READ ALL 설비 (쿼리 파라미터로 필터링 가능)
// ?workplace_id=1&equipment_type=CNC&status=active&search=설비명
router.get('/', equipmentController.getAllEquipments);
// READ ACTIVE 설비
router.get('/active/list', equipmentController.getActiveEquipments);
// READ 설비 유형 목록
router.get('/types', equipmentController.getEquipmentTypes);
// READ 작업장별 설비
router.get('/workplace/:workplaceId', equipmentController.getEquipmentsByWorkplace);
// READ ONE 설비
router.get('/:id', equipmentController.getEquipmentById);
// UPDATE 설비
router.put('/:id', equipmentController.updateEquipment);
// UPDATE 설비 지도 위치
router.patch('/:id/map-position', equipmentController.updateMapPosition);
// DELETE 설비
router.delete('/:id', equipmentController.deleteEquipment);
module.exports = router;

View File

@@ -1,8 +1,36 @@
// routes/workplaceRoutes.js
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const workplaceController = require('../controllers/workplaceController');
// Multer 설정 - 작업장 레이아웃 이미지 업로드
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(__dirname, '../uploads'));
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'workplace-layout-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif)'));
}
},
limits: { fileSize: 5 * 1024 * 1024 } // 5MB 제한
});
// ==================== 카테고리(공장) 관리 ====================
// CREATE 카테고리
@@ -43,4 +71,27 @@ router.put('/:id', workplaceController.updateWorkplace);
// DELETE 작업장
router.delete('/:id', workplaceController.deleteWorkplace);
// ==================== 작업장 지도 영역 관리 ====================
// 카테고리 레이아웃 이미지 업로드
router.post('/categories/:id/layout-image', upload.single('image'), workplaceController.uploadCategoryLayoutImage);
// 작업장 레이아웃 이미지 업로드
router.post('/:id/layout-image', upload.single('image'), workplaceController.uploadWorkplaceLayoutImage);
// CREATE 지도 영역
router.post('/map-regions', workplaceController.createMapRegion);
// READ 카테고리별 지도 영역
router.get('/categories/:categoryId/map-regions', workplaceController.getMapRegionsByCategory);
// READ 작업장별 지도 영역
router.get('/map-regions/workplace/:workplaceId', workplaceController.getMapRegionByWorkplace);
// UPDATE 지도 영역
router.put('/map-regions/:id', workplaceController.updateMapRegion);
// DELETE 지도 영역
router.delete('/map-regions/:id', workplaceController.deleteMapRegion);
module.exports = router;

View File

@@ -0,0 +1,344 @@
// equipment-management.js
// 설비 관리 페이지 JavaScript
let equipments = [];
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadInitialData();
});
// 초기 데이터 로드
async function loadInitialData() {
try {
await Promise.all([
loadEquipments(),
loadWorkplaces(),
loadEquipmentTypes()
]);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
alert('데이터를 불러오는데 실패했습니다.');
}
}
// 설비 목록 로드
async function loadEquipments() {
try {
const response = await axios.get('/api/equipments');
if (response.data.success) {
equipments = response.data.data;
renderEquipmentList();
}
} catch (error) {
console.error('설비 목록 로드 실패:', error);
throw error;
}
}
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await axios.get('/api/workplaces');
if (response.data.success) {
workplaces = response.data.data;
populateWorkplaceFilters();
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
throw error;
}
}
// 설비 유형 목록 로드
async function loadEquipmentTypes() {
try {
const response = await axios.get('/api/equipments/types');
if (response.data.success) {
equipmentTypes = response.data.data;
populateTypeFilter();
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
// 실패해도 계속 진행 (유형이 없을 수 있음)
}
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w =>
`<option value="${w.workplace_id}">${w.category_name} - ${w.workplace_name}</option>`
).join('');
filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
const typeOptions = equipmentTypes.map(type =>
`<option value="${type}">${type}</option>`
).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
}
// 설비 목록 렌더링
function renderEquipmentList() {
const container = document.getElementById('equipmentList');
if (equipments.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>설비 코드</th>
<th>설비명</th>
<th>유형</th>
<th>작업장</th>
<th>제조사</th>
<th>모델명</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody>
${equipments.map(equipment => `
<tr>
<td><strong>${equipment.equipment_code}</strong></td>
<td>${equipment.equipment_name}</td>
<td>${equipment.equipment_type || '-'}</td>
<td>${equipment.workplace_name || '-'}</td>
<td>${equipment.manufacturer || '-'}</td>
<td>${equipment.model_name || '-'}</td>
<td>
<span class="status-badge status-${equipment.status}">
${getStatusText(equipment.status)}
</span>
</td>
<td>
<div class="action-buttons">
<button class="btn-small btn-primary" onclick="editEquipment(${equipment.equipment_id})" title="수정">
✏️
</button>
<button class="btn-small btn-danger" onclick="deleteEquipment(${equipment.equipment_id})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
// 상태 텍스트 변환
function getStatusText(status) {
const statusMap = {
'active': '활성',
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status;
}
// 필터링
function filterEquipments() {
const workplaceFilter = document.getElementById('filterWorkplace').value;
const typeFilter = document.getElementById('filterType').value;
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
// API에서 필터링된 데이터를 가져오는 것이 더 효율적이지만,
// 클라이언트 측에서도 필터링을 적용합니다.
let filtered = [...equipments];
if (workplaceFilter) {
filtered = filtered.filter(e => e.workplace_id == workplaceFilter);
}
if (typeFilter) {
filtered = filtered.filter(e => e.equipment_type === typeFilter);
}
if (statusFilter) {
filtered = filtered.filter(e => e.status === statusFilter);
}
if (searchTerm) {
filtered = filtered.filter(e =>
e.equipment_name.toLowerCase().includes(searchTerm) ||
e.equipment_code.toLowerCase().includes(searchTerm)
);
}
// 임시로 equipments를 필터링된 것으로 교체하고 렌더링
const originalEquipments = equipments;
equipments = filtered;
renderEquipmentList();
equipments = originalEquipments;
}
// 설비 추가 모달 열기
function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
const form = document.getElementById('equipmentForm');
form.reset();
document.getElementById('equipmentId').value = '';
if (equipmentId) {
modalTitle.textContent = '설비 수정';
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
}
modal.style.display = 'flex';
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/api/equipments/${equipmentId}`);
if (response.data.success) {
const equipment = response.data.data;
document.getElementById('equipmentId').value = equipment.equipment_id;
document.getElementById('equipmentCode').value = equipment.equipment_code;
document.getElementById('equipmentName').value = equipment.equipment_name;
document.getElementById('equipmentType').value = equipment.equipment_type || '';
document.getElementById('workplaceId').value = equipment.workplace_id || '';
document.getElementById('manufacturer').value = equipment.manufacturer || '';
document.getElementById('modelName').value = equipment.model_name || '';
document.getElementById('serialNumber').value = equipment.serial_number || '';
document.getElementById('installationDate').value = equipment.installation_date ? equipment.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = equipment.status || 'active';
document.getElementById('specifications').value = equipment.specifications || '';
document.getElementById('notes').value = equipment.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
// 설비 모달 닫기
function closeEquipmentModal() {
document.getElementById('equipmentModal').style.display = 'none';
currentEquipment = null;
}
// 설비 저장
async function saveEquipment() {
const equipmentId = document.getElementById('equipmentId').value;
const equipmentData = {
equipment_code: document.getElementById('equipmentCode').value.trim(),
equipment_name: document.getElementById('equipmentName').value.trim(),
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
status: document.getElementById('equipmentStatus').value,
specifications: document.getElementById('specifications').value.trim() || null,
notes: document.getElementById('notes').value.trim() || null
};
// 유효성 검사
if (!equipmentData.equipment_code) {
alert('설비 코드를 입력해주세요.');
return;
}
if (!equipmentData.equipment_name) {
alert('설비명을 입력해주세요.');
return;
}
try {
let response;
if (equipmentId) {
// 수정
response = await axios.put(`/api/equipments/${equipmentId}`, equipmentData);
} else {
// 추가
response = await axios.post('/api/equipments', equipmentData);
}
if (response.data.success) {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes(); // 새로운 유형이 추가될 수 있으므로
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response && error.response.data && error.response.data.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
}
}
}
// 설비 수정
function editEquipment(equipmentId) {
openEquipmentModal(equipmentId);
}
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = equipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await axios.delete(`/api/equipments/${equipmentId}`);
if (response.data.success) {
alert('설비가 삭제되었습니다.');
await loadEquipments();
}
} catch (error) {
console.error('설비 삭제 실패:', error);
alert('설비 삭제 중 오류가 발생했습니다.');
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeEquipmentModal();
}
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal').addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}
});

View File

@@ -36,7 +36,7 @@ async function loadAllData() {
// 카테고리 목록 로드
async function loadCategories() {
try {
const response = await apiCall('/workplaces/categories', 'GET');
const response = await window.apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
@@ -91,6 +91,241 @@ function switchCategory(categoryId) {
currentCategoryId = categoryId === '' ? '' : categoryId;
renderCategoryTabs();
renderWorkplaces();
// 레이아웃 지도 섹션 표시/숨김
const layoutMapSection = document.getElementById('layoutMapSection');
const selectedCategoryName = document.getElementById('selectedCategoryName');
if (currentCategoryId && layoutMapSection) {
const category = categories.find(c => c.category_id == currentCategoryId);
if (category) {
layoutMapSection.style.display = 'block';
if (selectedCategoryName) {
selectedCategoryName.textContent = category.category_name;
}
// 레이아웃 미리보기 업데이트
updateLayoutPreview(category);
}
} else if (layoutMapSection) {
layoutMapSection.style.display = 'none';
}
}
// 레이아웃 미리보기 업데이트
async function updateLayoutPreview(category) {
const previewDiv = document.getElementById('layoutMapPreview');
if (!previewDiv) return;
if (category.layout_image) {
// 이미지 경로를 전체 URL로 변환
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
// Canvas 컨테이너 생성
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 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 function loadImageWithRegions(imageUrl, categoryId) {
const canvas = document.getElementById('previewCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const img = new Image();
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 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;
}
// 각 영역 그리기
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.fillStyle = '#10b981';
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;
}
// 작업장 카드에 지도 썸네일 로드
async function loadWorkplaceMapThumbnail(workplace) {
const thumbnailDiv = document.getElementById(`workplace-map-${workplace.workplace_id}`);
if (!thumbnailDiv) return;
// 작업장 자체에 레이아웃 이미지가 있는 경우
if (workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 8px; background: #f0f9ff; border-radius: 6px; border: 1px solid #bae6fd;">
<div style="font-size: 12px; color: #0369a1; margin-bottom: 6px; font-weight: 500;">📍 작업장 레이아웃</div>
<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
</div>
`;
return;
}
// 작업장에 이미지가 없으면 카테고리 지도의 영역 표시
try {
// 해당 작업장의 지도 영역 정보 가져오기
const response = await window.apiCall(`/workplaces/map-regions/workplace/${workplace.workplace_id}`, 'GET');
if (!response || (!response.success && !response.region_id)) {
return; // 영역이 정의되지 않은 경우 아무것도 표시하지 않음
}
const region = response.success ? response.data : response;
// 카테고리 정보에서 레이아웃 이미지 가져오기
const category = categories.find(c => c.category_id === workplace.category_id);
if (!category || !category.layout_image) return;
const fullImageUrl = category.layout_image.startsWith('http')
? category.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
// 캔버스 생성
const canvasId = `thumbnail-canvas-${workplace.workplace_id}`;
thumbnailDiv.innerHTML = `
<div style="text-align: center; padding: 8px; background: #f9fafb; border-radius: 6px; border: 1px solid #e5e7eb;">
<div style="font-size: 12px; color: #64748b; margin-bottom: 6px; font-weight: 500;">📍 지도 위치</div>
<canvas id="${canvasId}" style="max-width: 100%; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</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;
// 썸네일 크기 설정 (최대 너비 300px)
const maxThumbWidth = 300;
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}의 지도 영역 없음`);
}
}
// 카테고리 모달 열기
@@ -161,10 +396,10 @@ async function saveCategory() {
let response;
if (categoryId) {
// 수정
response = await apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
response = await window.apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
// 신규 등록
response = await apiCall('/workplaces/categories', 'POST', categoryData);
response = await window.apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
@@ -191,7 +426,7 @@ async function deleteCategory() {
}
try {
const response = await apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
const response = await window.apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
if (response && response.success) {
showToast('공장이 성공적으로 삭제되었습니다.', 'success');
@@ -212,7 +447,7 @@ async function deleteCategory() {
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await apiCall('/workplaces', 'GET');
const response = await window.apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
@@ -259,15 +494,32 @@ function renderWorkplaces() {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
// 작업장 용도 아이콘 매핑
const purposeIcons = {
'작업구역': '🔧',
'설비': '⚙️',
'휴게시설': '☕',
'회의실': '💼',
'창고': '📦',
'기타': '📍'
};
const purposeIcon = workplace.workplace_purpose ? purposeIcons[workplace.workplace_purpose] || '📍' : '🏗️';
gridHtml += `
<div class="code-card workplace-card ${isActive ? '' : 'inactive'}" onclick="editWorkplace(${workplace.workplace_id})">
<div class="code-header">
<div class="code-icon" style="background: #dbeafe;">🏗️</div>
<div class="code-icon" style="background: #dbeafe;">${purposeIcon}</div>
<div class="code-info">
<h3 class="code-name">${workplace.workplace_name}</h3>
${workplace.category_id ? `<span class="code-label">🏭 ${categoryName}</span>` : ''}
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px;">
${workplace.category_id ? `<span class="code-label">🏭 ${categoryName}</span>` : ''}
${workplace.workplace_purpose ? `<span class="code-label" style="background: #f3e8ff; color: #7c3aed;">${workplace.workplace_purpose}</span>` : ''}
</div>
</div>
<div class="code-actions">
<button class="btn-small btn-primary" onclick="event.stopPropagation(); openWorkplaceMapModal(${workplace.workplace_id})" title="지도 관리" style="font-size: 14px;">
🗺️
</button>
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
@@ -277,6 +529,7 @@ function renderWorkplaces() {
</div>
</div>
${workplace.description ? `<p class="code-description">${workplace.description}</p>` : ''}
<div id="workplace-map-${workplace.workplace_id}" style="margin: 12px 0;"></div>
<div class="code-meta">
<span class="code-date">등록: ${formatDate(workplace.created_at)}</span>
${workplace.updated_at !== workplace.created_at ? `<span class="code-date">수정: ${formatDate(workplace.updated_at)}</span>` : ''}
@@ -286,6 +539,13 @@ function renderWorkplaces() {
});
grid.innerHTML = gridHtml;
// 각 작업장의 지도 미리보기 로드
filtered.forEach(workplace => {
if (workplace.category_id) {
loadWorkplaceMapThumbnail(workplace);
}
});
}
// 작업장 모달 열기
@@ -314,6 +574,8 @@ function openWorkplaceModal(workplaceData = null) {
document.getElementById('workplaceId').value = workplaceData.workplace_id;
document.getElementById('workplaceCategoryId').value = workplaceData.category_id || '';
document.getElementById('workplaceName').value = workplaceData.workplace_name || '';
document.getElementById('workplacePurpose').value = workplaceData.workplace_purpose || '';
document.getElementById('displayPriority').value = workplaceData.display_priority || 0;
document.getElementById('workplaceDescription').value = workplaceData.description || '';
} else {
// 신규 등록 모드
@@ -365,6 +627,8 @@ async function saveWorkplace() {
const workplaceData = {
category_id: document.getElementById('workplaceCategoryId').value || null,
workplace_name: document.getElementById('workplaceName').value.trim(),
workplace_purpose: document.getElementById('workplacePurpose').value || null,
display_priority: parseInt(document.getElementById('displayPriority').value) || 0,
description: document.getElementById('workplaceDescription').value.trim() || null,
is_active: true
};
@@ -379,10 +643,10 @@ async function saveWorkplace() {
let response;
if (workplaceId) {
// 수정
response = await apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
response = await window.apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
// 신규 등록
response = await apiCall('/workplaces', 'POST', workplaceData);
response = await window.apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
@@ -424,7 +688,7 @@ function deleteWorkplace() {
// 작업장 삭제 실행
async function deleteWorkplaceById(workplaceId) {
try {
const response = await apiCall(`/workplaces/${workplaceId}`, 'DELETE');
const response = await window.apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
showToast('작업장이 성공적으로 삭제되었습니다.', 'success');
@@ -536,7 +800,346 @@ function showToast(message, type = 'info') {
}, 3000);
}
// 전역 함수로 노출
// 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록)
// getter/setter를 사용하여 항상 최신 값을 반환
Object.defineProperty(window, 'categories', {
get: function() {
return categories;
}
});
Object.defineProperty(window, 'workplaces', {
get: function() {
return workplaces;
}
});
Object.defineProperty(window, 'currentCategoryId', {
get: function() {
return currentCategoryId;
},
set: function(value) {
currentCategoryId = value;
}
});
// ==================== 작업장 지도 관리 ====================
// 작업장 지도 관련 전역 변수
let workplaceCanvas = null;
let workplaceCtx = null;
let workplaceImage = null;
let workplaceIsDrawing = false;
let workplaceStartX = 0;
let workplaceStartY = 0;
let workplaceCurrentRect = null;
let workplaceEquipmentRegions = [];
// 작업장 지도 모달 열기
async function openWorkplaceMapModal(workplaceId) {
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
if (!workplace) {
showToast('작업장 정보를 찾을 수 없습니다.', 'error');
return;
}
// 작업장 이름 설정
const modalTitle = document.getElementById('workplaceMapModalTitle');
if (modalTitle) {
modalTitle.textContent = `${workplace.workplace_name} - 지도 관리`;
}
// 현재 작업장 ID 저장
window.currentWorkplaceMapId = workplaceId;
// 레이아웃 이미지 미리보기 영역 초기화
const preview = document.getElementById('workplaceLayoutPreview');
if (preview && workplace.layout_image) {
const fullImageUrl = workplace.layout_image.startsWith('http')
? workplace.layout_image
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${workplace.layout_image}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
// 캔버스 초기화
initWorkplaceCanvas(fullImageUrl);
} else if (preview) {
preview.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">레이아웃 이미지를 업로드해주세요</p>';
}
// 설비 영역 목록 로드 (TODO: API 연동)
workplaceEquipmentRegions = [];
renderWorkplaceEquipmentList();
// 모달 표시
const modal = document.getElementById('workplaceMapModal');
if (modal) {
modal.style.display = 'flex';
}
}
// 작업장 지도 모달 닫기
function closeWorkplaceMapModal() {
const modal = document.getElementById('workplaceMapModal');
if (modal) {
modal.style.display = 'none';
}
window.currentWorkplaceMapId = null;
}
// 작업장 레이아웃 이미지 업로드
async function uploadWorkplaceLayout() {
const fileInput = document.getElementById('workplaceLayoutFile');
if (!fileInput || !fileInput.files || !fileInput.files[0]) {
showToast('파일을 선택해주세요.', 'warning');
return;
}
if (!window.currentWorkplaceMapId) {
showToast('작업장 정보를 찾을 수 없습니다.', 'error');
return;
}
const formData = new FormData();
formData.append('image', fileInput.files[0]);
try {
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/${window.currentWorkplaceMapId}/layout-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
const result = await response.json();
if (result.success) {
showToast('레이아웃 이미지가 업로드되었습니다.', 'success');
// 작업장 목록 새로고침
await loadWorkplaces();
renderWorkplaces();
// 미리보기 업데이트
const preview = document.getElementById('workplaceLayoutPreview');
if (preview && result.data.image_path) {
const fullImageUrl = result.data.image_path.startsWith('http')
? result.data.image_path
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
preview.innerHTML = `<img src="${fullImageUrl}" alt="작업장 레이아웃" style="max-width: 100%; border-radius: 4px;">`;
}
// 파일 입력 초기화
fileInput.value = '';
} else {
showToast(result.message || '업로드 실패', 'error');
}
} catch (error) {
console.error('레이아웃 이미지 업로드 오류:', error);
showToast('레이아웃 이미지 업로드 중 오류가 발생했습니다.', 'error');
}
}
// 작업장 캔버스 초기화
function initWorkplaceCanvas(imageUrl) {
const img = new Image();
img.onload = function() {
workplaceImage = img;
workplaceCanvas = document.getElementById('workplaceRegionCanvas');
if (!workplaceCanvas) return;
workplaceCtx = workplaceCanvas.getContext('2d');
// 캔버스 크기 설정 (최대 800px 너비)
const maxWidth = 800;
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
workplaceCanvas.width = img.width * scale;
workplaceCanvas.height = img.height * scale;
// 이미지 그리기
workplaceCtx.drawImage(img, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
// 기존 영역들 표시
drawWorkplaceRegions();
// 이벤트 리스너 등록
workplaceCanvas.onmousedown = startWorkplaceDraw;
workplaceCanvas.onmousemove = drawWorkplace;
workplaceCanvas.onmouseup = endWorkplaceDraw;
};
img.src = imageUrl;
}
// 드래그 시작
function startWorkplaceDraw(e) {
workplaceIsDrawing = true;
const rect = workplaceCanvas.getBoundingClientRect();
workplaceStartX = e.clientX - rect.left;
workplaceStartY = e.clientY - rect.top;
}
// 드래그 중
function drawWorkplace(e) {
if (!workplaceIsDrawing) return;
const rect = workplaceCanvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
// 캔버스 초기화 및 이미지 다시 그리기
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
// 기존 영역들 표시
drawWorkplaceRegions();
// 현재 그리는 사각형
workplaceCtx.strokeStyle = '#3b82f6';
workplaceCtx.lineWidth = 3;
workplaceCtx.strokeRect(
workplaceStartX,
workplaceStartY,
currentX - workplaceStartX,
currentY - workplaceStartY
);
workplaceCurrentRect = {
x: workplaceStartX,
y: workplaceStartY,
width: currentX - workplaceStartX,
height: currentY - workplaceStartY
};
}
// 드래그 종료
function endWorkplaceDraw(e) {
workplaceIsDrawing = false;
}
// 기존 영역들 그리기
function drawWorkplaceRegions() {
workplaceEquipmentRegions.forEach((region, index) => {
workplaceCtx.strokeStyle = '#10b981';
workplaceCtx.lineWidth = 2;
workplaceCtx.strokeRect(region.x, region.y, region.width, region.height);
// 영역 이름 표시
workplaceCtx.fillStyle = '#10b981';
workplaceCtx.font = '14px sans-serif';
workplaceCtx.fillText(region.equipment_name, region.x + 5, region.y + 20);
});
}
// 현재 영역 지우기
function clearWorkplaceCurrentRegion() {
workplaceCurrentRect = null;
if (workplaceCanvas && workplaceImage) {
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
drawWorkplaceRegions();
}
}
// 설비 위치 저장
function saveWorkplaceEquipmentRegion() {
const equipmentName = document.getElementById('equipmentNameInput');
if (!equipmentName || !equipmentName.value.trim()) {
showToast('설비 이름을 입력해주세요.', 'warning');
return;
}
if (!workplaceCurrentRect) {
showToast('영역을 드래그하여 선택해주세요.', 'warning');
return;
}
// 퍼센트로 변환
const xPercent = (workplaceCurrentRect.x / workplaceCanvas.width) * 100;
const yPercent = (workplaceCurrentRect.y / workplaceCanvas.height) * 100;
const widthPercent = (workplaceCurrentRect.width / workplaceCanvas.width) * 100;
const heightPercent = (workplaceCurrentRect.height / workplaceCanvas.height) * 100;
const newRegion = {
equipment_name: equipmentName.value.trim(),
x: workplaceCurrentRect.x,
y: workplaceCurrentRect.y,
width: workplaceCurrentRect.width,
height: workplaceCurrentRect.height,
x_percent: xPercent,
y_percent: yPercent,
width_percent: widthPercent,
height_percent: heightPercent
};
workplaceEquipmentRegions.push(newRegion);
// UI 업데이트
renderWorkplaceEquipmentList();
clearWorkplaceCurrentRegion();
equipmentName.value = '';
showToast(`설비 "${newRegion.equipment_name}" 위치가 저장되었습니다.`, 'success');
}
// 설비 목록 렌더링
function renderWorkplaceEquipmentList() {
const listDiv = document.getElementById('workplaceEquipmentList');
if (!listDiv) return;
if (workplaceEquipmentRegions.length === 0) {
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>';
return;
}
let html = '';
workplaceEquipmentRegions.forEach((region, index) => {
html += `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px; background: white; border-radius: 6px; margin-bottom: 8px;">
<div>
<strong style="color: #1e293b;">${region.equipment_name}</strong>
<span style="color: #64748b; font-size: 12px; margin-left: 8px;">
(${region.x_percent.toFixed(1)}%, ${region.y_percent.toFixed(1)}%)
</span>
</div>
<button class="btn-small btn-delete" onclick="removeWorkplaceEquipmentRegion(${index})" title="삭제">
🗑️
</button>
</div>
`;
});
listDiv.innerHTML = html;
}
// 설비 영역 삭제
function removeWorkplaceEquipmentRegion(index) {
workplaceEquipmentRegions.splice(index, 1);
renderWorkplaceEquipmentList();
// 캔버스 다시 그리기
if (workplaceCanvas && workplaceImage) {
workplaceCtx.drawImage(workplaceImage, 0, 0, workplaceCanvas.width, workplaceCanvas.height);
drawWorkplaceRegions();
}
showToast('설비 위치가 삭제되었습니다.', 'success');
}
// 작업장 레이아웃 이미지 미리보기
function previewWorkplaceLayoutImage(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const preview = document.getElementById('workplaceLayoutPreview');
if (preview) {
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 100%; border-radius: 4px;">`;
}
};
reader.readAsDataURL(file);
}
}
window.switchCategory = switchCategory;
window.openCategoryModal = openCategoryModal;
window.closeCategoryModal = closeCategoryModal;
@@ -549,3 +1152,13 @@ window.saveWorkplace = saveWorkplace;
window.deleteWorkplace = deleteWorkplace;
window.confirmDeleteWorkplace = confirmDeleteWorkplace;
window.refreshWorkplaces = refreshWorkplaces;
window.showToast = showToast;
window.loadCategories = loadCategories;
window.updateLayoutPreview = updateLayoutPreview;
window.openWorkplaceMapModal = openWorkplaceMapModal;
window.closeWorkplaceMapModal = closeWorkplaceMapModal;
window.uploadWorkplaceLayout = uploadWorkplaceLayout;
window.clearWorkplaceCurrentRegion = clearWorkplaceCurrentRegion;
window.saveWorkplaceEquipmentRegion = saveWorkplaceEquipmentRegion;
window.removeWorkplaceEquipmentRegion = removeWorkplaceEquipmentRegion;
window.previewWorkplaceLayoutImage = previewWorkplaceLayoutImage;

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
<div class="page-container">
<!-- 사이드바 -->
<aside class="sidebar">
<nav class="sidebar-nav">
<div class="sidebar-header">
<h3 class="sidebar-title">관리 메뉴</h3>
</div>
<ul class="sidebar-menu">
<li class="menu-item">
<a href="/pages/admin/projects.html">
<span class="menu-icon">📁</span>
<span class="menu-text">프로젝트 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workers.html">
<span class="menu-icon">👥</span>
<span class="menu-text">작업자 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workplaces.html">
<span class="menu-icon">🏗️</span>
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item active">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>
<span class="menu-text">작업 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/codes.html">
<span class="menu-icon">🏷️</span>
<span class="menu-text">코드 관리</span>
</a>
</li>
<li class="menu-divider"></li>
<li class="menu-item">
<a href="/pages/dashboard.html">
<span class="menu-icon">🏠</span>
<span class="menu-text">대시보드로</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">
<span class="title-icon">⚙️</span>
설비 관리
</h1>
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openEquipmentModal()">
<span>+ 설비 추가</span>
</button>
</div>
</div>
<!-- 필터 영역 -->
<div class="filter-section">
<div class="filter-group">
<label for="filterWorkplace">작업장</label>
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterType">설비 유형</label>
<select id="filterType" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterStatus">상태</label>
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="filter-group">
<label for="searchInput">검색</label>
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
</div>
</div>
<!-- 설비 목록 -->
<div class="content-section">
<div id="equipmentList" class="data-table-container">
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</main>
</div>
<!-- 설비 추가/수정 모달 -->
<div id="equipmentModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2 id="modalTitle">설비 추가</h2>
<button class="btn-close" onclick="closeEquipmentModal()">&times;</button>
</div>
<div class="modal-body">
<form id="equipmentForm">
<input type="hidden" id="equipmentId">
<div class="form-row">
<div class="form-group">
<label for="equipmentCode">설비 코드 *</label>
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
</div>
<div class="form-group">
<label for="equipmentName">설비명 *</label>
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="equipmentType">설비 유형</label>
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
</div>
<div class="form-group">
<label for="workplaceId">작업장</label>
<select id="workplaceId" class="form-control">
<option value="">선택 안함</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="manufacturer">제조사</label>
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
</div>
<div class="form-group">
<label for="modelName">모델명</label>
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialNumber">시리얼 번호</label>
<input type="text" id="serialNumber" class="form-control">
</div>
<div class="form-group">
<label for="installationDate">설치일</label>
<input type="date" id="installationDate" class="form-control">
</div>
</div>
<div class="form-group">
<label for="equipmentStatus">상태</label>
<select id="equipmentStatus" class="form-control">
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="form-group">
<label for="specifications">사양 정보</label>
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
</div>
<div class="form-group">
<label for="notes">비고</label>
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/navbar-loader.js?v=5"></script>
<script src="/js/equipment-management.js?v=1"></script>
</body>
</html>

View File

@@ -101,6 +101,23 @@
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
<div class="code-section" id="layoutMapSection" style="display: none;">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">🗺️</span>
<span id="selectedCategoryName"></span> 레이아웃 지도
</h2>
<button class="btn btn-secondary" onclick="openLayoutMapModal()">
<span class="btn-icon">⚙️</span>
지도 설정
</button>
</div>
<div id="layoutMapPreview" style="padding: 20px; background: #f9fafb; border-radius: 8px; text-align: center;">
<!-- 레이아웃 이미지 미리보기가 여기에 표시됩니다 -->
</div>
</div>
<!-- 작업장 목록 -->
<div class="code-section">
<div class="section-header">
@@ -195,6 +212,26 @@
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
</div>
<div class="form-group">
<label class="form-label">작업장 용도</label>
<select id="workplacePurpose" class="form-control">
<option value="">선택 안 함</option>
<option value="작업구역">작업구역</option>
<option value="설비">설비</option>
<option value="휴게시설">휴게시설</option>
<option value="회의실">회의실</option>
<option value="창고">창고</option>
<option value="기타">기타</option>
</select>
<small class="form-help">작업장의 주요 용도를 선택하세요</small>
</div>
<div class="form-group">
<label class="form-label">표시 순서</label>
<input type="number" id="displayPriority" class="form-control" value="0" min="0">
<small class="form-help">숫자가 작을수록 먼저 표시됩니다</small>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
@@ -213,9 +250,159 @@
</div>
</div>
</div>
<!-- 작업장 지도 관리 모달 -->
<div id="workplaceMapModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
<div class="modal-header">
<h2 id="workplaceMapModalTitle">작업장 지도 관리</h2>
<button class="modal-close-btn" onclick="closeWorkplaceMapModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- Step 1: 이미지 업로드 -->
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 작업장 레이아웃 이미지 업로드</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
작업장의 상세 레이아웃 이미지를 업로드하세요
</p>
<div class="form-group">
<label class="form-label">현재 이미지</label>
<div id="workplaceLayoutPreview" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 200px;">
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
</div>
</div>
<div class="form-group">
<label class="form-label">새 이미지 업로드</label>
<input type="file" id="workplaceLayoutFile" accept="image/*" class="form-control" onchange="previewWorkplaceLayoutImage(event)">
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
</div>
<button type="button" class="btn btn-primary" onclick="uploadWorkplaceLayout()">
📤 이미지 업로드
</button>
</div>
<!-- Step 2: 설비/영역 정의 -->
<div class="form-section">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 설비 위치 정의 (선택사항)</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
작업장 이미지 위에 마우스로 드래그하여 각 설비의 위치를 지정하세요
</p>
<!-- 영역 그리기 캔버스 -->
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="workplaceCanvasContainer">
<canvas id="workplaceRegionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
</div>
<!-- 설비 선택 및 영역 목록 -->
<div class="form-group">
<label class="form-label">설비 이름 입력</label>
<input type="text" id="equipmentNameInput" class="form-control" placeholder="예: CNC-01, 선반기-A" style="margin-bottom: 12px;">
<small class="form-help">드래그로 영역을 선택한 후 설비 이름을 입력하고 저장하세요</small>
<div style="display: flex; gap: 8px; margin-top: 12px;">
<button type="button" class="btn btn-secondary" onclick="clearWorkplaceCurrentRegion()">
🗑️ 현재 영역 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveWorkplaceEquipmentRegion()">
💾 설비 위치 저장
</button>
</div>
</div>
<!-- 정의된 영역 목록 -->
<div class="form-group">
<label class="form-label">정의된 설비 목록</label>
<div id="workplaceEquipmentList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceMapModal()">닫기</button>
</div>
</div>
</div>
<!-- 레이아웃 지도 설정 모달 -->
<div id="layoutMapModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
<div class="modal-header">
<h2>🗺️ 공장 레이아웃 지도 설정</h2>
<button class="modal-close-btn" onclick="closeLayoutMapModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- Step 1: 이미지 업로드 -->
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
<div class="form-group">
<label class="form-label">현재 이미지</label>
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
</div>
</div>
<div class="form-group">
<label class="form-label">새 이미지 업로드</label>
<input type="file" id="layoutImageFile" accept="image/*" class="form-control" onchange="previewLayoutImage(event)">
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
</div>
<button type="button" class="btn btn-primary" onclick="uploadLayoutImage()">
📤 이미지 업로드
</button>
</div>
<!-- Step 2: 작업장 영역 정의 -->
<div class="form-section">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
</p>
<!-- 영역 그리기 캔버스 -->
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
</div>
<!-- 작업장 선택 및 영역 목록 -->
<div class="form-group">
<label class="form-label">작업장 선택</label>
<select id="regionWorkplaceSelect" class="form-control" style="margin-bottom: 12px;">
<option value="">작업장을 선택하세요</option>
</select>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn btn-secondary" onclick="clearCurrentRegion()">
🗑️ 현재 영역 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveRegion()">
💾 선택 영역 저장
</button>
</div>
</div>
<!-- 정의된 영역 목록 -->
<div class="form-group">
<label class="form-label">정의된 영역 목록</label>
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
<!-- 영역 목록이 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeLayoutMapModal()">닫기</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/workplace-management.js?v=1"></script>
<script type="module" src="/js/workplace-management.js?v=3"></script>
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
</body>
</html>

View File

@@ -0,0 +1,248 @@
# 2026-01-28: 설비 관리 시스템 구축
## 📋 작업 개요
작업장별 설비 정보를 등록하고 관리할 수 있는 시스템을 구축했습니다.
## ✅ 완료된 작업
### 1. 데이터베이스 구조
**파일:** `api.hyungi.net/db/migrations/20260128000000_add_layout_image_to_workplaces.js`
- workplaces 테이블에 `layout_image` 컬럼 추가
- 작업장별 레이아웃 이미지 경로 저장
**파일:** `api.hyungi.net/db/migrations/20260128010000_create_equipments_table.js`
- equipments 테이블 생성
- 필드:
- 기본 정보: equipment_code, equipment_name, equipment_type
- 상세 정보: manufacturer, model_name, serial_number, installation_date
- 작업장 연결: workplace_id (외래키)
- 지도 위치: map_x_percent, map_y_percent, map_width_percent, map_height_percent
- 상태 관리: status (active, maintenance, inactive)
### 2. 백엔드 API
**파일:** `api.hyungi.net/models/equipmentModel.js`
- 설비 CRUD 작업
- 필터링 기능 (작업장, 유형, 상태, 검색)
- 지도 위치 업데이트
- 설비 코드 중복 확인
- 사용 중인 설비 유형 목록 조회
**파일:** `api.hyungi.net/controllers/equipmentController.js`
- 설비 생성/조회/수정/삭제 컨트롤러
- 유효성 검사 및 에러 처리
- 작업장별/유형별 필터링
**파일:** `api.hyungi.net/routes/equipmentRoutes.js`
- `/api/equipments` 엔드포인트
- REST API 구현 (GET, POST, PUT, PATCH, DELETE)
**파일:** `api.hyungi.net/config/routes.js`
- 설비 라우트 등록 (line 44, 142)
### 3. 프론트엔드
**파일:** `web-ui/pages/admin/equipments.html`
- 설비 관리 페이지 UI
- 필터링 섹션 (작업장, 유형, 상태, 검색)
- 설비 목록 테이블
- 설비 추가/수정 모달
**파일:** `web-ui/js/equipment-management.js`
- 설비 데이터 로드 및 렌더링
- 필터링 및 검색 기능
- CRUD 작업 처리
- 작업장/유형 드롭다운 자동 채우기
### 4. 작업장 관리 개선
**파일:** `web-ui/pages/admin/workplaces.html`
- 작업장 지도 모달 추가 (line 254-329)
- 설비 위치 정의 기능 (캔버스 기반 드래그)
**파일:** `web-ui/js/workplace-management.js`
- 작업장 레이아웃 이미지 업로드 (line 828-878)
- 설비 영역 정의 및 저장 (line 943-1126)
- 백분율 기반 좌표 시스템
### 5. 권한 시스템 연동
- pages 테이블에 설비 관리 페이지 등록
- page_key: `equipments`
- 경로: `/pages/admin/equipments.html`
- Admin 전용 페이지
## 🐛 버그 수정
### 1. 이미지 보존 로직 개선
**파일:** `api.hyungi.net/controllers/workplaceController.js`
- **위치:** line 130-136 (updateCategory), line 310-316 (updateWorkplace)
- **문제:** `null` 값으로 인한 이미지 삭제
- **해결:** `undefined``null` 모두 체크하도록 개선
```javascript
// 변경 전
layout_image: categoryData.layout_image !== undefined
? categoryData.layout_image
: existingCategory.layout_image
// 변경 후
layout_image: (categoryData.layout_image !== undefined && categoryData.layout_image !== null)
? categoryData.layout_image
: existingCategory.layout_image
```
### 2. 작업장 레이아웃 이미지 업로드 경로 수정
**파일:** `api.hyungi.net/routes/workplaceRoutes.js`
- **위치:** line 11
- **문제:** Docker 볼륨 마운트와 업로드 경로 불일치
- **해결:** `../public/uploads``../uploads`로 변경
## 📁 파일 구조
```
api.hyungi.net/
├── controllers/
│ ├── equipmentController.js (신규)
│ └── workplaceController.js (수정)
├── models/
│ └── equipmentModel.js (신규)
├── routes/
│ ├── equipmentRoutes.js (신규)
│ └── workplaceRoutes.js (수정)
├── config/
│ └── routes.js (수정)
└── db/migrations/
├── 20260128000000_add_layout_image_to_workplaces.js (신규)
└── 20260128010000_create_equipments_table.js (신규)
web-ui/
├── pages/admin/
│ ├── equipments.html (신규)
│ └── workplaces.html (수정)
└── js/
├── equipment-management.js (신규)
└── workplace-management.js (수정)
```
## 🎯 주요 기능
### 설비 관리
- ✅ 설비 등록/수정/삭제
- ✅ 작업장별 연결
- ✅ 설비 유형별 분류
- ✅ 상태 관리 (활성/정비중/비활성)
- ✅ 상세 정보 관리 (제조사, 모델명, 시리얼 번호, 설치일)
- ✅ 필터링 및 검색
### 작업장 지도 연동
- ✅ 작업장 레이아웃 이미지 업로드
- ✅ 설비 위치 캔버스 드래그로 정의
- ✅ 백분율 기반 좌표 (반응형 지원)
## 🔗 API 엔드포인트
```
POST /api/equipments - 설비 생성
GET /api/equipments - 설비 목록 조회 (필터링 가능)
GET /api/equipments/active/list - 활성 설비 조회
GET /api/equipments/types - 설비 유형 목록
GET /api/equipments/:id - 설비 상세 조회
PUT /api/equipments/:id - 설비 수정
PATCH /api/equipments/:id/map-position - 지도 위치 업데이트
DELETE /api/equipments/:id - 설비 삭제
```
## 🚀 사용 방법
1. **설비 관리 페이지 접속**
- URL: http://localhost:20000/pages/admin/equipments.html
2. **설비 추가**
- "설비 추가" 버튼 클릭
- 필수 정보 입력 (설비 코드, 설비명)
- 선택 정보 입력 (유형, 작업장, 제조사 등)
- 저장
3. **작업장 지도에서 설비 위치 정의**
- 작업장 관리 페이지 접속
- 작업장 카드에서 🗺️ 버튼 클릭
- Step 1: 레이아웃 이미지 업로드
- Step 2: 설비 영역 드래그로 정의
4. **필터링 및 검색**
- 작업장별 필터
- 유형별 필터
- 상태별 필터
- 설비명/코드 검색
## 📊 데이터베이스 변경사항
### workplaces 테이블
```sql
ALTER TABLE workplaces
ADD COLUMN layout_image VARCHAR(500) NULL COMMENT '작업장 레이아웃 이미지 경로';
```
### equipments 테이블 (신규)
```sql
CREATE TABLE equipments (
equipment_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_code VARCHAR(50) UNIQUE NOT NULL,
equipment_name VARCHAR(100) NOT NULL,
equipment_type VARCHAR(50),
model_name VARCHAR(100),
manufacturer VARCHAR(100),
installation_date DATE,
serial_number VARCHAR(100),
specifications TEXT,
status ENUM('active', 'maintenance', 'inactive') DEFAULT 'active',
notes TEXT,
workplace_id INT,
map_x_percent DECIMAL(5,2),
map_y_percent DECIMAL(5,2),
map_width_percent DECIMAL(5,2),
map_height_percent DECIMAL(5,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (workplace_id) REFERENCES workplaces(workplace_id) ON DELETE SET NULL,
INDEX idx_workplace (workplace_id),
INDEX idx_type (equipment_type),
INDEX idx_status (status)
);
```
### pages 테이블
```sql
INSERT INTO pages (page_key, page_name, page_path, category, description, is_admin_only, display_order)
VALUES ('equipments', '설비 관리', '/pages/admin/equipments.html', '관리',
'작업장별 설비 정보를 등록하고 관리합니다', 1, 35);
```
## 💡 기술적 고려사항
### 1. 백분율 기반 좌표 시스템
- 반응형 지원을 위해 픽셀 대신 백분율 사용
- 다양한 화면 크기에서 일관된 표시
### 2. Docker 볼륨 마운트
- `/uploads` 폴더로 통일
- 컨테이너 재시작 시에도 파일 유지
### 3. 이미지 보존 로직
- null과 undefined 모두 처리
- 의도하지 않은 이미지 삭제 방지
## 🔄 향후 개선사항
- [ ] 설비 이력 관리 (정비 내역, 고장 이력)
- [ ] 설비 성능 지표 추적
- [ ] 설비별 작업 배정 기능
- [ ] 설비 가동률 통계
- [ ] QR 코드 스캔 기능
- [ ] 설비 이미지 업로드
## 📝 참고사항
- API 컨테이너는 자동으로 재시작되어 변경사항 반영
- 기존 작업장 레이아웃 이미지는 수동으로 이동됨
- 권한은 Admin 전용으로 설정됨