feat: 작업장 관리 기능 추가 (공장-작업장 계층 구조)

- 공장(카테고리) 및 작업장 CRUD API 구현
- 탭 기반 UI로 공장별 작업장 필터링
- 터치 최적화된 관리자 페이지
- DB 테이블: workplace_categories, workplaces
- 관리자 메뉴에 작업장 관리 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-01-26 14:31:58 +09:00
parent ffabcaf579
commit f27728b168
11 changed files with 1498 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ function setupRoutes(app) {
const attendanceRoutes = require('../routes/attendanceRoutes');
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
const workplaceRoutes = require('../routes/workplaceRoutes');
// const tbmRoutes = require('../routes/tbmRoutes'); // 임시 비활성화 - db/connection 문제
// Rate Limiters 설정
@@ -127,6 +128,7 @@ function setupRoutes(app) {
app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute);
app.use('/api/users', userRoutes);
app.use('/api/workplaces', workplaceRoutes);
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
// app.use('/api/tbm', tbmRoutes); // TBM 시스템 - 임시 비활성화
app.use('/api', uploadBgRoutes);

View File

@@ -0,0 +1,314 @@
/**
* 작업장 관리 컨트롤러
*
* 작업장 카테고리(공장) 및 작업장 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const workplaceModel = require('../models/workplaceModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
exports.createCategory = asyncHandler(async (req, res) => {
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 생성 요청', { name: categoryData.category_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createCategory(categoryData, (err, lastID) => {
if (err) reject(new DatabaseError('카테고리 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('카테고리 생성 성공', { category_id: id });
res.status(201).json({
success: true,
data: { category_id: id },
message: '카테고리가 성공적으로 생성되었습니다'
});
});
/**
* 전체 카테고리 조회
*/
exports.getAllCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllCategories((err, data) => {
if (err) reject(new DatabaseError('카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '카테고리 목록 조회 성공'
});
});
/**
* 활성 카테고리만 조회
*/
exports.getActiveCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveCategories((err, data) => {
if (err) reject(new DatabaseError('활성 카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 카테고리 목록 조회 성공'
});
});
/**
* 단일 카테고리 조회
*/
exports.getCategoryById = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
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('카테고리를 찾을 수 없습니다');
}
res.json({
success: true,
data: category,
message: '카테고리 조회 성공'
});
});
/**
* 카테고리 수정
*/
exports.updateCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 수정 요청', { category_id: categoryId });
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, categoryData, (err, result) => {
if (err) reject(new DatabaseError('카테고리 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 수정 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 수정되었습니다'
});
});
/**
* 카테고리 삭제
*/
exports.deleteCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
logger.info('카테고리 삭제 요청', { category_id: categoryId });
await new Promise((resolve, reject) => {
workplaceModel.deleteCategory(categoryId, (err, result) => {
if (err) reject(new DatabaseError('카테고리 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 삭제 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
exports.createWorkplace = asyncHandler(async (req, res) => {
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 생성 요청', { name: workplaceData.workplace_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createWorkplace(workplaceData, (err, lastID) => {
if (err) reject(new DatabaseError('작업장 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('작업장 생성 성공', { workplace_id: id });
res.status(201).json({
success: true,
data: { workplace_id: id },
message: '작업장이 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업장 조회
*/
exports.getAllWorkplaces = asyncHandler(async (req, res) => {
const categoryId = req.query.category_id;
// 카테고리별 필터링
if (categoryId) {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getWorkplacesByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
return res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
}
// 전체 조회
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllWorkplaces((err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
});
/**
* 활성 작업장만 조회
*/
exports.getActiveWorkplaces = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveWorkplaces((err, data) => {
if (err) reject(new DatabaseError('활성 작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 작업장 목록 조회 성공'
});
});
/**
* 단일 작업장 조회
*/
exports.getWorkplaceById = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
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('작업장을 찾을 수 없습니다');
}
res.json({
success: true,
data: workplace,
message: '작업장 조회 성공'
});
});
/**
* 작업장 수정
*/
exports.updateWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 수정 요청', { workplace_id: workplaceId });
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, workplaceData, (err, result) => {
if (err) reject(new DatabaseError('작업장 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 수정 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 수정되었습니다'
});
});
/**
* 작업장 삭제
*/
exports.deleteWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
logger.info('작업장 삭제 요청', { workplace_id: workplaceId });
await new Promise((resolve, reject) => {
workplaceModel.deleteWorkplace(workplaceId, (err, result) => {
if (err) reject(new DatabaseError('작업장 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 삭제 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,24 @@
/**
* 작업장 카테고리(공장) 테이블 생성 마이그레이션
* 대분류: 제 1공장, 제 2공장 등
*/
exports.up = function(knex) {
return knex.schema.createTable('workplace_categories', function(table) {
table.increments('category_id').primary().comment('카테고리 ID');
table.string('category_name', 100).notNullable().comment('카테고리명 (예: 제 1공장)');
table.text('description').nullable().comment('설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 인덱스
table.index('is_active');
table.index('display_order');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('workplace_categories');
};

View File

@@ -0,0 +1,31 @@
/**
* 작업장(작업 구역) 테이블 생성 마이그레이션
* 소분류: 서스작업장, 조립구역 등
*/
exports.up = function(knex) {
return knex.schema.createTable('workplaces', function(table) {
table.increments('workplace_id').primary().comment('작업장 ID');
table.integer('category_id').unsigned().nullable().comment('카테고리 ID (공장)');
table.string('workplace_name', 255).notNullable().comment('작업장명');
table.text('description').nullable().comment('설명');
table.boolean('is_active').defaultTo(true).comment('활성화 여부');
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('생성일시');
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정일시');
// 외래키
table.foreign('category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('category_id');
table.index('is_active');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('workplaces');
};

View File

@@ -0,0 +1,297 @@
const { getDb } = require('../dbPool');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
const createCategory = async (category, callback) => {
try {
const db = await getDb();
const {
category_name,
description = null,
display_order = 0,
is_active = true
} = category;
const [result] = await db.query(
`INSERT INTO workplace_categories
(category_name, description, display_order, is_active)
VALUES (?, ?, ?, ?)`,
[category_name, description, display_order, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
FROM workplace_categories
ORDER BY display_order ASC, category_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 카테고리만 조회
*/
const getActiveCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
FROM workplace_categories
WHERE is_active = TRUE
ORDER BY display_order ASC, category_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 카테고리 조회
*/
const getCategoryById = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
FROM workplace_categories
WHERE category_id = ?`,
[categoryId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, category, callback) => {
try {
const db = await getDb();
const {
category_name,
description,
display_order,
is_active
} = category;
const [result] = await db.query(
`UPDATE workplace_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?, updated_at = NOW()
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
const createWorkplace = async (workplace, callback) => {
try {
const db = await getDb();
const {
category_id = null,
workplace_name,
description = null,
is_active = true
} = workplace;
const [result] = await db.query(
`INSERT INTO workplaces
(category_id, workplace_name, description, is_active)
VALUES (?, ?, ?, ?)`,
[category_id, workplace_name, description, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 작업장 조회 (카테고리 정보 포함)
*/
const getAllWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
ORDER BY wc.display_order ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 작업장만 조회
*/
const getActiveWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.is_active = TRUE
ORDER BY wc.display_order ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 작업장 조회
*/
const getWorkplacesByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.category_id = ?
ORDER BY w.workplace_id DESC`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 작업장 조회
*/
const getWorkplaceById = async (workplaceId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?`,
[workplaceId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 작업장 수정
*/
const updateWorkplace = async (workplaceId, workplace, callback) => {
try {
const db = await getDb();
const {
category_id,
workplace_name,
description,
is_active
} = workplace;
const [result] = await db.query(
`UPDATE workplaces
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?, updated_at = NOW()
WHERE workplace_id = ?`,
[category_id, workplace_name, description, is_active, workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 작업장 삭제
*/
const deleteWorkplace = async (workplaceId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplaces WHERE workplace_id = ?`,
[workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
createCategory,
getAllCategories,
getActiveCategories,
getCategoryById,
updateCategory,
deleteCategory,
// 작업장
createWorkplace,
getAllWorkplaces,
getActiveWorkplaces,
getWorkplacesByCategory,
getWorkplaceById,
updateWorkplace,
deleteWorkplace
};

View File

@@ -0,0 +1,46 @@
// routes/workplaceRoutes.js
const express = require('express');
const router = express.Router();
const workplaceController = require('../controllers/workplaceController');
// ==================== 카테고리(공장) 관리 ====================
// CREATE 카테고리
router.post('/categories', workplaceController.createCategory);
// READ ALL 카테고리
router.get('/categories', workplaceController.getAllCategories);
// READ ACTIVE 카테고리
router.get('/categories/active/list', workplaceController.getActiveCategories);
// READ ONE 카테고리
router.get('/categories/:id', workplaceController.getCategoryById);
// UPDATE 카테고리
router.put('/categories/:id', workplaceController.updateCategory);
// DELETE 카테고리
router.delete('/categories/:id', workplaceController.deleteCategory);
// ==================== 작업장 관리 ====================
// CREATE 작업장
router.post('/', workplaceController.createWorkplace);
// READ ALL 작업장 (쿼리 파라미터로 카테고리 필터링 가능: ?category_id=1)
router.get('/', workplaceController.getAllWorkplaces);
// READ ACTIVE 작업장
router.get('/active/list', workplaceController.getActiveWorkplaces);
// READ ONE 작업장
router.get('/:id', workplaceController.getWorkplaceById);
// UPDATE 작업장
router.put('/:id', workplaceController.updateWorkplace);
// DELETE 작업장
router.delete('/:id', workplaceController.deleteWorkplace);
module.exports = router;

View File

@@ -0,0 +1,551 @@
// 작업장 관리 페이지 JavaScript
// 전역 변수
let categories = [];
let workplaces = [];
let currentCategoryId = '';
let currentEditingCategory = null;
let currentEditingWorkplace = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('🏗️ 작업장 관리 페이지 초기화 시작');
loadAllData();
});
// 모든 데이터 로드
async function loadAllData() {
try {
await Promise.all([
loadCategories(),
loadWorkplaces()
]);
renderCategoryTabs();
renderWorkplaces();
updateStatistics();
} catch (error) {
console.error('데이터 로딩 오류:', error);
showToast('데이터를 불러오는데 실패했습니다.', 'error');
}
}
// ==================== 카테고리(공장) 관련 ====================
// 카테고리 목록 로드
async function loadCategories() {
try {
const response = await apiCall('/workplaces/categories', 'GET');
let categoryData = [];
if (response && response.success && Array.isArray(response.data)) {
categoryData = response.data;
} else if (Array.isArray(response)) {
categoryData = response;
}
categories = categoryData;
console.log(`✅ 카테고리 ${categories.length}개 로드 완료`);
} catch (error) {
console.error('카테고리 로딩 오류:', error);
categories = [];
}
}
// 카테고리 탭 렌더링
function renderCategoryTabs() {
const tabsContainer = document.getElementById('categoryTabs');
if (!tabsContainer) return;
// 전체 탭은 항상 표시
let tabsHtml = `
<button class="tab-btn ${currentCategoryId === '' ? 'active' : ''}"
data-category=""
onclick="switchCategory('')">
<span class="tab-icon">🏗️</span>
전체 (${workplaces.length})
</button>
`;
// 각 카테고리 탭 추가
categories.forEach(category => {
const count = workplaces.filter(w => w.category_id === category.category_id).length;
const isActive = currentCategoryId === category.category_id;
tabsHtml += `
<button class="tab-btn ${isActive ? 'active' : ''}"
data-category="${category.category_id}"
onclick="switchCategory(${category.category_id})">
<span class="tab-icon">🏭</span>
${category.category_name} (${count})
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
// 카테고리 전환
function switchCategory(categoryId) {
currentCategoryId = categoryId === '' ? '' : categoryId;
renderCategoryTabs();
renderWorkplaces();
}
// 카테고리 모달 열기
function openCategoryModal(categoryData = null) {
const modal = document.getElementById('categoryModal');
const modalTitle = document.getElementById('categoryModalTitle');
const deleteBtn = document.getElementById('deleteCategoryBtn');
if (!modal) return;
currentEditingCategory = categoryData;
if (categoryData) {
// 수정 모드
modalTitle.textContent = '공장 수정';
deleteBtn.style.display = 'inline-flex';
document.getElementById('categoryId').value = categoryData.category_id;
document.getElementById('categoryName').value = categoryData.category_name || '';
document.getElementById('categoryDescription').value = categoryData.description || '';
document.getElementById('categoryOrder').value = categoryData.display_order || 0;
} else {
// 신규 등록 모드
modalTitle.textContent = '공장 추가';
deleteBtn.style.display = 'none';
document.getElementById('categoryForm').reset();
document.getElementById('categoryId').value = '';
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
setTimeout(() => {
document.getElementById('categoryName').focus();
}, 100);
}
// 카테고리 모달 닫기
function closeCategoryModal() {
const modal = document.getElementById('categoryModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingCategory = null;
}
}
// 카테고리 저장
async function saveCategory() {
try {
const categoryId = document.getElementById('categoryId').value;
const categoryData = {
category_name: document.getElementById('categoryName').value.trim(),
description: document.getElementById('categoryDescription').value.trim() || null,
display_order: parseInt(document.getElementById('categoryOrder').value) || 0,
is_active: true
};
if (!categoryData.category_name) {
showToast('공장명은 필수 입력 항목입니다.', 'error');
return;
}
console.log('💾 저장할 카테고리 데이터:', categoryData);
let response;
if (categoryId) {
// 수정
response = await apiCall(`/workplaces/categories/${categoryId}`, 'PUT', categoryData);
} else {
// 신규 등록
response = await apiCall('/workplaces/categories', 'POST', categoryData);
}
if (response && (response.success || response.category_id)) {
const action = categoryId ? '수정' : '등록';
showToast(`공장이 성공적으로 ${action}되었습니다.`, 'success');
closeCategoryModal();
await loadAllData();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 저장 오류:', error);
showToast(error.message || '카테고리 저장 중 오류가 발생했습니다.', 'error');
}
}
// 카테고리 삭제
async function deleteCategory() {
if (!currentEditingCategory) return;
if (!confirm(`"${currentEditingCategory.category_name}" 공장을 정말 삭제하시겠습니까?\n\n⚠️ 이 공장에 속한 모든 작업장의 공장 정보가 제거됩니다.`)) {
return;
}
try {
const response = await apiCall(`/workplaces/categories/${currentEditingCategory.category_id}`, 'DELETE');
if (response && response.success) {
showToast('공장이 성공적으로 삭제되었습니다.', 'success');
closeCategoryModal();
await loadAllData();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 삭제 오류:', error);
showToast(error.message || '카테고리 삭제 중 오류가 발생했습니다.', 'error');
}
}
// ==================== 작업장 관련 ====================
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await apiCall('/workplaces', 'GET');
let workplaceData = [];
if (response && response.success && Array.isArray(response.data)) {
workplaceData = response.data;
} else if (Array.isArray(response)) {
workplaceData = response;
}
workplaces = workplaceData;
console.log(`✅ 작업장 ${workplaces.length}개 로드 완료`);
} catch (error) {
console.error('작업장 로딩 오류:', error);
workplaces = [];
}
}
// 작업장 렌더링
function renderWorkplaces() {
const grid = document.getElementById('workplaceGrid');
if (!grid) return;
// 현재 카테고리별 필터링
const filtered = currentCategoryId === ''
? workplaces
: workplaces.filter(w => w.category_id == currentCategoryId);
if (filtered.length === 0) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🏗️</div>
<h3>등록된 작업장이 없습니다.</h3>
<p>"작업장 추가" 버튼을 눌러 작업장을 등록해보세요.</p>
<button class="btn btn-primary" onclick="openWorkplaceModal()">
첫 작업장 추가하기
</button>
</div>
`;
return;
}
let gridHtml = '';
filtered.forEach(workplace => {
const categoryName = workplace.category_name || '미분류';
const isActive = workplace.is_active === 1 || workplace.is_active === true;
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-info">
<h3 class="code-name">${workplace.workplace_name}</h3>
${workplace.category_id ? `<span class="code-label">🏭 ${categoryName}</span>` : ''}
</div>
<div class="code-actions">
<button class="btn-small btn-edit" onclick="event.stopPropagation(); editWorkplace(${workplace.workplace_id})" title="수정">
✏️
</button>
<button class="btn-small btn-delete" onclick="event.stopPropagation(); confirmDeleteWorkplace(${workplace.workplace_id})" title="삭제">
🗑️
</button>
</div>
</div>
${workplace.description ? `<p class="code-description">${workplace.description}</p>` : ''}
<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>` : ''}
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
}
// 작업장 모달 열기
function openWorkplaceModal(workplaceData = null) {
const modal = document.getElementById('workplaceModal');
const modalTitle = document.getElementById('workplaceModalTitle');
const deleteBtn = document.getElementById('deleteWorkplaceBtn');
const categorySelect = document.getElementById('workplaceCategoryId');
if (!modal) return;
currentEditingWorkplace = workplaceData;
// 카테고리 선택 옵션 업데이트
let categoryOptions = '<option value="">미분류</option>';
categories.forEach(cat => {
categoryOptions += `<option value="${cat.category_id}">${cat.category_name}</option>`;
});
categorySelect.innerHTML = categoryOptions;
if (workplaceData) {
// 수정 모드
modalTitle.textContent = '작업장 수정';
deleteBtn.style.display = 'inline-flex';
document.getElementById('workplaceId').value = workplaceData.workplace_id;
document.getElementById('workplaceCategoryId').value = workplaceData.category_id || '';
document.getElementById('workplaceName').value = workplaceData.workplace_name || '';
document.getElementById('workplaceDescription').value = workplaceData.description || '';
} else {
// 신규 등록 모드
modalTitle.textContent = '작업장 추가';
deleteBtn.style.display = 'none';
document.getElementById('workplaceForm').reset();
document.getElementById('workplaceId').value = '';
// 현재 선택된 카테고리가 있으면 자동 선택
if (currentCategoryId) {
document.getElementById('workplaceCategoryId').value = currentCategoryId;
}
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
setTimeout(() => {
document.getElementById('workplaceName').focus();
}, 100);
}
// 작업장 모달 닫기
function closeWorkplaceModal() {
const modal = document.getElementById('workplaceModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingWorkplace = null;
}
}
// 작업장 편집
function editWorkplace(workplaceId) {
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
if (workplace) {
openWorkplaceModal(workplace);
} else {
showToast('작업장을 찾을 수 없습니다.', 'error');
}
}
// 작업장 저장
async function saveWorkplace() {
try {
const workplaceId = document.getElementById('workplaceId').value;
const workplaceData = {
category_id: document.getElementById('workplaceCategoryId').value || null,
workplace_name: document.getElementById('workplaceName').value.trim(),
description: document.getElementById('workplaceDescription').value.trim() || null,
is_active: true
};
if (!workplaceData.workplace_name) {
showToast('작업장명은 필수 입력 항목입니다.', 'error');
return;
}
console.log('💾 저장할 작업장 데이터:', workplaceData);
let response;
if (workplaceId) {
// 수정
response = await apiCall(`/workplaces/${workplaceId}`, 'PUT', workplaceData);
} else {
// 신규 등록
response = await apiCall('/workplaces', 'POST', workplaceData);
}
if (response && (response.success || response.workplace_id)) {
const action = workplaceId ? '수정' : '등록';
showToast(`작업장이 성공적으로 ${action}되었습니다.`, 'success');
closeWorkplaceModal();
await loadAllData();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('작업장 저장 오류:', error);
showToast(error.message || '작업장 저장 중 오류가 발생했습니다.', 'error');
}
}
// 작업장 삭제 확인
function confirmDeleteWorkplace(workplaceId) {
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
if (!workplace) {
showToast('작업장을 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${workplace.workplace_name}" 작업장을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 작업장은 복구할 수 없습니다.`)) {
deleteWorkplaceById(workplaceId);
}
}
// 작업장 삭제 (수정 모드에서)
function deleteWorkplace() {
if (currentEditingWorkplace) {
confirmDeleteWorkplace(currentEditingWorkplace.workplace_id);
}
}
// 작업장 삭제 실행
async function deleteWorkplaceById(workplaceId) {
try {
const response = await apiCall(`/workplaces/${workplaceId}`, 'DELETE');
if (response && response.success) {
showToast('작업장이 성공적으로 삭제되었습니다.', 'success');
closeWorkplaceModal();
await loadAllData();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('작업장 삭제 오류:', error);
showToast(error.message || '작업장 삭제 중 오류가 발생했습니다.', 'error');
}
}
// ==================== 유틸리티 ====================
// 전체 새로고침
async function refreshWorkplaces() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon">⏳</span>새로고침 중...';
refreshBtn.disabled = true;
await loadAllData();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await loadAllData();
}
showToast('데이터가 새로고침되었습니다.', 'success');
}
// 통계 업데이트
function updateStatistics() {
const total = workplaces.length;
const active = workplaces.filter(w => w.is_active === 1 || w.is_active === true).length;
document.getElementById('totalCount').textContent = total;
document.getElementById('activeCount').textContent = active;
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 토스트 메시지 표시
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// 새 토스트 생성
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// 스타일 적용
Object.assign(toast.style, {
position: 'fixed',
top: '20px',
right: '20px',
padding: '12px 24px',
borderRadius: '8px',
color: 'white',
fontWeight: '500',
zIndex: '1000',
transform: 'translateX(100%)',
transition: 'transform 0.3s ease'
});
// 타입별 배경색
const colors = {
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
};
toast.style.backgroundColor = colors[type] || colors.info;
document.body.appendChild(toast);
// 애니메이션
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// 자동 제거
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 전역 함수로 노출
window.switchCategory = switchCategory;
window.openCategoryModal = openCategoryModal;
window.closeCategoryModal = closeCategoryModal;
window.saveCategory = saveCategory;
window.deleteCategory = deleteCategory;
window.openWorkplaceModal = openWorkplaceModal;
window.closeWorkplaceModal = closeWorkplaceModal;
window.editWorkplace = editWorkplace;
window.saveWorkplace = saveWorkplace;
window.deleteWorkplace = deleteWorkplace;
window.confirmDeleteWorkplace = confirmDeleteWorkplace;
window.refreshWorkplaces = refreshWorkplaces;

View File

@@ -35,6 +35,12 @@
<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/codes.html">
<span class="menu-icon">🏷️</span>

View File

@@ -34,6 +34,12 @@
<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">
<a href="/pages/admin/codes.html">
<span class="menu-icon">🏷️</span>

View File

@@ -35,6 +35,12 @@
<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">
<a href="/pages/admin/codes.html">
<span class="menu-icon">🏷️</span>

View File

@@ -0,0 +1,215 @@
<!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 active">
<a href="/pages/admin/workplaces.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="openCategoryModal()">
<span class="btn-icon"></span>
공장 추가
</button>
<button class="btn btn-primary" onclick="openWorkplaceModal()">
<span class="btn-icon"></span>
작업장 추가
</button>
<button class="btn btn-secondary" onclick="refreshWorkplaces()">
<span class="btn-icon">🔄</span>
새로고침
</button>
</div>
</div>
<!-- 공장(카테고리) 탭 -->
<div class="code-tabs" id="categoryTabs">
<button class="tab-btn active" data-category="" onclick="switchCategory('')">
<span class="tab-icon">🏗️</span>
전체
</button>
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 작업장 목록 -->
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">🏭</span>
작업장 목록
</h2>
</div>
<div class="code-stats" id="workplaceStats">
<span class="stat-item">
<span class="stat-icon">🏗️</span>
전체 <span id="totalCount">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
활성 <span id="activeCount">0</span>
</span>
</div>
<div class="code-grid" id="workplaceGrid">
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</main>
<!-- 공장(카테고리) 추가/수정 모달 -->
<div id="categoryModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="categoryModalTitle">공장 추가</h2>
<button class="modal-close-btn" onclick="closeCategoryModal()">×</button>
</div>
<div class="modal-body">
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
<input type="hidden" id="categoryId">
<div class="form-group">
<label class="form-label">공장명 *</label>
<input type="text" id="categoryName" class="form-control" placeholder="예: 제 1공장" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="categoryDescription" class="form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
</div>
<div class="form-group">
<label class="form-label">표시 순서</label>
<input type="number" id="categoryOrder" class="form-control" value="0" min="0">
<small class="form-help">작은 숫자가 먼저 표시됩니다</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCategoryModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveCategory()">
💾 저장
</button>
</div>
</div>
</div>
<!-- 작업장 추가/수정 모달 -->
<div id="workplaceModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="workplaceModalTitle">작업장 추가</h2>
<button class="modal-close-btn" onclick="closeWorkplaceModal()">×</button>
</div>
<div class="modal-body">
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
<input type="hidden" id="workplaceId">
<div class="form-group">
<label class="form-label">소속 공장</label>
<select id="workplaceCategoryId" class="form-control">
<option value="">공장 선택</option>
<!-- 공장 목록이 동적으로 생성됩니다 -->
</select>
</div>
<div class="form-group">
<label class="form-label">작업장명 *</label>
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveWorkplace()">
💾 저장
</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>
</body>
</html>