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:
@@ -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 문서
|
||||
|
||||
349
api.hyungi.net/controllers/equipmentController.js
Normal file
349
api.hyungi.net/controllers/equipmentController.js
Normal 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;
|
||||
@@ -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: '지도 영역이 성공적으로 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 컬럼 제거 완료');
|
||||
};
|
||||
@@ -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 테이블 삭제 완료');
|
||||
};
|
||||
300
api.hyungi.net/models/equipmentModel.js
Normal file
300
api.hyungi.net/models/equipmentModel.js
Normal 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;
|
||||
36
api.hyungi.net/routes/equipmentRoutes.js
Normal file
36
api.hyungi.net/routes/equipmentRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
344
web-ui/js/equipment-management.js
Normal file
344
web-ui/js/equipment-management.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
219
web-ui/pages/admin/equipments.html
Normal file
219
web-ui/pages/admin/equipments.html
Normal 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()">×</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>
|
||||
@@ -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>
|
||||
|
||||
248
개발 log/2026-01-28-equipment-management-system.md
Normal file
248
개발 log/2026-01-28-equipment-management-system.md
Normal 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 전용으로 설정됨
|
||||
Reference in New Issue
Block a user