fix: 보안 취약점 수정 및 XSS 방지 적용

## 백엔드 보안 수정
- 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거
- SQL Injection 방지를 위한 화이트리스트 검증 추가
- 인증 미적용 API 라우트에 requireAuth 미들웨어 적용
- CSRF 보호 미들웨어 구현 (csrf.js)
- 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js)
- 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js)

## 프론트엔드 XSS 방지
- api-base.js에 전역 escapeHtml() 함수 추가
- 17개 주요 JS 파일에 escapeHtml 적용:
  - tbm.js, daily-patrol.js, daily-work-report.js
  - task-management.js, workplace-status.js
  - equipment-detail.js, equipment-management.js
  - issue-detail.js, issue-report.js
  - vacation-common.js, worker-management.js
  - safety-report-list.js, nonconformity-list.js
  - project-management.js, workplace-management.js

## 정리
- 백업 폴더 및 빈 파일 삭제
- SECURITY_GUIDE.md 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-05 06:33:10 +09:00
parent 7c38c555f5
commit 36f110c90a
97 changed files with 2523 additions and 24267 deletions

View File

@@ -1,2 +0,0 @@
# 삭제 예정 파일 폴더
# 이 폴더의 파일들은 정리 후 삭제해주세요.

View File

@@ -51,13 +51,58 @@ function setupMiddlewares(app) {
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));
app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Rate Limiting (필요시 활성화) // Rate Limiting - API 요청 제한
// const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
// const limiter = rateLimit({
// windowMs: 15 * 60 * 1000, // 15분 // 일반 API 요청 제한
// max: 100 // IP당 최대 100 요청 const apiLimiter = rateLimit({
// }); windowMs: 15 * 60 * 1000, // 15분
// app.use('/api/', limiter); max: 200, // IP당 최대 200 요청
message: {
success: false,
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
});
// 로그인 시도 제한 (브루트포스 방지)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 10, // IP당 최대 10회 로그인 시도
message: {
success: false,
error: '로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.',
code: 'LOGIN_RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
});
// Rate limiter 적용
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
logger.info('Rate Limiting 설정 완료');
// CSRF Protection (선택적 - 필요 시 주석 해제)
// const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
//
// CSRF 토큰 발급 엔드포인트
// app.get('/api/csrf-token', getCsrfToken);
//
// CSRF 검증 미들웨어 (로그인 등 일부 경로 제외)
// app.use('/api/', verifyCsrfToken({
// ignorePaths: [
// '/api/auth/login',
// '/api/auth/register',
// '/api/health',
// '/api/csrf-token'
// ]
// }));
//
// logger.info('CSRF Protection 설정 완료');
logger.info('미들웨어 설정 완료'); logger.info('미들웨어 설정 완료');
} }

View File

@@ -21,12 +21,7 @@ exports.createProject = asyncHandler(async (req, res) => {
logger.info('프로젝트 생성 요청', { name: projectData.name }); logger.info('프로젝트 생성 요청', { name: projectData.name });
const id = await new Promise((resolve, reject) => { const id = await projectModel.create(projectData);
projectModel.create(projectData, (err, lastID) => {
if (err) reject(new DatabaseError('프로젝트 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
// 프로젝트 캐시 무효화 // 프로젝트 캐시 무효화
await cache.invalidateCache.project(); await cache.invalidateCache.project();
@@ -44,12 +39,7 @@ exports.createProject = asyncHandler(async (req, res) => {
* 전체 프로젝트 조회 * 전체 프로젝트 조회
*/ */
exports.getAllProjects = asyncHandler(async (req, res) => { exports.getAllProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await projectModel.getAll();
projectModel.getAll((err, data) => {
if (err) reject(new DatabaseError('프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -62,12 +52,7 @@ exports.getAllProjects = asyncHandler(async (req, res) => {
* 활성 프로젝트만 조회 (작업보고서용) * 활성 프로젝트만 조회 (작업보고서용)
*/ */
exports.getActiveProjects = asyncHandler(async (req, res) => { exports.getActiveProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await projectModel.getActiveProjects();
projectModel.getActiveProjects((err, data) => {
if (err) reject(new DatabaseError('활성 프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -86,12 +71,7 @@ exports.getProjectById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다'); throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
} }
const row = await new Promise((resolve, reject) => { const row = await projectModel.getById(id);
projectModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('프로젝트 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!row) { if (!row) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -116,12 +96,7 @@ exports.updateProject = asyncHandler(async (req, res) => {
const data = { ...req.body, project_id: id }; const data = { ...req.body, project_id: id };
const changes = await new Promise((resolve, reject) => { const changes = await projectModel.update(data);
projectModel.update(data, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 수정 중 오류가 발생했습니다'));
else resolve(ch);
});
});
if (changes === 0) { if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -149,12 +124,7 @@ exports.removeProject = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다'); throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
} }
const changes = await new Promise((resolve, reject) => { const changes = await projectModel.remove(id);
projectModel.remove(id, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 삭제 중 오류가 발생했습니다'));
else resolve(ch);
});
});
if (changes === 0) { if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');

View File

@@ -27,12 +27,7 @@ exports.createTask = asyncHandler(async (req, res) => {
logger.info('작업 생성 요청', { name: taskData.task_name }); logger.info('작업 생성 요청', { name: taskData.task_name });
const id = await new Promise((resolve, reject) => { const id = await taskModel.createTask(taskData);
taskModel.createTask(taskData, (err, lastID) => {
if (err) reject(new DatabaseError('작업 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('작업 생성 성공', { task_id: id }); logger.info('작업 생성 성공', { task_id: id });
@@ -47,12 +42,7 @@ exports.createTask = asyncHandler(async (req, res) => {
* 전체 작업 조회 * 전체 작업 조회
*/ */
exports.getAllTasks = asyncHandler(async (req, res) => { exports.getAllTasks = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await taskModel.getAllTasks();
taskModel.getAllTasks((err, data) => {
if (err) reject(new DatabaseError('작업 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -65,12 +55,7 @@ exports.getAllTasks = asyncHandler(async (req, res) => {
* 활성 작업만 조회 * 활성 작업만 조회
*/ */
exports.getActiveTasks = asyncHandler(async (req, res) => { exports.getActiveTasks = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await taskModel.getActiveTasks();
taskModel.getActiveTasks((err, data) => {
if (err) reject(new DatabaseError('활성 작업 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -89,12 +74,7 @@ exports.getTasksByWorkType = asyncHandler(async (req, res) => {
throw new ValidationError('공정 ID가 필요합니다'); throw new ValidationError('공정 ID가 필요합니다');
} }
const rows = await new Promise((resolve, reject) => { const rows = await taskModel.getTasksByWorkType(workTypeId);
taskModel.getTasksByWorkType(workTypeId, (err, data) => {
if (err) reject(new DatabaseError('공정별 작업 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -109,12 +89,7 @@ exports.getTasksByWorkType = asyncHandler(async (req, res) => {
exports.getTaskById = asyncHandler(async (req, res) => { exports.getTaskById = asyncHandler(async (req, res) => {
const taskId = req.params.id; const taskId = req.params.id;
const task = await new Promise((resolve, reject) => { const task = await taskModel.getTaskById(taskId);
taskModel.getTaskById(taskId, (err, data) => {
if (err) reject(new DatabaseError('작업 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!task) { if (!task) {
throw new NotFoundError('작업을 찾을 수 없습니다'); throw new NotFoundError('작업을 찾을 수 없습니다');
@@ -140,12 +115,7 @@ exports.updateTask = asyncHandler(async (req, res) => {
logger.info('작업 수정 요청', { task_id: taskId }); logger.info('작업 수정 요청', { task_id: taskId });
await new Promise((resolve, reject) => { await taskModel.updateTask(taskId, taskData);
taskModel.updateTask(taskId, taskData, (err, result) => {
if (err) reject(new DatabaseError('작업 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업 수정 성공', { task_id: taskId }); logger.info('작업 수정 성공', { task_id: taskId });
@@ -163,12 +133,7 @@ exports.deleteTask = asyncHandler(async (req, res) => {
logger.info('작업 삭제 요청', { task_id: taskId }); logger.info('작업 삭제 요청', { task_id: taskId });
await new Promise((resolve, reject) => { await taskModel.deleteTask(taskId);
taskModel.deleteTask(taskId, (err, result) => {
if (err) reject(new DatabaseError('작업 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업 삭제 성공', { task_id: taskId }); logger.info('작업 삭제 성공', { task_id: taskId });

View File

@@ -0,0 +1,201 @@
/**
* CSRF Protection Middleware
*
* Cross-Site Request Forgery 방지를 위한 토큰 기반 보호
*
* 구현 방식:
* 1. 서버에서 CSRF 토큰 생성 및 응답 헤더로 전송
* 2. 클라이언트는 state-changing 요청 시 토큰을 헤더에 포함
* 3. 서버에서 토큰 검증
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const crypto = require('crypto');
const logger = require('../utils/logger');
// 토큰 저장소 (프로덕션에서는 Redis 사용 권장)
const tokenStore = new Map();
// 토큰 유효 시간 (1시간)
const TOKEN_EXPIRY = 60 * 60 * 1000;
// 토큰 정리 주기 (5분)
const CLEANUP_INTERVAL = 5 * 60 * 1000;
/**
* 만료된 토큰 정리
*/
const cleanupExpiredTokens = () => {
const now = Date.now();
for (const [token, data] of tokenStore.entries()) {
if (now > data.expiresAt) {
tokenStore.delete(token);
}
}
};
// 주기적 정리
setInterval(cleanupExpiredTokens, CLEANUP_INTERVAL);
/**
* CSRF 토큰 생성
*
* @param {string} sessionId - 세션 ID 또는 사용자 식별자
* @returns {string} 생성된 CSRF 토큰
*/
const generateToken = (sessionId) => {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + TOKEN_EXPIRY;
tokenStore.set(token, {
sessionId,
expiresAt,
createdAt: Date.now()
});
return token;
};
/**
* CSRF 토큰 검증
*
* @param {string} token - 검증할 토큰
* @param {string} sessionId - 세션 ID
* @returns {boolean} 유효 여부
*/
const validateToken = (token, sessionId) => {
if (!token || !tokenStore.has(token)) {
return false;
}
const data = tokenStore.get(token);
// 만료 체크
if (Date.now() > data.expiresAt) {
tokenStore.delete(token);
return false;
}
// 세션 일치 체크
if (data.sessionId !== sessionId) {
return false;
}
return true;
};
/**
* CSRF 토큰을 응답 헤더에 설정하는 미들웨어
*
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Next middleware
*/
const setCsrfToken = (req, res, next) => {
// 세션 ID 또는 사용자 ID 사용
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 새 토큰 생성
const token = generateToken(sessionId);
// 응답 헤더에 토큰 설정
res.setHeader('X-CSRF-Token', token);
// 요청 객체에 저장 (다른 미들웨어에서 사용 가능)
req.csrfToken = token;
next();
};
/**
* CSRF 토큰 검증 미들웨어
* POST, PUT, DELETE, PATCH 요청에 적용
*
* @param {Object} options - 옵션
* @param {string[]} options.ignoreMethods - 검증 제외 메서드 (기본: GET, HEAD, OPTIONS)
* @param {string[]} options.ignorePaths - 검증 제외 경로 (정규식 패턴 가능)
* @returns {Function} Express 미들웨어
*/
const verifyCsrfToken = (options = {}) => {
const {
ignoreMethods = ['GET', 'HEAD', 'OPTIONS'],
ignorePaths = ['/api/auth/login', '/api/auth/register', '/api/health']
} = options;
return (req, res, next) => {
// 제외 메서드 체크
if (ignoreMethods.includes(req.method)) {
return next();
}
// 제외 경로 체크
for (const pattern of ignorePaths) {
if (typeof pattern === 'string' && req.path === pattern) {
return next();
}
if (pattern instanceof RegExp && pattern.test(req.path)) {
return next();
}
}
// 토큰 추출 (헤더 또는 body에서)
const token = req.headers['x-csrf-token'] ||
req.headers['csrf-token'] ||
req.body?._csrf ||
req.query?._csrf;
// 세션 ID
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 토큰 검증
if (!validateToken(token, sessionId)) {
logger.warn('CSRF 토큰 검증 실패', {
path: req.path,
method: req.method,
ip: req.ip,
hasToken: !!token
});
return res.status(403).json({
success: false,
error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도해주세요.',
code: 'CSRF_TOKEN_INVALID'
});
}
// 사용된 토큰 제거 (일회성 사용)
tokenStore.delete(token);
// 새 토큰 발급
const newToken = generateToken(sessionId);
res.setHeader('X-CSRF-Token', newToken);
req.csrfToken = newToken;
next();
};
};
/**
* CSRF 토큰 발급 엔드포인트 핸들러
* GET /api/csrf-token
*/
const getCsrfToken = (req, res) => {
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
const token = generateToken(sessionId);
res.json({
success: true,
csrfToken: token,
expiresIn: TOKEN_EXPIRY / 1000 // 초 단위
});
};
module.exports = {
generateToken,
validateToken,
setCsrfToken,
verifyCsrfToken,
getCsrfToken
};

View File

@@ -1,53 +1,37 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// CREATE // CREATE
const create = async (type, callback) => { const create = async (type) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query(
const [result] = await db.query( `INSERT INTO IssueTypes (category, subcategory) VALUES (?, ?)`,
`INSERT INTO IssueTypes (category, subcategory) VALUES (?, ?)`, [type.category, type.subcategory]
[type.category, type.subcategory] );
); return result.insertId;
callback(null, result.insertId);
} catch (err) {
callback(err);
}
}; };
// READ ALL // READ ALL
const getAll = async (callback) => { const getAll = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`);
const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
// UPDATE // UPDATE
const update = async (id, type, callback) => { const update = async (id, type) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query(
const [result] = await db.query( `UPDATE IssueTypes SET category = ?, subcategory = ? WHERE id = ?`,
`UPDATE IssueTypes SET category = ?, subcategory = ? WHERE id = ?`, [type.category, type.subcategory, id]
[type.category, type.subcategory, id] );
); return result.affectedRows;
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
}; };
// DELETE // DELETE
const remove = async (id, callback) => { const remove = async (id) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query(`DELETE FROM IssueTypes WHERE id = ?`, [id]);
const [result] = await db.query(`DELETE FROM IssueTypes WHERE id = ?`, [id]); return result.affectedRows;
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {
@@ -55,4 +39,4 @@ module.exports = {
getAll, getAll,
update, update,
remove remove
}; };

View File

@@ -1,113 +1,89 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const create = async (project, callback) => { const create = async (project) => {
try { const db = await getDb();
const db = await getDb(); const {
const { job_no, project_name,
job_no, project_name, contract_date, due_date,
contract_date, due_date, delivery_method, site, pm,
delivery_method, site, pm, is_active = true,
is_active = true, project_status = 'active',
project_status = 'active', completed_date = null
completed_date = null } = project;
} = project;
const [result] = await db.query(
`INSERT INTO projects
(job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date]
);
callback(null, result.insertId); const [result] = await db.query(
} catch (err) { `INSERT INTO projects
callback(err); (job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date)
} VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date]
);
return result.insertId;
}; };
const getAll = async (callback) => { const getAll = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC`
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC` );
); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
// 활성 프로젝트만 조회 (작업보고서용) // 활성 프로젝트만 조회 (작업보고서용)
const getActiveProjects = async (callback) => { const getActiveProjects = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE is_active = TRUE
WHERE is_active = TRUE ORDER BY project_name ASC`
ORDER BY project_name ASC` );
); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
const getById = async (project_id, callback) => { const getById = async (project_id) => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`,
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`, [project_id]
[project_id] );
); return rows[0];
callback(null, rows[0]);
} catch (err) {
callback(err);
}
}; };
const update = async (project, callback) => { const update = async (project) => {
try { const db = await getDb();
const db = await getDb(); const {
const { project_id, job_no, project_name,
project_id, job_no, project_name, contract_date, due_date,
contract_date, due_date, delivery_method, site, pm,
delivery_method, site, pm, is_active, project_status, completed_date
is_active, project_status, completed_date } = project;
} = project;
const [result] = await db.query( const [result] = await db.query(
`UPDATE projects `UPDATE projects
SET job_no = ?, SET job_no = ?,
project_name = ?, project_name = ?,
contract_date = ?, contract_date = ?,
due_date = ?, due_date = ?,
delivery_method= ?, delivery_method= ?,
site = ?, site = ?,
pm = ?, pm = ?,
is_active = ?, is_active = ?,
project_status = ?, project_status = ?,
completed_date = ? completed_date = ?
WHERE project_id = ?`, WHERE project_id = ?`,
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id] [job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
const remove = async (project_id, callback) => { const remove = async (project_id) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query(
const [result] = await db.query( `DELETE FROM projects WHERE project_id = ?`,
`DELETE FROM projects WHERE project_id = ?`, [project_id]
[project_id] );
); return result.affectedRows;
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {
@@ -117,4 +93,4 @@ module.exports = {
getById, getById,
update, update,
remove remove
}; };

View File

@@ -12,151 +12,123 @@ const { getDb } = require('../dbPool');
/** /**
* 작업 생성 * 작업 생성
*/ */
const createTask = async (taskData, callback) => { const createTask = async (taskData) => {
try { const db = await getDb();
const db = await getDb(); const { work_type_id, task_name, description } = taskData;
const { work_type_id, task_name, description } = taskData;
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO tasks (work_type_id, task_name, description, is_active) `INSERT INTO tasks (work_type_id, task_name, description, is_active)
VALUES (?, ?, ?, 1)`, VALUES (?, ?, ?, 1)`,
[work_type_id || null, task_name, description || null] [work_type_id || null, task_name, description || null]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
/** /**
* 전체 작업 목록 조회 (공정 정보 포함) * 전체 작업 목록 조회 (공정 정보 포함)
*/ */
const getAllTasks = async (callback) => { const getAllTasks = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, t.created_at, t.updated_at,
t.created_at, t.updated_at, wt.name as work_type_name, wt.category
wt.name as work_type_name, wt.category FROM tasks t
FROM tasks t LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_types wt ON t.work_type_id = wt.id ORDER BY wt.category ASC, t.task_id DESC`
ORDER BY wt.category ASC, t.task_id DESC` );
); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
/** /**
* 활성 작업만 조회 * 활성 작업만 조회
*/ */
const getActiveTasks = async (callback) => { const getActiveTasks = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT t.task_id, t.work_type_id, t.task_name, t.description,
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, wt.name as work_type_name, wt.category
wt.name as work_type_name, wt.category FROM tasks t
FROM tasks t LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_types wt ON t.work_type_id = wt.id WHERE t.is_active = 1
WHERE t.is_active = 1 ORDER BY wt.category ASC, t.task_name ASC`
ORDER BY wt.category ASC, t.task_name ASC` );
); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
/** /**
* 공정별 작업 목록 조회 * 공정별 작업 목록 조회
*/ */
const getTasksByWorkType = async (workTypeId, callback) => { const getTasksByWorkType = async (workTypeId) => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, t.created_at, t.updated_at,
t.created_at, t.updated_at, wt.name as work_type_name, wt.category
wt.name as work_type_name, wt.category FROM tasks t
FROM tasks t LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_types wt ON t.work_type_id = wt.id WHERE t.work_type_id = ?
WHERE t.work_type_id = ? ORDER BY t.task_id DESC`,
ORDER BY t.task_id DESC`, [workTypeId]
[workTypeId] );
); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
/** /**
* 단일 작업 조회 * 단일 작업 조회
*/ */
const getTaskById = async (taskId, callback) => { const getTaskById = async (taskId) => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(
const [rows] = await db.query( `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, t.created_at, t.updated_at,
t.created_at, t.updated_at, wt.name as work_type_name, wt.category
wt.name as work_type_name, wt.category FROM tasks t
FROM tasks t LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_types wt ON t.work_type_id = wt.id WHERE t.task_id = ?`,
WHERE t.task_id = ?`, [taskId]
[taskId] );
); return rows[0] || null;
callback(null, rows[0] || null);
} catch (err) {
callback(err);
}
}; };
/** /**
* 작업 수정 * 작업 수정
*/ */
const updateTask = async (taskId, taskData, callback) => { const updateTask = async (taskId, taskData) => {
try { const db = await getDb();
const db = await getDb(); const { work_type_id, task_name, description, is_active } = taskData;
const { work_type_id, task_name, description, is_active } = taskData;
const [result] = await db.query( const [result] = await db.query(
`UPDATE tasks `UPDATE tasks
SET work_type_id = ?, SET work_type_id = ?,
task_name = ?, task_name = ?,
description = ?, description = ?,
is_active = ?, is_active = ?,
updated_at = NOW() updated_at = NOW()
WHERE task_id = ?`, WHERE task_id = ?`,
[ [
work_type_id || null, work_type_id || null,
task_name, task_name,
description || null, description || null,
is_active !== undefined ? is_active : 1, is_active !== undefined ? is_active : 1,
taskId taskId
] ]
); );
callback(null, result); return result;
} catch (err) {
callback(err);
}
}; };
/** /**
* 작업 삭제 * 작업 삭제
*/ */
const deleteTask = async (taskId, callback) => { const deleteTask = async (taskId) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query(
const [result] = await db.query( `DELETE FROM tasks WHERE task_id = ?`,
`DELETE FROM tasks WHERE task_id = ?`, [taskId]
[taskId] );
); return result;
callback(null, result);
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {

View File

@@ -1,89 +1,68 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// 1. 전체 도구 조회 // 1. 전체 도구 조회
const getAll = async (callback) => { const getAll = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools');
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools'); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
// 2. 단일 도구 조회 // 2. 단일 도구 조회
const getById = async (id, callback) => { const getById = async (id) => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]);
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]); return rows[0];
callback(null, rows[0]);
} catch (err) {
callback(err);
}
}; };
// 3. 도구 생성 // 3. 도구 생성
const create = async (tool, callback) => { const create = async (tool) => {
try { const db = await getDb();
const db = await getDb(); const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO Tools `INSERT INTO Tools
(name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note) (name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note] [name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
// 4. 도구 수정 // 4. 도구 수정
const update = async (id, tool, callback) => { const update = async (id, tool) => {
try { const db = await getDb();
const db = await getDb(); const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const [result] = await db.query( const [result] = await db.query(
`UPDATE Tools `UPDATE Tools
SET name = ?, SET name = ?,
location = ?, location = ?,
stock = ?, stock = ?,
status = ?, status = ?,
factory_id = ?, factory_id = ?,
map_x = ?, map_x = ?,
map_y = ?, map_y = ?,
map_zone = ?, map_zone = ?,
map_note = ? map_note = ?
WHERE id = ?`, WHERE id = ?`,
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id] [name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
// 5. 도구 삭제 // 5. 도구 삭제
const remove = async (id, callback) => { const remove = async (id) => {
try { const db = await getDb();
const db = await getDb(); const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]);
const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]); return result.affectedRows;
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
}; };
// ✅ export 정리
module.exports = { module.exports = {
getAll, getAll,
getById, getById,
create, create,
update, update,
remove remove
}; };

View File

@@ -1,45 +1,36 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// 1. 문서 업로드 // 1. 문서 업로드
const create = async (doc, callback) => { const create = async (doc) => {
try { const db = await getDb();
const db = await getDb(); const sql = `
const sql = ` INSERT INTO uploaded_documents
INSERT INTO uploaded_documents (title, tags, description, original_name, stored_name, file_path, file_type, file_size, submitted_by)
(title, tags, description, original_name, stored_name, file_path, file_type, file_size, submitted_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `;
`; const values = [
const values = [ doc.title,
doc.title, doc.tags,
doc.tags, doc.description,
doc.description, doc.original_name,
doc.original_name, doc.stored_name,
doc.stored_name, doc.file_path,
doc.file_path, doc.file_type,
doc.file_type, doc.file_size,
doc.file_size, doc.submitted_by
doc.submitted_by ];
]; const [result] = await db.query(sql, values);
const [result] = await db.query(sql, values); return result.insertId;
callback(null, result.insertId);
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
// 2. 전체 문서 목록 조회 // 2. 전체 문서 목록 조회
const getAll = async (callback) => { const getAll = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`);
const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
// ✅ 내보내기
module.exports = { module.exports = {
create, create,
getAll getAll
}; };

View File

@@ -10,152 +10,133 @@ const formatDate = (dateStr) => {
}; };
// 1. 작업자 생성 // 1. 작업자 생성
const create = async (worker, callback) => { const create = async (worker) => {
try { const db = await getDb();
const db = await getDb(); const {
const { worker_name,
worker_name, job_type = null,
job_type = null, join_date = null,
join_date = null, salary = null,
salary = null, annual_leave = null,
annual_leave = null, status = 'active',
status = 'active', employment_status = 'employed',
employment_status = 'employed', department_id = null
department_id = null } = worker;
} = worker;
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO workers `INSERT INTO workers
(worker_name, job_type, join_date, salary, annual_leave, status, employment_status, department_id) (worker_name, job_type, join_date, salary, annual_leave, status, employment_status, department_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id] [worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
console.error('❌ create 함수 에러:', err);
callback(err);
}
}; };
// 2. 전체 조회 // 2. 전체 조회
const getAll = async (callback) => { const getAll = async () => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(`
const [rows] = await db.query(` SELECT
SELECT w.*,
w.*, CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active, u.user_id,
u.user_id, d.department_name
d.department_name FROM workers w
FROM workers w LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN users u ON w.worker_id = u.worker_id LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN departments d ON w.department_id = d.department_id ORDER BY w.worker_id DESC
ORDER BY w.worker_id DESC `);
`); return rows;
callback(null, rows);
} catch (err) {
callback(err);
}
}; };
// 3. 단일 조회 // 3. 단일 조회
const getById = async (worker_id, callback) => { const getById = async (worker_id) => {
try { const db = await getDb();
const db = await getDb(); const [rows] = await db.query(`
const [rows] = await db.query(` SELECT
SELECT w.*,
w.*, CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active, u.user_id,
u.user_id, d.department_name
d.department_name FROM workers w
FROM workers w LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN users u ON w.worker_id = u.worker_id LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN departments d ON w.department_id = d.department_id WHERE w.worker_id = ?
WHERE w.worker_id = ? `, [worker_id]);
`, [worker_id]); return rows[0];
callback(null, rows[0]);
} catch (err) {
callback(err);
}
}; };
// 4. 작업자 수정 // 4. 작업자 수정
const update = async (worker, callback) => { const update = async (worker) => {
try { const db = await getDb();
const db = await getDb(); const {
const { worker_id,
worker_id, worker_name,
worker_name, job_type,
job_type, status,
status, join_date,
join_date, salary,
salary, annual_leave,
annual_leave, employment_status,
employment_status, department_id
department_id } = worker;
} = worker;
// 업데이트할 필드만 동적으로 구성 // 업데이트할 필드만 동적으로 구성
const updates = []; const updates = [];
const values = []; const values = [];
if (worker_name !== undefined) { if (worker_name !== undefined) {
updates.push('worker_name = ?'); updates.push('worker_name = ?');
values.push(worker_name); values.push(worker_name);
}
if (job_type !== undefined) {
updates.push('job_type = ?');
values.push(job_type);
}
if (status !== undefined) {
updates.push('status = ?');
values.push(status);
}
if (join_date !== undefined) {
updates.push('join_date = ?');
values.push(formatDate(join_date));
}
if (salary !== undefined) {
updates.push('salary = ?');
values.push(salary);
}
if (annual_leave !== undefined) {
updates.push('annual_leave = ?');
values.push(annual_leave);
}
if (employment_status !== undefined) {
updates.push('employment_status = ?');
values.push(employment_status);
}
if (department_id !== undefined) {
updates.push('department_id = ?');
values.push(department_id);
}
if (updates.length === 0) {
callback(new Error('업데이트할 필드가 없습니다'));
return;
}
values.push(worker_id); // WHERE 조건용
const query = `UPDATE workers SET ${updates.join(', ')} WHERE worker_id = ?`;
console.log('🔍 실행할 SQL:', query);
console.log('🔍 SQL 파라미터:', values);
const [result] = await db.query(query, values);
callback(null, result.affectedRows);
} catch (err) {
console.error('❌ update 함수 에러:', err);
callback(new Error(err.message || String(err)));
} }
if (job_type !== undefined) {
updates.push('job_type = ?');
values.push(job_type);
}
if (status !== undefined) {
updates.push('status = ?');
values.push(status);
}
if (join_date !== undefined) {
updates.push('join_date = ?');
values.push(formatDate(join_date));
}
if (salary !== undefined) {
updates.push('salary = ?');
values.push(salary);
}
if (annual_leave !== undefined) {
updates.push('annual_leave = ?');
values.push(annual_leave);
}
if (employment_status !== undefined) {
updates.push('employment_status = ?');
values.push(employment_status);
}
if (department_id !== undefined) {
updates.push('department_id = ?');
values.push(department_id);
}
if (updates.length === 0) {
throw new Error('업데이트할 필드가 없습니다');
}
values.push(worker_id); // WHERE 조건용
const query = `UPDATE workers SET ${updates.join(', ')} WHERE worker_id = ?`;
console.log('🔍 실행할 SQL:', query);
console.log('🔍 SQL 파라미터:', values);
const [result] = await db.query(query, values);
return result.affectedRows;
}; };
// 5. 삭제 (외래키 제약조건 처리) // 5. 삭제 (외래키 제약조건 처리)
const remove = async (worker_id, callback) => { const remove = async (worker_id) => {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection(); const conn = await db.getConnection();
@@ -196,22 +177,21 @@ const remove = async (worker_id, callback) => {
console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`); console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`);
await conn.commit(); await conn.commit();
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) { } catch (err) {
await conn.rollback(); await conn.rollback();
console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err); console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err);
callback(new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`)); throw new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`);
} finally { } finally {
conn.release(); conn.release();
} }
}; };
// ✅ 모듈 내보내기 (정상 구조)
module.exports = { module.exports = {
create, create,
getAll, getAll,
getById, getById,
update, update,
remove remove
}; };

View File

@@ -5,12 +5,13 @@ const jwt = require('jsonwebtoken');
const { requireAuth, requireRole } = require('../middlewares/auth'); const { requireAuth, requireRole } = require('../middlewares/auth');
const router = express.Router(); const router = express.Router();
// 임시 사용자 데이터 // 임시 사용자 데이터 (실제 운영 시 DB 사용 필수)
// 비밀번호 해시는 bcrypt.hash('password', 10)으로 생성됨
let users = [ let users = [
{ {
user_id: 1, user_id: 1,
username: 'admin', username: 'admin',
password: '$2b$10$example', password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
name: '관리자', name: '관리자',
access_level: 'admin', access_level: 'admin',
worker_id: null, worker_id: null,
@@ -19,7 +20,7 @@ let users = [
{ {
user_id: 2, user_id: 2,
username: 'group_leader1', username: 'group_leader1',
password: '$2b$10$example', password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
name: '김그룹장', name: '김그룹장',
access_level: 'group_leader', access_level: 'group_leader',
worker_id: 1, worker_id: 1,
@@ -27,6 +28,11 @@ let users = [
} }
]; ];
// 보안 경고: 운영 환경에서는 반드시 .env의 JWT_SECRET을 설정해야 합니다
if (!process.env.JWT_SECRET) {
console.warn('⚠️ WARNING: JWT_SECRET이 설정되지 않았습니다. 운영 환경에서는 반드시 설정하세요!');
}
/** /**
* 로그인 * 로그인
*/ */
@@ -43,8 +49,8 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: '사용자를 찾을 수 없습니다.' }); return res.status(401).json({ error: '사용자를 찾을 수 없습니다.' });
} }
// 비밀번호 확인 (실제로는 bcrypt.compare 사용) // 비밀번호 확인 (bcrypt.compare 사용)
const isValid = password === 'password'; // 임시 const isValid = await bcrypt.compare(password, user.password);
if (!isValid) { if (!isValid) {
return res.status(401).json({ error: '비밀번호가 올바르지 않습니다.' }); return res.status(401).json({ error: '비밀번호가 올바르지 않습니다.' });
} }
@@ -57,7 +63,7 @@ router.post('/login', async (req, res) => {
access_level: user.access_level, access_level: user.access_level,
worker_id: user.worker_id worker_id: user.worker_id
}, },
process.env.JWT_SECRET || 'your-secret-key', process.env.JWT_SECRET,
{ expiresIn: '24h' } { expiresIn: '24h' }
); );

View File

@@ -11,6 +11,7 @@ const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const { verifyToken } = require('../middlewares/authMiddleware'); const { verifyToken } = require('../middlewares/authMiddleware');
const { validatePassword, getPasswordError } = require('../utils/passwordValidator');
const router = express.Router(); const router = express.Router();
const authController = require('../controllers/authController'); const authController = require('../controllers/authController');
@@ -213,16 +214,19 @@ router.post('/change-password', verifyToken, async (req, res) => {
}); });
} }
// 비밀번호 강도 검증 // 비밀번호 강도 검증 (12자 이상, 대/소문자, 숫자, 특수문자 필수)
if (newPassword.length < 6) { const passwordValidation = validatePassword(newPassword);
return res.status(400).json({ if (!passwordValidation.valid) {
return res.status(400).json({
success: false, success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.' error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: passwordValidation.errors,
code: 'WEAK_PASSWORD'
}); });
} }
connection = await mysql.createConnection(dbConfig); connection = await mysql.createConnection(dbConfig);
// 현재 사용자의 비밀번호 조회 // 현재 사용자의 비밀번호 조회
const [users] = await connection.execute( const [users] = await connection.execute(
'SELECT password FROM Users WHERE user_id = ?', 'SELECT password FROM Users WHERE user_id = ?',
@@ -320,16 +324,19 @@ router.post('/admin/change-password', verifyToken, async (req, res) => {
}); });
} }
// 비밀번호 강도 검증 // 비밀번호 강도 검증 (12자 이상, 대/소문자, 숫자, 특수문자 필수)
if (newPassword.length < 6) { const passwordValidation = validatePassword(newPassword);
return res.status(400).json({ if (!passwordValidation.valid) {
return res.status(400).json({
success: false, success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.' error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: passwordValidation.errors,
code: 'WEAK_PASSWORD'
}); });
} }
connection = await mysql.createConnection(dbConfig); connection = await mysql.createConnection(dbConfig);
// 대상 사용자 확인 // 대상 사용자 확인
const [users] = await connection.execute( const [users] = await connection.execute(
'SELECT username, name FROM Users WHERE user_id = ?', 'SELECT username, name FROM Users WHERE user_id = ?',

View File

@@ -2,8 +2,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const notificationController = require('../controllers/notificationController'); const notificationController = require('../controllers/notificationController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
// 읽지 않은 알림 조회 // 모든 알림 라우트는 인증 필요
router.use(requireAuth);
// 읽지 않은 알림 조회 (본인 알림만)
router.get('/unread', notificationController.getUnread); router.get('/unread', notificationController.getUnread);
// 읽지 않은 알림 개수 // 읽지 않은 알림 개수
@@ -13,15 +17,15 @@ router.get('/unread/count', notificationController.getUnreadCount);
router.get('/', notificationController.getAll); router.get('/', notificationController.getAll);
// 알림 생성 (시스템/관리자용) // 알림 생성 (시스템/관리자용)
router.post('/', notificationController.create); router.post('/', requireMinLevel('support_team'), notificationController.create);
// 모든 알림 읽음 처리 // 모든 알림 읽음 처리 (본인 알림만)
router.post('/read-all', notificationController.markAllAsRead); router.post('/read-all', notificationController.markAllAsRead);
// 특정 알림 읽음 처리 // 특정 알림 읽음 처리 (본인 알림만)
router.post('/:id/read', notificationController.markAsRead); router.post('/:id/read', notificationController.markAsRead);
// 알림 삭제 // 알림 삭제 (본인 알림만)
router.delete('/:id', notificationController.delete); router.delete('/:id', notificationController.delete);
module.exports = router; module.exports = router;

View File

@@ -2,23 +2,18 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const projectController = require('../controllers/projectController'); const projectController = require('../controllers/projectController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
// CREATE // READ - 인증된 사용자
router.post('/', projectController.createProject); router.get('/', requireAuth, projectController.getAllProjects);
router.get('/active/list', requireAuth, projectController.getActiveProjects);
router.get('/:project_id', requireAuth, projectController.getProjectById);
// READ ALL // CREATE/UPDATE - support_team 이상 권한 필요
router.get('/', projectController.getAllProjects); router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject);
router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject);
// READ ACTIVE ONLY (작업보고서용) // DELETE - admin 이상 권한 필요
router.get('/active/list', projectController.getActiveProjects); router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject);
// READ ONE
router.get('/:project_id', projectController.getProjectById);
// UPDATE
router.put('/:project_id', projectController.updateProject);
// DELETE
router.delete('/:project_id', projectController.removeProject);
module.exports = router; module.exports = router;

View File

@@ -2,11 +2,15 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const controller = require('../controllers/toolsController'); const controller = require('../controllers/toolsController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
router.get('/', controller.getAll); // 읽기 작업: 인증된 사용자
router.get('/:id', controller.getById); router.get('/', requireAuth, controller.getAll);
router.post('/', controller.create); router.get('/:id', requireAuth, controller.getById);
router.put('/:id', controller.update);
router.delete('/:id', controller.delete); // 쓰기 작업: group_leader 이상 권한 필요
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update);
router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete);
module.exports = router; module.exports = router;

View File

@@ -1,8 +1,10 @@
// ✅ routes/uploadBgRoutes.js (신규: 배경 이미지 전용 업로드 라우터) // ✅ routes/uploadBgRoutes.js (배경 이미지 전용 업로드 라우터 - 보안 강화)
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
@@ -13,12 +15,37 @@ const storage = multer.diskStorage({
} }
}); });
const upload = multer({ storage }); // 보안 강화된 파일 필터 (이미지만 허용)
const imageFileFilter = createFileFilter({
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
allowedMimes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
});
router.post('/upload-bg', upload.single('image'), (req, res) => { const upload = multer({
storage,
fileFilter: imageFileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한 (배경 이미지는 크기가 클 수 있음)
files: 1
}
});
// 관리자 권한 필요
router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' }); return res.status(400).json({ success: false, message: '파일이 없습니다.' });
} }
// 업로드된 파일의 실제 내용 검증 (Magic number)
const validation = await validateUploadedFile(req.file.path, req.file.mimetype);
if (!validation.valid) {
return res.status(400).json({
success: false,
message: validation.message,
code: 'INVALID_FILE_TYPE'
});
}
res.json({ success: true, path: '/img/login-bg.jpeg' }); res.json({ success: true, path: '/img/login-bg.jpeg' });
}); });

View File

@@ -4,31 +4,37 @@ const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const workplaceController = require('../controllers/workplaceController'); const workplaceController = require('../controllers/workplaceController');
const {
generateSafeFilename,
createFileFilter,
ALLOWED_IMAGE_EXTENSIONS
} = require('../utils/fileUploadSecurity');
// Multer 설정 - 작업장 레이아웃 이미지 업로드 // Multer 설정 - 작업장 레이아웃 이미지 업로드 (보안 강화)
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, path.join(__dirname, '../uploads')); cb(null, path.join(__dirname, '../uploads'));
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // 안전한 랜덤 파일명 생성 (원본 파일명 노출 방지)
cb(null, 'workplace-layout-' + uniqueSuffix + path.extname(file.originalname)); const safeName = generateSafeFilename(file.originalname);
cb(null, `workplace-layout-${safeName}`);
} }
}); });
// 보안 강화된 파일 필터
const imageFileFilter = createFileFilter({
allowedExtensions: ALLOWED_IMAGE_EXTENSIONS,
allowedMimes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
});
const upload = multer({ const upload = multer({
storage, storage,
fileFilter: (req, file, cb) => { fileFilter: imageFileFilter,
const allowedTypes = /jpeg|jpg|png|gif/; limits: {
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); fileSize: 5 * 1024 * 1024, // 5MB 제한
const mimetype = allowedTypes.test(file.mimetype); files: 1 // 단일 파일만 허용
if (mimetype && extname) { }
return cb(null, true);
} else {
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif)'));
}
},
limits: { fileSize: 5 * 1024 * 1024 } // 5MB 제한
}); });
// ==================== 카테고리(공장) 관리 ==================== // ==================== 카테고리(공장) 관리 ====================

View File

@@ -18,12 +18,7 @@ const getAllToolsService = async () => {
logger.info('도구 목록 조회 요청'); logger.info('도구 목록 조회 요청');
try { try {
const rows = await new Promise((resolve, reject) => { const rows = await toolsModel.getAll();
toolsModel.getAll((err, data) => {
if (err) reject(err);
else resolve(data);
});
});
logger.info('도구 목록 조회 성공', { count: rows.length }); logger.info('도구 목록 조회 성공', { count: rows.length });
@@ -46,12 +41,7 @@ const getToolByIdService = async (id) => {
logger.info('도구 조회 요청', { tool_id: id }); logger.info('도구 조회 요청', { tool_id: id });
try { try {
const row = await new Promise((resolve, reject) => { const row = await toolsModel.getById(id);
toolsModel.getById(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (!row) { if (!row) {
logger.warn('도구를 찾을 수 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없음', { tool_id: id });
@@ -88,12 +78,7 @@ const createToolService = async (toolData) => {
logger.info('도구 생성 요청', { name, location, stock, status }); logger.info('도구 생성 요청', { name, location, stock, status });
try { try {
const insertId = await new Promise((resolve, reject) => { const insertId = await toolsModel.create(toolData);
toolsModel.create(toolData, (err, id) => {
if (err) reject(err);
else resolve(id);
});
});
logger.info('도구 생성 성공', { tool_id: insertId, name }); logger.info('도구 생성 성공', { tool_id: insertId, name });
@@ -119,12 +104,7 @@ const updateToolService = async (id, toolData) => {
logger.info('도구 수정 요청', { tool_id: id, updates: toolData }); logger.info('도구 수정 요청', { tool_id: id, updates: toolData });
try { try {
const affectedRows = await new Promise((resolve, reject) => { const affectedRows = await toolsModel.update(id, toolData);
toolsModel.update(id, toolData, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
if (affectedRows === 0) { if (affectedRows === 0) {
logger.warn('도구를 찾을 수 없거나 변경사항 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없거나 변경사항 없음', { tool_id: id });
@@ -159,12 +139,7 @@ const deleteToolService = async (id) => {
logger.info('도구 삭제 요청', { tool_id: id }); logger.info('도구 삭제 요청', { tool_id: id });
try { try {
const affectedRows = await new Promise((resolve, reject) => { const affectedRows = await toolsModel.remove(id);
toolsModel.remove(id, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
if (affectedRows === 0) { if (affectedRows === 0) {
logger.warn('도구를 찾을 수 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없음', { tool_id: id });

View File

@@ -0,0 +1,315 @@
/**
* File Upload Security - 파일 업로드 보안 유틸리티
*
* - Magic number (파일 시그니처) 검증
* - 파일명 sanitize
* - 확장자 화이트리스트 검증
* - 파일 크기 제한
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
/**
* 파일 시그니처 (Magic Numbers)
* 파일의 실제 타입을 확인하기 위한 바이너리 시그니처
*/
const FILE_SIGNATURES = {
// 이미지
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
'89504e47': { mime: 'image/png', ext: ['.png'] },
'47494638': { mime: 'image/gif', ext: ['.gif'] },
'52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작)
// 문서
'25504446': { mime: 'application/pdf', ext: ['.pdf'] },
'504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] },
// 주의: BMP, TIFF 등 추가 가능
};
/**
* 허용된 이미지 확장자
*/
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* 허용된 문서 확장자
*/
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip'];
/**
* 위험한 확장자 (절대 허용 안 함)
*/
const DANGEROUS_EXTENSIONS = [
'.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar',
'.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb',
'.htaccess', '.htpasswd', '.ini', '.config', '.env'
];
/**
* 파일 시그니처(Magic Number) 검증
*
* @param {Buffer} buffer - 파일 버퍼 (최소 8바이트)
* @returns {Object|null} 매칭된 파일 정보 또는 null
*/
const checkMagicNumber = (buffer) => {
if (!buffer || buffer.length < 4) {
return null;
}
// 처음 8바이트를 hex로 변환
const hex = buffer.slice(0, 8).toString('hex').toLowerCase();
// 시그니처 매칭
for (const [signature, info] of Object.entries(FILE_SIGNATURES)) {
if (hex.startsWith(signature)) {
return info;
}
}
return null;
};
/**
* 파일 버퍼에서 실제 MIME 타입 검증
*
* @param {Buffer} buffer - 파일 버퍼
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Object} { valid: boolean, actualType: string|null, message: string }
*/
const validateFileType = (buffer, declaredMime) => {
const detected = checkMagicNumber(buffer);
if (!detected) {
return {
valid: false,
actualType: null,
message: '알 수 없는 파일 형식입니다.'
};
}
// MIME 타입이 일치하는지 확인
if (detected.mime !== declaredMime) {
return {
valid: false,
actualType: detected.mime,
message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})`
};
}
return {
valid: true,
actualType: detected.mime,
message: 'OK'
};
};
/**
* 파일명 sanitize
* 경로 조작 및 특수문자 제거
*
* @param {string} filename - 원본 파일명
* @returns {string} 안전한 파일명
*/
const sanitizeFilename = (filename) => {
if (!filename || typeof filename !== 'string') {
return 'unnamed';
}
// 경로 구분자 제거 (path traversal 방지)
let safe = path.basename(filename);
// 특수문자 제거 (영문, 숫자, -, _, . 만 허용)
safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_');
// 연속된 점 제거 (이중 확장자 방지)
safe = safe.replace(/\.{2,}/g, '.');
// 앞뒤 점/공백 제거
safe = safe.replace(/^[\s.]+|[\s.]+$/g, '');
// 빈 파일명 처리
if (!safe || safe === '') {
safe = 'unnamed';
}
// 최대 길이 제한 (255자)
if (safe.length > 255) {
const ext = path.extname(safe);
const name = path.basename(safe, ext);
safe = name.slice(0, 255 - ext.length) + ext;
}
return safe;
};
/**
* 확장자 검증
*
* @param {string} filename - 파일명
* @param {string[]} allowedExtensions - 허용된 확장자 배열
* @returns {Object} { valid: boolean, extension: string, message: string }
*/
const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => {
const ext = path.extname(filename).toLowerCase();
// 위험한 확장자 체크
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return {
valid: false,
extension: ext,
message: `보안상 허용되지 않는 파일 형식입니다: ${ext}`
};
}
// 허용된 확장자 체크
if (!allowedExtensions.includes(ext)) {
return {
valid: false,
extension: ext,
message: `허용된 파일 형식: ${allowedExtensions.join(', ')}`
};
}
return {
valid: true,
extension: ext,
message: 'OK'
};
};
/**
* 안전한 랜덤 파일명 생성
*
* @param {string} originalFilename - 원본 파일명 (확장자 추출용)
* @returns {string} 랜덤 파일명
*/
const generateSafeFilename = (originalFilename) => {
const ext = path.extname(originalFilename).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
return `${timestamp}_${randomName}${ext}`;
};
/**
* 안전한 업로드 경로 생성
* 경로 조작(path traversal) 방지
*
* @param {string} baseDir - 기본 업로드 디렉토리
* @param {string} filename - 파일명
* @returns {string} 안전한 전체 경로
*/
const getSafeUploadPath = (baseDir, filename) => {
const safeName = sanitizeFilename(filename);
const fullPath = path.join(baseDir, safeName);
// 결과 경로가 baseDir 안에 있는지 확인
const resolvedBase = path.resolve(baseDir);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
throw new Error('경로 조작이 감지되었습니다.');
}
return resolvedFull;
};
/**
* Multer 파일 필터 생성
*
* @param {Object} options - 옵션
* @param {string[]} options.allowedExtensions - 허용된 확장자
* @param {string[]} options.allowedMimes - 허용된 MIME 타입
* @param {boolean} options.checkMagicNumber - Magic number 검증 여부
* @returns {Function} Multer fileFilter 함수
*/
const createFileFilter = (options = {}) => {
const {
allowedExtensions = ALLOWED_IMAGE_EXTENSIONS,
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false
} = options;
return (req, file, cb) => {
// 확장자 검증
const extResult = validateExtension(file.originalname, allowedExtensions);
if (!extResult.valid) {
return cb(new Error(extResult.message), false);
}
// MIME 타입 검증
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false);
}
cb(null, true);
};
};
/**
* 업로드된 파일 검증 (후처리용)
* Multer 업로드 후 파일 내용을 검증
*
* @param {string} filePath - 업로드된 파일 경로
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Promise<Object>} 검증 결과
*/
const validateUploadedFile = async (filePath, declaredMime) => {
try {
// 파일 시작 부분 읽기
const fd = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(8);
await fd.read(buffer, 0, 8, 0);
await fd.close();
// Magic number 검증
const typeResult = validateFileType(buffer, declaredMime);
if (!typeResult.valid) {
// 위험한 파일이면 삭제
await fs.unlink(filePath);
return {
valid: false,
deleted: true,
message: typeResult.message
};
}
return {
valid: true,
deleted: false,
message: 'OK',
actualType: typeResult.actualType
};
} catch (error) {
return {
valid: false,
deleted: false,
message: `파일 검증 중 오류: ${error.message}`
};
}
};
module.exports = {
// 상수
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_DOCUMENT_EXTENSIONS,
DANGEROUS_EXTENSIONS,
FILE_SIGNATURES,
// 함수
checkMagicNumber,
validateFileType,
sanitizeFilename,
validateExtension,
generateSafeFilename,
getSafeUploadPath,
createFileFilter,
validateUploadedFile
};

View File

@@ -0,0 +1,173 @@
/**
* Password Validator - 비밀번호 정책 검증
*
* 강력한 비밀번호 정책:
* - 최소 12자 이상
* - 대문자 포함
* - 소문자 포함
* - 숫자 포함
* - 특수문자 포함
*
* @author TK-FB-Project
* @since 2026-02-04
*/
/**
* 비밀번호 강도 검증
*
* @param {string} password - 검증할 비밀번호
* @param {Object} options - 옵션 (기본값 사용 권장)
* @returns {Object} { valid: boolean, errors: string[], strength: string }
*/
const validatePassword = (password, options = {}) => {
const config = {
minLength: options.minLength || 12,
requireUppercase: options.requireUppercase !== false,
requireLowercase: options.requireLowercase !== false,
requireNumbers: options.requireNumbers !== false,
requireSpecialChars: options.requireSpecialChars !== false,
maxLength: options.maxLength || 128
};
const errors = [];
let strength = 0;
// 필수 검증
if (!password || typeof password !== 'string') {
return {
valid: false,
errors: ['비밀번호를 입력해주세요.'],
strength: 'invalid'
};
}
// 길이 검증
if (password.length < config.minLength) {
errors.push(`비밀번호는 최소 ${config.minLength}자 이상이어야 합니다.`);
} else {
strength += 1;
}
if (password.length > config.maxLength) {
errors.push(`비밀번호는 ${config.maxLength}자를 초과할 수 없습니다.`);
}
// 대문자 검증
if (config.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('대문자를 1개 이상 포함해야 합니다.');
} else if (/[A-Z]/.test(password)) {
strength += 1;
}
// 소문자 검증
if (config.requireLowercase && !/[a-z]/.test(password)) {
errors.push('소문자를 1개 이상 포함해야 합니다.');
} else if (/[a-z]/.test(password)) {
strength += 1;
}
// 숫자 검증
if (config.requireNumbers && !/\d/.test(password)) {
errors.push('숫자를 1개 이상 포함해야 합니다.');
} else if (/\d/.test(password)) {
strength += 1;
}
// 특수문자 검증
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
if (config.requireSpecialChars && !specialChars.test(password)) {
errors.push('특수문자를 1개 이상 포함해야 합니다. (!@#$%^&*()_+-=[]{};\':"|,.<>/?)');
} else if (specialChars.test(password)) {
strength += 1;
}
// 공백 검증
if (/\s/.test(password)) {
errors.push('비밀번호에 공백을 포함할 수 없습니다.');
}
// 연속된 문자 검증 (선택적)
if (/(.)\1{2,}/.test(password)) {
errors.push('동일한 문자를 3회 이상 연속 사용할 수 없습니다.');
}
// 강도 계산
let strengthLabel;
if (strength <= 2) {
strengthLabel = 'weak';
} else if (strength <= 3) {
strengthLabel = 'medium';
} else if (strength <= 4) {
strengthLabel = 'strong';
} else {
strengthLabel = 'very_strong';
}
return {
valid: errors.length === 0,
errors,
strength: strengthLabel,
score: strength
};
};
/**
* 간단한 비밀번호 검증 (기존 호환용)
* 모든 조건을 만족하면 true, 아니면 false
*
* @param {string} password - 검증할 비밀번호
* @returns {boolean} 유효 여부
*/
const isValidPassword = (password) => {
return validatePassword(password).valid;
};
/**
* 비밀번호 검증 결과를 한국어 메시지로 반환
*
* @param {string} password - 검증할 비밀번호
* @returns {string|null} 오류 메시지 (유효하면 null)
*/
const getPasswordError = (password) => {
const result = validatePassword(password);
if (result.valid) {
return null;
}
return result.errors.join(' ');
};
/**
* Express 미들웨어: 요청 body의 password 또는 newPassword 필드 검증
*
* @param {string} fieldName - 검증할 필드명 (기본: 'password')
* @returns {Function} Express 미들웨어
*/
const validatePasswordMiddleware = (fieldName = 'password') => {
return (req, res, next) => {
const password = req.body[fieldName] || req.body.newPassword;
if (!password) {
return next(); // 비밀번호 필드가 없으면 다음 미들웨어로
}
const result = validatePassword(password);
if (!result.valid) {
return res.status(400).json({
success: false,
error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: result.errors,
code: 'WEAK_PASSWORD'
});
}
next();
};
};
module.exports = {
validatePassword,
isValidPassword,
getPasswordError,
validatePasswordMiddleware
};

View File

@@ -2,6 +2,41 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
/**
* SQL Injection 방지를 위한 화이트리스트 검증
*/
const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
const ALLOWED_TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ALLOWED_COLUMN_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
const validateOrderDirection = (direction) => {
const normalized = (direction || 'DESC').toUpperCase();
if (!ALLOWED_ORDER_DIRECTIONS.includes(normalized)) {
throw new Error(`Invalid order direction: ${direction}`);
}
return normalized;
};
const validateIdentifier = (identifier, type = 'column') => {
if (!identifier || typeof identifier !== 'string') {
throw new Error(`Invalid ${type} name`);
}
if (!ALLOWED_COLUMN_NAME_PATTERN.test(identifier)) {
throw new Error(`Invalid ${type} name: ${identifier}`);
}
return identifier;
};
const validateTableName = (tableName) => {
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name');
}
if (!ALLOWED_TABLE_NAME_PATTERN.test(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
return tableName;
};
/** /**
* 페이지네이션 헬퍼 * 페이지네이션 헬퍼
*/ */
@@ -24,6 +59,10 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options; const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options;
const { limit: limitNum, offset, page: pageNum } = paginate(page, limit); const { limit: limitNum, offset, page: pageNum } = paginate(page, limit);
// SQL Injection 방지: 컬럼명과 정렬방향 검증
const safeOrderBy = validateIdentifier(orderBy, 'column');
const safeOrderDirection = validateOrderDirection(orderDirection);
try { try {
const db = await getDb(); const db = await getDb();
@@ -31,8 +70,8 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
const [countResult] = await db.execute(countQuery, params); const [countResult] = await db.execute(countQuery, params);
const totalCount = countResult[0]?.total || 0; const totalCount = countResult[0]?.total || 0;
// 데이터 조회 (ORDER BY와 LIMIT 추가) // 데이터 조회 (ORDER BY와 LIMIT 추가) - 검증된 값만 사용
const pagedQuery = `${baseQuery} ORDER BY ${orderBy} ${orderDirection} LIMIT ${limitNum} OFFSET ${offset}`; const pagedQuery = `${baseQuery} ORDER BY ${safeOrderBy} ${safeOrderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
const [rows] = await db.execute(pagedQuery, params); const [rows] = await db.execute(pagedQuery, params);
// 페이지네이션 메타데이터 계산 // 페이지네이션 메타데이터 계산
@@ -59,14 +98,17 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
* 인덱스 최적화 제안 * 인덱스 최적화 제안
*/ */
const suggestIndexes = async (tableName) => { const suggestIndexes = async (tableName) => {
// SQL Injection 방지: 테이블명 검증
const safeTableName = validateTableName(tableName);
try { try {
const db = await getDb(); const db = await getDb();
// 현재 인덱스 조회 // 현재 인덱스 조회 - 검증된 테이블명 사용
const [indexes] = await db.execute(`SHOW INDEX FROM ${tableName}`); const [indexes] = await db.execute(`SHOW INDEX FROM \`${safeTableName}\``);
// 테이블 구조 조회 // 테이블 구조 조회 - 검증된 테이블명 사용
const [columns] = await db.execute(`DESCRIBE ${tableName}`); const [columns] = await db.execute(`DESCRIBE \`${safeTableName}\``);
const suggestions = []; const suggestions = [];
@@ -80,7 +122,7 @@ const suggestIndexes = async (tableName) => {
type: 'INDEX', type: 'INDEX',
column: col.Field, column: col.Field,
reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상', reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
}); });
}); });
@@ -95,12 +137,12 @@ const suggestIndexes = async (tableName) => {
type: 'INDEX', type: 'INDEX',
column: col.Field, column: col.Field,
reason: '날짜 범위 검색 성능 향상', reason: '날짜 범위 검색 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
}); });
}); });
return { return {
tableName, tableName: safeTableName,
currentIndexes: indexes.map(idx => ({ currentIndexes: indexes.map(idx => ({
name: idx.Key_name, name: idx.Key_name,
column: idx.Column_name, column: idx.Column_name,
@@ -179,6 +221,9 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
throw new Error('삽입할 데이터가 없습니다.'); throw new Error('삽입할 데이터가 없습니다.');
} }
// SQL Injection 방지: 테이블명 검증
const safeTableName = validateTableName(tableName);
try { try {
const db = await getDb(); const db = await getDb();
const connection = await db.getConnection(); const connection = await db.getConnection();
@@ -186,8 +231,11 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
await connection.beginTransaction(); await connection.beginTransaction();
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
const placeholders = columns.map(() => '?').join(', '); // 컬럼명도 검증
const insertQuery = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`; const safeColumns = columns.map(col => validateIdentifier(col, 'column'));
const placeholders = safeColumns.map(() => '?').join(', ');
const columnList = safeColumns.map(col => `\`${col}\``).join(', ');
const insertQuery = `INSERT INTO \`${safeTableName}\` (${columnList}) VALUES (${placeholders})`;
let insertedCount = 0; let insertedCount = 0;

625
docs/SECURITY_GUIDE.md Normal file
View File

@@ -0,0 +1,625 @@
# TK-FB-Project 보안 가이드
> 최종 업데이트: 2026-02-04
> 작성자: TK-FB-Project Security Review
이 문서는 TK-FB-Project의 보안 취약점 분석 결과와 개발 시 준수해야 할 보안 가이드라인을 정리한 것입니다.
---
## 목차
1. [보안 취약점 요약](#1-보안-취약점-요약)
2. [수정 완료된 취약점](#2-수정-완료된-취약점)
3. [추가 조치 필요 항목](#3-추가-조치-필요-항목)
4. [백엔드 보안 가이드](#4-백엔드-보안-가이드)
5. [프론트엔드 보안 가이드](#5-프론트엔드-보안-가이드)
6. [배포 보안 체크리스트](#6-배포-보안-체크리스트)
7. [보안 유틸리티 사용법](#7-보안-유틸리티-사용법)
---
## 1. 보안 취약점 요약
### 1.1 심각도별 분류
| 심각도 | 발견 | 수정됨 | 미수정 |
|--------|------|--------|--------|
| CRITICAL | 2 | 2 | 0 |
| HIGH | 14 | 11 | 3 |
| MEDIUM | 9 | 3 | 6 |
| **총계** | **25** | **16** | **9** |
### 1.2 카테고리별 분류
| 카테고리 | 백엔드 | 프론트엔드 |
|----------|--------|------------|
| 인증/인가 | 3 | 0 |
| SQL Injection | 1 | 0 |
| XSS | 0 | 3 |
| 민감정보 노출 | 2 | 2 |
| 파일 업로드 | 2 | 0 |
| CSRF | 1 | 1 |
| 입력 검증 | 2 | 2 |
| 세션/토큰 | 2 | 1 |
| 기타 | 2 | 1 |
---
## 2. 수정 완료된 취약점
### 2.1 [CRITICAL] 하드코딩된 테스트 비밀번호
- **파일**: `api.hyungi.net/routes/auth.js`
- **문제**: `password === 'password'` 하드코딩
- **해결**: bcrypt.compare() 사용으로 변경
```javascript
// Before (취약)
const isValid = password === 'password'; // 임시
// After (수정됨)
const isValid = await bcrypt.compare(password, user.password);
```
### 2.2 [CRITICAL] JWT 시크릿 폴백 값
- **파일**: `api.hyungi.net/routes/auth.js`
- **문제**: `process.env.JWT_SECRET || 'your-secret-key'`
- **해결**: 폴백 제거 및 환경변수 필수화
```javascript
// Before (취약)
process.env.JWT_SECRET || 'your-secret-key'
// After (수정됨)
process.env.JWT_SECRET // 미설정 시 경고 로그 출력
```
### 2.3 [HIGH] 인증 미적용 API 라우트
- **파일들**:
- `routes/toolsRoute.js`
- `routes/projectRoutes.js`
- `routes/notificationRoutes.js`
- **해결**: `requireAuth`, `requireMinLevel` 미들웨어 적용
```javascript
// Before (취약)
router.get('/', controller.getAll);
router.post('/', controller.create);
// After (수정됨)
router.get('/', requireAuth, controller.getAll);
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
```
### 2.4 [HIGH] SQL Injection 취약점
- **파일**: `utils/queryOptimizer.js`
- **문제**: ORDER BY, 테이블명 직접 삽입
- **해결**: 화이트리스트 검증 함수 추가
```javascript
// Before (취약)
const pagedQuery = `${baseQuery} ORDER BY ${orderBy} ${orderDirection}...`;
// After (수정됨)
const safeOrderBy = validateIdentifier(orderBy, 'column');
const safeOrderDirection = validateOrderDirection(orderDirection);
const pagedQuery = `${baseQuery} ORDER BY ${safeOrderBy} ${safeOrderDirection}...`;
```
---
## 3. 추가 조치 필요 항목
### 3.1 [HIGH] XSS 취약점 - innerHTML 사용 (대부분 수정됨)
**수정 완료** (총 17개 파일):
- `api-base.js`에 전역 `escapeHtml()` 함수 추가됨
- `tbm.js` - 세션 카드, 작업자 목록, 작업 라인, 드롭다운 등 주요 렌더링
- `daily-patrol.js` - 공장 카드, 점검 현황, 작업장 지도, 체크리스트, 물품 섹션
- `daily-work-report.js` - 완료 보고서, 작업자 현황, 부적합 목록, 드롭다운 등
- `task-management.js` - 작업 탭, 작업 카드, 공정 선택
- `workplace-status.js` - 작업 현황, 설비 상태, 작업자/방문자 탭
- `equipment-detail.js` - 설비 정보, 사진, 수리 이력, 외부 반출, 이동 이력
- `issue-detail.js` - 기본 정보, 신고 내용, 처리 정보, 상태 타임라인, 담당자 배정
- `vacation-common.js` - 휴가 신청 목록, 액션 버튼
- `equipment-management.js` - 설비 목록, 작업장/유형 필터
- `worker-management.js` - 부서 목록, 작업자 목록
- `safety-report-list.js` - 안전신고 목록 렌더링
- `nonconformity-list.js` - 부적합 목록 렌더링
- `project-management.js` - 프로젝트 카드 렌더링
- `issue-report.js` - 작업 선택 모달, 위치 정보 표시
**추가 수정 권장 파일** (70+ 파일에 innerHTML 사용):
- `admin-settings.js`
- `modern-dashboard.js`
- `work-report-calendar.js`
- 기타 innerHTML을 사용하는 파일들 (우선순위에 따라 점진적 수정 권장)
**수정 패턴**:
```javascript
// Before (취약)
element.innerHTML = `<option>${data.name}</option>`;
// After (안전)
element.innerHTML = `<option>${escapeHtml(data.name)}</option>`;
// 숫자 값도 검증
onclick="handler(${parseInt(data.id) || 0})"
```
**조치 방법**:
1. `escapeHtml()``api-base.js`에서 전역으로 제공됨
2. 모든 innerHTML에서 사용자/API 데이터에 `escapeHtml()` 적용
3. onclick 핸들러의 ID 값은 `parseInt()` 사용
4. 검색 패턴: `\.innerHTML\s*=.*\$\{` 로 취약점 찾기
### 3.2 [HIGH] CSRF 보호 ✅ 구현 완료 (비활성화 상태)
- **파일**: `middlewares/csrf.js` (신규)
- **상태**: 구현 완료, `config/middleware.js`에서 활성화 가능
- **설명**: 토큰 기반 CSRF 보호 구현됨
**활성화 방법** (`config/middleware.js`):
```javascript
// 주석 해제하여 활성화
const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
app.get('/api/csrf-token', getCsrfToken);
app.use('/api/', verifyCsrfToken({
ignorePaths: ['/api/auth/login', '/api/auth/register', '/api/health', '/api/csrf-token']
}));
```
**프론트엔드 적용**:
```javascript
// CSRF 토큰 발급 받기
const response = await fetch('/api/csrf-token');
const { csrfToken } = await response.json();
// 요청에 토큰 포함
headers: { 'X-CSRF-Token': csrfToken }
```
### 3.3 [HIGH] JWT localStorage 저장
- **문제**: XSS 공격 시 토큰 탈취 가능
- **현재**: `localStorage.setItem('token', token)`
**권장 조치**:
- 서버에서 HttpOnly 쿠키로 JWT 전송
- 또는 메모리에만 저장하고 refresh token 사용
### 3.4 [HIGH] 파일 업로드 보안 ✅ 수정 완료
- **파일**: `utils/fileUploadSecurity.js` (신규)
- **상태**: 구현 완료, 주요 업로드 라우트에 적용됨
**구현된 기능**:
```javascript
// utils/fileUploadSecurity.js
const FILE_SIGNATURES = {
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
'89504e47': { mime: 'image/png', ext: ['.png'] },
'47494638': { mime: 'image/gif', ext: ['.gif'] },
'25504446': { mime: 'application/pdf', ext: ['.pdf'] }
};
// Magic number로 파일 유형 검증
const validateFileByMagicNumber = async (filePath, allowedMimes) => { ... };
// 안전한 파일명 생성
const generateSafeFilename = (originalFilename) => {
const sanitized = originalFilename.replace(/[^a-zA-Z0-9.-]/g, '_');
const uniquePrefix = crypto.randomBytes(8).toString('hex');
return `${uniquePrefix}_${sanitized}`;
};
// Multer 필터로 사용
const createFileFilter = (allowedExtensions, allowedMimes) => { ... };
```
**적용 예시** (`routes/workplaceRoutes.js`):
```javascript
const { generateSafeFilename, createFileFilter, ALLOWED_IMAGE_EXTENSIONS } = require('../utils/fileUploadSecurity');
```
### 3.5 [MEDIUM] Rate Limiting ✅ 수정 완료
- **파일**: `config/middleware.js`
- **상태**: 활성화됨
**적용된 설정**:
```javascript
// 일반 API: 15분당 200 요청
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
message: { success: false, error: '너무 많은 요청입니다...', code: 'RATE_LIMIT_EXCEEDED' }
});
// 로그인: 15분당 10회 (브루트포스 방지)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { success: false, error: '로그인 시도 횟수를 초과했습니다...', code: 'LOGIN_RATE_LIMIT_EXCEEDED' }
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
```
### 3.6 [MEDIUM] 비밀번호 정책 ✅ 수정 완료
- **파일**: `utils/passwordValidator.js` (신규)
- **상태**: 구현 완료
**적용된 정책**:
```javascript
// utils/passwordValidator.js
const validatePassword = (password, options = {}) => {
const config = {
minLength: options.minLength || 12,
requireUppercase: options.requireUppercase !== false,
requireLowercase: options.requireLowercase !== false,
requireNumbers: options.requireNumbers !== false,
requireSpecialChars: options.requireSpecialChars !== false,
maxLength: options.maxLength || 128
};
// ... 검증 로직
};
// 미들웨어로 사용
const { passwordValidationMiddleware } = require('../utils/passwordValidator');
router.post('/register', passwordValidationMiddleware(), controller.register);
```
---
## 4. 백엔드 보안 가이드
### 4.1 인증/인가
```javascript
// 모든 보호된 라우트에 인증 미들웨어 적용
const { requireAuth, requireMinLevel, requireRole } = require('../middlewares/auth');
// 읽기 작업: 인증만
router.get('/', requireAuth, controller.getAll);
// 쓰기 작업: 권한 체크
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
// 삭제 작업: 관리자 권한
router.delete('/:id', requireAuth, requireRole('admin'), controller.delete);
```
### 4.2 SQL Injection 방지
```javascript
// BAD - 직접 문자열 삽입
const query = `SELECT * FROM users WHERE name = '${userName}'`;
// GOOD - 파라미터화된 쿼리
const query = 'SELECT * FROM users WHERE name = ?';
const [rows] = await db.execute(query, [userName]);
// 동적 컬럼명/테이블명이 필요한 경우
const allowedColumns = ['name', 'email', 'created_at'];
if (!allowedColumns.includes(sortColumn)) {
throw new Error('Invalid column name');
}
```
### 4.3 입력 검증
```javascript
// express-validator 사용
const { body, param, validationResult } = require('express-validator');
router.post('/users',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 12 }),
body('name').trim().escape().isLength({ min: 2, max: 50 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 처리 로직
}
);
```
### 4.4 에러 처리
```javascript
// 프로덕션에서 스택 트레이스 노출 금지
app.use((err, req, res, next) => {
logger.error(err.stack);
const response = {
error: err.message || '서버 오류가 발생했습니다.'
};
// 개발 환경에서만 상세 정보
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(err.status || 500).json(response);
});
```
### 4.5 파일 업로드
```javascript
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(__dirname, '../uploads'));
},
filename: (req, file, cb) => {
// 랜덤 파일명 생성 (원본 파일명 사용 금지)
const ext = path.extname(file.originalname).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
cb(null, `${randomName}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
const ext = path.extname(file.originalname).toLowerCase();
const mimeOk = allowedMimes.includes(file.mimetype);
const extOk = allowedExts.includes(ext);
if (mimeOk && extOk) {
cb(null, true);
} else {
cb(new Error('허용되지 않는 파일 형식입니다.'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
});
```
---
## 5. 프론트엔드 보안 가이드
### 5.1 XSS 방지
```javascript
// security.js 로드 필수
<script src="/js/common/security.js"></script>
// BAD - 직접 innerHTML
element.innerHTML = `<div>${userData.name}</div>`;
// GOOD - escapeHtml 사용
element.innerHTML = `<div>${escapeHtml(userData.name)}</div>`;
// BETTER - textContent 사용 (HTML이 필요 없는 경우)
element.textContent = userData.name;
// BEST - 안전한 템플릿 함수 사용
SecurityUtils.setHtmlSafe(element, '<div>{{name}}</div>', { name: userData.name });
```
### 5.2 안전한 이벤트 핸들러
```javascript
// BAD - 인라인 이벤트 핸들러 (onclick 속성)
<button onclick="deleteItem(${item.id})">삭제</button>
// GOOD - 이벤트 리스너 사용
const button = document.createElement('button');
button.textContent = '삭제';
button.addEventListener('click', () => deleteItem(item.id));
```
### 5.3 URL 파라미터 검증
```javascript
// BAD - 검증 없이 사용
const id = new URLSearchParams(location.search).get('id');
fetch(`/api/items/${id}`);
// GOOD - 검증 후 사용
const id = SecurityUtils.getIdParamSafe('id');
if (id === null) {
showToast('잘못된 요청입니다.', 'error');
return;
}
fetch(`/api/items/${id}`);
```
### 5.4 localStorage 사용
```javascript
// 민감 정보 저장 최소화
// - 토큰: 가능하면 HttpOnly 쿠키 사용
// - 사용자 정보: 필수 정보만 저장
// BAD
localStorage.setItem('user', JSON.stringify(fullUserObject));
// GOOD
localStorage.setItem('user', JSON.stringify({
user_id: user.user_id,
name: user.name
// 민감 정보 제외: email, access_level 등
}));
```
### 5.5 API 호출 보안
```javascript
// 항상 HTTPS 사용 (개발 환경 제외)
// CSRF 토큰 포함 (구현 시)
async function apiCall(endpoint, method = 'GET', data = null) {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
// 'X-CSRF-Token': getCsrfToken() // CSRF 구현 시
};
const options = { method, headers };
if (data && method !== 'GET') {
options.body = JSON.stringify(data);
}
const response = await fetch(endpoint, options);
// 401 응답 시 로그인 페이지로 이동
if (response.status === 401) {
localStorage.removeItem('token');
window.location.href = '/pages/login.html';
return;
}
return response.json();
}
```
---
## 6. 배포 보안 체크리스트
### 6.1 환경변수
- [ ] `.env` 파일이 `.gitignore`에 포함되어 있는가?
- [ ] 모든 시크릿이 환경변수로 관리되는가?
- [ ] 프로덕션과 개발 환경의 시크릿이 분리되어 있는가?
- [ ] 기본/폴백 시크릿 값이 코드에 없는가?
### 6.2 인증/인가
- [ ] 모든 API 엔드포인트에 인증이 적용되어 있는가?
- [ ] 관리자 기능에 적절한 권한 체크가 있는가?
- [ ] 비밀번호 정책이 충분히 강력한가? (최소 12자, 복잡도)
- [ ] 로그인 시도 제한(Rate Limiting)이 활성화되어 있는가?
### 6.3 데이터 보호
- [ ] SQL Injection 방지가 모든 쿼리에 적용되어 있는가?
- [ ] XSS 방지가 모든 사용자 입력에 적용되어 있는가?
- [ ] 민감 정보가 로그에 기록되지 않는가?
- [ ] HTTPS가 강제되는가?
### 6.4 파일 업로드
- [ ] 파일 타입 검증이 서버에서 이루어지는가?
- [ ] 업로드 파일 크기 제한이 있는가?
- [ ] 업로드 경로가 웹 루트 외부인가?
- [ ] 실행 파일 업로드가 차단되는가?
### 6.5 헤더 및 설정
- [ ] 보안 헤더가 설정되어 있는가? (CSP, X-Frame-Options 등)
- [ ] CORS가 필요한 도메인만 허용하는가?
- [ ] 에러 메시지에 시스템 정보가 노출되지 않는가?
- [ ] 디버그 모드가 비활성화되어 있는가?
---
## 7. 보안 유틸리티 사용법
### 7.1 백엔드 - queryOptimizer.js
```javascript
const {
executePagedQuery,
validateIdentifier,
validateTableName
} = require('../utils/queryOptimizer');
// 페이지네이션 쿼리 (자동 검증)
const result = await executePagedQuery(
'SELECT * FROM users WHERE status = ?',
'SELECT COUNT(*) as total FROM users WHERE status = ?',
['active'],
{ page: 1, limit: 10, orderBy: 'created_at', orderDirection: 'DESC' }
);
```
### 7.2 프론트엔드 - security.js
```html
<!-- HTML에서 로드 -->
<script src="/js/common/security.js"></script>
```
```javascript
// XSS 방지
const safeHtml = escapeHtml(userInput);
element.innerHTML = `<span>${safeHtml}</span>`;
// URL 파라미터 안전하게 가져오기
const id = SecurityUtils.getIdParamSafe('id');
// 안전한 JSON 파싱
const data = SecurityUtils.parseJsonSafe(jsonString, {});
// 입력 검증
if (!SecurityUtils.validateEmail(email)) {
showToast('올바른 이메일 형식이 아닙니다.', 'error');
return;
}
// 안전한 HTML 템플릿
SecurityUtils.setHtmlSafe(container,
'<div class="user">{{name}} ({{email}})</div>',
{ name: user.name, email: user.email }
);
```
---
## 부록: 참고 자료
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/)
- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
- [Express.js Security Best Practices](https://expressjs.com/en/advanced/best-practice-security.html)
---
---
## 새로 추가된 보안 파일
| 파일 경로 | 설명 |
|-----------|------|
| `api.hyungi.net/utils/passwordValidator.js` | 비밀번호 강도 검증 유틸리티 |
| `api.hyungi.net/utils/fileUploadSecurity.js` | 파일 업로드 보안 (Magic number 검증) |
| `api.hyungi.net/middlewares/csrf.js` | CSRF 보호 미들웨어 |
| `web-ui/js/common/security.js` | 프론트엔드 보안 유틸리티 (상세 버전) |
## 수정된 파일
| 파일 | 수정 내용 |
|------|----------|
| `api.hyungi.net/routes/auth.js` | bcrypt 비교 적용, 폴백 시크릿 제거 |
| `api.hyungi.net/routes/authRoutes.js` | 강화된 비밀번호 정책 적용 |
| `api.hyungi.net/routes/toolsRoute.js` | 인증 미들웨어 추가 |
| `api.hyungi.net/routes/projectRoutes.js` | 인증 미들웨어 추가 |
| `api.hyungi.net/routes/notificationRoutes.js` | 인증 미들웨어 추가 |
| `api.hyungi.net/routes/workplaceRoutes.js` | 안전한 파일 업로드 적용 |
| `api.hyungi.net/routes/uploadBgRoutes.js` | 파일 검증 및 인증 추가 |
| `api.hyungi.net/utils/queryOptimizer.js` | SQL Injection 방지 검증 추가 |
| `api.hyungi.net/config/middleware.js` | Rate Limiting 활성화 |
| `web-ui/js/api-base.js` | escapeHtml 전역 함수 추가 |
| `web-ui/js/tbm.js` | XSS 방지 escapeHtml 적용 |
---
## 변경 이력
| 날짜 | 버전 | 변경 내용 |
|------|------|----------|
| 2026-02-04 | 1.0 | 최초 작성 |
| 2026-02-04 | 1.1 | 보안 취약점 수정 및 유틸리티 추가 |

View File

@@ -1,9 +1,48 @@
// /js/api-base.js // /js/api-base.js
// API 기본 설정 (비모듈 - 빠른 로딩용) // API 기본 설정 및 보안 유틸리티 (비모듈 - 빠른 로딩용)
(function() { (function() {
'use strict'; 'use strict';
// ==================== 보안 유틸리티 (XSS 방지) ====================
/**
* HTML 특수문자 이스케이프 (XSS 방지)
* innerHTML에 사용자 입력/API 데이터를 삽입할 때 반드시 사용
*
* @param {string} str - 이스케이프할 문자열
* @returns {string} 이스케이프된 문자열
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
const htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
};
/**
* URL 파라미터 이스케이프
*/
window.escapeUrl = function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
};
// ==================== API 설정 ====================
const API_PORT = 20005; const API_PORT = 20005;
const API_PATH = '/api'; const API_PATH = '/api';

View File

@@ -44,20 +44,18 @@ function waitForAxiosConfig() {
}); });
} }
// 자동 날짜/시간대 결정
function getAutoPatrolDateTime() {
const now = new Date();
const patrolDate = now.toISOString().slice(0, 10);
const hour = now.getHours();
// 오전(~12시), 오후(12시~)
const patrolTime = hour < 12 ? 'morning' : 'afternoon';
return { patrolDate, patrolTime };
}
// 페이지 초기화 // 페이지 초기화
async function initializePage() { async function initializePage() {
// 오늘 날짜 설정
const today = new Date().toISOString().slice(0, 10);
document.getElementById('patrolDate').value = today;
// 시간대 버튼 이벤트
document.querySelectorAll('.patrol-time-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.patrol-time-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// 데이터 로드 // 데이터 로드
await Promise.all([ await Promise.all([
loadCategories(), loadCategories(),
@@ -72,15 +70,74 @@ async function loadCategories() {
const response = await axios.get('/workplaces/categories'); const response = await axios.get('/workplaces/categories');
if (response.data.success) { if (response.data.success) {
categories = response.data.data; categories = response.data.data;
const select = document.getElementById('categorySelect');
select.innerHTML = '<option value="">공장 선택...</option>' +
categories.map(c => `<option value="${c.category_id}">${c.category_name}</option>`).join('');
} }
} catch (error) { } catch (error) {
console.error('공장 목록 로드 실패:', error); console.error('공장 목록 로드 실패:', error);
} }
} }
// 공장 선택 화면 표시
function showFactorySelection() {
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
const timeLabel = patrolTime === 'morning' ? '오전' : '오후';
const dateObj = new Date(patrolDate);
const dateLabel = dateObj.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric', weekday: 'short' });
// 세션 정보 표시
document.getElementById('patrolSessionInfo').textContent = `${dateLabel} ${timeLabel} 순회점검`;
// 공장 카드 렌더링
const container = document.getElementById('factoryCardsContainer');
container.innerHTML = categories.map(c => `
<div class="factory-card" onclick="selectFactory(${parseInt(c.category_id) || 0})">
<div class="factory-card-icon">
${c.layout_image ? `<img src="${escapeHtml(getImageUrl(c.layout_image))}" alt="${escapeHtml(c.category_name)}">` : '<span>🏭</span>'}
</div>
<div class="factory-card-name">${escapeHtml(c.category_name)}</div>
</div>
`).join('');
// 시작 버튼 숨기고 공장 선택 영역 표시
document.getElementById('startPatrolBtn').style.display = 'none';
document.getElementById('factorySelectionArea').style.display = 'block';
}
// 공장 선택 후 점검 시작
async function selectFactory(categoryId) {
const { patrolDate, patrolTime } = getAutoPatrolDateTime();
try {
// 세션 생성 또는 조회
const response = await axios.post('/patrol/sessions', {
patrol_date: patrolDate,
patrol_time: patrolTime,
category_id: categoryId
});
if (response.data.success) {
currentSession = response.data.data;
currentSession.patrol_date = patrolDate;
currentSession.patrol_time = patrolTime;
currentSession.category_id = categoryId;
// 작업장 목록 로드
await loadWorkplaces(categoryId);
// 체크리스트 항목 로드
await loadChecklistItems(categoryId);
// 공장 선택 영역 숨기고 점검 영역 표시
document.getElementById('factorySelectionArea').style.display = 'none';
document.getElementById('patrolArea').style.display = 'block';
renderSessionInfo();
renderWorkplaceMap();
}
} catch (error) {
console.error('순회점검 시작 실패:', error);
alert('순회점검을 시작할 수 없습니다.');
}
}
// 물품 유형 로드 // 물품 유형 로드
async function loadItemTypes() { async function loadItemTypes() {
try { try {
@@ -133,63 +190,18 @@ function renderTodayStatus(statusList) {
<div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}"> <div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}">
${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'} ${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'}
</div> </div>
${morning ? `<div class="status-sub">${morning.inspector_name || ''}</div>` : ''} ${morning ? `<div class="status-sub">${escapeHtml(morning.inspector_name || '')}</div>` : ''}
</div> </div>
<div class="status-card"> <div class="status-card">
<div class="status-label">오후</div> <div class="status-label">오후</div>
<div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}"> <div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}">
${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'} ${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'}
</div> </div>
${afternoon ? `<div class="status-sub">${afternoon.inspector_name || ''}</div>` : ''} ${afternoon ? `<div class="status-sub">${escapeHtml(afternoon.inspector_name || '')}</div>` : ''}
</div> </div>
`; `;
} }
// 순회점검 시작
async function startPatrol() {
const patrolDate = document.getElementById('patrolDate').value;
const patrolTime = document.querySelector('.patrol-time-btn.active')?.dataset.time;
const categoryId = document.getElementById('categorySelect').value;
if (!patrolDate || !patrolTime || !categoryId) {
alert('점검 일자, 시간대, 공장을 모두 선택해주세요.');
return;
}
try {
// 세션 생성 또는 조회
const response = await axios.post('/patrol/sessions', {
patrol_date: patrolDate,
patrol_time: patrolTime,
category_id: categoryId
});
if (response.data.success) {
currentSession = response.data.data;
currentSession.patrol_date = patrolDate;
currentSession.patrol_time = patrolTime;
currentSession.category_id = categoryId;
// 작업장 목록 로드
await loadWorkplaces(categoryId);
// 체크리스트 항목 로드
await loadChecklistItems(categoryId);
// 점검 영역 표시
document.getElementById('patrolArea').style.display = 'block';
renderSessionInfo();
renderWorkplaceMap();
// 시작 버튼 비활성화
document.getElementById('startPatrolBtn').textContent = '점검 진행중...';
document.getElementById('startPatrolBtn').disabled = true;
}
} catch (error) {
console.error('순회점검 시작 실패:', error);
alert('순회점검을 시작할 수 없습니다.');
}
}
// 작업장 목록 로드 // 작업장 목록 로드
async function loadWorkplaces(categoryId) { async function loadWorkplaces(categoryId) {
@@ -227,7 +239,7 @@ function renderSessionInfo() {
<div class="session-info"> <div class="session-info">
<div class="session-info-item"> <div class="session-info-item">
<span class="session-info-label">점검일자</span> <span class="session-info-label">점검일자</span>
<span class="session-info-value">${formatDate(currentSession.patrol_date)}</span> <span class="session-info-value">${escapeHtml(formatDate(currentSession.patrol_date))}</span>
</div> </div>
<div class="session-info-item"> <div class="session-info-item">
<span class="session-info-label">시간대</span> <span class="session-info-label">시간대</span>
@@ -235,14 +247,14 @@ function renderSessionInfo() {
</div> </div>
<div class="session-info-item"> <div class="session-info-item">
<span class="session-info-label">공장</span> <span class="session-info-label">공장</span>
<span class="session-info-value">${category?.category_name || ''}</span> <span class="session-info-value">${escapeHtml(category?.category_name || '')}</span>
</div> </div>
</div> </div>
<div class="session-progress"> <div class="session-progress">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div> <div class="progress-fill" style="width: ${parseInt(progress) || 0}%"></div>
</div> </div>
<span class="progress-text">${progress}%</span> <span class="progress-text">${parseInt(progress) || 0}%</span>
</div> </div>
`; `;
} }
@@ -255,18 +267,19 @@ function renderWorkplaceMap() {
// 지도 이미지가 있으면 지도 표시 // 지도 이미지가 있으면 지도 표시
if (category?.layout_image) { if (category?.layout_image) {
mapContainer.innerHTML = `<img src="${getImageUrl(category.layout_image)}" alt="${category.category_name} 지도">`; mapContainer.innerHTML = `<img src="${escapeHtml(getImageUrl(category.layout_image))}" alt="${escapeHtml(category.category_name)} 지도">`;
mapContainer.style.display = 'block'; mapContainer.style.display = 'block';
listContainer.style.display = 'none';
// 작업장 마커 추가 // 좌표가 있는 작업장 마커 추가
const hasMarkers = workplaces.some(wp => wp.x_percent && wp.y_percent);
workplaces.forEach(wp => { workplaces.forEach(wp => {
if (wp.x_percent && wp.y_percent) { if (wp.x_percent && wp.y_percent) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'workplace-marker'; marker.className = 'workplace-marker';
marker.style.left = `${wp.x_percent}%`; marker.style.left = `${parseFloat(wp.x_percent) || 0}%`;
marker.style.top = `${wp.y_percent}%`; marker.style.top = `${parseFloat(wp.y_percent) || 0}%`;
marker.textContent = wp.workplace_name; marker.textContent = wp.workplace_name; // textContent는 자동 이스케이프
marker.dataset.workplaceId = wp.workplace_id; marker.dataset.workplaceId = wp.workplace_id;
marker.onclick = () => selectWorkplace(wp.workplace_id); marker.onclick = () => selectWorkplace(wp.workplace_id);
@@ -279,30 +292,43 @@ function renderWorkplaceMap() {
mapContainer.appendChild(marker); mapContainer.appendChild(marker);
} }
}); });
// 좌표가 없는 작업장이 있으면 카드 목록도 표시
if (!hasMarkers || workplaces.some(wp => !wp.x_percent || !wp.y_percent)) {
listContainer.style.display = 'grid';
renderWorkplaceCards(listContainer);
} else {
listContainer.style.display = 'none';
}
} else { } else {
// 지도 없으면 카드 목록으로 표시 // 지도 없으면 카드 목록으로 표시
mapContainer.style.display = 'none'; mapContainer.style.display = 'none';
listContainer.style.display = 'grid'; listContainer.style.display = 'grid';
renderWorkplaceCards(listContainer);
listContainer.innerHTML = workplaces.map(wp => {
const records = checkRecords[wp.workplace_id];
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
const isInProgress = records && records.some(r => r.is_checked);
return `
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
data-workplace-id="${wp.workplace_id}"
onclick="selectWorkplace(${wp.workplace_id})">
<div class="workplace-card-name">${wp.workplace_name}</div>
<div class="workplace-card-status">
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
</div>
</div>
`;
}).join('');
} }
} }
// 작업장 카드 렌더링
function renderWorkplaceCards(container) {
container.innerHTML = workplaces.map(wp => {
const records = checkRecords[wp.workplace_id];
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
const isInProgress = records && records.some(r => r.is_checked);
const workplaceId = parseInt(wp.workplace_id) || 0;
return `
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${isInProgress && !isCompleted ? 'in-progress' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
data-workplace-id="${workplaceId}"
onclick="selectWorkplace(${workplaceId})">
<div class="workplace-card-name">${escapeHtml(wp.workplace_name)}</div>
<div class="workplace-card-status">
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
</div>
</div>
`;
}).join('');
}
// 작업장 선택 // 작업장 선택
async function selectWorkplace(workplaceId) { async function selectWorkplace(workplaceId) {
selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId); selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId);
@@ -345,7 +371,7 @@ function renderChecklist(workplaceId) {
const workplace = workplaces.find(w => w.workplace_id === workplaceId); const workplace = workplaces.find(w => w.workplace_id === workplaceId);
header.innerHTML = ` header.innerHTML = `
<h3>${workplace?.workplace_name || ''} 체크리스트</h3> <h3>${escapeHtml(workplace?.workplace_name || '')} 체크리스트</h3>
<p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p> <p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p>
`; `;
@@ -362,32 +388,34 @@ function renderChecklist(workplaceId) {
content.innerHTML = Object.entries(grouped).map(([category, items]) => ` content.innerHTML = Object.entries(grouped).map(([category, items]) => `
<div class="checklist-category"> <div class="checklist-category">
<div class="checklist-category-title">${getCategoryName(category)}</div> <div class="checklist-category-title">${escapeHtml(getCategoryName(category))}</div>
${items.map(item => { ${items.map(item => {
const record = records.find(r => r.check_item_id === item.item_id); const record = records.find(r => r.check_item_id === item.item_id);
const isChecked = record?.is_checked; const isChecked = record?.is_checked;
const checkResult = record?.check_result; const checkResult = record?.check_result;
const itemId = parseInt(item.item_id) || 0;
const wpId = parseInt(workplaceId) || 0;
return ` return `
<div class="check-item ${isChecked ? 'checked' : ''}" <div class="check-item ${isChecked ? 'checked' : ''}"
data-item-id="${item.item_id}" data-item-id="${itemId}"
onclick="toggleCheckItem(${workplaceId}, ${item.item_id})"> onclick="toggleCheckItem(${wpId}, ${itemId})">
<div class="check-item-checkbox"> <div class="check-item-checkbox">
${isChecked ? '&#10003;' : ''} ${isChecked ? '&#10003;' : ''}
</div> </div>
<div class="check-item-content"> <div class="check-item-content">
<div class="check-item-text"> <div class="check-item-text">
${item.check_item} ${escapeHtml(item.check_item)}
${item.is_required ? '<span class="check-item-required">*</span>' : ''} ${item.is_required ? '<span class="check-item-required">*</span>' : ''}
</div> </div>
${isChecked ? ` ${isChecked ? `
<div class="check-result-selector" onclick="event.stopPropagation()"> <div class="check-result-selector" onclick="event.stopPropagation()">
<button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}" <button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}"
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'good')">양호</button> onclick="setCheckResult(${wpId}, ${itemId}, 'good')">양호</button>
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}" <button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'warning')">주의</button> onclick="setCheckResult(${wpId}, ${itemId}, 'warning')">주의</button>
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}" <button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'bad')">불량</button> onclick="setCheckResult(${wpId}, ${itemId}, 'bad')">불량</button>
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -551,19 +579,21 @@ function renderItemsSection(workplaceId) {
// 작업장 레이아웃 이미지가 있으면 표시 // 작업장 레이아웃 이미지가 있으면 표시
if (workplace?.layout_image) { if (workplace?.layout_image) {
container.innerHTML = `<img src="${getImageUrl(workplace.layout_image)}" alt="${workplace.workplace_name}">`; container.innerHTML = `<img src="${escapeHtml(getImageUrl(workplace.layout_image))}" alt="${escapeHtml(workplace.workplace_name)}">`;
// 물품 마커 추가 // 물품 마커 추가
workplaceItems.forEach(item => { workplaceItems.forEach(item => {
if (item.x_percent && item.y_percent) { if (item.x_percent && item.y_percent) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = `item-marker ${item.item_type}`; // item_type은 화이트리스트로 검증
marker.style.left = `${item.x_percent}%`; const safeItemType = ['container', 'plate', 'material', 'tool', 'other'].includes(item.item_type) ? item.item_type : 'other';
marker.style.top = `${item.y_percent}%`; marker.className = `item-marker ${safeItemType}`;
marker.style.width = `${item.width_percent || 5}%`; marker.style.left = `${parseFloat(item.x_percent) || 0}%`;
marker.style.height = `${item.height_percent || 5}%`; marker.style.top = `${parseFloat(item.y_percent) || 0}%`;
marker.innerHTML = item.icon || getItemTypeIcon(item.item_type); marker.style.width = `${parseFloat(item.width_percent) || 5}%`;
marker.title = `${item.item_name || item.type_name} (${item.quantity}개)`; marker.style.height = `${parseFloat(item.height_percent) || 5}%`;
marker.textContent = item.icon || getItemTypeIcon(item.item_type); // textContent 사용
marker.title = `${item.item_name || item.type_name} (${parseInt(item.quantity) || 0}개)`;
marker.dataset.itemId = item.item_id; marker.dataset.itemId = item.item_id;
marker.onclick = () => openItemModal(item); marker.onclick = () => openItemModal(item);
container.appendChild(marker); container.appendChild(marker);
@@ -593,7 +623,7 @@ function renderItemTypesSelect() {
const select = document.getElementById('itemType'); const select = document.getElementById('itemType');
if (!select) return; if (!select) return;
select.innerHTML = itemTypes.map(t => select.innerHTML = itemTypes.map(t =>
`<option value="${t.type_code}">${t.icon} ${t.type_name}</option>` `<option value="${escapeHtml(t.type_code)}">${escapeHtml(t.icon)} ${escapeHtml(t.type_name)}</option>`
).join(''); ).join('');
} }
@@ -601,14 +631,19 @@ function renderItemTypesSelect() {
function renderItemsLegend() { function renderItemsLegend() {
const container = document.getElementById('itemsLegend'); const container = document.getElementById('itemsLegend');
if (!container) return; if (!container) return;
container.innerHTML = itemTypes.map(t => ` // 색상 값 검증 (hex color만 허용)
const isValidColor = (color) => /^#[0-9A-Fa-f]{3,6}$/.test(color);
container.innerHTML = itemTypes.map(t => {
const safeColor = isValidColor(t.color) ? t.color : '#888888';
return `
<div class="item-legend-item"> <div class="item-legend-item">
<div class="item-legend-icon" style="background: ${t.color}20; border: 1px solid ${t.color};"> <div class="item-legend-icon" style="background: ${safeColor}20; border: 1px solid ${safeColor};">
${t.icon} ${escapeHtml(t.icon)}
</div> </div>
<span>${t.type_name}</span> <span>${escapeHtml(t.type_name)}</span>
</div> </div>
`).join(''); `;
}).join('');
} }
// 편집 모드 토글 // 편집 모드 토글

View File

@@ -843,22 +843,22 @@ window.addManualWorkRow = function() {
<td> <td>
<select class="form-input-compact" id="worker_${manualIndex}" style="width: 120px;" required> <select class="form-input-compact" id="worker_${manualIndex}" style="width: 120px;" required>
<option value="">작업자 선택</option> <option value="">작업자 선택</option>
${workers.map(w => `<option value="${w.worker_id}">${w.worker_name} (${w.job_type || '-'})</option>`).join('')} ${workers.map(w => `<option value="${escapeHtml(String(w.worker_id))}">${escapeHtml(w.worker_name)} (${escapeHtml(w.job_type || '-')})</option>`).join('')}
</select> </select>
</td> </td>
<td> <td>
<input type="date" class="form-input-compact" id="date_${manualIndex}" value="${getKoreaToday()}" required style="width: 130px;"> <input type="date" class="form-input-compact" id="date_${manualIndex}" value="${escapeHtml(getKoreaToday())}" required style="width: 130px;">
</td> </td>
<td> <td>
<select class="form-input-compact" id="project_${manualIndex}" style="width: 120px;" required> <select class="form-input-compact" id="project_${manualIndex}" style="width: 120px;" required>
<option value="">프로젝트 선택</option> <option value="">프로젝트 선택</option>
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')} ${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
</select> </select>
</td> </td>
<td> <td>
<select class="form-input-compact" id="workType_${manualIndex}" style="width: 120px;" required onchange="loadTasksForWorkType('${manualIndex}')"> <select class="form-input-compact" id="workType_${manualIndex}" style="width: 120px;" required onchange="loadTasksForWorkType('${manualIndex}')">
<option value="">공정 선택</option> <option value="">공정 선택</option>
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')} ${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
</select> </select>
</td> </td>
<td> <td>
@@ -969,7 +969,7 @@ window.loadTasksForWorkType = async function(manualIndex) {
taskSelect.disabled = false; taskSelect.disabled = false;
taskSelect.innerHTML = ` taskSelect.innerHTML = `
<option value="">작업 선택</option> <option value="">작업 선택</option>
${tasks.map(task => `<option value="${task.task_id}">${task.task_name}</option>`).join('')} ${tasks.map(task => `<option value="${escapeHtml(String(task.task_id))}">${escapeHtml(task.task_name)}</option>`).join('')}
`; `;
} else { } else {
taskSelect.disabled = true; taskSelect.disabled = true;
@@ -1022,12 +1022,17 @@ window.openWorkplaceMapForManual = async function(manualIndex) {
const modal = document.getElementById('workplaceModal'); const modal = document.getElementById('workplaceModal');
const categoryList = document.getElementById('workplaceCategoryList'); const categoryList = document.getElementById('workplaceCategoryList');
categoryList.innerHTML = categories.map(cat => ` categoryList.innerHTML = categories.map(cat => {
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceCategory(${cat.category_id}, "${cat.category_name.replace(/"/g, '&quot;')}", "${(cat.layout_image || '').replace(/"/g, '&quot;')}")'> const safeId = parseInt(cat.category_id) || 0;
const safeName = escapeHtml(cat.category_name);
const safeImage = escapeHtml(cat.layout_image || '');
return `
<button type="button" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceCategory(${safeId}, "${safeName.replace(/"/g, '&quot;')}", "${safeImage.replace(/"/g, '&quot;')}")'>
<span style="margin-right: 0.5rem;">🏭</span> <span style="margin-right: 0.5rem;">🏭</span>
${cat.category_name} ${safeName}
</button> </button>
`).join(''); `;
}).join('');
// 카테고리 선택 화면 표시 // 카테고리 선택 화면 표시
document.getElementById('categorySelectionArea').style.display = 'block'; document.getElementById('categorySelectionArea').style.display = 'block';
@@ -1071,12 +1076,16 @@ window.selectWorkplaceCategory = async function(categoryId, categoryName, layout
// 리스트 항상 표시 // 리스트 항상 표시
const workplaceListArea = document.getElementById('workplaceListArea'); const workplaceListArea = document.getElementById('workplaceListArea');
workplaceListArea.innerHTML = workplaces.map(wp => ` workplaceListArea.innerHTML = workplaces.map(wp => {
<button type="button" id="workplace-${wp.workplace_id}" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceFromList(${wp.workplace_id}, "${wp.workplace_name.replace(/"/g, '&quot;')}")'> const safeId = parseInt(wp.workplace_id) || 0;
const safeName = escapeHtml(wp.workplace_name);
return `
<button type="button" id="workplace-${safeId}" class="btn btn-secondary" style="width: 100%; text-align: left;" onclick='selectWorkplaceFromList(${safeId}, "${safeName.replace(/"/g, '&quot;')}")'>
<span style="margin-right: 0.5rem;">📍</span> <span style="margin-right: 0.5rem;">📍</span>
${wp.workplace_name} ${safeName}
</button> </button>
`).join(''); `;
}).join('');
} catch (error) { } catch (error) {
console.error('작업장소 로드 오류:', error); console.error('작업장소 로드 오류:', error);
@@ -1270,8 +1279,8 @@ window.confirmWorkplaceSelection = function() {
<span>작업장소 선택됨</span> <span>작업장소 선택됨</span>
</div> </div>
<div style="font-size: 0.8rem; color: #111827; font-weight: 500;"> <div style="font-size: 0.8rem; color: #111827; font-weight: 500;">
<div style="color: #6b7280; font-size: 0.7rem; margin-bottom: 2px;">🏭 ${selectedWorkplaceCategoryName}</div> <div style="color: #6b7280; font-size: 0.7rem; margin-bottom: 2px;">🏭 ${escapeHtml(selectedWorkplaceCategoryName)}</div>
<div>📍 ${selectedWorkplaceName}</div> <div>📍 ${escapeHtml(selectedWorkplaceName)}</div>
</div> </div>
`; `;
displayDiv.style.background = '#ecfdf5'; displayDiv.style.background = '#ecfdf5';
@@ -1482,49 +1491,49 @@ function renderCompletedReports(reports) {
<div class="completed-report-card"> <div class="completed-report-card">
<div class="report-header"> <div class="report-header">
<div> <div>
<h4>${report.worker_name || '작업자'}</h4> <h4>${escapeHtml(report.worker_name || '작업자')}</h4>
${report.tbm_session_id ? '<span class="tbm-badge">TBM 연동</span>' : '<span class="manual-badge">수동 입력</span>'} ${report.tbm_session_id ? '<span class="tbm-badge">TBM 연동</span>' : '<span class="manual-badge">수동 입력</span>'}
</div> </div>
<span class="report-date">${formatDate(report.report_date)}</span> <span class="report-date">${escapeHtml(formatDate(report.report_date))}</span>
</div> </div>
<div class="report-info"> <div class="report-info">
<div class="info-row"> <div class="info-row">
<span class="label">프로젝트:</span> <span class="label">프로젝트:</span>
<span class="value">${report.project_name || '-'}</span> <span class="value">${escapeHtml(report.project_name || '-')}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">공정:</span> <span class="label">공정:</span>
<span class="value">${report.work_type_name || '-'}</span> <span class="value">${escapeHtml(report.work_type_name || '-')}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">작업시간:</span> <span class="label">작업시간:</span>
<span class="value">${report.total_hours || report.work_hours || 0}시간</span> <span class="value">${parseFloat(report.total_hours || report.work_hours || 0)}시간</span>
</div> </div>
${report.regular_hours !== undefined && report.regular_hours !== null ? ` ${report.regular_hours !== undefined && report.regular_hours !== null ? `
<div class="info-row"> <div class="info-row">
<span class="label">정규 시간:</span> <span class="label">정규 시간:</span>
<span class="value">${report.regular_hours}시간</span> <span class="value">${parseFloat(report.regular_hours)}시간</span>
</div> </div>
` : ''} ` : ''}
${report.error_hours && report.error_hours > 0 ? ` ${report.error_hours && report.error_hours > 0 ? `
<div class="info-row"> <div class="info-row">
<span class="label">부적합 처리:</span> <span class="label">부적합 처리:</span>
<span class="value" style="color: #dc2626;">${report.error_hours}시간</span> <span class="value" style="color: #dc2626;">${parseFloat(report.error_hours)}시간</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">부적합 원인:</span> <span class="label">부적합 원인:</span>
<span class="value">${report.error_type_name || '-'}</span> <span class="value">${escapeHtml(report.error_type_name || '-')}</span>
</div> </div>
` : ''} ` : ''}
<div class="info-row"> <div class="info-row">
<span class="label">작성자:</span> <span class="label">작성자:</span>
<span class="value">${report.created_by_name || '-'}</span> <span class="value">${escapeHtml(report.created_by_name || '-')}</span>
</div> </div>
${report.start_time && report.end_time ? ` ${report.start_time && report.end_time ? `
<div class="info-row"> <div class="info-row">
<span class="label">작업 시간:</span> <span class="label">작업 시간:</span>
<span class="value">${report.start_time} ~ ${report.end_time}</span> <span class="value">${escapeHtml(report.start_time)} ~ ${escapeHtml(report.end_time)}</span>
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -1972,10 +1981,10 @@ function addWorkEntry() {
</div> </div>
<select class="form-select project-select" required> <select class="form-select project-select" required>
<option value="">프로젝트를 선택하세요</option> <option value="">프로젝트를 선택하세요</option>
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')} ${projects.map(p => `<option value="${escapeHtml(String(p.project_id))}">${escapeHtml(p.project_name)}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-field-group"> <div class="form-field-group">
<div class="form-field-label"> <div class="form-field-label">
<span class="form-field-icon">⚙️</span> <span class="form-field-icon">⚙️</span>
@@ -1983,7 +1992,7 @@ function addWorkEntry() {
</div> </div>
<select class="form-select work-type-select" required> <select class="form-select work-type-select" required>
<option value="">작업 유형을 선택하세요</option> <option value="">작업 유형을 선택하세요</option>
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')} ${workTypes.map(wt => `<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}</option>`).join('')}
</select> </select>
</div> </div>
</div> </div>
@@ -1996,7 +2005,7 @@ function addWorkEntry() {
</div> </div>
<select class="form-select work-status-select" required> <select class="form-select work-status-select" required>
<option value="">업무 상태를 선택하세요</option> <option value="">업무 상태를 선택하세요</option>
${workStatusTypes.map(ws => `<option value="${ws.id}">${ws.name}</option>`).join('')} ${workStatusTypes.map(ws => `<option value="${escapeHtml(String(ws.id))}">${escapeHtml(ws.name)}</option>`).join('')}
</select> </select>
</div> </div>
</div> </div>
@@ -2422,7 +2431,7 @@ function displayMyDailyWorkers(data, date) {
const headerHtml = ` const headerHtml = `
<div class="daily-workers-header"> <div class="daily-workers-header">
<h4>📊 내가 입력한 오늘(${date}) 작업 현황 - 총 ${totalWorkers}명, ${totalWorks}개 작업</h4> <h4>📊 내가 입력한 오늘(${escapeHtml(date)}) 작업 현황 - 총 ${parseInt(totalWorkers) || 0}명, ${parseInt(totalWorks) || 0}개 작업</h4>
<button class="refresh-btn" onclick="refreshTodayWorkers()"> <button class="refresh-btn" onclick="refreshTodayWorkers()">
🔄 새로고침 🔄 새로고침
</button> </button>
@@ -2436,12 +2445,12 @@ function displayMyDailyWorkers(data, date) {
// 개별 작업 항목들 (수정/삭제 버튼 포함) // 개별 작업 항목들 (수정/삭제 버튼 포함)
const individualWorksHtml = works.map((work) => { const individualWorksHtml = works.map((work) => {
const projectName = work.project_name || '미지정'; const projectName = escapeHtml(work.project_name || '미지정');
const workTypeName = work.work_type_name || '미지정'; const workTypeName = escapeHtml(work.work_type_name || '미지정');
const workStatusName = work.work_status_name || '미지정'; const workStatusName = escapeHtml(work.work_status_name || '미지정');
const workHours = work.work_hours || 0; const workHours = parseFloat(work.work_hours || 0);
const errorTypeName = work.error_type_name || null; const errorTypeName = work.error_type_name ? escapeHtml(work.error_type_name) : null;
const workId = work.id; const workId = parseInt(work.id) || 0;
return ` return `
<div class="individual-work-item"> <div class="individual-work-item">
@@ -2484,8 +2493,8 @@ function displayMyDailyWorkers(data, date) {
return ` return `
<div class="worker-status-item"> <div class="worker-status-item">
<div class="worker-header"> <div class="worker-header">
<div class="worker-name">👤 ${workerName}</div> <div class="worker-name">👤 ${escapeHtml(workerName)}</div>
<div class="worker-total-hours">총 ${totalHours}시간</div> <div class="worker-total-hours">총 ${parseFloat(totalHours)}시간</div>
</div> </div>
<div class="individual-works-container"> <div class="individual-works-container">
${individualWorksHtml} ${individualWorksHtml}
@@ -2981,8 +2990,8 @@ function renderInlineDefectList(index) {
let html = ` let html = `
<div class="defect-issue-section"> <div class="defect-issue-section">
<div class="defect-issue-header"> <div class="defect-issue-header">
<span class="defect-issue-title">📋 ${workerWorkplaceName || '작업장소'} 관련 부적합</span> <span class="defect-issue-title">📋 ${escapeHtml(workerWorkplaceName || '작업장소')} 관련 부적합</span>
<span class="defect-issue-count">${nonconformityIssues.length}건</span> <span class="defect-issue-count">${parseInt(nonconformityIssues.length) || 0}건</span>
</div> </div>
<div class="defect-issue-list"> <div class="defect-issue-list">
`; `;
@@ -2999,23 +3008,24 @@ function renderInlineDefectList(index) {
itemText = itemText ? `${itemText} - ${issue.additional_description}` : issue.additional_description; itemText = itemText ? `${itemText} - ${issue.additional_description}` : issue.additional_description;
} }
const safeReportId = parseInt(issue.report_id) || 0;
html += ` html += `
<div class="defect-issue-item ${isSelected ? 'selected' : ''}" data-issue-id="${issue.report_id}"> <div class="defect-issue-item ${isSelected ? 'selected' : ''}" data-issue-id="${safeReportId}">
<div class="defect-issue-checkbox"> <div class="defect-issue-checkbox">
<input type="checkbox" <input type="checkbox"
id="issueCheck_${index}_${issue.report_id}" id="issueCheck_${index}_${safeReportId}"
${isSelected ? 'checked' : ''} ${isSelected ? 'checked' : ''}
onchange="toggleIssueDefect('${index}', ${issue.report_id}, this.checked)"> onchange="toggleIssueDefect('${index}', ${safeReportId}, this.checked)">
</div> </div>
<div class="defect-issue-info"> <div class="defect-issue-info">
<span class="defect-issue-category">${issue.issue_category_name || '부적합'}</span> <span class="defect-issue-category">${escapeHtml(issue.issue_category_name || '부적합')}</span>
<span class="defect-issue-item-name">${itemText || '-'}</span> <span class="defect-issue-item-name">${escapeHtml(itemText || '-')}</span>
<span class="defect-issue-location">${issue.workplace_name || issue.custom_location || ''}</span> <span class="defect-issue-location">${escapeHtml(issue.workplace_name || issue.custom_location || '')}</span>
</div> </div>
<div class="defect-issue-time ${isSelected ? 'active' : ''}"> <div class="defect-issue-time ${isSelected ? 'active' : ''}">
<div class="defect-time-input ${isSelected ? '' : 'disabled'}" <div class="defect-time-input ${isSelected ? '' : 'disabled'}"
onclick="${isSelected ? `openIssueDefectTimePicker('${index}', ${issue.report_id})` : ''}"> onclick="${isSelected ? `openIssueDefectTimePicker('${index}', ${safeReportId})` : ''}">
<span class="defect-time-value" id="issueDefectTime_${index}_${issue.report_id}">${defectHours}</span> <span class="defect-time-value" id="issueDefectTime_${index}_${safeReportId}">${parseFloat(defectHours) || 0}</span>
<span class="defect-time-unit">시간</span> <span class="defect-time-unit">시간</span>
</div> </div>
</div> </div>
@@ -3052,7 +3062,7 @@ function renderInlineDefectList(index) {
} else { } else {
// 이슈가 없으면 레거시 UI (error_types 선택) // 이슈가 없으면 레거시 UI (error_types 선택)
const noIssueMsg = workerWorkplaceName const noIssueMsg = workerWorkplaceName
? `${workerWorkplaceName}에 신고된 부적합이 없습니다.` ? `${escapeHtml(workerWorkplaceName)}에 신고된 부적합이 없습니다.`
: '신고된 부적합이 없습니다.'; : '신고된 부적합이 없습니다.';
listContainer.innerHTML = ` listContainer.innerHTML = `
<div class="defect-no-issues"> <div class="defect-no-issues">

View File

@@ -99,27 +99,27 @@ function renderEquipmentInfo() {
<div class="eq-info-grid"> <div class="eq-info-grid">
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">관리번호</span> <span class="eq-info-label">관리번호</span>
<span class="eq-info-value">${eq.equipment_code}</span> <span class="eq-info-value">${escapeHtml(eq.equipment_code || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">설비명</span> <span class="eq-info-label">설비명</span>
<span class="eq-info-value">${eq.equipment_name}</span> <span class="eq-info-value">${escapeHtml(eq.equipment_name || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">모델명</span> <span class="eq-info-label">모델명</span>
<span class="eq-info-value">${eq.model_name || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.model_name || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">규격</span> <span class="eq-info-label">규격</span>
<span class="eq-info-value">${eq.specifications || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.specifications || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">제조사</span> <span class="eq-info-label">제조사</span>
<span class="eq-info-value">${eq.manufacturer || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.manufacturer || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">구입처</span> <span class="eq-info-label">구입처</span>
<span class="eq-info-value">${eq.supplier || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.supplier || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">구입일</span> <span class="eq-info-label">구입일</span>
@@ -131,11 +131,11 @@ function renderEquipmentInfo() {
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">시리얼번호</span> <span class="eq-info-label">시리얼번호</span>
<span class="eq-info-value">${eq.serial_number || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.serial_number || '-')}</span>
</div> </div>
<div class="eq-info-item"> <div class="eq-info-item">
<span class="eq-info-label">설비유형</span> <span class="eq-info-label">설비유형</span>
<span class="eq-info-value">${eq.equipment_type || '-'}</span> <span class="eq-info-value">${escapeHtml(eq.equipment_type || '-')}</span>
</div> </div>
</div> </div>
`; `;
@@ -219,12 +219,17 @@ function renderPhotos(photos) {
return; return;
} }
grid.innerHTML = photos.map(photo => ` grid.innerHTML = photos.map(photo => {
<div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${photo.photo_path}')"> const safePhotoId = parseInt(photo.photo_id) || 0;
<img src="${window.API_BASE_URL}${photo.photo_path}" alt="${photo.description || '설비 사진'}"> const safePhotoPath = encodeURI(photo.photo_path || '');
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${photo.photo_id})">&times;</button> const safeDescription = escapeHtml(photo.description || '설비 사진');
</div> return `
`).join(''); <div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${safePhotoPath}')">
<img src="${window.API_BASE_URL}${safePhotoPath}" alt="${safeDescription}">
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${safePhotoId})">&times;</button>
</div>
`;
}).join('');
} }
function openPhotoModal() { function openPhotoModal() {
@@ -323,7 +328,8 @@ function openMoveModal() {
const factorySelect = document.getElementById('moveFactorySelect'); const factorySelect = document.getElementById('moveFactorySelect');
factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>'; factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>';
factories.forEach(f => { factories.forEach(f => {
factorySelect.innerHTML += `<option value="${f.category_id}">${f.category_name}</option>`; const safeCategoryId = parseInt(f.category_id) || 0;
factorySelect.innerHTML += `<option value="${safeCategoryId}">${escapeHtml(f.category_name || '-')}</option>`;
}); });
document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>'; document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>';
@@ -354,7 +360,8 @@ async function loadMoveWorkplaces() {
workplaces = response.data.data; workplaces = response.data.data;
workplaces.forEach(wp => { workplaces.forEach(wp => {
if (wp.map_image_url) { if (wp.map_image_url) {
workplaceSelect.innerHTML += `<option value="${wp.workplace_id}">${wp.workplace_name}</option>`; const safeWorkplaceId = parseInt(wp.workplace_id) || 0;
workplaceSelect.innerHTML += `<option value="${safeWorkplaceId}">${escapeHtml(wp.workplace_name || '-')}</option>`;
} }
}); });
} }
@@ -475,7 +482,8 @@ function openRepairModal() {
const select = document.getElementById('repairItemSelect'); const select = document.getElementById('repairItemSelect');
select.innerHTML = '<option value="">선택하세요</option>'; select.innerHTML = '<option value="">선택하세요</option>';
repairCategories.forEach(item => { repairCategories.forEach(item => {
select.innerHTML += `<option value="${item.item_id}">${item.item_name}</option>`; const safeItemId = parseInt(item.item_id) || 0;
select.innerHTML += `<option value="${safeItemId}">${escapeHtml(item.item_name || '-')}</option>`;
}); });
document.getElementById('repairDescription').value = ''; document.getElementById('repairDescription').value = '';
@@ -557,16 +565,20 @@ function renderRepairHistory(history) {
return; return;
} }
container.innerHTML = history.map(h => ` const validStatuses = ['pending', 'in_progress', 'completed', 'closed'];
<div class="eq-history-item"> container.innerHTML = history.map(h => {
<span class="eq-history-date">${formatDate(h.created_at)}</span> const safeStatus = validStatuses.includes(h.status) ? h.status : 'pending';
<div class="eq-history-content"> return `
<div class="eq-history-title">${h.item_name || '수리 요청'}</div> <div class="eq-history-item">
<div class="eq-history-detail">${h.description || '-'}</div> <span class="eq-history-date">${formatDate(h.created_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(h.item_name || '수리 요청')}</div>
<div class="eq-history-detail">${escapeHtml(h.description || '-')}</div>
</div>
<span class="eq-history-status ${safeStatus}">${getRepairStatusLabel(h.status)}</span>
</div> </div>
<span class="eq-history-status ${h.status}">${getRepairStatusLabel(h.status)}</span> `;
</div> }).join('');
`).join('');
} }
function getRepairStatusLabel(status) { function getRepairStatusLabel(status) {
@@ -664,16 +676,17 @@ function renderExternalLogs(logs) {
const isReturned = !!log.actual_return_date; const isReturned = !!log.actual_return_date;
const statusClass = isReturned ? 'returned' : 'exported'; const statusClass = isReturned ? 'returned' : 'exported';
const statusLabel = isReturned ? '반입완료' : '반출중'; const statusLabel = isReturned ? '반입완료' : '반출중';
const safeLogId = parseInt(log.log_id) || 0;
return ` return `
<div class="eq-history-item"> <div class="eq-history-item">
<span class="eq-history-date">${dateRange}</span> <span class="eq-history-date">${dateRange}</span>
<div class="eq-history-content"> <div class="eq-history-content">
<div class="eq-history-title">${log.destination || '외부'}</div> <div class="eq-history-title">${escapeHtml(log.destination || '외부')}</div>
<div class="eq-history-detail">${log.reason || '-'}</div> <div class="eq-history-detail">${escapeHtml(log.reason || '-')}</div>
</div> </div>
<span class="eq-history-status ${statusClass}">${statusLabel}</span> <span class="eq-history-status ${statusClass}">${statusLabel}</span>
${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${log.log_id})">반입처리</button>` : ''} ${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${safeLogId})">반입처리</button>` : ''}
</div> </div>
`; `;
}).join(''); }).join('');
@@ -748,15 +761,15 @@ function renderMoveLogs(logs) {
container.innerHTML = logs.map(log => { container.innerHTML = logs.map(log => {
const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀'; const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀';
const location = log.move_type === 'temporary' const location = log.move_type === 'temporary'
? `${log.to_workplace_name || '-'}` ? escapeHtml(log.to_workplace_name || '-')
: `원위치 복귀`; : '원위치 복귀';
return ` return `
<div class="eq-history-item"> <div class="eq-history-item">
<span class="eq-history-date">${formatDateTime(log.moved_at)}</span> <span class="eq-history-date">${formatDateTime(log.moved_at)}</span>
<div class="eq-history-content"> <div class="eq-history-content">
<div class="eq-history-title">${typeLabel}: ${location}</div> <div class="eq-history-title">${typeLabel}: ${location}</div>
<div class="eq-history-detail">${log.reason || '-'} (${log.moved_by_name || '시스템'})</div> <div class="eq-history-detail">${escapeHtml(log.reason || '-')} (${escapeHtml(log.moved_by_name || '시스템')})</div>
</div> </div>
</div> </div>
`; `;

View File

@@ -135,9 +135,13 @@ function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace'); const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId'); const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w => const workplaceOptions = workplaces.map(w => {
`<option value="${w.workplace_id}">${w.category_name ? w.category_name + ' - ' : ''}${w.workplace_name}</option>` const safeId = parseInt(w.workplace_id) || 0;
).join(''); const categoryName = escapeHtml(w.category_name || '');
const workplaceName = escapeHtml(w.workplace_name || '');
const label = categoryName ? categoryName + ' - ' + workplaceName : workplaceName;
return `<option value="${safeId}">${label}</option>`;
}).join('');
if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions; if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions; if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
@@ -148,9 +152,10 @@ function populateTypeFilter() {
const filterType = document.getElementById('filterType'); const filterType = document.getElementById('filterType');
if (!filterType) return; if (!filterType) return;
const typeOptions = equipmentTypes.map(type => const typeOptions = equipmentTypes.map(type => {
`<option value="${type}">${type}</option>` const safeType = escapeHtml(type || '');
).join(''); return `<option value="${safeType}">${safeType}</option>`;
}).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions; filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
} }
@@ -189,33 +194,44 @@ function renderEquipmentList() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${equipments.map(eq => ` ${equipments.map(eq => {
<tr> const safeId = parseInt(eq.equipment_id) || 0;
<td class="eq-col-code">${eq.equipment_code || '-'}</td> const safeCode = escapeHtml(eq.equipment_code || '-');
<td class="eq-col-name" title="${eq.equipment_name || ''}">${eq.equipment_name || '-'}</td> const safeName = escapeHtml(eq.equipment_name || '-');
<td class="eq-col-model" title="${eq.model_name || ''}">${eq.model_name || '-'}</td> const safeModel = escapeHtml(eq.model_name || '-');
<td class="eq-col-spec" title="${eq.specifications || ''}">${eq.specifications || '-'}</td> const safeSpec = escapeHtml(eq.specifications || '-');
<td>${eq.manufacturer || '-'}</td> const safeManufacturer = escapeHtml(eq.manufacturer || '-');
<td>${eq.supplier || '-'}</td> const safeSupplier = escapeHtml(eq.supplier || '-');
<td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td> const validStatuses = ['active', 'maintenance', 'inactive'];
<td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td> const safeStatus = validStatuses.includes(eq.status) ? eq.status : 'inactive';
<td> return `
<span class="eq-status eq-status-${eq.status}"> <tr>
${getStatusText(eq.status)} <td class="eq-col-code">${safeCode}</td>
</span> <td class="eq-col-name" title="${safeName}">${safeName}</td>
</td> <td class="eq-col-model" title="${safeModel}">${safeModel}</td>
<td> <td class="eq-col-spec" title="${safeSpec}">${safeSpec}</td>
<div class="eq-actions"> <td>${safeManufacturer}</td>
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${eq.equipment_id})" title="수정"> <td>${safeSupplier}</td>
✏️ <td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td>
</button> <td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${eq.equipment_id})" title="삭제"> <td>
🗑️ <span class="eq-status eq-status-${safeStatus}">
</button> ${getStatusText(eq.status)}
</div> </span>
</td> </td>
</tr> <td>
`).join('')} <div class="eq-actions">
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${safeId})" title="수정">
✏️
</button>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${safeId})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -146,11 +146,17 @@ function renderBasicInfo(d) {
}); });
}; };
const validTypes = ['nonconformity', 'safety'];
const safeType = validTypes.includes(d.category_type) ? d.category_type : '';
const reporterName = escapeHtml(d.reporter_full_name || d.reporter_name || '-');
const locationText = escapeHtml(d.custom_location || d.workplace_name || '-');
const factoryText = d.factory_name ? ` (${escapeHtml(d.factory_name)})` : '';
container.innerHTML = ` container.innerHTML = `
<div class="info-item"> <div class="info-item">
<div class="info-label">신고 유형</div> <div class="info-label">신고 유형</div>
<div class="info-value"> <div class="info-value">
<span class="type-badge ${d.category_type}">${typeNames[d.category_type] || d.category_type}</span> <span class="type-badge ${safeType}">${typeNames[d.category_type] || escapeHtml(d.category_type || '-')}</span>
</div> </div>
</div> </div>
<div class="info-item"> <div class="info-item">
@@ -159,11 +165,11 @@ function renderBasicInfo(d) {
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">신고자</div> <div class="info-label">신고자</div>
<div class="info-value">${d.reporter_full_name || d.reporter_name || '-'}</div> <div class="info-value">${reporterName}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">위치</div> <div class="info-label">위치</div>
<div class="info-value">${d.custom_location || d.workplace_name || '-'}${d.factory_name ? ` (${d.factory_name})` : ''}</div> <div class="info-value">${locationText}${factoryText}</div>
</div> </div>
`; `;
} }
@@ -174,17 +180,20 @@ function renderBasicInfo(d) {
function renderIssueContent(d) { function renderIssueContent(d) {
const container = document.getElementById('issueContent'); const container = document.getElementById('issueContent');
const validSeverities = ['critical', 'high', 'medium', 'low'];
const safeSeverity = validSeverities.includes(d.severity) ? d.severity : '';
let html = ` let html = `
<div class="info-grid" style="margin-bottom: 1rem;"> <div class="info-grid" style="margin-bottom: 1rem;">
<div class="info-item"> <div class="info-item">
<div class="info-label">카테고리</div> <div class="info-label">카테고리</div>
<div class="info-value">${d.issue_category_name || '-'}</div> <div class="info-value">${escapeHtml(d.issue_category_name || '-')}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">항목</div> <div class="info-label">항목</div>
<div class="info-value"> <div class="info-value">
${d.issue_item_name || '-'} ${escapeHtml(d.issue_item_name || '-')}
${d.severity ? `<span class="severity-badge ${d.severity}">${severityNames[d.severity]}</span>` : ''} ${d.severity ? `<span class="severity-badge ${safeSeverity}">${severityNames[d.severity] || escapeHtml(d.severity)}</span>` : ''}
</div> </div>
</div> </div>
</div> </div>
@@ -262,11 +271,11 @@ function renderProcessInfo(d) {
html += ` html += `
<div class="info-item"> <div class="info-item">
<div class="info-label">담당자</div> <div class="info-label">담당자</div>
<div class="info-value">${d.assigned_full_name || d.assigned_user_name || '-'}</div> <div class="info-value">${escapeHtml(d.assigned_full_name || d.assigned_user_name || '-')}</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">담당 부서</div> <div class="info-label">담당 부서</div>
<div class="info-value">${d.assigned_department || '-'}</div> <div class="info-value">${escapeHtml(d.assigned_department || '-')}</div>
</div> </div>
`; `;
} }
@@ -279,7 +288,7 @@ function renderProcessInfo(d) {
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">처리자</div> <div class="info-label">처리자</div>
<div class="info-value">${d.resolved_by_name || '-'}</div> <div class="info-value">${escapeHtml(d.resolved_by_name || '-')}</div>
</div> </div>
`; `;
} }
@@ -402,10 +411,10 @@ function renderStatusTimeline(logs) {
container.innerHTML = logs.map(log => ` container.innerHTML = logs.map(log => `
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-status"> <div class="timeline-status">
${log.previous_status ? `${statusNames[log.previous_status]}` : ''}${statusNames[log.new_status]} ${log.previous_status ? `${statusNames[log.previous_status] || escapeHtml(log.previous_status)}` : ''}${statusNames[log.new_status] || escapeHtml(log.new_status)}
</div> </div>
<div class="timeline-meta"> <div class="timeline-meta">
${log.changed_by_full_name || log.changed_by_name} | ${formatDate(log.changed_at)} ${escapeHtml(log.changed_by_full_name || log.changed_by_name || '-')} | ${formatDate(log.changed_at)}
${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''} ${log.change_reason ? `<br><small>${escapeHtml(log.change_reason)}</small>` : ''}
</div> </div>
</div> </div>
@@ -530,7 +539,8 @@ async function openAssignModal() {
if (data.success && data.data) { if (data.success && data.data) {
data.data.forEach(user => { data.data.forEach(user => {
select.innerHTML += `<option value="${user.user_id}">${user.name} (${user.username})</option>`; const safeUserId = parseInt(user.user_id) || 0;
select.innerHTML += `<option value="${safeUserId}">${escapeHtml(user.name || '-')} (${escapeHtml(user.username || '-')})</option>`;
}); });
} }
} }

View File

@@ -490,9 +490,12 @@ function showWorkSelectionModal(workers, visitors) {
workers.forEach(w => { workers.forEach(w => {
const option = document.createElement('div'); const option = document.createElement('div');
option.className = 'work-option'; option.className = 'work-option';
const safeTaskName = escapeHtml(w.task_name || '작업');
const safeProjectName = escapeHtml(w.project_name || '');
const memberCount = parseInt(w.member_count) || 0;
option.innerHTML = ` option.innerHTML = `
<div class="work-option-title">TBM: ${w.task_name || '작업'}</div> <div class="work-option-title">TBM: ${safeTaskName}</div>
<div class="work-option-desc">${w.project_name || ''} - ${w.member_count || 0}명</div> <div class="work-option-desc">${safeProjectName} - ${memberCount}명</div>
`; `;
option.onclick = () => { option.onclick = () => {
selectedTbmSessionId = w.session_id; selectedTbmSessionId = w.session_id;
@@ -507,9 +510,12 @@ function showWorkSelectionModal(workers, visitors) {
visitors.forEach(v => { visitors.forEach(v => {
const option = document.createElement('div'); const option = document.createElement('div');
option.className = 'work-option'; option.className = 'work-option';
const safeCompany = escapeHtml(v.visitor_company || '-');
const safePurpose = escapeHtml(v.purpose_name || '방문');
const visitorCount = parseInt(v.visitor_count) || 0;
option.innerHTML = ` option.innerHTML = `
<div class="work-option-title">출입: ${v.visitor_company}</div> <div class="work-option-title">출입: ${safeCompany}</div>
<div class="work-option-desc">${v.purpose_name || '방문'} - ${v.visitor_count || 0}명</div> <div class="work-option-desc">${safePurpose} - ${visitorCount}명</div>
`; `;
option.onclick = () => { option.onclick = () => {
selectedVisitRequestId = v.request_id; selectedVisitRequestId = v.request_id;
@@ -540,20 +546,20 @@ function updateLocationInfo() {
if (useCustom && customLocation) { if (useCustom && customLocation) {
infoBox.classList.remove('empty'); infoBox.classList.remove('empty');
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${customLocation}`; infoBox.innerHTML = `<strong>선택된 위치:</strong> ${escapeHtml(customLocation)}`;
} else if (selectedWorkplaceName) { } else if (selectedWorkplaceName) {
infoBox.classList.remove('empty'); infoBox.classList.remove('empty');
let html = `<strong>선택된 위치:</strong> ${selectedWorkplaceName}`; let html = `<strong>선택된 위치:</strong> ${escapeHtml(selectedWorkplaceName)}`;
if (selectedTbmSessionId) { if (selectedTbmSessionId) {
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId); const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
if (worker) { if (worker) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${worker.task_name} (TBM)</span>`; html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(worker.task_name || '-')} (TBM)</span>`;
} }
} else if (selectedVisitRequestId) { } else if (selectedVisitRequestId) {
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId); const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
if (visitor) { if (visitor) {
html += `<br><span style="color: var(--primary-600);">연결 작업: ${visitor.visitor_company} (출입)</span>`; html += `<br><span style="color: var(--primary-600);">연결 작업: ${escapeHtml(visitor.visitor_company || '-')} (출입)</span>`;
} }
} }

View File

@@ -121,17 +121,18 @@ function renderIssues(issues) {
minute: '2-digit' minute: '2-digit'
}); });
// 위치 정보 // 위치 정보 (escaped)
let location = issue.custom_location || ''; let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) { if (issue.factory_name) {
location = issue.factory_name; location = escapeHtml(issue.factory_name);
if (issue.workplace_name) { if (issue.workplace_name) {
location += ` - ${issue.workplace_name}`; location += ` - ${escapeHtml(issue.workplace_name)}`;
} }
} }
// 신고 제목 (항목명 또는 카테고리명) // 신고 제목 (항목명 또는 카테고리명)
const title = issue.issue_item_name || issue.issue_category_name || '부적합 신고'; const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '부적합 신고');
const categoryName = escapeHtml(issue.issue_category_name || '부적합');
// 사진 목록 // 사진 목록
const photos = [ const photos = [
@@ -142,15 +143,22 @@ function renderIssues(issues) {
issue.photo_path5 issue.photo_path5
].filter(Boolean); ].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return ` return `
<div class="issue-card" onclick="viewIssue(${issue.report_id})"> <div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header"> <div class="issue-header">
<span class="issue-id">#${issue.report_id}</span> <span class="issue-id">#${safeReportId}</span>
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span> <span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div> </div>
<div class="issue-title"> <div class="issue-title">
<span class="issue-category-badge">${issue.issue_category_name || '부적합'}</span> <span class="issue-category-badge">${categoryName}</span>
${title} ${title}
</div> </div>
@@ -160,7 +168,7 @@ function renderIssues(issues) {
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/> <circle cx="12" cy="7" r="4"/>
</svg> </svg>
${issue.reporter_full_name || issue.reporter_name} ${reporterName}
</span> </span>
<span class="issue-meta-item"> <span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -180,7 +188,7 @@ function renderIssues(issues) {
${location} ${location}
</span> </span>
` : ''} ` : ''}
${issue.assigned_full_name ? ` ${assignedName ? `
<span class="issue-meta-item"> <span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -188,7 +196,7 @@ function renderIssues(issues) {
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
담당: ${issue.assigned_full_name} 담당: ${assignedName}
</span> </span>
` : ''} ` : ''}
</div> </div>
@@ -196,7 +204,7 @@ function renderIssues(issues) {
${photos.length > 0 ? ` ${photos.length > 0 ? `
<div class="issue-photos"> <div class="issue-photos">
${photos.slice(0, 3).map(p => ` ${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy"> <img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')} `).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''} ${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div> </div>

View File

@@ -165,26 +165,35 @@ function renderProjects() {
'completed': { icon: '✅', text: '완료', color: '#3b82f6' }, 'completed': { icon: '✅', text: '완료', color: '#3b82f6' },
'cancelled': { icon: '❌', text: '취소', color: '#ef4444' } 'cancelled': { icon: '❌', text: '취소', color: '#ef4444' }
}; };
const status = statusMap[project.project_status] || statusMap['active']; const validStatuses = ['planning', 'active', 'completed', 'cancelled'];
const safeProjectStatus = validStatuses.includes(project.project_status) ? project.project_status : 'active';
const status = statusMap[safeProjectStatus];
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비) // is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false'; const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
// XSS 방지를 위한 안전한 값
const safeProjectId = parseInt(project.project_id) || 0;
const safeJobNo = escapeHtml(project.job_no || 'Job No. 없음');
const safeProjectName = escapeHtml(project.project_name || '-');
const safePm = escapeHtml(project.pm || '-');
const safeSite = escapeHtml(project.site || '-');
console.log('🎨 카드 렌더링:', { console.log('🎨 카드 렌더링:', {
project_id: project.project_id, project_id: project.project_id,
project_name: project.project_name, project_name: project.project_name,
is_active_raw: project.is_active, is_active_raw: project.is_active,
isInactive: isInactive isInactive: isInactive
}); });
return ` return `
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${project.project_id})"> <div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${safeProjectId})">
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''} ${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge">🚫 비활성화됨</span></div>' : ''}
<div class="project-header"> <div class="project-header">
<div class="project-info"> <div class="project-info">
<div class="project-job-no">${project.job_no || 'Job No. 없음'}</div> <div class="project-job-no">${safeJobNo}</div>
<h3 class="project-name"> <h3 class="project-name">
${project.project_name} ${safeProjectName}
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''} ${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
</h3> </h3>
<div class="project-meta"> <div class="project-meta">
@@ -202,20 +211,20 @@ function renderProjects() {
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">PM</span> <span class="meta-label">PM</span>
<span class="meta-value">${project.pm || '-'}</span> <span class="meta-value">${safePm}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="meta-label">현장</span> <span class="meta-label">현장</span>
<span class="meta-value">${project.site || '-'}</span> <span class="meta-value">${safeSite}</span>
</div> </div>
${isInactive ? '<div class="inactive-notice">⚠️ 작업보고서에서 숨김</div>' : ''} ${isInactive ? '<div class="inactive-notice">⚠️ 작업보고서에서 숨김</div>' : ''}
</div> </div>
</div> </div>
<div class="project-actions"> <div class="project-actions">
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${project.project_id})" title="수정"> <button class="btn-edit" onclick="event.stopPropagation(); editProject(${safeProjectId})" title="수정">
✏️ 수정 ✏️ 수정
</button> </button>
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${project.project_id})" title="삭제"> <button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${safeProjectId})" title="삭제">
🗑️ 삭제 🗑️ 삭제
</button> </button>
</div> </div>

View File

@@ -121,17 +121,18 @@ function renderIssues(issues) {
minute: '2-digit' minute: '2-digit'
}); });
// 위치 정보 // 위치 정보 (escaped)
let location = issue.custom_location || ''; let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) { if (issue.factory_name) {
location = issue.factory_name; location = escapeHtml(issue.factory_name);
if (issue.workplace_name) { if (issue.workplace_name) {
location += ` - ${issue.workplace_name}`; location += ` - ${escapeHtml(issue.workplace_name)}`;
} }
} }
// 신고 제목 (항목명 또는 카테고리명) // 신고 제목 (항목명 또는 카테고리명)
const title = issue.issue_item_name || issue.issue_category_name || '안전 신고'; const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '안전 신고');
const categoryName = escapeHtml(issue.issue_category_name || '안전');
// 사진 목록 // 사진 목록
const photos = [ const photos = [
@@ -142,15 +143,22 @@ function renderIssues(issues) {
issue.photo_path5 issue.photo_path5
].filter(Boolean); ].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return ` return `
<div class="issue-card" onclick="viewIssue(${issue.report_id})"> <div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header"> <div class="issue-header">
<span class="issue-id">#${issue.report_id}</span> <span class="issue-id">#${safeReportId}</span>
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span> <span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div> </div>
<div class="issue-title"> <div class="issue-title">
<span class="issue-category-badge">${issue.issue_category_name || '안전'}</span> <span class="issue-category-badge">${categoryName}</span>
${title} ${title}
</div> </div>
@@ -160,7 +168,7 @@ function renderIssues(issues) {
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/> <circle cx="12" cy="7" r="4"/>
</svg> </svg>
${issue.reporter_full_name || issue.reporter_name} ${reporterName}
</span> </span>
<span class="issue-meta-item"> <span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -180,7 +188,7 @@ function renderIssues(issues) {
${location} ${location}
</span> </span>
` : ''} ` : ''}
${issue.assigned_full_name ? ` ${assignedName ? `
<span class="issue-meta-item"> <span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
@@ -188,7 +196,7 @@ function renderIssues(issues) {
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
담당: ${issue.assigned_full_name} 담당: ${assignedName}
</span> </span>
` : ''} ` : ''}
</div> </div>
@@ -196,7 +204,7 @@ function renderIssues(issues) {
${photos.length > 0 ? ` ${photos.length > 0 ? `
<div class="issue-photos"> <div class="issue-photos">
${photos.slice(0, 3).map(p => ` ${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy"> <img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')} `).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''} ${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div> </div>

View File

@@ -100,14 +100,15 @@ function renderWorkTypeTabs() {
const count = tasks.filter(t => t.work_type_id === workType.id).length; const count = tasks.filter(t => t.work_type_id === workType.id).length;
const isActive = currentWorkTypeId === workType.id; const isActive = currentWorkTypeId === workType.id;
const safeId = parseInt(workType.id) || 0;
tabsHtml += ` tabsHtml += `
<button class="tab-btn ${isActive ? 'active' : ''}" <button class="tab-btn ${isActive ? 'active' : ''}"
data-work-type="${workType.id}" data-work-type="${safeId}"
onclick="switchWorkType(${workType.id})" onclick="switchWorkType(${safeId})"
style="position: relative; padding-right: 3rem;"> style="position: relative; padding-right: 3rem;">
<span class="tab-icon">🔧</span> <span class="tab-icon">🔧</span>
${workType.name} (${count}) ${escapeHtml(workType.name)} (${parseInt(count) || 0})
<span onclick="event.stopPropagation(); editWorkType(${workType.id});" <span onclick="event.stopPropagation(); editWorkType(${safeId});"
style="position: absolute; right: 0.5rem; padding: 0.25rem 0.5rem; opacity: 0.7; cursor: pointer; font-size: 0.75rem;" style="position: absolute; right: 0.5rem; padding: 0.25rem 0.5rem; opacity: 0.7; cursor: pointer; font-size: 0.75rem;"
title="공정 수정"> title="공정 수정">
✏️ ✏️
@@ -157,35 +158,36 @@ function createTaskCard(task) {
const statusBadge = task.is_active const statusBadge = task.is_active
? '<span class="badge" style="background: #dcfce7; color: #166534;">활성</span>' ? '<span class="badge" style="background: #dcfce7; color: #166534;">활성</span>'
: '<span class="badge" style="background: #f3f4f6; color: #6b7280;">비활성</span>'; : '<span class="badge" style="background: #f3f4f6; color: #6b7280;">비활성</span>';
const safeTaskId = parseInt(task.task_id) || 0;
return ` return `
<div class="code-card" onclick="editTask(${task.task_id})"> <div class="code-card" onclick="editTask(${safeTaskId})">
<div class="code-card-header"> <div class="code-card-header">
<h3 class="code-name">${task.task_name}</h3> <h3 class="code-name">${escapeHtml(task.task_name)}</h3>
${statusBadge} ${statusBadge}
</div> </div>
<div class="code-info"> <div class="code-info">
<div class="info-item"> <div class="info-item">
<span class="info-label">소속 공정</span> <span class="info-label">소속 공정</span>
<span class="info-value">${task.work_type_name || '-'}</span> <span class="info-value">${escapeHtml(task.work_type_name || '-')}</span>
</div> </div>
${task.category ? ` ${task.category ? `
<div class="info-item"> <div class="info-item">
<span class="info-label">카테고리</span> <span class="info-label">카테고리</span>
<span class="info-value">${task.category}</span> <span class="info-value">${escapeHtml(task.category)}</span>
</div> </div>
` : ''} ` : ''}
</div> </div>
${task.description ? ` ${task.description ? `
<div class="code-description"> <div class="code-description">
${task.description} ${escapeHtml(task.description)}
</div> </div>
` : ''} ` : ''}
<div class="code-meta"> <div class="code-meta">
<span>등록: ${formatDate(task.created_at)}</span> <span>등록: ${escapeHtml(formatDate(task.created_at))}</span>
</div> </div>
</div> </div>
`; `;
@@ -275,7 +277,7 @@ function populateWorkTypeSelect() {
select.innerHTML = '<option value="">공정 선택...</option>' + select.innerHTML = '<option value="">공정 선택...</option>' +
workTypes.map(wt => ` workTypes.map(wt => `
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option> <option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
`).join(''); `).join('');
} }

View File

@@ -493,13 +493,14 @@ function createSessionCard(session) {
}[session.status] || ''; }[session.status] || '';
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시) // 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
const leaderName = session.leader_name || session.created_by_name || '작업 책임자'; const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
const leaderRole = session.leader_name const leaderRole = escapeHtml(session.leader_name
? (session.leader_job_type || '작업자') ? (session.leader_job_type || '작업자')
: '관리자'; : '관리자');
const safeSessionId = parseInt(session.session_id) || 0;
return ` return `
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})"> <div class="tbm-session-card" onclick="viewTbmSession(${safeSessionId})">
<div class="tbm-card-header"> <div class="tbm-card-header">
<div class="tbm-card-header-top"> <div class="tbm-card-header-top">
<div> <div>
@@ -512,7 +513,7 @@ function createSessionCard(session) {
</div> </div>
<div class="tbm-card-date"> <div class="tbm-card-date">
<span>&#128197;</span> <span>&#128197;</span>
${formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''} ${escapeHtml(formatDate(session.session_date))} ${session.start_time ? '| ' + escapeHtml(session.start_time) : ''}
</div> </div>
</div> </div>
@@ -520,29 +521,29 @@ function createSessionCard(session) {
<div class="tbm-card-info-grid"> <div class="tbm-card-info-grid">
<div class="tbm-card-info-item"> <div class="tbm-card-info-item">
<span class="tbm-card-info-label">프로젝트</span> <span class="tbm-card-info-label">프로젝트</span>
<span class="tbm-card-info-value">${session.project_name || '-'}</span> <span class="tbm-card-info-value">${escapeHtml(session.project_name || '-')}</span>
</div> </div>
<div class="tbm-card-info-item"> <div class="tbm-card-info-item">
<span class="tbm-card-info-label">공정</span> <span class="tbm-card-info-label">공정</span>
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span> <span class="tbm-card-info-value">${escapeHtml(session.work_type_name || '-')}</span>
</div> </div>
<div class="tbm-card-info-item"> <div class="tbm-card-info-item">
<span class="tbm-card-info-label">작업장</span> <span class="tbm-card-info-label">작업장</span>
<span class="tbm-card-info-value">${session.work_location || '-'}</span> <span class="tbm-card-info-value">${escapeHtml(session.work_location || '-')}</span>
</div> </div>
<div class="tbm-card-info-item"> <div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span> <span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span> <span class="tbm-card-info-value">${parseInt(session.team_member_count) || 0}명</span>
</div> </div>
</div> </div>
</div> </div>
${session.status === 'draft' ? ` ${session.status === 'draft' ? `
<div class="tbm-card-footer"> <div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})"> <button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 팀 구성 &#128101; 팀 구성
</button> </button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})"> <button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크 &#10003; 안전 체크
</button> </button>
</div> </div>
@@ -604,8 +605,8 @@ function populateLeaderSelect() {
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화 // 작업자와 연결된 경우: 자동으로 선택하고 비활성화
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id); const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
if (worker) { if (worker) {
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : ''; const jobTypeText = worker.job_type ? ` (${escapeHtml(worker.job_type)})` : '';
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</option>`; leaderSelect.innerHTML = `<option value="${escapeHtml(worker.worker_id)}" selected>${escapeHtml(worker.worker_name)}${jobTypeText}</option>`;
leaderSelect.disabled = true; leaderSelect.disabled = true;
console.log('✅ 입력자 자동 설정:', worker.worker_name); console.log('✅ 입력자 자동 설정:', worker.worker_name);
} else { } else {
@@ -621,8 +622,8 @@ function populateLeaderSelect() {
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' + leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
leaders.map(w => { leaders.map(w => {
const jobTypeText = w.job_type ? ` (${w.job_type})` : ''; const jobTypeText = w.job_type ? ` (${escapeHtml(w.job_type)})` : '';
return `<option value="${w.worker_id}">${w.worker_name}${jobTypeText}</option>`; return `<option value="${escapeHtml(w.worker_id)}">${escapeHtml(w.worker_name)}${jobTypeText}</option>`;
}).join(''); }).join('');
leaderSelect.disabled = false; leaderSelect.disabled = false;
console.log('✅ 관리자: 입력자 선택 가능'); console.log('✅ 관리자: 입력자 선택 가능');
@@ -636,7 +637,7 @@ function populateProjectSelect() {
projectSelect.innerHTML = '<option value="">프로젝트 선택...</option>' + projectSelect.innerHTML = '<option value="">프로젝트 선택...</option>' +
allProjects.map(p => ` allProjects.map(p => `
<option value="${p.project_id}">${p.project_name} (${p.job_no})</option> <option value="${escapeHtml(p.project_id)}">${escapeHtml(p.project_name)} (${escapeHtml(p.job_no)})</option>
`).join(''); `).join('');
} }
@@ -647,7 +648,7 @@ function populateWorkTypeSelect() {
workTypeSelect.innerHTML = '<option value="">공정 선택...</option>' + workTypeSelect.innerHTML = '<option value="">공정 선택...</option>' +
allWorkTypes.map(wt => ` allWorkTypes.map(wt => `
<option value="${wt.id}">${wt.name}${wt.category ? ' (' + wt.category + ')' : ''}</option> <option value="${escapeHtml(wt.id)}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
`).join(''); `).join('');
} }
@@ -658,7 +659,7 @@ function populateWorkplaceSelect() {
workLocationSelect.innerHTML = '<option value="">작업장 선택...</option>' + workLocationSelect.innerHTML = '<option value="">작업장 선택...</option>' +
allWorkplaces.map(wp => ` allWorkplaces.map(wp => `
<option value="${wp.workplace_name}">${wp.workplace_name}${wp.location ? ' - ' + wp.location : ''}</option> <option value="${escapeHtml(wp.workplace_name)}">${escapeHtml(wp.workplace_name)}${wp.location ? ' - ' + escapeHtml(wp.location) : ''}</option>
`).join(''); `).join('');
} }
@@ -683,7 +684,7 @@ function loadTasksByWorkType() {
taskSelect.disabled = false; taskSelect.disabled = false;
taskSelect.innerHTML = '<option value="">작업 선택...</option>' + taskSelect.innerHTML = '<option value="">작업 선택...</option>' +
filteredTasks.map(task => ` filteredTasks.map(task => `
<option value="${task.task_id}">${task.task_name}</option> <option value="${escapeHtml(String(task.task_id))}">${escapeHtml(task.task_name)}</option>
`).join(''); `).join('');
if (filteredTasks.length === 0) { if (filteredTasks.length === 0) {
@@ -872,12 +873,12 @@ function renderWorkerTaskList() {
<!-- 작업자 헤더 --> <!-- 작업자 헤더 -->
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb;"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb;">
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">👤 ${workerData.worker_name}</span> <span style="font-weight: 600; font-size: 1.1rem; color: #1f2937;">👤 ${escapeHtml(workerData.worker_name)}</span>
<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem;"> <span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem;">
${workerData.job_type || '작업자'} ${escapeHtml(workerData.job_type || '작업자')}
</span> </span>
</div> </div>
<button type="button" onclick="removeWorkerFromList(${workerIndex})" class="btn btn-sm btn-danger"> <button type="button" onclick="removeWorkerFromList(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-danger">
<span style="font-size: 1rem;">✕ 작업자 제거</span> <span style="font-size: 1rem;">✕ 작업자 제거</span>
</button> </button>
</div> </div>
@@ -887,7 +888,7 @@ function renderWorkerTaskList() {
<!-- 작업 추가 버튼 --> <!-- 작업 추가 버튼 -->
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed #d1d5db;"> <div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px dashed #d1d5db;">
<button type="button" onclick="addTaskLineToWorker(${workerIndex})" class="btn btn-sm btn-secondary" style="width: 100%;"> <button type="button" onclick="addTaskLineToWorker(${parseInt(workerIndex) || 0})" class="btn btn-sm btn-secondary" style="width: 100%;">
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span> <span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
이 작업자의 추가 작업 등록 이 작업자의 추가 작업 등록
</button> </button>
@@ -902,12 +903,14 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
const project = allProjects.find(p => p.project_id === taskLine.project_id); const project = allProjects.find(p => p.project_id === taskLine.project_id);
const workType = allWorkTypes.find(wt => wt.id === taskLine.work_type_id); const workType = allWorkTypes.find(wt => wt.id === taskLine.work_type_id);
const task = allTasks.find(t => t.task_id === taskLine.task_id); const task = allTasks.find(t => t.task_id === taskLine.task_id);
const safeWorkerIndex = parseInt(workerIndex) || 0;
const safeTaskIndex = parseInt(taskIndex) || 0;
const projectText = project ? project.project_name : '프로젝트 선택'; const projectText = escapeHtml(project ? project.project_name : '프로젝트 선택');
const workTypeText = workType ? workType.name : '공정 선택 *'; const workTypeText = escapeHtml(workType ? workType.name : '공정 선택 *');
const taskText = task ? task.task_name : '작업 선택 *'; const taskText = escapeHtml(task ? task.task_name : '작업 선택 *');
const workplaceText = taskLine.workplace_name const workplaceText = taskLine.workplace_name
? `${taskLine.workplace_category_name || ''}${taskLine.workplace_name}` ? escapeHtml(`${taskLine.workplace_category_name || ''}${taskLine.workplace_name}`)
: '작업장 선택 *'; : '작업장 선택 *';
return ` return `
@@ -915,7 +918,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;"> <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-bottom: 0.5rem;">
<!-- 프로젝트 선택 --> <!-- 프로젝트 선택 -->
<button type="button" <button type="button"
onclick="openItemSelect('project', ${workerIndex}, ${taskIndex})" onclick="openItemSelect('project', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${project ? 'btn-primary' : 'btn-secondary'}" class="btn btn-sm ${project ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"> style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
📁 ${projectText} 📁 ${projectText}
@@ -923,7 +926,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
<!-- 작업장 선택 --> <!-- 작업장 선택 -->
<button type="button" <button type="button"
onclick="openWorkplaceSelect(${workerIndex}, ${taskIndex})" onclick="openWorkplaceSelect(${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${taskLine.workplace_id ? 'btn-primary' : 'btn-secondary'}" class="btn btn-sm ${taskLine.workplace_id ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"> style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
📍 ${workplaceText} 📍 ${workplaceText}
@@ -931,7 +934,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
<!-- 공정 선택 --> <!-- 공정 선택 -->
<button type="button" <button type="button"
onclick="openItemSelect('workType', ${workerIndex}, ${taskIndex})" onclick="openItemSelect('workType', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${workType ? 'btn-primary' : 'btn-secondary'}" class="btn btn-sm ${workType ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"> style="text-align: left; justify-content: flex-start; font-size: 0.875rem;">
⚙️ ${workTypeText} ⚙️ ${workTypeText}
@@ -939,7 +942,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
<!-- 작업 선택 --> <!-- 작업 선택 -->
<button type="button" <button type="button"
onclick="openItemSelect('task', ${workerIndex}, ${taskIndex})" onclick="openItemSelect('task', ${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm ${task ? 'btn-primary' : 'btn-secondary'}" class="btn btn-sm ${task ? 'btn-primary' : 'btn-secondary'}"
style="text-align: left; justify-content: flex-start; font-size: 0.875rem;" style="text-align: left; justify-content: flex-start; font-size: 0.875rem;"
${!taskLine.work_type_id ? 'disabled' : ''}> ${!taskLine.work_type_id ? 'disabled' : ''}>
@@ -949,7 +952,7 @@ function renderTaskLine(workerData, workerIndex, taskLine, taskIndex) {
<!-- 작업 라인 제거 버튼 --> <!-- 작업 라인 제거 버튼 -->
${workerData.tasks.length > 1 ? ` ${workerData.tasks.length > 1 ? `
<button type="button" onclick="removeTaskLine(${workerIndex}, ${taskIndex})" <button type="button" onclick="removeTaskLine(${safeWorkerIndex}, ${safeTaskIndex})"
class="btn btn-sm btn-danger" style="width: 100%; font-size: 0.8rem;"> class="btn btn-sm btn-danger" style="width: 100%; font-size: 0.8rem;">
<span style="margin-right: 0.25rem;"></span> 이 작업 라인 제거 <span style="margin-right: 0.25rem;"></span> 이 작업 라인 제거
</button> </button>
@@ -971,16 +974,17 @@ function openWorkerSelectionModal() {
workerCardGrid.innerHTML = allWorkers.map(worker => { workerCardGrid.innerHTML = allWorkers.map(worker => {
const isAdded = addedWorkerIds.has(worker.worker_id); const isAdded = addedWorkerIds.has(worker.worker_id);
const safeWorkerId = parseInt(worker.worker_id) || 0;
return ` return `
<div id="worker-card-${worker.worker_id}" <div id="worker-card-${safeWorkerId}"
onclick="toggleWorkerSelection(${worker.worker_id})" onclick="toggleWorkerSelection(${safeWorkerId})"
style="padding: 1rem; border: 2px solid ${isAdded ? '#d1d5db' : '#e5e7eb'}; border-radius: 0.5rem; cursor: ${isAdded ? 'not-allowed' : 'pointer'}; background: ${isAdded ? '#f3f4f6' : 'white'}; opacity: ${isAdded ? '0.5' : '1'}; transition: all 0.2s;"> style="padding: 1rem; border: 2px solid ${isAdded ? '#d1d5db' : '#e5e7eb'}; border-radius: 0.5rem; cursor: ${isAdded ? 'not-allowed' : 'pointer'}; background: ${isAdded ? '#f3f4f6' : 'white'}; opacity: ${isAdded ? '0.5' : '1'}; transition: all 0.2s;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
${isAdded ? '✓' : '☐'} ${isAdded ? '✓' : '☐'}
<span style="font-weight: 600; font-size: 0.95rem;">${worker.worker_name}</span> <span style="font-weight: 600; font-size: 0.95rem;">${escapeHtml(worker.worker_name)}</span>
</div> </div>
<div style="font-size: 0.8rem; color: #6b7280;"> <div style="font-size: 0.8rem; color: #6b7280;">
${worker.job_type || '작업자'}${worker.department ? ' · ' + worker.department : ''} ${escapeHtml(worker.job_type || '작업자')}${worker.department ? ' · ' + escapeHtml(worker.department) : ''}
</div> </div>
${isAdded ? '<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">이미 추가됨</div>' : ''} ${isAdded ? '<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">이미 추가됨</div>' : ''}
</div> </div>

View File

@@ -83,25 +83,31 @@ function renderVacationRequests(requests, containerId, showActions = false, acti
</thead> </thead>
<tbody> <tbody>
${requests.map(request => { ${requests.map(request => {
const statusClass = request.status === 'pending' ? 'status-pending' : const validStatuses = ['pending', 'approved', 'rejected'];
request.status === 'approved' ? 'status-approved' : 'status-rejected'; const safeStatus = validStatuses.includes(request.status) ? request.status : 'pending';
const statusText = request.status === 'pending' ? '대기' : const statusClass = safeStatus === 'pending' ? 'status-pending' :
request.status === 'approved' ? '승인' : '거부'; safeStatus === 'approved' ? 'status-approved' : 'status-rejected';
const statusText = safeStatus === 'pending' ? '대기' :
safeStatus === 'approved' ? '승인' : '거부';
const workerName = escapeHtml(request.worker_name || '알 수 없음');
const typeName = escapeHtml(request.vacation_type_name || request.type_name || '알 수 없음');
const reasonText = escapeHtml(request.reason || '-');
const daysUsed = parseFloat(request.days_used) || 0;
return ` return `
<tr> <tr>
<td><strong>${request.worker_name || '알 수 없음'}</strong></td> <td><strong>${workerName}</strong></td>
<td>${request.vacation_type_name || request.type_name || '알 수 없음'}</td> <td>${typeName}</td>
<td>${request.start_date}</td> <td>${escapeHtml(request.start_date || '-')}</td>
<td>${request.end_date}</td> <td>${escapeHtml(request.end_date || '-')}</td>
<td>${request.days_used}일</td> <td>${daysUsed}일</td>
<td> <td>
<span class="status-badge ${statusClass}"> <span class="status-badge ${statusClass}">
${statusText} ${statusText}
</span> </span>
</td> </td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${request.reason || '-'}"> <td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${reasonText}">
${request.reason || '-'} ${reasonText}
</td> </td>
${showActions ? renderActionButtons(request, actionType) : ''} ${showActions ? renderActionButtons(request, actionType) : ''}
</tr> </tr>
@@ -118,14 +124,15 @@ function renderVacationRequests(requests, containerId, showActions = false, acti
* 액션 버튼 렌더링 * 액션 버튼 렌더링
*/ */
function renderActionButtons(request, actionType) { function renderActionButtons(request, actionType) {
const safeRequestId = parseInt(request.request_id) || 0;
if (actionType === 'approval' && request.status === 'pending') { if (actionType === 'approval' && request.status === 'pending') {
return ` return `
<td> <td>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button class="btn-small btn-success" onclick="approveVacationRequest(${request.request_id})" title="승인"> <button class="btn-small btn-success" onclick="approveVacationRequest(${safeRequestId})" title="승인">
</button> </button>
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${request.request_id})" title="거부"> <button class="btn-small btn-danger" onclick="rejectVacationRequest(${safeRequestId})" title="거부">
</button> </button>
</div> </div>
@@ -134,7 +141,7 @@ function renderActionButtons(request, actionType) {
} else if (actionType === 'delete' && request.status === 'pending') { } else if (actionType === 'delete' && request.status === 'pending') {
return ` return `
<td> <td>
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${request.request_id})" title="삭제"> <button class="btn-small btn-danger" onclick="deleteVacationRequest(${safeRequestId})" title="삭제">
삭제 삭제
</button> </button>
</td> </td>

View File

@@ -69,29 +69,34 @@ function renderDepartmentList() {
return; return;
} }
container.innerHTML = departments.map(dept => ` container.innerHTML = departments.map(dept => {
<div class="department-item ${currentDepartmentId === dept.department_id ? 'active' : ''}" const safeDeptId = parseInt(dept.department_id) || 0;
onclick="selectDepartment(${dept.department_id})"> const safeDeptName = escapeHtml(dept.department_name || '-');
<div class="department-info"> const workerCount = parseInt(dept.worker_count) || 0;
<span class="department-name">${dept.department_name}</span> return `
<span class="department-count">${dept.worker_count || 0}명</span> <div class="department-item ${currentDepartmentId === dept.department_id ? 'active' : ''}"
onclick="selectDepartment(${safeDeptId})">
<div class="department-info">
<span class="department-name">${safeDeptName}</span>
<span class="department-count">${workerCount}명</span>
</div>
<div class="department-actions" onclick="event.stopPropagation()">
<button class="btn-icon" onclick="editDepartment(${safeDeptId})" title="수정">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="confirmDeleteDepartment(${safeDeptId})" title="삭제">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div> </div>
<div class="department-actions" onclick="event.stopPropagation()"> `;
<button class="btn-icon" onclick="editDepartment(${dept.department_id})" title="수정"> }).join('');
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="confirmDeleteDepartment(${dept.department_id})" title="삭제">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`).join('');
} }
// 부서 선택 // 부서 선택
@@ -117,7 +122,10 @@ function updateParentDepartmentSelect() {
select.innerHTML = '<option value="">없음 (최상위 부서)</option>' + select.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
departments departments
.filter(d => d.department_id !== parseInt(currentId)) .filter(d => d.department_id !== parseInt(currentId))
.map(d => `<option value="${d.department_id}">${d.department_name}</option>`) .map(d => {
const safeDeptId = parseInt(d.department_id) || 0;
return `<option value="${safeDeptId}">${escapeHtml(d.department_name || '-')}</option>`;
})
.join(''); .join('');
} }
@@ -316,7 +324,8 @@ function renderWorkerList() {
'leader': '그룹장', 'leader': '그룹장',
'admin': '관리자' 'admin': '관리자'
}; };
const jobType = jobTypeMap[worker.job_type] || worker.job_type || '-'; const safeJobType = ['worker', 'leader', 'admin'].includes(worker.job_type) ? worker.job_type : '';
const jobType = jobTypeMap[safeJobType] || escapeHtml(worker.job_type || '-');
const isInactive = worker.status === 'inactive'; const isInactive = worker.status === 'inactive';
const isResigned = worker.employment_status === 'resigned'; const isResigned = worker.employment_status === 'resigned';
@@ -332,12 +341,16 @@ function renderWorkerList() {
statusText = '사무직'; statusText = '사무직';
} }
const safeWorkerId = parseInt(worker.worker_id) || 0;
const safeWorkerName = escapeHtml(worker.worker_name || '');
const firstChar = safeWorkerName ? safeWorkerName.charAt(0) : '?';
return ` return `
<tr style="${isResigned ? 'opacity: 0.6;' : ''}"> <tr style="${isResigned ? 'opacity: 0.6;' : ''}">
<td> <td>
<div class="worker-name-cell"> <div class="worker-name-cell">
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div> <div class="worker-avatar">${firstChar}</div>
<span style="font-weight: 500;">${worker.worker_name}</span> <span style="font-weight: 500;">${safeWorkerName}</span>
</div> </div>
</td> </td>
<td>${jobType}</td> <td>${jobType}</td>
@@ -350,8 +363,8 @@ function renderWorkerList() {
</td> </td>
<td> <td>
<div style="display: flex; gap: 0.25rem; justify-content: center;"> <div style="display: flex; gap: 0.25rem; justify-content: center;">
<button class="btn-icon" onclick="editWorker(${worker.worker_id})" title="수정"></button> <button class="btn-icon" onclick="editWorker(${safeWorkerId})" title="수정"></button>
<button class="btn-icon danger" onclick="confirmDeleteWorker(${worker.worker_id})" title="삭제">🗑</button> <button class="btn-icon danger" onclick="confirmDeleteWorker(${safeWorkerId})" title="삭제">🗑</button>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,21 +1,38 @@
// 작업장 관리 페이지 JavaScript // 작업장 관리 페이지 JavaScript
//
// 참고: 이 파일은 점진적 마이그레이션 중입니다.
// 새로운 모듈 시스템: /js/workplace-management/
// - state.js: 전역 상태 관리
// - utils.js: 유틸리티 함수
// - api.js: API 클라이언트
// - index.js: 메인 컨트롤러
// 전역 변수 // 전역 변수 (모듈 시스템이 없을 때만 사용)
let categories = []; let categories = window.WorkplaceState?.categories || [];
let workplaces = []; let workplaces = window.WorkplaceState?.workplaces || [];
let currentCategoryId = ''; let currentCategoryId = window.WorkplaceState?.currentCategoryId || '';
let currentEditingCategory = null; let currentEditingCategory = null;
let currentEditingWorkplace = null; let currentEditingWorkplace = null;
// 페이지 초기화 // 페이지 초기화 (모듈 시스템이 없을 때만)
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
console.log('🏗️ 작업장 관리 페이지 초기화 시작'); // 모듈 시스템이 이미 로드되어 있으면 초기화 건너뜀
if (window.WorkplaceController) {
console.log('[workplace-management.js] 모듈 시스템 감지 - 기존 초기화 건너뜀');
return;
}
console.log('🏗️ 작업장 관리 페이지 초기화 시작 (레거시)');
loadAllData(); loadAllData();
}); });
// 모든 데이터 로드 // 모든 데이터 로드
async function loadAllData() { async function loadAllData() {
// 모듈 시스템이 있으면 위임
if (window.WorkplaceController) {
return window.WorkplaceController.loadAllData();
}
try { try {
await Promise.all([ await Promise.all([
loadCategories(), loadCategories(),
@@ -35,6 +52,13 @@ async function loadAllData() {
// 카테고리 목록 로드 // 카테고리 목록 로드
async function loadCategories() { async function loadCategories() {
// 모듈 시스템이 있으면 위임
if (window.WorkplaceAPI) {
const result = await window.WorkplaceAPI.loadCategories();
categories = window.WorkplaceState?.categories || result;
return result;
}
try { try {
const response = await window.apiCall('/workplaces/categories', 'GET'); const response = await window.apiCall('/workplaces/categories', 'GET');
@@ -934,27 +958,30 @@ function showToast(message, type = 'info') {
} }
// 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록) // 전역 함수 및 변수로 노출 (다른 모듈에서 접근 가능하도록)
// getter/setter를 사용하여 항상 최신 값을 반환 // 모듈 시스템이 이미 정의했으면 건너뜀
Object.defineProperty(window, 'categories', { if (!window.WorkplaceState) {
get: function() { // getter/setter를 사용하여 항상 최신 값을 반환
return categories; Object.defineProperty(window, 'categories', {
} get: function() {
}); return categories;
}
});
Object.defineProperty(window, 'workplaces', { Object.defineProperty(window, 'workplaces', {
get: function() { get: function() {
return workplaces; return workplaces;
} }
}); });
Object.defineProperty(window, 'currentCategoryId', { Object.defineProperty(window, 'currentCategoryId', {
get: function() { get: function() {
return currentCategoryId; return currentCategoryId;
}, },
set: function(value) { set: function(value) {
currentCategoryId = value; currentCategoryId = value;
} }
}); });
}
// ==================== 작업장 지도 관리 ==================== // ==================== 작업장 지도 관리 ====================

View File

@@ -442,15 +442,15 @@ function renderCurrentTasks(workers) {
html += ` html += `
<div class="current-task-item"> <div class="current-task-item">
<div class="task-info"> <div class="task-info">
<p class="task-name">${worker.task_name}</p> <p class="task-name">${escapeHtml(worker.task_name)}</p>
<p class="task-detail"> <p class="task-detail">
${worker.work_location ? `📍 ${worker.work_location}` : ''} ${worker.work_location ? `📍 ${escapeHtml(worker.work_location)}` : ''}
${worker.project_name ? ` • 📁 ${worker.project_name}` : ''} ${worker.project_name ? ` • 📁 ${escapeHtml(worker.project_name)}` : ''}
</p> </p>
</div> </div>
<div class="task-badge"> <div class="task-badge">
<span>👷</span> <span>👷</span>
<span>${worker.member_count}명</span> <span>${parseInt(worker.member_count) || 0}명</span>
</div> </div>
</div> </div>
`; `;
@@ -481,7 +481,7 @@ async function loadEquipmentStatus(workplaceId) {
<div class="equipment-item"> <div class="equipment-item">
<span class="equipment-icon">⚙️</span> <span class="equipment-icon">⚙️</span>
<div class="equipment-info"> <div class="equipment-info">
<p class="equipment-name">${eq.equipment_name}</p> <p class="equipment-name">${escapeHtml(eq.equipment_name)}</p>
<p class="equipment-status ${statusClass}">${statusText}</p> <p class="equipment-status ${statusClass}">${statusText}</p>
</div> </div>
</div> </div>
@@ -516,11 +516,11 @@ function renderWorkersTab(workers) {
html += ` html += `
<div class="worker-item"> <div class="worker-item">
<div class="worker-item-header"> <div class="worker-item-header">
<p class="worker-item-title">${worker.task_name}</p> <p class="worker-item-title">${escapeHtml(worker.task_name)}</p>
<span class="worker-item-badge">${worker.member_count}명</span> <span class="worker-item-badge">${parseInt(worker.member_count) || 0}명</span>
</div> </div>
${worker.work_location ? `<p class="worker-item-detail">📍 ${worker.work_location}</p>` : ''} ${worker.work_location ? `<p class="worker-item-detail">📍 ${escapeHtml(worker.work_location)}</p>` : ''}
${worker.project_name ? `<p class="worker-item-detail">📁 ${worker.project_name}</p>` : ''} ${worker.project_name ? `<p class="worker-item-detail">📁 ${escapeHtml(worker.project_name)}</p>` : ''}
</div> </div>
`; `;
}); });
@@ -544,11 +544,11 @@ function renderVisitorsTab(visitors) {
html += ` html += `
<div class="visitor-item"> <div class="visitor-item">
<div class="visitor-item-header"> <div class="visitor-item-header">
<p class="visitor-item-title">${visitor.visitor_company}</p> <p class="visitor-item-title">${escapeHtml(visitor.visitor_company)}</p>
<span class="visitor-item-badge">${visitor.visitor_count}명 • ${statusText}</span> <span class="visitor-item-badge">${parseInt(visitor.visitor_count) || 0}명 • ${statusText}</span>
</div> </div>
<p class="visitor-item-detail">⏰ ${visitor.visit_time}</p> <p class="visitor-item-detail">⏰ ${escapeHtml(visitor.visit_time)}</p>
<p class="visitor-item-detail">📋 ${visitor.purpose_name}</p> <p class="visitor-item-detail">📋 ${escapeHtml(visitor.purpose_name)}</p>
</div> </div>
`; `;
}); });

View File

@@ -1,44 +0,0 @@
<section>
<h2>📄 작업 보고서</h2>
<ul>
<li><a href="/pages/work-reports/create.html">작업보고서 입력</a></li>
<li><a href="/pages/work-reports/manage.html">작업보고서 수정/삭제</a></li>
</ul>
</section>
<section>
<h2>📊 출근/공수 관리</h2>
<ul>
<li><a href="/pages/common/attendance.html">출근부</a></li>
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
</ul>
</section>
<section>
<h2>🔧 관리콘솔</h2>
<ul>
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
</ul>
</section>
<section>
<h2>🏭 공장 정보</h2>
<ul>
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
<li><a href="/pages/common/factory-list.html">공장 목록 보기</a></li>
</ul>
</section>
<section>
<h2>📊 이슈 리포트</h2>
<ul>
<li><a href="/pages/issue-reports/daily-issue.html">일일 이슈 보고</a></li>
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
</ul>
</section>

View File

@@ -1,35 +0,0 @@
<section>
<h2>📄 작업 보고서</h2>
<ul>
<li><a href="/pages/work/work-report-create.html">작업보고서 입력</a></li>
<li><a href="/pages/work/work-report-manage.html">작업보고서 수정/삭제</a></li>
</ul>
</section>
<section>
<h2>📊 출근/공수 관리</h2>
<ul>
<li><a href="/pages/attendance/attendance.html">출근부</a></li>
<li><a href="/pages/attendance/project-labor-summary.html">프로젝트별 공수 계산</a></li>
<li><a href="/pages/attendance/monthly-labor-report.html">월간 공수 보고서</a></li>
</ul>
</section>
<section>
<h2>관리콘솔</h2>
<ul>
<li><a href="/pages/admin/manage-all.html">📋 기본정보 관리</a></li>
</ul>
</section>
<section>
<h2>🏭 공장 정보</h2>
<ul>
<li><a href="/pages/factory/factory-map-upload.html">공장 지도 업로드</a></li>
</ul>
</section>
<section>
<h2>🗂 기타 관리</h2>
<p>프로젝트 및 작업자 관련 기능은 추후 확장 예정</p>
</section>

View File

@@ -1,667 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Users, AlertTriangle, CheckCircle, Edit3, Filter } from 'lucide-react';
const AttendanceValidationPage = () => {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
const [attendanceData, setAttendanceData] = useState({});
const [selectedDateWorkers, setSelectedDateWorkers] = useState([]);
const [filter, setFilter] = useState('all'); // all, needsReview, normal, missing
const [monthlyData, setMonthlyData] = useState({ workReports: [], dailyReports: [] });
const [loading, setLoading] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(true); // TODO: 실제 권한 체크
// 월이 변경될 때마다 해당 월의 전체 데이터 로드
useEffect(() => {
loadMonthlyData();
}, [currentDate]);
const loadMonthlyData = async () => {
setLoading(true);
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
console.log(`${year}년 ${month}월 데이터 로딩 중...`);
const data = await fetchMonthlyData(year, month);
setMonthlyData(data);
} catch (error) {
console.error('월간 데이터 로딩 실패:', error);
// 실패 시 빈 데이터로 설정
setMonthlyData({ workReports: [], dailyReports: [] });
} finally {
setLoading(false);
}
};
// 실제 API 호출 함수들
const fetchWorkReports = async (date) => {
try {
const response = await fetch(`/api/workreports/date/${date}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
return await response.json();
} catch (error) {
console.error('WorkReports API 호출 오류:', error);
return [];
}
};
const fetchDailyWorkReports = async (date) => {
try {
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
return await response.json();
} catch (error) {
console.error('DailyWorkReports API 호출 오류:', error);
return [];
}
};
// 월간 데이터 가져오기 (캘린더용)
const fetchMonthlyData = async (year, month) => {
const start = `${year}-${month.toString().padStart(2, '0')}-01`;
const end = `${year}-${month.toString().padStart(2, '0')}-31`;
try {
const [workReports, dailyReports] = await Promise.all([
fetch(`/api/workreports?start=${start}&end=${end}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}).then(res => res.json()),
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
}).then(res => res.json())
]);
return { workReports, dailyReports: dailyReports.reports || [] };
} catch (error) {
console.error('월간 데이터 가져오기 오류:', error);
return { workReports: [], dailyReports: [] };
}
};
// 목업 데이터 (개발/테스트용)
const mockWorkReports = {
'2025-06-16': [
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
],
'2025-06-17': [
{ worker_id: 1, worker_name: '김철수', overtime_hours: 2, status: 'normal' },
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'normal' },
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'early_leave' },
],
'2025-06-19': [
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'normal' },
]
};
const mockDailyReports = {
'2025-06-16': [
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
],
'2025-06-17': [
{ worker_id: 1, worker_name: '김철수', work_hours: 10 },
{ worker_id: 2, worker_name: '이영희', work_hours: 8 },
{ worker_id: 4, worker_name: '정수현', work_hours: 6 },
],
'2025-06-19': [
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
// 정수현 데이터 누락 - 미입력 상태
]
};
// 시간 계산 함수
const calculateExpectedHours = (status, overtime_hours = 0) => {
const baseHours = {
'normal': 8,
'half_day': 4,
'early_leave': 6,
'quarter_day': 2,
'vacation': 0,
'sick_leave': 0
};
return (baseHours[status] || 8) + (overtime_hours || 0);
};
// 날짜별 상태 계산 (월간 데이터 기반)
const calculateDateStatus = (dateStr) => {
const workReports = monthlyData.workReports.filter(wr => wr.date === dateStr);
const dailyReports = monthlyData.dailyReports.filter(dr => dr.report_date === dateStr);
if (workReports.length === 0 && dailyReports.length === 0) {
return 'no-data';
}
if (workReports.length === 0 || dailyReports.length === 0) {
return 'missing';
}
// 작업자별 시간 집계
const dailyGrouped = dailyReports.reduce((acc, dr) => {
if (!acc[dr.worker_id]) {
acc[dr.worker_id] = 0;
}
acc[dr.worker_id] += parseFloat(dr.work_hours || 0);
return acc;
}, {});
// 불일치 검사
const hasDiscrepancy = workReports.some(wr => {
const reportedHours = dailyGrouped[wr.worker_id] || 0;
const expectedHours = calculateExpectedHours('normal', wr.overtime_hours || 0);
return Math.abs(reportedHours - expectedHours) > 0;
});
return hasDiscrepancy ? 'needs-review' : 'normal';
};
// 권한 체크 (실제 구현 시)
const checkPermission = () => {
// TODO: 실제 권한 체크 로직
const userRole = localStorage.getItem('userRole') || 'user';
return userRole === 'admin' || userRole === 'manager';
};
// 권한 없음 UI
if (!isAuthorized) {
return (
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔒</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">접근 권한이 없습니다</h1>
<p className="text-gray-600">이 페이지는 관리자(Admin) 이상만 접근 가능합니다.</p>
</div>
</div>
);
}
// 로딩 중 UI
if (loading) {
return (
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">데이터를 불러오는 중...</span>
</div>
</div>
</div>
);
}
// 캘린더 생성
const generateCalendar = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const calendar = [];
const current = new Date(startDate);
for (let week = 0; week < 6; week++) {
const weekDays = [];
for (let day = 0; day < 7; day++) {
const dateStr = current.toISOString().split('T')[0];
const isCurrentMonth = current.getMonth() === month;
const status = isCurrentMonth ? calculateDateStatus(dateStr) : 'no-data';
weekDays.push({
date: new Date(current),
dateStr,
isCurrentMonth,
status
});
current.setDate(current.getDate() + 1);
}
calendar.push(weekDays);
}
return calendar;
};
// 실제 API를 사용한 작업자 데이터 조합
const getWorkersForDate = async (dateStr) => {
try {
// 실제 API 호출
const [workReports, dailyReports] = await Promise.all([
fetchWorkReports(dateStr),
fetchDailyWorkReports(dateStr)
]);
console.log('API 응답:', { workReports, dailyReports });
const workerMap = new Map();
// WorkReports 데이터 추가 (생산지원팀 입력)
workReports.forEach(wr => {
workerMap.set(wr.worker_id, {
worker_id: wr.worker_id,
worker_name: wr.worker_name,
overtime_hours: wr.overtime_hours || 0,
status: 'normal', // 실제 테이블 구조에 맞게 수정 필요
expected_hours: calculateExpectedHours('normal', wr.overtime_hours),
reported_hours: null,
hasWorkReport: true,
hasDailyReport: false
});
});
// DailyReports 데이터 추가 (그룹장 입력) - 작업자별 총 시간 집계
const dailyGrouped = dailyReports.reduce((acc, dr) => {
if (!acc[dr.worker_id]) {
acc[dr.worker_id] = {
worker_id: dr.worker_id,
worker_name: dr.worker_name,
total_work_hours: 0
};
}
acc[dr.worker_id].total_work_hours += parseFloat(dr.work_hours || 0);
return acc;
}, {});
Object.values(dailyGrouped).forEach(dr => {
if (workerMap.has(dr.worker_id)) {
const worker = workerMap.get(dr.worker_id);
worker.reported_hours = dr.total_work_hours;
worker.hasDailyReport = true;
} else {
workerMap.set(dr.worker_id, {
worker_id: dr.worker_id,
worker_name: dr.worker_name,
overtime_hours: 0,
status: 'normal',
expected_hours: 8,
reported_hours: dr.total_work_hours,
hasWorkReport: false,
hasDailyReport: true
});
}
});
return Array.from(workerMap.values()).map(worker => ({
...worker,
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
validationStatus: getValidationStatus(worker)
}));
} catch (error) {
console.error('데이터 조합 오류:', error);
// 오류 시 목업 데이터 사용
return getWorkersForDateMock(dateStr);
}
};
// 목업 데이터용 함수 (개발/테스트)
const getWorkersForDateMock = (dateStr) => {
const workReports = mockWorkReports[dateStr] || [];
const dailyReports = mockDailyReports[dateStr] || [];
const workerMap = new Map();
// WorkReports 데이터 추가
workReports.forEach(wr => {
workerMap.set(wr.worker_id, {
worker_id: wr.worker_id,
worker_name: wr.worker_name,
overtime_hours: wr.overtime_hours,
status: wr.status,
expected_hours: calculateExpectedHours(wr.status, wr.overtime_hours),
reported_hours: null,
hasWorkReport: true,
hasDailyReport: false
});
});
// DailyReports 데이터 추가
dailyReports.forEach(dr => {
if (workerMap.has(dr.worker_id)) {
const worker = workerMap.get(dr.worker_id);
worker.reported_hours = dr.work_hours;
worker.hasDailyReport = true;
} else {
workerMap.set(dr.worker_id, {
worker_id: dr.worker_id,
worker_name: dr.worker_name,
overtime_hours: 0,
status: 'normal',
expected_hours: 8,
reported_hours: dr.work_hours,
hasWorkReport: false,
hasDailyReport: true
});
}
});
return Array.from(workerMap.values()).map(worker => ({
...worker,
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
validationStatus: getValidationStatus(worker)
}));
};
const getValidationStatus = (worker) => {
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
if (Math.abs(worker.difference) > 0) return 'needs-review';
return 'normal';
};
// 작업자 수정 핸들러
const handleEditWorker = (worker) => {
// TODO: 수정 모달 또는 인라인 편집 구현
const newHours = prompt(
`${worker.worker_name}의 근무시간을 수정하세요.\n현재: ${worker.reported_hours || 0}시간`,
worker.reported_hours || 0
);
if (newHours !== null && !isNaN(newHours)) {
updateWorkerHours(worker.worker_id, parseFloat(newHours));
}
};
// 작업자 시간 업데이트 (실제 API 호출)
const updateWorkerHours = async (workerId, newHours) => {
try {
// TODO: 실제 수정 API 호출
console.log(`작업자 ${workerId}의 시간을 ${newHours}시간으로 수정`);
// 임시: 로컬 상태 업데이트
setSelectedDateWorkers(prev =>
prev.map(worker =>
worker.worker_id === workerId
? {
...worker,
reported_hours: newHours,
difference: newHours - worker.expected_hours,
validationStatus: Math.abs(newHours - worker.expected_hours) > 0 ? 'needs-review' : 'normal'
}
: worker
)
);
alert('수정이 완료되었습니다.');
} catch (error) {
console.error('수정 실패:', error);
alert('수정 중 오류가 발생했습니다.');
}
};
// 날짜 클릭 핸들러 (실제 API 호출)
const handleDateClick = async (dateInfo) => {
if (!dateInfo.isCurrentMonth) return;
setSelectedDate(dateInfo.dateStr);
try {
// 로딩 상태 표시
setSelectedDateWorkers([]);
// 실제 API에서 데이터 가져오기
const workers = await getWorkersForDate(dateInfo.dateStr);
setSelectedDateWorkers(workers);
} catch (error) {
console.error('날짜별 데이터 로딩 오류:', error);
// 오류 시 목업 데이터 사용
const workers = getWorkersForDateMock(dateInfo.dateStr);
setSelectedDateWorkers(workers);
}
};
// 필터링된 작업자 목록
const filteredWorkers = selectedDateWorkers.filter(worker => {
if (filter === 'all') return true;
if (filter === 'needsReview') return worker.validationStatus === 'needs-review';
if (filter === 'normal') return worker.validationStatus === 'normal';
if (filter === 'missing') return worker.validationStatus === 'missing';
return true;
});
// 상태별 아이콘 및 색상
const getStatusIcon = (status) => {
switch (status) {
case 'normal': return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'needs-review': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'missing': return <Clock className="w-4 h-4 text-red-500" />;
default: return null;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'normal': return 'bg-green-100 text-green-800';
case 'needs-review': return 'bg-yellow-100 text-yellow-800';
case 'missing': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const calendar = generateCalendar();
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
return (
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<Users className="w-6 h-6 text-blue-600" />
<h1 className="text-2xl font-bold text-gray-900">근태 검증 관리</h1>
</div>
<div className="text-sm text-gray-500">
Admin 전용 페이지
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 캘린더 섹션 */}
<div className="lg:col-span-2">
<div className="bg-white border border-gray-200 rounded-lg p-4">
{/* 캘린더 헤더 */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => {
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1);
setCurrentDate(newDate);
}}
className="p-2 hover:bg-gray-100 rounded-md"
disabled={loading}
>
</button>
<h2 className="text-xl font-semibold">
{currentDate.getFullYear()}년 {monthNames[currentDate.getMonth()]}
</h2>
<button
onClick={() => {
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
setCurrentDate(newDate);
}}
className="p-2 hover:bg-gray-100 rounded-md"
disabled={loading}
>
</button>
</div>
{/* 월간 요약 정보 */}
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="text-green-600 font-semibold">
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'normal').length}
</div>
<div className="text-gray-600">정상</div>
</div>
<div className="text-center">
<div className="text-yellow-600 font-semibold">
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'needs-review').length}
</div>
<div className="text-gray-600">검토필요</div>
</div>
<div className="text-center">
<div className="text-red-600 font-semibold">
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'missing').length}
</div>
<div className="text-gray-600">미입력</div>
</div>
</div>
</div>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{dayNames.map(day => (
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
{day}
</div>
))}
</div>
{/* 캘린더 본체 */}
<div className="grid grid-cols-7 gap-1">
{calendar.flat().map((dateInfo, index) => (
<button
key={index}
onClick={() => handleDateClick(dateInfo)}
className={`
p-2 text-sm rounded-md h-12 relative transition-colors
${dateInfo.isCurrentMonth ? 'text-gray-900' : 'text-gray-400'}
${selectedDate === dateInfo.dateStr ? 'bg-blue-100 border-2 border-blue-500' : 'hover:bg-gray-50'}
${dateInfo.status === 'needs-review' ? 'bg-yellow-50 border border-yellow-200' : ''}
${dateInfo.status === 'missing' ? 'bg-red-50 border border-red-200' : ''}
${dateInfo.status === 'normal' ? 'bg-green-50 border border-green-200' : ''}
`}
>
<div className="flex flex-col items-center">
<span>{dateInfo.date.getDate()}</span>
{dateInfo.isCurrentMonth && dateInfo.status !== 'no-data' && (
<div className="absolute top-1 right-1">
{dateInfo.status === 'needs-review' && <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>}
{dateInfo.status === 'missing' && <div className="w-2 h-2 bg-red-500 rounded-full"></div>}
{dateInfo.status === 'normal' && <div className="w-2 h-2 bg-green-500 rounded-full"></div>}
</div>
)}
</div>
</button>
))}
</div>
{/* 범례 */}
<div className="flex items-center justify-center space-x-4 mt-4 text-xs">
<div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span>정상</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<span>검토필요</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span>미입력</span>
</div>
</div>
</div>
</div>
{/* 작업자 리스트 섹션 */}
<div className="lg:col-span-1">
{selectedDate ? (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
📅 {selectedDate}
</h3>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-md px-2 py-1"
>
<option value="all">전체</option>
<option value="needsReview">검토필요</option>
<option value="normal">정상</option>
<option value="missing">미입력</option>
</select>
</div>
<div className="space-y-3">
{filteredWorkers.map(worker => (
<div key={worker.worker_id} className={`p-3 rounded-lg border ${getStatusColor(worker.validationStatus)}`}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{worker.worker_name}</span>
{getStatusIcon(worker.validationStatus)}
</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span>그룹장 입력:</span>
<span className="font-mono">
{worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
</span>
</div>
<div className="flex justify-between">
<span>시스템 계산:</span>
<span className="font-mono">{worker.expected_hours}시간</span>
</div>
{worker.difference !== 0 && (
<div className="flex justify-between font-semibold">
<span>차이:</span>
<span className={`font-mono ${worker.difference > 0 ? 'text-red-600' : 'text-blue-600'}`}>
{worker.difference > 0 ? '+' : ''}{worker.difference}시간
</span>
</div>
)}
</div>
{worker.validationStatus === 'needs-review' && (
<button
onClick={() => handleEditWorker(worker)}
className="mt-2 w-full text-xs bg-blue-600 text-white px-2 py-1 rounded-md hover:bg-blue-700 flex items-center justify-center space-x-1"
>
<Edit3 className="w-3 h-3" />
<span>수정</span>
</button>
)}
</div>
))}
</div>
{filteredWorkers.length === 0 && (
<div className="text-center text-gray-500 py-8">
해당 조건의 작업자가 없습니다.
</div>
)}
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>날짜를 선택하면</p>
<p>작업자 검증 내역을 확인할 수 있습니다.</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AttendanceValidationPage;

View File

@@ -1,62 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>⚙️ 이슈 유형 관리</h1>
<p class="subtitle">프로젝트에서 발생하는 이슈 유형을 관리합니다.</p>
</div>
<div class="card">
<h3>새 이슈 유형 등록</h3>
<form id="issueTypeForm" class="form-horizontal">
<div class="form-row">
<input type="text" id="category" placeholder="카테고리" required>
<input type="text" id="subcategory" placeholder="서브카테고리" required>
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<div class="card">
<h3>등록된 이슈 유형</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>카테고리</th>
<th>서브카테고리</th>
<th>작업</th>
</tr>
</thead>
<tbody id="issueTypeTableBody">
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-issue.js"></script>
</body>
</html>

View File

@@ -1,76 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>🏗 프로젝트 관리</h1>
<p class="subtitle">진행 중인 프로젝트를 등록하고 관리합니다.</p>
</div>
<div class="card">
<h3>새 프로젝트 등록</h3>
<form id="projectForm" class="form-vertical">
<div class="form-row">
<input type="text" id="job_no" placeholder="공사번호" required>
<input type="text" id="project_name" placeholder="프로젝트명" required>
</div>
<div class="form-row">
<input type="date" id="contract_date" placeholder="계약일">
<input type="date" id="due_date" placeholder="납기일">
</div>
<div class="form-row">
<input type="text" id="delivery_method" placeholder="납품방식">
<input type="text" id="site" placeholder="현장명">
<input type="text" id="pm" placeholder="담당 PM">
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<div class="card">
<h3>등록된 프로젝트</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>공사번호</th>
<th>프로젝트명</th>
<th>계약일</th>
<th>납기일</th>
<th>납품방식</th>
<th>현장</th>
<th>PM</th>
<th>작업</th>
</tr>
</thead>
<tbody id="projectTableBody">
<tr><td colspan="9" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-project.js"></script>
</body>
</html>

View File

@@ -1,68 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>🛠 작업 항목 관리</h1>
<p class="subtitle">프로젝트에서 수행되는 작업 항목을 관리합니다.</p>
</div>
<div class="card">
<h3>새 작업 항목 등록</h3>
<form id="taskForm" class="form-vertical">
<div class="form-row">
<input type="text" id="category" placeholder="카테고리" required>
<input type="text" id="subcategory" placeholder="서브카테고리" required>
</div>
<div class="form-row">
<input type="text" id="task_name" placeholder="작업명" required>
<input type="text" id="description" placeholder="설명">
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<div class="card">
<h3>등록된 작업 항목</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>카테고리</th>
<th>서브카테고리</th>
<th>작업명</th>
<th>설명</th>
<th>작업</th>
</tr>
</thead>
<tbody id="taskTableBody">
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-task.js"></script>
</body>
</html>

View File

@@ -1,287 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>👤 사용자 관리</h1>
<p class="subtitle">시스템 사용자를 등록하고 관리합니다.</p>
</div>
<!-- 내 비밀번호 변경 -->
<div class="card">
<h3>🔐 내 비밀번호 변경</h3>
<form id="myPasswordForm" class="form-horizontal">
<div class="form-row">
<input type="password" id="currentPassword" placeholder="현재 비밀번호" required />
<input type="password" id="newPassword" placeholder="새 비밀번호" required />
<input type="password" id="confirmPassword" placeholder="새 비밀번호 확인" required />
<button type="submit" class="btn btn-warning">내 비밀번호 변경</button>
</div>
</form>
</div>
<!-- 등록 폼 -->
<div class="card">
<h3>새 사용자 등록</h3>
<form id="userForm" class="form-horizontal">
<div class="form-row">
<input type="text" id="username" placeholder="아이디" required />
<input type="password" id="password" placeholder="비밀번호" required />
<input type="text" id="name" placeholder="이름" required />
</div>
<div class="form-row">
<select id="access_level" required>
<option value="">권한 선택</option>
<option value="worker">작업자</option>
<option value="group_leader">그룹장</option>
<option value="support_team">지원팀</option>
<option value="admin">관리자</option>
<option value="system">시스템</option>
</select>
<select id="worker_id">
<option value="">작업자 연결 (선택)</option>
</select>
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<!-- 사용자 비밀번호 변경 (시스템 권한자만) -->
<div class="card" id="systemPasswordChangeCard" style="display: none;">
<h3>🔑 사용자 비밀번호 변경 (시스템 권한자 전용)</h3>
<form id="userPasswordForm" class="form-horizontal">
<div class="form-row">
<select id="targetUserId" required>
<option value="">사용자 선택</option>
</select>
<input type="password" id="targetNewPassword" placeholder="새 비밀번호" required />
<button type="submit" class="btn btn-danger">비밀번호 변경</button>
</div>
</form>
</div>
<!-- 사용자 목록 -->
<div class="card">
<h3>등록된 사용자</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>아이디</th>
<th>이름</th>
<th>권한</th>
<th>연결 작업자</th>
<th>작업</th>
</tr>
</thead>
<tbody id="userTableBody">
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 페이지 권한 관리 모달 -->
<div id="pageAccessModal" class="modal" style="display: none;">
<div class="modal-content large">
<div class="modal-header">
<h2>🔐 페이지 접근 권한 관리</h2>
<span class="close">&times;</span>
</div>
<div class="modal-body">
<div class="user-info-section">
<h3 id="modalUserInfo">사용자 정보</h3>
<p id="modalUserRole" class="text-muted"></p>
</div>
<div class="page-access-grid">
<div class="category-section" id="dashboardPages">
<h4>📊 대시보드</h4>
<div class="page-list" id="dashboardPageList"></div>
</div>
<div class="category-section" id="managementPages">
<h4>⚙️ 관리</h4>
<div class="page-list" id="managementPageList"></div>
</div>
<div class="category-section" id="commonPages">
<h4>📝 공통</h4>
<div class="page-list" id="commonPageList"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="savePageAccessChanges()">저장</button>
</div>
</div>
</div>
<style>
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 0;
border: 1px solid #888;
width: 90%;
max-width: 800px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
}
.modal-content.large {
max-width: 1000px;
}
.modal-header {
padding: 20px;
background-color: #4CAF50;
color: white;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.modal-body {
padding: 30px;
max-height: 600px;
overflow-y: auto;
}
.modal-footer {
padding: 15px 20px;
background-color: #f1f1f1;
border-radius: 0 0 8px 8px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.close {
color: white;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.3s;
}
.close:hover,
.close:focus {
color: #ddd;
}
.user-info-section {
margin-bottom: 30px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 5px;
}
.user-info-section h3 {
margin: 0 0 5px 0;
color: #333;
}
.page-access-grid {
display: grid;
gap: 20px;
}
.category-section h4 {
margin: 0 0 15px 0;
color: #4CAF50;
font-size: 1.1rem;
padding-bottom: 10px;
border-bottom: 2px solid #4CAF50;
}
.page-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.page-item {
display: flex;
align-items: center;
padding: 12px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 5px;
transition: all 0.3s;
}
.page-item:hover {
background-color: #f8f9fa;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.page-item input[type="checkbox"] {
margin-right: 15px;
width: 20px;
height: 20px;
cursor: pointer;
}
.page-item label {
flex: 1;
cursor: pointer;
font-size: 1rem;
margin: 0;
}
.page-item .page-path {
font-size: 0.85rem;
color: #666;
margin-left: 10px;
}
.text-muted {
color: #6c757d;
font-size: 0.9rem;
}
</style>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-user.js"></script>
</body>
</html>

View File

@@ -1,62 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>👷 작업자 관리</h1>
<p class="subtitle">프로젝트에 참여하는 작업자를 관리합니다.</p>
</div>
<div class="card">
<h3>새 작업자 등록</h3>
<form id="workerForm" class="form-horizontal">
<div class="form-row">
<input type="text" id="workerName" placeholder="작업자명" required>
<input type="text" id="position" placeholder="직책">
<button type="submit" class="btn btn-primary">등록</button>
</div>
</form>
</div>
<div class="card">
<h3>등록된 작업자</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>이름</th>
<th>직책</th>
<th>작업</th>
</tr>
</thead>
<tbody id="workerTableBody">
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/manage-worker.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,363 +0,0 @@
<!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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/work-analysis.css?v=42">
<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=1" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head>
<body>
<div class="analysis-container">
<!-- 페이지 헤더 -->
<header class="page-header fade-in">
<h1 class="page-title">
<span class="icon">📊</span>
작업 분석
</h1>
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
</header>
<!-- 분석 모드 탭 -->
<nav class="analysis-tabs fade-in">
<button class="tab-button active" data-mode="period">
📅 기간별 분석
</button>
<button class="tab-button" data-mode="project">
🏗️ 프로젝트별 분석
</button>
</nav>
<!-- 분석 조건 설정 -->
<section class="analysis-controls fade-in">
<div class="controls-grid">
<!-- 기간 설정 -->
<div class="form-group">
<label class="form-label" for="startDate">
<span class="icon">📅</span>
시작일
</label>
<input type="date" id="startDate" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="endDate">
<span class="icon">📅</span>
종료일
</label>
<input type="date" id="endDate" class="form-input" required>
</div>
<!-- 기간 확정 버튼 -->
<div class="form-group">
<button class="confirm-period-button" id="confirmPeriodBtn">
<span class="icon"></span>
기간 확정
</button>
</div>
<!-- 기간 상태 표시 -->
<div class="form-group" id="periodStatusGroup" style="display: none;">
<div class="period-status">
<span class="icon"></span>
<div>
<div style="font-size: 0.8rem; opacity: 0.8; margin-bottom: 2px;">분석 기간</div>
<div id="periodStatus">기간이 설정되지 않았습니다</div>
</div>
</div>
</div>
</div>
</section>
<!-- 분석 결과 영역 -->
<main id="analysisResults" class="fade-in">
<!-- 로딩 상태 -->
<div id="loadingState" class="loading-container" style="display: none;">
<div class="loading-spinner"></div>
<p class="loading-text">분석 중입니다...</p>
</div>
<!-- 분석 탭 네비게이션 -->
<div id="analysisTabNavigation" class="tab-navigation" style="display: none;">
<div class="tab-buttons">
<button class="tab-button active" data-tab="work-status">
<span class="icon">📈</span>
기간별 작업 현황
</button>
<button class="tab-button" data-tab="project-distribution">
<span class="icon">🥧</span>
프로젝트별 분포
</button>
<button class="tab-button" data-tab="worker-performance">
<span class="icon">👤</span>
작업자별 성과
</button>
<button class="tab-button" data-tab="error-analysis">
<span class="icon">⚠️</span>
오류 분석
</button>
</div>
</div>
<!-- 결과 카드 그리드 -->
<div id="resultsGrid" class="results-grid" style="display: none;">
<!-- 통계 카드들 -->
<div class="stats-cards">
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-content">
<div class="stat-label">총 작업시간</div>
<div class="stat-value" id="totalHours">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-content">
<div class="stat-label">정상 시간</div>
<div class="stat-value" id="normalHours">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">⚠️</div>
<div class="stat-content">
<div class="stat-label">오류 시간</div>
<div class="stat-value" id="errorHours">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">👥</div>
<div class="stat-content">
<div class="stat-label">참여 작업자</div>
<div class="stat-value" id="workerCount">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<div class="stat-label">오류율</div>
<div class="stat-value" id="errorRate">0%</div>
</div>
</div>
</div>
<!-- 분석 탭 컨텐츠 -->
<div class="tab-contents">
<!-- 기간별 작업 현황 -->
<div id="work-status-tab" class="tab-content active">
<div class="chart-container table-type">
<div class="chart-header">
<h3 class="chart-title">
<span class="icon">📈</span>
기간별 작업 현황
</h3>
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
<span class="icon">🔍</span>
분석 실행
</button>
</div>
<div class="table-container">
<!-- 테이블이 동적으로 생성됩니다 -->
</div>
</div>
</div>
<!-- 프로젝트별 분포 -->
<div id="project-distribution-tab" class="tab-content">
<div class="chart-container table-type">
<div class="chart-header">
<h3 class="chart-title">
<span class="icon">🥧</span>
프로젝트별 분포
</h3>
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
<span class="icon">🔍</span>
분석 실행
</button>
</div>
<div class="table-container">
<table class="production-report-table">
<thead>
<tr>
<th class="job-no-header">Job No.</th>
<th class="work-content-header">작업내용</th>
<th class="man-days-header">공수</th>
<th class="load-rate-header">전체 부하율</th>
<th class="labor-cost-header">인건비</th>
</tr>
</thead>
<tbody id="projectDistributionTableBody">
<tr>
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
분석을 실행해주세요
</td>
</tr>
</tbody>
<tfoot id="projectDistributionTableFooter" style="display: none;">
<tr class="total-row">
<td colspan="2"><strong>총계</strong></td>
<td><strong id="totalManDays">0</strong></td>
<td><strong>100%</strong></td>
<td><strong id="totalLaborCost">₩0</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<!-- 작업자별 성과 -->
<div id="worker-performance-tab" class="tab-content">
<div class="chart-container chart-type">
<div class="chart-header">
<h3 class="chart-title">
<span class="icon">👤</span>
작업자별 성과
</h3>
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
<span class="icon">🔍</span>
분석 실행
</button>
</div>
<canvas id="workerPerformanceChart"></canvas>
</div>
</div>
<!-- 오류 분석 -->
<div id="error-analysis-tab" class="tab-content">
<div class="chart-container table-type">
<div class="chart-header">
<h3 class="chart-title">
<span class="icon">⚠️</span>
오류 분석
</h3>
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>
<span class="icon">🔍</span>
분석 실행
</button>
</div>
<div class="table-container">
<table class="error-analysis-table">
<thead>
<tr>
<th>Job No.</th>
<th>작업내용</th>
<th>총 시간</th>
<th>세부시간</th>
<th>작업 타입</th>
<th>오류율</th>
</tr>
</thead>
<tbody id="errorAnalysisTableBody">
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
분석을 실행해주세요
</td>
</tr>
</tbody>
<tfoot id="errorAnalysisTableFooter" style="display: none;">
<tr class="total-row">
<td colspan="2"><strong>총계</strong></td>
<td><strong id="totalErrorHours">0h</strong></td>
<td><strong>-</strong></td>
<td><strong>-</strong></td>
<td><strong>0.0%</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 모듈화된 JavaScript 로딩 -->
<script src="/js/work-analysis/module-loader.js?v=1" defer></script>
<script>
// 서울 표준시(KST) 기준 날짜 함수들 (하위 호환성 유지)
function getKSTDate() {
const now = new Date();
// UTC 시간에 9시간 추가 (KST = UTC+9)
const kstOffset = 9 * 60; // 9시간을 분으로 변환
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kst = new Date(utc + (kstOffset * 60000));
return kst;
}
function formatDateToString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 날짜 문자열을 간단한 형식으로 변환하는 함수 (하위 호환성 유지)
function formatSimpleDate(dateStr) {
if (!dateStr) return '날짜 없음';
if (typeof dateStr === 'string' && dateStr.includes('T')) {
return dateStr.split('T')[0]; // 2025-11-01T00:00:00.000Z → 2025-11-01
}
return dateStr;
}
// 현재 시간 업데이트 (하위 호환성 유지)
function updateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// 시간 표시 요소가 있다면 업데이트
const timeElement = document.querySelector('.time-value');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('📦 작업 분석 모듈 로딩 시작...');
// 서울 표준시(KST) 기준 날짜 설정
const today = getKSTDate();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); // 이번 달 마지막 날
document.getElementById('startDate').value = formatDateToString(monthStart);
document.getElementById('endDate').value = formatDateToString(monthEnd);
// 시간 업데이트 시작
updateTime();
setInterval(updateTime, 1000);
});
// 모듈 로딩 완료 후 초기화
window.addEventListener('workAnalysisModulesLoaded', function(event) {
console.log('🎉 작업 분석 모듈 로딩 완료:', event.detail.modules);
// 모듈 로딩 완료 후 추가 초기화 작업이 있다면 여기에 추가
});
// 초기 모드 설정 (하위 호환성 유지)
window.currentAnalysisMode = 'period';
</script>
</body>
</html>

View File

@@ -1,890 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 현황 분석</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f7fa;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.date-selector {
display: flex;
gap: 15px;
align-items: center;
margin-top: 15px;
}
.date-selector input {
padding: 8px 12px;
border: none;
border-radius: 5px;
font-size: 14px;
}
.btn {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.3);
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s;
}
.btn:hover {
background: rgba(255,255,255,0.3);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
}
.stat-number {
font-size: 36px;
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
}
.stat-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-change {
font-size: 12px;
margin-top: 5px;
padding: 3px 8px;
border-radius: 15px;
}
.stat-change.positive {
background: #e8f5e8;
color: #2e7d2e;
}
.stat-change.negative {
background: #ffeaea;
color: #c53030;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.chart-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
color: #333;
}
.chart-canvas {
position: relative;
height: 300px;
}
.table-container {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.table-header {
background: #667eea;
color: white;
padding: 15px 20px;
font-size: 18px;
font-weight: 600;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
.table tr:hover {
background: #f8f9fa;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-completed {
background: #e8f5e8;
color: #2e7d2e;
}
.status-progress {
background: #fff3cd;
color: #856404;
}
.status-pending {
background: #f8d7da;
color: #721c24;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
border: 1px solid #f5c6cb;
}
.debug-info {
background: #d1ecf1;
color: #0c5460;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
font-family: monospace;
font-size: 12px;
display: none;
}
@media (max-width: 768px) {
.charts-grid {
grid-template-columns: 1fr;
}
.date-selector {
flex-direction: column;
align-items: stretch;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 일일 작업 현황 분석</h1>
<p>실시간 작업 현황과 주요 지표를 확인하세요</p>
<div class="date-selector">
<label>조회 기간:</label>
<input type="date" id="startDate">
<span>~</span>
<input type="date" id="endDate">
<button class="btn" onclick="loadData()">조회</button>
<button class="btn" onclick="setToday()">오늘</button>
<button class="btn" onclick="setThisWeek()">이번주</button>
<button class="btn" onclick="setThisMonth()">이번달</button>
<button class="btn" onclick="toggleDebug()">디버그</button>
</div>
</div>
<div id="errorContainer"></div>
<div id="debugInfo" class="debug-info"></div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number" id="totalHours">0</div>
<div class="stat-label">총 작업시간</div>
<div class="stat-change positive" id="hoursChange">+0%</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalReports">0</div>
<div class="stat-label">보고서 건수</div>
<div class="stat-change positive" id="reportsChange">+0%</div>
</div>
<div class="stat-card">
<div class="stat-number" id="activeProjects">0</div>
<div class="stat-label">진행 프로젝트</div>
<div class="stat-change" id="projectsChange">+0%</div>
</div>
<div class="stat-card">
<div class="stat-number" id="errorRate">0%</div>
<div class="stat-label">에러율</div>
<div class="stat-change negative" id="errorChange">+0%</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-container">
<div class="chart-title">📈 일별 작업시간 추이</div>
<div class="chart-canvas">
<canvas id="dailyHoursChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">👥 작업자별 작업량</div>
<div class="chart-canvas">
<canvas id="workerChart"></canvas>
</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-container">
<div class="chart-title">🏗️ 프로젝트별 투입시간</div>
<div class="chart-canvas">
<canvas id="projectChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">⚙️ 작업 유형별 분포</div>
<div class="chart-canvas">
<canvas id="workTypeChart"></canvas>
</div>
</div>
</div>
<div class="table-container">
<div class="table-header">🔍 최근 작업 현황</div>
<table class="table">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>프로젝트</th>
<th>작업유형</th>
<th>작업시간</th>
<th>상태</th>
</tr>
</thead>
<tbody id="recentWorkTable">
<tr>
<td colspan="6" class="loading">데이터를 불러오는 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
// 전역 변수
let API_URL = 'http://192.168.0.3:3005';
let dailyChart, workerChart, projectChart, workTypeChart;
let debugMode = false;
// 디버그 함수
function log(message, data = null) {
console.log(message, data);
if (debugMode) {
const debugDiv = document.getElementById('debugInfo');
const timestamp = new Date().toLocaleTimeString();
debugDiv.innerHTML += `[${timestamp}] ${message}${data ? ': ' + JSON.stringify(data, null, 2) : ''}<br>`;
debugDiv.scrollTop = debugDiv.scrollHeight;
}
}
function toggleDebug() {
debugMode = !debugMode;
const debugDiv = document.getElementById('debugInfo');
debugDiv.style.display = debugMode ? 'block' : 'none';
if (debugMode) {
debugDiv.innerHTML = '=== 디버그 모드 활성화 ===<br>';
}
}
function showError(message, details = null) {
const errorContainer = document.getElementById('errorContainer');
errorContainer.innerHTML = `
<div class="error-message">
<strong>오류:</strong> ${message}
${details ? `<br><small>${details}</small>` : ''}
</div>
`;
log('ERROR: ' + message, details);
}
function clearError() {
document.getElementById('errorContainer').innerHTML = '';
}
// API 설정 초기화
async function initializeAPI() {
log('API 초기화 시작');
// 기존 API 설정 파일 시도
try {
const module = await import('/js/api-config.js');
if (module.API && module.API.BASE_URL) {
API_URL = module.API.BASE_URL;
log('API config 파일에서 설정 로드', API_URL);
} else {
log('API config 파일에서 BASE_URL을 찾을 수 없음');
}
} catch (error) {
log('API config 파일 로드 실패, 기본값 사용', error.message);
}
// API 연결 테스트
await testAPIConnection();
}
// API 연결 테스트 함수
async function testAPIConnection() {
const testUrls = [
{ url: 'http://192.168.0.3:3005/api', hasApi: false }, // 직접 연결 우선
{ url: 'http://192.168.0.3:3001', hasApi: true } // nginx 프록시
];
for (const testConfig of testUrls) {
try {
const healthUrl = testConfig.hasApi
? `${testConfig.url}/health`
: `${testConfig.url}/health`;
log(`API 연결 테스트: ${healthUrl}`);
const response = await fetch(healthUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.text();
// HTML 응답이면 실패로 간주
if (data.includes('<!DOCTYPE html>')) {
log(`❌ HTML 응답 (로그인 페이지): ${healthUrl}`);
continue;
}
API_URL = testConfig.hasApi ? testConfig.url : testConfig.url;
log(`✅ API 연결 성공: ${API_URL}`);
return;
} else {
log(`❌ API 연결 실패 (${response.status}): ${healthUrl}`);
}
} catch (error) {
log(`❌ API 연결 오류: ${testConfig.url}`, error.message);
}
}
// 모든 URL 실패 시 직접 연결 사용
API_URL = 'http://192.168.0.3:3005/api';
log(`⚠️ 모든 연결 실패, 직접 연결 사용: ${API_URL}`);
}
// 차트 초기화
function initializeCharts() {
log('차트 초기화 시작');
try {
// 일별 작업시간 차트
const dailyCtx = document.getElementById('dailyHoursChart').getContext('2d');
dailyChart = new Chart(dailyCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: '작업시간',
data: [],
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '시간'
}
}
}
}
});
// 작업자별 차트
const workerCtx = document.getElementById('workerChart').getContext('2d');
workerChart = new Chart(workerCtx, {
type: 'doughnut',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: [
'#667eea',
'#764ba2',
'#f093fb',
'#f5576c',
'#4facfe',
'#43e97b'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// 프로젝트별 차트
const projectCtx = document.getElementById('projectChart').getContext('2d');
projectChart = new Chart(projectCtx, {
type: 'bar',
data: {
labels: [],
datasets: [{
label: '투입시간',
data: [],
backgroundColor: '#667eea'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '시간'
}
}
}
}
});
// 작업유형별 차트
const workTypeCtx = document.getElementById('workTypeChart').getContext('2d');
workTypeChart = new Chart(workTypeCtx, {
type: 'pie',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: [
'#667eea',
'#764ba2',
'#f093fb',
'#f5576c',
'#4facfe',
'#43e97b',
'#f6d55c',
'#ed4a7b'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
log('차트 초기화 완료');
} catch (error) {
log('차트 초기화 실패', error);
showError('차트 초기화에 실패했습니다.', error.message);
}
}
// 날짜 설정 함수들
function setToday() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('startDate').value = today;
document.getElementById('endDate').value = today;
log('오늘 날짜 설정', today);
loadData();
}
function setThisWeek() {
const today = new Date();
const monday = new Date(today.setDate(today.getDate() - today.getDay() + 1));
const sunday = new Date(today.setDate(today.getDate() - today.getDay() + 7));
const mondayStr = monday.toISOString().split('T')[0];
const sundayStr = sunday.toISOString().split('T')[0];
document.getElementById('startDate').value = mondayStr;
document.getElementById('endDate').value = sundayStr;
log('이번주 날짜 설정', { start: mondayStr, end: sundayStr });
loadData();
}
function setThisMonth() {
const today = new Date();
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const firstDayStr = firstDay.toISOString().split('T')[0];
const lastDayStr = lastDay.toISOString().split('T')[0];
document.getElementById('startDate').value = firstDayStr;
document.getElementById('endDate').value = lastDayStr;
log('이번달 날짜 설정', { start: firstDayStr, end: lastDayStr });
loadData();
}
// 토큰 확인 함수
function checkToken() {
const token = localStorage.getItem('token');
if (!token) {
showError('로그인이 필요합니다.');
setTimeout(() => {
location.href = '/login';
}, 2000);
return null;
}
return token;
}
// API 호출 헬퍼 함수
async function makeAPICall(url, options = {}) {
log('API 호출', url);
const token = checkToken();
if (!token) return null;
const defaultOptions = {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
const finalOptions = { ...defaultOptions, ...options };
log('요청 옵션', finalOptions);
try {
const response = await fetch(url, finalOptions);
log('응답 상태', { status: response.status, statusText: response.statusText });
const responseText = await response.text();
log('응답 내용 (텍스트)', responseText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${responseText}`);
}
const data = JSON.parse(responseText);
log('파싱된 데이터', data);
return data;
} catch (error) {
log('API 호출 실패', error);
throw error;
}
}
// 데이터 로딩
async function loadData() {
clearError();
log('데이터 로딩 시작');
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
if (!startDate || !endDate) {
showError('조회 기간을 선택해주세요.');
return;
}
log('조회 기간', { startDate, endDate });
try {
// 기본 통계 조회
log('기본 통계 조회 시작');
const statsUrl = `${API_URL}/work-analysis/stats?start=${startDate}&end=${endDate}`;
const statsData = await makeAPICall(statsUrl);
if (statsData) {
updateStats(statsData.data || statsData);
}
// 일별 추이 데이터
log('일별 추이 조회 시작');
const dailyUrl = `${API_URL}/work-analysis/daily-trend?start=${startDate}&end=${endDate}`;
const dailyData = await makeAPICall(dailyUrl);
if (dailyData) {
updateDailyChart(dailyData.data || dailyData);
}
// 작업자별 데이터
log('작업자별 통계 조회 시작');
const workerUrl = `${API_URL}/work-analysis/worker-stats?start=${startDate}&end=${endDate}`;
const workerData = await makeAPICall(workerUrl);
if (workerData) {
updateWorkerChart(workerData.data || workerData);
}
// 프로젝트별 데이터
log('프로젝트별 통계 조회 시작');
const projectUrl = `${API_URL}/work-analysis/project-stats?start=${startDate}&end=${endDate}`;
const projectData = await makeAPICall(projectUrl);
if (projectData) {
updateProjectChart(projectData.data || projectData);
}
// 작업유형별 데이터
log('작업유형별 통계 조회 시작');
const workTypeUrl = `${API_URL}/work-analysis/worktype-stats?start=${startDate}&end=${endDate}`;
const workTypeData = await makeAPICall(workTypeUrl);
if (workTypeData) {
updateWorkTypeChart(workTypeData.data || workTypeData);
}
// 최근 작업 현황
log('최근 작업 현황 조회 시작');
const recentUrl = `${API_URL}/work-analysis/recent-work?start=${startDate}&end=${endDate}&limit=10`;
const recentData = await makeAPICall(recentUrl);
if (recentData) {
updateRecentWorkTable(recentData.data || recentData);
}
log('모든 데이터 로딩 완료');
} catch (error) {
log('데이터 로딩 오류', error);
if (error.message.includes('403')) {
showError('접근 권한이 없습니다. 관리자에게 문의하세요.');
} else if (error.message.includes('401')) {
showError('로그인이 만료되었습니다. 다시 로그인해주세요.');
setTimeout(() => {
location.href = '/login';
}, 2000);
} else {
showError('데이터를 불러오는 중 오류가 발생했습니다.', error.message);
}
}
}
// 통계 업데이트
function updateStats(stats) {
log('통계 업데이트', stats);
document.getElementById('totalHours').textContent = (stats.totalHours || 0).toFixed(1);
document.getElementById('totalReports').textContent = stats.totalReports || 0;
document.getElementById('activeProjects').textContent = stats.activeProjects || 0;
document.getElementById('errorRate').textContent = (stats.errorRate || 0).toFixed(1) + '%';
// 변화율 표시 (임시 데이터)
document.getElementById('hoursChange').textContent = '+12.5%';
document.getElementById('reportsChange').textContent = '+8.3%';
document.getElementById('projectsChange').textContent = '+2';
document.getElementById('errorChange').textContent = '-0.5%';
}
// 차트 업데이트 함수들
function updateDailyChart(data) {
log('일별 차트 업데이트', data);
if (!Array.isArray(data) || data.length === 0) {
log('일별 차트 데이터 없음');
return;
}
dailyChart.data.labels = data.map(item => item.date);
dailyChart.data.datasets[0].data = data.map(item => parseFloat(item.hours) || 0);
dailyChart.update();
}
function updateWorkerChart(data) {
log('작업자 차트 업데이트', data);
if (!Array.isArray(data) || data.length === 0) {
log('작업자 차트 데이터 없음');
return;
}
workerChart.data.labels = data.map(item => item.workerName || `작업자 ${item.worker_id}`);
workerChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
workerChart.update();
}
function updateProjectChart(data) {
log('프로젝트 차트 업데이트', data);
if (!Array.isArray(data) || data.length === 0) {
log('프로젝트 차트 데이터 없음');
return;
}
projectChart.data.labels = data.map(item => item.projectName || `프로젝트 ${item.project_id}`);
projectChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
projectChart.update();
}
function updateWorkTypeChart(data) {
log('작업유형 차트 업데이트', data);
if (!Array.isArray(data) || data.length === 0) {
log('작업유형 차트 데이터 없음');
return;
}
workTypeChart.data.labels = data.map(item => item.workTypeName || `작업유형 ${item.work_type_id}`);
workTypeChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
workTypeChart.update();
}
// 테이블 업데이트
function updateRecentWorkTable(data) {
log('테이블 업데이트', data);
const tbody = document.getElementById('recentWorkTable');
if (!Array.isArray(data) || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #666;">조회된 데이터가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = data.map(item => `
<tr>
<td>${item.report_date}</td>
<td>작업자 ${item.worker_id}</td>
<td>프로젝트 ${item.project_id}</td>
<td>작업유형 ${item.work_type_id}</td>
<td>${item.work_hours}시간</td>
<td><span class="status-badge ${getStatusClass(item.work_status_id)}">${getStatusText(item.work_status_id)}</span></td>
</tr>
`).join('');
}
function getStatusClass(statusId) {
switch(statusId) {
case 1: return 'status-completed';
case 2: return 'status-progress';
default: return 'status-pending';
}
}
function getStatusText(statusId) {
switch(statusId) {
case 1: return '완료';
case 2: return '진행중';
default: return '대기';
}
}
// 페이지 초기화
async function initializePage() {
log('페이지 초기화 시작');
try {
await initializeAPI();
initializeCharts();
setToday(); // 오늘 날짜로 초기 설정 및 데이터 로드
log('페이지 초기화 완료');
} catch (error) {
log('페이지 초기화 실패', error);
showError('페이지 초기화에 실패했습니다.', error.message);
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', initializePage);
</script>
</body>
</html>

View File

@@ -1,300 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시스템 관리자 대시보드 - TK Portal</title>
<link rel="stylesheet" href="/css/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/system-dashboard.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script type="module" src="/js/auth-check.js"></script>
</head>
<body>
<div class="main-layout">
<!-- 기존 네비게이션 바 사용 -->
<div id="navbar-container"></div>
<div class="content-wrapper">
<!-- 시스템 관리자 배너 -->
<div class="system-banner">
<div class="banner-content">
<div class="banner-left">
<div class="system-icon">🔧</div>
<div class="banner-text">
<h1>시스템 관리자</h1>
<p>시스템 전반의 설정, 모니터링 및 관리를 담당합니다</p>
</div>
</div>
<div class="banner-right">
<span class="system-badge">SYSTEM</span>
<div class="system-status">
<span class="status-dot online"></span>
<span>시스템 정상</span>
</div>
<div class="quick-actions">
<button class="quick-btn" onclick="window.location.href='/pages/admin/manage-user.html'" title="사용자 관리">
👤
</button>
<button class="quick-btn" onclick="window.location.href='/pages/analysis/work-report-analytics.html'" title="분석 대시보드">
📊
</button>
<button class="quick-btn" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'" title="프로젝트별 작업 시간 분석">
🏗️
</button>
<button class="quick-btn" onclick="refreshSystemStatus()" title="시스템 새로고침">
🔄
</button>
</div>
</div>
</div>
</div>
<!-- 메인 컨텐츠 -->
<main class="main-content">
<!-- 시스템 상태 개요 -->
<section class="system-overview">
<h2><i class="fas fa-tachometer-alt"></i> 시스템 상태</h2>
<div class="status-grid">
<div class="status-card">
<div class="status-info">
<h3>서버 상태</h3>
<p class="status-value online">온라인</p>
<small>마지막 확인: <span id="server-check-time">--</span></small>
</div>
</div>
<div class="status-card">
<div class="status-info">
<h3>데이터베이스</h3>
<p class="status-value online">정상</p>
<small>연결 수: <span id="db-connections">--</span></small>
</div>
</div>
<div class="status-card">
<div class="status-info">
<h3>활성 사용자</h3>
<p class="status-value" id="active-users">--</p>
<small>총 사용자: <span id="total-users">--</span></small>
</div>
</div>
<div class="status-card">
<div class="status-info">
<h3>시스템 알림</h3>
<p class="status-value warning" id="system-alerts">--</p>
<small>미처리 알림</small>
</div>
</div>
</div>
</section>
<!-- 주요 관리 기능 -->
<section class="management-section">
<h2><i class="fas fa-tools"></i> 시스템 관리</h2>
<div class="management-grid">
<!-- 계정 관리 -->
<div class="management-card primary">
<div class="card-header">
<i class="fas fa-user-cog"></i>
<h3>계정 관리</h3>
</div>
<div class="card-content">
<p>사용자 계정 생성, 수정, 삭제 및 권한 관리</p>
<div class="card-actions">
<button class="btn btn-primary" data-action="account-management">
<i class="fas fa-users"></i> 계정 관리
</button>
</div>
</div>
</div>
<!-- 시스템 로그 -->
<div class="management-card">
<div class="card-header">
<i class="fas fa-file-alt"></i>
<h3>시스템 로그</h3>
</div>
<div class="card-content">
<p>로그인 이력, 시스템 활동 및 오류 로그 조회</p>
<div class="card-actions">
<button class="btn btn-secondary" data-action="system-logs">
<i class="fas fa-search"></i> 로그 조회
</button>
</div>
</div>
</div>
<!-- 데이터베이스 관리 -->
<div class="management-card">
<div class="card-header">
<i class="fas fa-database"></i>
<h3>데이터베이스</h3>
</div>
<div class="card-content">
<p>데이터베이스 백업, 복원 및 최적화</p>
<div class="card-actions">
<button class="btn btn-secondary" data-action="database-management">
<i class="fas fa-cog"></i> DB 관리
</button>
</div>
</div>
</div>
<!-- 시스템 설정 -->
<div class="management-card">
<div class="card-header">
<i class="fas fa-sliders-h"></i>
<h3>시스템 설정</h3>
</div>
<div class="card-content">
<p>전역 설정, 보안 정책 및 시스템 매개변수</p>
<div class="card-actions">
<button class="btn btn-secondary" data-action="system-settings">
<i class="fas fa-wrench"></i> 설정
</button>
</div>
</div>
</div>
<!-- 백업 관리 -->
<div class="management-card">
<div class="card-header">
<i class="fas fa-shield-alt"></i>
<h3>백업 관리</h3>
</div>
<div class="card-content">
<p>자동 백업 설정 및 복원 관리</p>
<div class="card-actions">
<button class="btn btn-secondary" data-action="backup-management">
<i class="fas fa-download"></i> 백업
</button>
</div>
</div>
</div>
<!-- 프로젝트별 작업 시간 분석 -->
<div class="management-card primary">
<div class="card-header">
<i class="fas fa-project-diagram"></i>
<h3>프로젝트 작업 분석</h3>
</div>
<div class="card-content">
<p>프로젝트별-작업별 시간 분석 및 에러율 모니터링</p>
<div class="card-actions">
<button class="btn btn-primary" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'">
<i class="fas fa-chart-bar"></i> 분석 보기
</button>
</div>
</div>
</div>
<!-- 모니터링 -->
<div class="management-card">
<div class="card-header">
<i class="fas fa-chart-line"></i>
<h3>시스템 모니터링</h3>
</div>
<div class="card-content">
<p>성능 지표, 리소스 사용량 및 트래픽 분석</p>
<div class="card-actions">
<button class="btn btn-secondary" data-action="monitoring">
<i class="fas fa-eye"></i> 모니터링
</button>
</div>
</div>
</div>
</div>
</section>
<!-- 최근 활동 -->
<section class="recent-activity">
<h2><i class="fas fa-history"></i> 최근 시스템 활동</h2>
<div class="activity-container">
<div class="activity-list" id="recent-activities">
<!-- 동적으로 로드됨 -->
</div>
</div>
</section>
</main>
<!-- 계정 관리 모달 -->
<div id="account-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-user-cog"></i> 계정 관리</h3>
<button class="close-btn" data-action="close-modal">&times;</button>
</div>
<div class="modal-body">
<div id="account-management-content">
<!-- 계정 관리 내용이 여기에 로드됩니다 -->
</div>
</div>
</main>
</div>
</div>
<!-- 시스템 관리자 스크립트 -->
<script>
console.log('🔧 시스템 관리자 대시보드 로드됨');
// 시스템 상태 새로고침 함수
function refreshSystemStatus() {
console.log('🔄 시스템 상태 새로고침 중...');
// 시각적 피드백
const statusDot = document.querySelector('.status-dot');
const refreshBtn = document.querySelector('.quick-btn[title="시스템 새로고침"]');
if (refreshBtn) {
refreshBtn.style.transform = 'rotate(360deg)';
setTimeout(() => {
refreshBtn.style.transform = '';
}, 1000);
}
// 실제 상태 업데이트 (시뮬레이션)
setTimeout(() => {
updateSystemTime();
console.log('✅ 시스템 상태 업데이트 완료');
}, 1000);
}
// 시스템 시간 업데이트
function updateSystemTime() {
const timeElement = document.getElementById('server-check-time');
if (timeElement) {
timeElement.textContent = new Date().toLocaleTimeString('ko-KR');
}
}
// 간단한 테스트 함수
function testClick() {
console.log('🎯 버튼 클릭 테스트 성공!');
alert('버튼이 정상적으로 작동합니다!');
}
// DOM 로드 후 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('📄 시스템 대시보드 DOM 로드 완료');
// 초기 시간 설정
updateSystemTime();
// 주기적 시간 업데이트 (30초마다)
setInterval(updateSystemTime, 30000);
// 계정 관리 버튼 이벤트
const accountBtn = document.querySelector('[data-action="account-management"]');
if (accountBtn) {
accountBtn.addEventListener('click', testClick);
console.log('✅ 계정 관리 버튼 이벤트 설정 완료');
}
console.log('🚀 시스템 관리자 대시보드 초기화 완료');
});
</script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/system-dashboard.js"></script>
</body>
</html>

View File

@@ -1,70 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/user.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- ✅ auth-check를 가장 먼저 로딩 -->
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<!-- ✅ ID는 이미 올바름: navbar-container -->
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<header class="user-header">
<h1>👷 내 작업 정보</h1>
<p id="welcome-message">환영합니다. 개인 작업 포털입니다.</p>
</header>
<main id="user-sections">
<section class="card">
<h2>📅 오늘의 작업 일정</h2>
<div id="today-schedule">
<p>작업 일정을 불러오는 중...</p>
</div>
</section>
<section class="card">
<h2>🔧 빠른 메뉴</h2>
<div class="quick-menu">
<a href="/pages/work-reports/create.html" class="menu-item">
<span class="icon">📝</span>
<span>작업 일보 작성</span>
</a>
<a href="/pages/issue-reports/daily-issue.html" class="menu-item">
<span class="icon">📊</span>
<span>일일 이슈 보고</span>
</a>
<a href="/pages/common/my-attendance.html" class="menu-item">
<span class="icon">📋</span>
<span>출근부 확인</span>
</a>
</div>
</section>
<section class="card">
<h2>📈 내 작업 현황</h2>
<div id="work-stats">
<p>통계를 불러오는 중...</p>
</div>
</section>
</main>
</div>
</div>
</div>
<!-- ✅ 스크립트 로딩 순서 최적화 -->
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/load-sections.js"></script>
<script type="module" src="/js/user-dashboard.js"></script>
</body>
</html>

View File

@@ -1,215 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/management-dashboard.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script type="module" src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout-with-navbar">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<div class="content-wrapper">
<div class="dashboard-container">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-btn">
← 뒤로가기
</a>
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>📊 관리자 대시보드</h1>
<p class="subtitle">팀 전체의 일일 작업 입력 현황을 한눈에 확인하세요</p>
</div>
<!-- 권한 체크 메시지 -->
<div id="permission-check-message" class="message warning" style="display: none;">
⚠️ 권한을 확인하는 중입니다...
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 날짜 선택 섹션 -->
<div class="date-selection-card">
<div class="date-selection-header">
<h3>📅 조회 날짜 선택</h3>
<button class="refresh-btn" id="refreshBtn">
🔄 새로고침
</button>
</div>
<div class="date-selection-body">
<input type="date" id="selectedDate" class="date-input">
<button class="btn btn-primary" id="loadDataBtn">📊 현황 조회</button>
</div>
</div>
<!-- 요약 대시보드 -->
<div id="summarySection" class="summary-section" style="display: none;">
<h3>📈 전체 현황 요약</h3>
<div class="summary-grid">
<div class="summary-card total-workers">
<div class="summary-icon">👥</div>
<div class="summary-content">
<div class="summary-number" id="totalWorkers">0</div>
<div class="summary-label">전체 작업자</div>
</div>
</div>
<div class="summary-card completed-workers">
<div class="summary-icon"></div>
<div class="summary-content">
<div class="summary-number" id="completedWorkers">0</div>
<div class="summary-label">입력 완료</div>
</div>
</div>
<div class="summary-card missing-workers">
<div class="summary-icon"></div>
<div class="summary-content">
<div class="summary-number" id="missingWorkers">0</div>
<div class="summary-label">입력 미완료</div>
</div>
</div>
<div class="summary-card total-hours">
<div class="summary-icon"></div>
<div class="summary-content">
<div class="summary-number" id="totalHours">0</div>
<div class="summary-label">총 작업시간</div>
</div>
</div>
<div class="summary-card total-entries">
<div class="summary-icon">📝</div>
<div class="summary-content">
<div class="summary-number" id="totalEntries">0</div>
<div class="summary-label">총 작업항목</div>
</div>
</div>
<div class="summary-card error-count">
<div class="summary-icon">⚠️</div>
<div class="summary-content">
<div class="summary-number" id="errorCount">0</div>
<div class="summary-label">에러 발생</div>
</div>
</div>
</div>
</div>
<!-- 필터 및 액션 바 -->
<div id="actionBar" class="action-bar" style="display: none;">
<div class="filter-section">
<label class="filter-checkbox">
<input type="checkbox" id="showOnlyMissing">
<span class="checkmark"></span>
미입력자만 보기
</label>
</div>
<div class="action-section">
<button class="btn btn-secondary" id="exportBtn">
📥 엑셀 다운로드
</button>
</div>
</div>
<!-- 작업자 현황 테이블 -->
<div id="workersSection" class="workers-section" style="display: none;">
<div class="section-header">
<h3>👥 작업자별 입력 현황</h3>
<div class="legend">
<span class="legend-item completed">✅ 입력완료</span>
<span class="legend-item missing">❌ 미입력</span>
<span class="legend-item partial">⚠️ 부분입력</span>
</div>
</div>
<div class="table-container">
<table class="workers-table" id="workersTable">
<thead>
<tr>
<th>작업자</th>
<th>상태</th>
<th>총시간</th>
<th>항목수</th>
<th>작업유형</th>
<th>프로젝트</th>
<th>기여자</th>
<th>최근업데이트</th>
<th>상세</th>
</tr>
</thead>
<tbody id="workersTableBody">
<!-- 작업자 데이터가 여기에 동적으로 추가됩니다 -->
</tbody>
</table>
</div>
</div>
<!-- 로딩 스피너 -->
<div id="loadingSpinner" class="loading-spinner" style="display: none;">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</div>
<!-- 데이터 없음 메시지 -->
<div id="noDataMessage" class="no-data-message" style="display: none;">
<div class="no-data-icon">📭</div>
<h3>표시할 데이터가 없습니다</h3>
<p>선택한 날짜에 입력된 작업 데이터가 없거나<br>조회 권한이 없습니다.</p>
</div>
<!-- 사용법 안내 -->
<div class="guide-section">
<h3>📖 사용 가이드</h3>
<div class="guide-grid">
<div class="guide-item">
<div class="guide-icon">📅</div>
<strong>날짜 선택</strong><br>
확인하고 싶은 날짜를 선택하세요
</div>
<div class="guide-item">
<div class="guide-icon">📊</div>
<strong>현황 확인</strong><br>
팀 전체의 입력 현황을 확인하세요
</div>
<div class="guide-item">
<div class="guide-icon">🔍</div>
<strong>필터링</strong><br>
미입력자만 따로 확인할 수 있습니다
</div>
<div class="guide-item">
<div class="guide-icon">📥</div>
<strong>내보내기</strong><br>
엑셀로 데이터를 다운로드하세요
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 작업자 상세 모달 -->
<div id="workerDetailModal" class="worker-detail-modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalWorkerName">작업자 상세</h3>
<button class="close-modal-btn" onclick="closeWorkerDetailModal()">×</button>
</div>
<div class="modal-body" id="modalWorkerDetails">
<!-- 작업자 상세 정보가 여기에 표시됩니다 -->
</div>
</div>
</div>
<!-- 스크립트 -->
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/management-dashboard.js"></script>
</body>
</html>

View File

@@ -1,151 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/attendance.css">
<link rel="stylesheet" href="/css/my-attendance.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<!-- 페이지 헤더 -->
<header class="page-header">
<div class="page-title-section">
<h1 class="page-title">
<span class="title-icon">📊</span>
나의 출근 현황
</h1>
<p class="page-description">나의 출근 기록과 근태 현황을 확인할 수 있습니다</p>
</div>
</header>
<!-- 필터 섹션 -->
<div class="controls">
<label for="yearSelect">연도:</label>
<select id="yearSelect"></select>
<label for="monthSelect">월:</label>
<select id="monthSelect"></select>
<button id="loadAttendance" class="btn-primary">조회</button>
</div>
<!-- 통계 카드 섹션 -->
<section class="stats-section">
<div class="stat-card">
<div class="stat-icon">⏱️</div>
<div class="stat-info">
<div class="stat-value" id="totalHours">-</div>
<div class="stat-label">총 근무시간</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📅</div>
<div class="stat-info">
<div class="stat-value" id="totalDays">-</div>
<div class="stat-label">근무일수</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🌴</div>
<div class="stat-info">
<div class="stat-value" id="remainingLeave">-</div>
<div class="stat-label">잔여 연차</div>
</div>
</div>
</section>
<!-- 탭 섹션 -->
<div class="tab-container">
<button class="tab-btn active" data-tab="list">
<span class="tab-icon">📋</span> 리스트 보기
</button>
<button class="tab-btn" data-tab="calendar">
<span class="tab-icon">📅</span> 달력 보기
</button>
</div>
<!-- 리스트 뷰 -->
<div id="listView" class="tab-content active">
<div id="attendanceTableContainer">
<table id="attendanceTable">
<thead>
<tr>
<th>날짜</th>
<th>요일</th>
<th>출근시간</th>
<th>퇴근시간</th>
<th>근무시간</th>
<th>상태</th>
<th>비고</th>
</tr>
</thead>
<tbody id="attendanceTableBody">
<tr>
<td colspan="7" class="loading-cell">데이터를 불러오는 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 달력 뷰 -->
<div id="calendarView" class="tab-content">
<div id="calendarContainer">
<div class="calendar-header">
<button id="prevMonth" class="calendar-nav-btn"></button>
<h3 id="calendarTitle">2026년 1월</h3>
<button id="nextMonth" class="calendar-nav-btn"></button>
</div>
<div id="calendarGrid" class="calendar-grid">
<!-- 달력이 여기에 동적으로 생성됩니다 -->
</div>
<div class="calendar-legend">
<span class="legend-item"><span class="legend-dot normal"></span> 정상</span>
<span class="legend-item"><span class="legend-dot late"></span> 지각</span>
<span class="legend-item"><span class="legend-dot early"></span> 조퇴</span>
<span class="legend-item"><span class="legend-dot absent"></span> 결근</span>
<span class="legend-item"><span class="legend-dot vacation"></span> 휴가</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 일별 상세 모달 -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">출근 상세 정보</h2>
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
</div>
<div class="modal-body" id="modalBody">
<!-- 상세 정보가 여기에 동적으로 생성됩니다 -->
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeDetailModal()">닫기</button>
</div>
</div>
</div>
<!-- 스크립트 로딩 -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script src="/js/my-attendance.js"></script>
</body>
</html>

View File

@@ -1,111 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/my-dashboard.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script type="module" src="/js/api-config.js?v=3"></script>
</head>
<body>
<div id="navbar-container"></div>
<main class="dashboard-container">
<a href="javascript:history.back()" class="back-button">
← 뒤로가기
</a>
<header class="page-header">
<h1>📊 나의 대시보드</h1>
<p>안녕하세요, <span id="userName"></span>님!</p>
</header>
<!-- 사용자 정보 카드 -->
<section class="user-info-card">
<div class="info-row">
<div class="info-item">
<span class="label">부서:</span>
<span id="department">-</span>
</div>
<div class="info-item">
<span class="label">직책:</span>
<span id="jobType">-</span>
</div>
<div class="info-item">
<span class="label">입사일:</span>
<span id="hireDate">-</span>
</div>
</div>
</section>
<!-- 연차 정보 위젯 -->
<section class="vacation-widget">
<h2>💼 연차 정보</h2>
<div class="vacation-summary">
<div class="stat">
<span class="label">총 연차</span>
<span class="value" id="totalLeave">15</span>
</div>
<div class="stat">
<span class="label">사용</span>
<span class="value used" id="usedLeave">0</span>
</div>
<div class="stat">
<span class="label">잔여</span>
<span class="value remaining" id="remainingLeave">15</span>
</div>
</div>
<div class="progress-bar">
<div class="progress" id="vacationProgress" style="width: 0%"></div>
</div>
</section>
<!-- 월별 출근 캘린더 -->
<section class="calendar-section">
<h2>📅 이번 달 출근 현황</h2>
<div class="calendar-controls">
<button onclick="previousMonth()"></button>
<span id="currentMonth">2026년 1월</span>
<button onclick="nextMonth()"></button>
</div>
<div id="calendar" class="calendar-grid">
<!-- 동적 생성 -->
</div>
<div class="calendar-legend">
<span><span class="dot normal"></span> 정상</span>
<span><span class="dot late"></span> 지각</span>
<span><span class="dot vacation"></span> 휴가</span>
<span><span class="dot absent"></span> 결근</span>
</div>
</section>
<!-- 근무 시간 통계 -->
<section class="work-hours-stats">
<h2>⏱️ 근무 시간 통계</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="label">이번 달</span>
<span class="value" id="monthHours">0</span>시간
</div>
<div class="stat-card">
<span class="label">근무 일수</span>
<span class="value" id="workDays">0</span>
</div>
</div>
</section>
<!-- 최근 작업 보고서 -->
<section class="recent-reports">
<h2>📝 최근 작업 보고서</h2>
<div id="recentReportsList">
<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>
</div>
</section>
</main>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/my-dashboard.js?v=1"></script>
</body>
</html>

View File

@@ -1,304 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
<style>
.period-selector {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.period-selector label {
font-weight: bold;
margin-right: 5px;
}
.period-selector input[type="date"] {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.analysis-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #ddd;
}
.tab-button {
padding: 10px 20px;
border: none;
background: #f5f5f5;
cursor: pointer;
border-radius: 4px 4px 0 0;
font-weight: bold;
}
.tab-button.active {
background: #007bff;
color: white;
}
.analysis-content {
display: none;
}
.analysis-content.active {
display: block;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.summary-card {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
text-align: center;
border-left: 4px solid #007bff;
}
.summary-card h4 {
margin: 0;
color: #333;
font-size: 14px;
}
.summary-card .value {
font-size: 24px;
font-weight: bold;
color: #007bff;
margin: 5px 0;
}
.data-table th {
background: #f8f9fa;
font-weight: bold;
}
.data-table .project-col {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-table .worker-col {
min-width: 80px;
}
.data-table .task-col {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-table .hours-col {
text-align: right;
font-weight: bold;
color: #007bff;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.no-data {
text-align: center;
padding: 40px;
color: #999;
}
.filter-section {
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.filter-row {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.filter-row select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 120px;
}
</style>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>📊 프로젝트 투입 분석</h1>
<p class="subtitle">기간별 프로젝트/작업자/작업 투입 현황을 분석합니다.</p>
</div>
<div class="card">
<h3>📅 분석 기간 설정</h3>
<div class="period-selector">
<label for="startDate">시작일:</label>
<input type="date" id="startDate">
<label for="endDate">종료일:</label>
<input type="date" id="endDate">
<button id="analyzeBtn" class="btn btn-primary">분석 실행</button>
<button id="quickMonth" class="btn btn-secondary">이번 달</button>
<button id="quickLastMonth" class="btn btn-secondary">지난 달</button>
</div>
</div>
<div class="card" id="analysisCard" style="display: none;">
<div class="summary-cards" id="summaryCards">
<!-- 요약 정보가 여기에 동적으로 추가됩니다 -->
</div>
<div class="filter-section">
<h4>🔍 필터 옵션</h4>
<div class="filter-row">
<label>프로젝트:</label>
<select id="projectFilter">
<option value="">전체</option>
</select>
<label>작업자:</label>
<select id="workerFilter">
<option value="">전체</option>
</select>
<label>작업 분류:</label>
<select id="taskFilter">
<option value="">전체</option>
</select>
<button id="applyFilter" class="btn btn-primary">필터 적용</button>
</div>
</div>
<div class="analysis-tabs">
<button class="tab-button active" data-tab="project">프로젝트별</button>
<button class="tab-button" data-tab="worker">작업자별</button>
<button class="tab-button" data-tab="task">작업별</button>
<button class="tab-button" data-tab="detail">상세내역</button>
</div>
<div id="projectTab" class="analysis-content active">
<h4>📋 프로젝트별 투입 현황</h4>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">순번</th>
<th>프로젝트명</th>
<th width="100">투입 시간</th>
<th width="80">비율</th>
<th width="100">참여 인원</th>
</tr>
</thead>
<tbody id="projectTableBody">
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
</tbody>
</table>
</div>
</div>
<div id="workerTab" class="analysis-content">
<h4>👥 작업자별 투입 현황</h4>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">순번</th>
<th>작업자명</th>
<th width="100">투입 시간</th>
<th width="80">비율</th>
<th width="100">참여 프로젝트</th>
</tr>
</thead>
<tbody id="workerTableBody">
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
</tbody>
</table>
</div>
</div>
<div id="taskTab" class="analysis-content">
<h4>⚙️ 작업별 투입 현황</h4>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">순번</th>
<th>작업 분류</th>
<th width="100">투입 시간</th>
<th width="80">비율</th>
<th width="100">참여 인원</th>
</tr>
</thead>
<tbody id="taskTableBody">
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
</tbody>
</table>
</div>
</div>
<div id="detailTab" class="analysis-content">
<h4>📄 상세 내역</h4>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">순번</th>
<th width="100">날짜</th>
<th>프로젝트</th>
<th>작업자</th>
<th>작업 분류</th>
<th width="80">시간</th>
<th>메모</th>
</tr>
</thead>
<tbody id="detailTableBody">
<tr><td colspan="7" class="no-data">분석을 실행해주세요</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/project-analysis.js"></script>
</body>
</html>

View File

@@ -1,672 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트별 작업 시간 분석</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.project-card {
transition: all 0.3s ease;
border-left: 4px solid #3B82F6;
}
.project-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.work-type-row {
transition: background-color 0.2s ease;
}
.work-type-row:hover {
background-color: #f8fafc;
}
.error-high { border-left-color: #EF4444; }
.error-medium { border-left-color: #F59E0B; }
.error-low { border-left-color: #10B981; }
.progress-bar {
transition: width 0.8s ease-in-out;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card.error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stat-card.regular {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stat-card.total {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 헤더 -->
<header class="bg-white shadow-lg border-b">
<div class="container mx-auto px-6 py-4">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">🏗️ 프로젝트별 작업 시간 분석</h1>
<p class="text-gray-600 mt-1">총시간 · 정규시간 · 에러시간 상세 분석</p>
<div class="mt-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
🔧 시스템 관리자 전용
</span>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-gray-500">
<span id="last-updated">마지막 업데이트: -</span>
</div>
<button id="refresh-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors">
🔄 새로고침
</button>
<button onclick="history.back()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
← 뒤로가기
</button>
</div>
</div>
</div>
</header>
<!-- 날짜 선택 -->
<div class="container mx-auto px-6 py-6">
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center space-x-2">
<label for="start-date" class="text-sm font-medium text-gray-700">시작일:</label>
<input type="date" id="start-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex items-center space-x-2">
<label for="end-date" class="text-sm font-medium text-gray-700">종료일:</label>
<input type="date" id="end-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<button id="analyze-btn" class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md text-sm font-medium transition-colors">
📊 분석 실행
</button>
<div class="flex items-center space-x-2">
<button id="preset-week" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1주일</button>
<button id="preset-month" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1개월</button>
<button id="preset-august" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">8월 전체</button>
</div>
</div>
</div>
</div>
<!-- 로딩 화면 -->
<div id="loading" class="hidden fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50">
<div class="text-center">
<div class="loading-spinner mx-auto mb-4"></div>
<p class="text-gray-600">데이터를 분석하는 중...</p>
</div>
</div>
<!-- 메인 컨테이너 -->
<div class="container mx-auto px-6 pb-8" id="main-content">
<!-- 전체 요약 통계 -->
<div id="summary-stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 hidden">
<div class="stat-card total rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="total-hours">-</div>
<div class="text-sm opacity-90">총 작업시간</div>
</div>
<div class="stat-card regular rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="regular-hours">-</div>
<div class="text-sm opacity-90">정규 시간</div>
</div>
<div class="stat-card error rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-hours">-</div>
<div class="text-sm opacity-90">에러 시간</div>
</div>
<div class="stat-card rounded-lg shadow-md p-6 text-center">
<div class="text-3xl font-bold" id="error-rate">-</div>
<div class="text-sm opacity-90">전체 에러율</div>
</div>
</div>
<!-- 차트 섹션 -->
<div id="charts-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 hidden">
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">프로젝트별 시간 분포</h3>
<canvas id="project-chart"></canvas>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-lg font-semibold mb-4">에러율 분석</h3>
<canvas id="error-chart"></canvas>
</div>
</div>
<!-- 프로젝트별 상세 데이터 -->
<div id="projects-container" class="space-y-6">
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 데이터 없음 메시지 -->
<div id="no-data" class="hidden text-center py-12">
<div class="text-gray-400 text-6xl mb-4">📊</div>
<h3 class="text-xl font-semibold text-gray-600 mb-2">분석할 데이터가 없습니다</h3>
<p class="text-gray-500">날짜 범위를 선택하고 분석을 실행해주세요.</p>
</div>
</div>
<!-- 기존 인증 시스템 사용 -->
<script>
// 전역 변수
let analysisData = null;
let charts = {};
// API 호출 함수 (토큰 포함)
async function apiCall(endpoint, options = {}) {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
const response = await fetch(`http://localhost:20005/api${endpoint}`, {
...options,
headers: {
...defaultHeaders,
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
}
// DOM 요소들
const elements = {
startDate: document.getElementById('start-date'),
endDate: document.getElementById('end-date'),
analyzeBtn: document.getElementById('analyze-btn'),
refreshBtn: document.getElementById('refresh-btn'),
loading: document.getElementById('loading'),
mainContent: document.getElementById('main-content'),
summaryStats: document.getElementById('summary-stats'),
chartsSection: document.getElementById('charts-section'),
projectsContainer: document.getElementById('projects-container'),
noData: document.getElementById('no-data'),
lastUpdated: document.getElementById('last-updated'),
presetWeek: document.getElementById('preset-week'),
presetMonth: document.getElementById('preset-month'),
presetAugust: document.getElementById('preset-august')
};
// 초기화
document.addEventListener('DOMContentLoaded', function() {
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
// 사용자 정보 및 권한 확인
const userStr = localStorage.getItem('user');
if (!userStr) {
alert('사용자 정보를 찾을 수 없습니다.');
window.location.href = '/index.html';
return;
}
const user = JSON.parse(userStr);
// 시스템 권한 확인 (system 역할만 접근 가능)
if (user.role !== 'system') {
alert('시스템 관리자 권한이 필요합니다.');
window.location.href = '/pages/dashboard/user.html'; // 일반 사용자 대시보드로 리디렉션
return;
}
console.log('시스템 관리자 인증 완료:', user.name || user.username);
initializeDateInputs();
bindEventListeners();
// 8월 전체를 기본값으로 설정
setDatePreset('august');
});
// 날짜 입력 초기화
function initializeDateInputs() {
const today = new Date();
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
elements.endDate.value = today.toISOString().split('T')[0];
elements.startDate.value = oneMonthAgo.toISOString().split('T')[0];
}
// 이벤트 리스너 바인딩
function bindEventListeners() {
elements.analyzeBtn.addEventListener('click', performAnalysis);
elements.refreshBtn.addEventListener('click', performAnalysis);
// 날짜 프리셋 버튼들
elements.presetWeek.addEventListener('click', () => setDatePreset('week'));
elements.presetMonth.addEventListener('click', () => setDatePreset('month'));
elements.presetAugust.addEventListener('click', () => setDatePreset('august'));
// Enter 키로 분석 실행
elements.startDate.addEventListener('keypress', handleEnterKey);
elements.endDate.addEventListener('keypress', handleEnterKey);
}
// Enter 키 처리
function handleEnterKey(event) {
if (event.key === 'Enter') {
performAnalysis();
}
}
// 날짜 프리셋 설정
function setDatePreset(preset) {
const today = new Date();
let startDate, endDate;
switch (preset) {
case 'week':
startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7);
endDate = today;
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
endDate = today;
break;
case 'august':
startDate = new Date(2025, 7, 1); // 2025년 8월 1일
endDate = new Date(2025, 7, 31); // 2025년 8월 31일
break;
}
elements.startDate.value = startDate.toISOString().split('T')[0];
elements.endDate.value = endDate.toISOString().split('T')[0];
}
// 분석 실행
async function performAnalysis() {
const startDate = elements.startDate.value;
const endDate = elements.endDate.value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
if (new Date(startDate) > new Date(endDate)) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
// 로그인 확인
const token = localStorage.getItem('token');
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
return;
}
showLoading(true);
try {
const response = await apiCall(`/work-analysis/project-worktype-analysis?start=${startDate}&end=${endDate}`);
const result = await response.json();
if (result.success) {
analysisData = result.data;
renderAnalysisResults();
updateLastUpdated();
} else {
throw new Error(result.error || '데이터 조회에 실패했습니다.');
}
} catch (error) {
console.error('분석 실패:', error);
showError(`분석 실패: ${error.message}`);
} finally {
showLoading(false);
}
}
// 로딩 표시/숨김
function showLoading(show) {
elements.loading.classList.toggle('hidden', !show);
}
// 에러 표시
function showError(message) {
alert(message);
}
// 분석 결과 렌더링
function renderAnalysisResults() {
if (!analysisData || !analysisData.projects || analysisData.projects.length === 0) {
showNoData();
return;
}
hideNoData();
renderSummaryStats();
renderCharts();
renderProjectCards();
}
// 데이터 없음 표시
function showNoData() {
elements.summaryStats.classList.add('hidden');
elements.chartsSection.classList.add('hidden');
elements.projectsContainer.innerHTML = '';
elements.noData.classList.remove('hidden');
}
// 데이터 없음 숨김
function hideNoData() {
elements.noData.classList.add('hidden');
elements.summaryStats.classList.remove('hidden');
elements.chartsSection.classList.remove('hidden');
}
// 요약 통계 렌더링
function renderSummaryStats() {
const summary = analysisData.summary;
document.getElementById('total-hours').textContent = `${(summary.grand_total_hours || 0).toFixed(1)}h`;
document.getElementById('regular-hours').textContent = `${(summary.grand_regular_hours || 0).toFixed(1)}h`;
document.getElementById('error-hours').textContent = `${(summary.grand_error_hours || 0).toFixed(1)}h`;
document.getElementById('error-rate').textContent = `${summary.grand_error_rate || 0}%`;
}
// 차트 렌더링
function renderCharts() {
renderProjectChart();
renderErrorChart();
}
// 프로젝트별 시간 분포 차트
function renderProjectChart() {
const ctx = document.getElementById('project-chart').getContext('2d');
// 기존 차트 삭제
if (charts.project) {
charts.project.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const totalHours = projects.map(p => p.total_project_hours || 0);
const regularHours = projects.map(p => p.total_regular_hours || 0);
const errorHours = projects.map(p => p.total_error_hours || 0);
charts.project = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: '정규 시간',
data: regularHours,
backgroundColor: '#10B981',
borderColor: '#059669',
borderWidth: 1
},
{
label: '에러 시간',
data: errorHours,
backgroundColor: '#EF4444',
borderColor: '#DC2626',
borderWidth: 1
}
]
},
options: {
responsive: true,
scales: {
x: {
stacked: true,
ticks: {
maxRotation: 45
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: '시간 (h)'
}
}
},
plugins: {
legend: {
position: 'top'
},
tooltip: {
callbacks: {
footer: function(tooltipItems) {
const index = tooltipItems[0].dataIndex;
const total = totalHours[index];
return `총 시간: ${total.toFixed(1)}h`;
}
}
}
}
}
});
}
// 에러율 분석 차트
function renderErrorChart() {
const ctx = document.getElementById('error-chart').getContext('2d');
// 기존 차트 삭제
if (charts.error) {
charts.error.destroy();
}
const projects = analysisData.projects;
const labels = projects.map(p => p.project_name || 'Unknown Project');
const errorRates = projects.map(p => p.project_error_rate || 0);
// 에러율에 따른 색상 결정
const colors = errorRates.map(rate => {
if (rate >= 10) return '#EF4444'; // 높음 (빨강)
if (rate >= 5) return '#F59E0B'; // 중간 (주황)
return '#10B981'; // 낮음 (초록)
});
charts.error = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: errorRates,
backgroundColor: colors,
borderColor: '#ffffff',
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.label}: ${context.parsed}%`;
}
}
}
}
}
});
}
// 프로젝트 카드 렌더링
function renderProjectCards() {
const container = elements.projectsContainer;
container.innerHTML = '';
analysisData.projects.forEach(project => {
const card = createProjectCard(project);
container.appendChild(card);
});
}
// 프로젝트 카드 생성
function createProjectCard(project) {
const card = document.createElement('div');
// 에러율에 따른 카드 스타일 결정
let errorClass = 'error-low';
const errorRate = project.project_error_rate || 0;
if (errorRate >= 10) errorClass = 'error-high';
else if (errorRate >= 5) errorClass = 'error-medium';
card.className = `project-card ${errorClass} bg-white rounded-lg shadow-md p-6`;
// 프로젝트 헤더
const header = `
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-xl font-bold text-gray-800">${project.project_name || 'Unknown Project'}</h3>
<p class="text-sm text-gray-600">Job No: ${project.job_no || 'N/A'}</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-blue-600">${(project.total_project_hours || 0).toFixed(1)}h</div>
<div class="text-sm text-gray-500">총 시간</div>
</div>
</div>
`;
// 프로젝트 요약 통계
const summary = `
<div class="grid grid-cols-3 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
<div class="text-center">
<div class="text-lg font-semibold text-green-600">${(project.total_regular_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">정규 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-red-600">${(project.total_error_hours || 0).toFixed(1)}h</div>
<div class="text-xs text-gray-600">에러 시간</div>
</div>
<div class="text-center">
<div class="text-lg font-semibold text-purple-600">${errorRate}%</div>
<div class="text-xs text-gray-600">에러율</div>
</div>
</div>
`;
// 작업 유형별 테이블
let workTypesTable = `
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">작업 유형</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">총 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">정규 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러 시간</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러율</th>
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">진행률</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
`;
if (project.work_types && project.work_types.length > 0) {
project.work_types.forEach(workType => {
const totalHours = workType.total_hours || 0;
const regularHours = workType.regular_hours || 0;
const errorHours = workType.error_hours || 0;
const errorRatePercent = workType.error_rate_percent || 0;
const regularPercent = totalHours > 0 ? (regularHours / totalHours) * 100 : 0;
const errorPercent = totalHours > 0 ? (errorHours / totalHours) * 100 : 0;
workTypesTable += `
<tr class="work-type-row">
<td class="px-4 py-3 text-sm font-medium text-gray-900">${workType.work_type_name || 'Unknown'}</td>
<td class="px-4 py-3 text-sm text-right font-semibold">${totalHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-green-600">${regularHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right text-red-600">${errorHours.toFixed(1)}h</td>
<td class="px-4 py-3 text-sm text-right font-medium ${errorRatePercent >= 10 ? 'text-red-600' : errorRatePercent >= 5 ? 'text-yellow-600' : 'text-green-600'}">${errorRatePercent}%</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full progress-bar" style="width: ${regularPercent}%"></div>
</div>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div class="bg-red-500 h-2 rounded-full progress-bar" style="width: ${errorPercent}%"></div>
</div>
</div>
</td>
</tr>
`;
});
} else {
workTypesTable += `
<tr>
<td colspan="6" class="px-4 py-3 text-center text-gray-500">작업 유형 데이터가 없습니다.</td>
</tr>
`;
}
workTypesTable += `
</tbody>
</table>
</div>
`;
card.innerHTML = header + summary + workTypesTable;
return card;
}
// 마지막 업데이트 시간 갱신
function updateLastUpdated() {
const now = new Date();
const timeString = now.toLocaleString('ko-KR');
elements.lastUpdated.textContent = `마지막 업데이트: ${timeString}`;
}
// 유틸리티 함수들
function formatNumber(num) {
return new Intl.NumberFormat('ko-KR').format(num);
}
function formatHours(hours) {
return `${hours.toFixed(1)}시간`;
}
</script>
</body>
</html>

View File

@@ -1,723 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
<style>
/* 검토 페이지 전용 스타일 */
.review-container {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 350px;
gap: 24px;
min-height: calc(100vh - 200px);
}
.main-content {
display: flex;
flex-direction: column;
gap: 24px;
}
/* 상단 대시보드 */
.dashboard-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.dashboard-card {
background: white;
padding: 24px;
border-radius: 12px;
border: 1px solid #e1e5e9;
text-align: center;
transition: transform 0.2s ease;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.dashboard-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.dashboard-label {
color: #666;
font-size: 14px;
font-weight: 500;
}
.dashboard-card.total .dashboard-number { color: #007bff; }
.dashboard-card.error .dashboard-number { color: #dc3545; }
.dashboard-card.warning .dashboard-number { color: #ffc107; }
.dashboard-card.missing .dashboard-number { color: #6c757d; }
/* 필터 섹션 */
.filter-section {
background: white;
padding: 24px;
border-radius: 12px;
border: 1px solid #e1e5e9;
}
.filter-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
align-items: end;
}
.filter-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #555;
}
.filter-input {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 14px;
}
.filter-input:focus {
outline: none;
border-color: #007bff;
}
.filter-btn {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.filter-btn:hover {
background: #0056b3;
}
/* 알림 영역 */
.alerts-section {
background: white;
border-radius: 12px;
border: 1px solid #e1e5e9;
overflow: hidden;
}
.alerts-header {
background: #f8f9fa;
padding: 16px 24px;
border-bottom: 1px solid #e1e5e9;
font-weight: 600;
color: #333;
}
.alert-item {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.alert-item:hover {
background: #f8f9fa;
}
.alert-item:last-child {
border-bottom: none;
}
.alert-type {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 12px;
}
.alert-type.error {
background: #f8d7da;
color: #721c24;
}
.alert-type.warning {
background: #fff3cd;
color: #856404;
}
.alert-type.missing {
background: #d1ecf1;
color: #0c5460;
}
.alert-type.pending {
background: #e2e3e5;
color: #383d41;
}
.alert-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-text {
flex: 1;
}
.alert-time {
color: #666;
font-size: 12px;
}
/* 메인 테이블 */
.table-section {
background: white;
border-radius: 12px;
border: 1px solid #e1e5e9;
overflow: hidden;
}
.table-header {
background: #f8f9fa;
padding: 16px 24px;
border-bottom: 1px solid #e1e5e9;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-weight: 600;
color: #333;
}
.table-actions {
display: flex;
gap: 12px;
}
.action-btn {
padding: 8px 16px;
border: 1px solid #dee2e6;
background: white;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
border-color: #007bff;
color: #007bff;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
background: #f8f9fa;
padding: 16px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #e1e5e9;
font-size: 14px;
}
.data-table td {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.data-table tr {
transition: background 0.2s;
cursor: pointer;
}
.data-table tr:hover {
background: #f8f9fa;
}
.data-table tr.selected {
background: #e7f3ff;
border-left: 4px solid #007bff;
}
/* 상태 표시 */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.status-badge.normal {
background: #d4edda;
color: #155724;
}
.status-badge.error {
background: #f8d7da;
color: #721c24;
}
.row-normal { background: #fff; }
.row-warning { background: #fffbf0; border-left: 4px solid #ffc107; }
.row-error { background: #fef5f5; border-left: 4px solid #dc3545; }
.row-missing { background: #f0f8ff; border-left: 4px solid #6c757d; }
.row-reviewed { background: #f0f9ff; border-left: 4px solid #28a745; }
/* 새로운 배지 스타일 */
.attendance-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.attendance-badge.NORMAL {
background: #e3f2fd;
color: #1565c0;
}
.attendance-badge.HALF_DAY {
background: #fff3e0;
color: #ef6c00;
}
.attendance-badge.HALF_HALF_DAY {
background: #f3e5f5;
color: #7b1fa2;
}
.attendance-badge.EARLY_LEAVE {
background: #ffebee;
color: #c62828;
}
.hours-status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.hours-status-badge.NORMAL {
background: #d4edda;
color: #155724;
}
.hours-status-badge.UNDER {
background: #fff3cd;
color: #856404;
}
.hours-status-badge.OVER {
background: #f8d7da;
color: #721c24;
}
.review-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.review-badge.reviewed {
background: #d4edda;
color: #155724;
}
.review-badge.pending {
background: #ffeaa7;
color: #856404;
}
.review-complete-btn {
background: #28a745;
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.review-complete-btn:hover {
background: #1e7e34;
transform: translateY(-1px);
}
/* 우측 수정 패널 */
.edit-panel {
background: white;
border-radius: 12px;
border: 1px solid #e1e5e9;
position: sticky;
top: 24px;
height: fit-content;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
.panel-header {
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e1e5e9;
}
.panel-title {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.panel-subtitle {
color: #666;
font-size: 14px;
}
.panel-content {
padding: 24px;
}
.panel-empty {
text-align: center;
color: #999;
padding: 60px 20px;
}
.panel-empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #555;
font-size: 14px;
}
.form-input {
width: 100%;
padding: 12px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: #007bff;
}
.panel-actions {
padding: 20px;
border-top: 1px solid #e1e5e9;
background: #f8f9fa;
display: flex;
gap: 12px;
}
.panel-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.panel-btn.save {
background: #28a745;
color: white;
}
.panel-btn.save:hover {
background: #1e7e34;
}
.panel-btn.delete {
background: #dc3545;
color: white;
}
.panel-btn.delete:hover {
background: #c82333;
}
.panel-btn.cancel {
background: #6c757d;
color: white;
}
.panel-btn.cancel:hover {
background: #545b62;
}
/* 로딩 및 메시지 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-spinner {
background: white;
padding: 40px;
border-radius: 12px;
text-align: center;
}
.message {
padding: 16px 24px;
border-radius: 8px;
margin-bottom: 24px;
font-weight: 500;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.message.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
/* 반응형 */
@media (max-width: 1200px) {
.review-container {
grid-template-columns: 1fr;
gap: 16px;
}
.edit-panel {
position: relative;
top: 0;
max-height: none;
}
}
@media (max-width: 768px) {
.dashboard-section {
grid-template-columns: repeat(2, 1fr);
}
.filter-row {
grid-template-columns: 1fr;
}
.table-section {
overflow-x: auto;
}
.data-table {
min-width: 800px;
}
}
</style>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>🔍 작업보고서 검토</h1>
<p class="subtitle">전체 현황을 파악하고 이상 사항을 빠르게 처리하세요.</p>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<div class="review-container">
<!-- 메인 콘텐츠 -->
<div class="main-content">
<!-- 상단 대시보드 -->
<div class="dashboard-section">
<div class="dashboard-card total">
<div class="dashboard-number" id="totalReports">-</div>
<div class="dashboard-label">총 보고서</div>
</div>
<div class="dashboard-card error">
<div class="dashboard-number" id="errorReports">-</div>
<div class="dashboard-label">에러 발생</div>
</div>
<div class="dashboard-card warning">
<div class="dashboard-number" id="warningReports">-</div>
<div class="dashboard-label">주의 필요</div>
</div>
<div class="dashboard-card missing">
<div class="dashboard-number" id="missingReports">-</div>
<div class="dashboard-label">미검토</div>
</div>
</div>
<!-- 필터 섹션 -->
<div class="filter-section">
<div class="filter-row">
<div class="filter-group">
<label>시작 날짜</label>
<input type="date" id="startDate" class="filter-input">
</div>
<div class="filter-group">
<label>종료 날짜</label>
<input type="date" id="endDate" class="filter-input">
</div>
<div class="filter-group">
<label>작업자</label>
<select id="workerFilter" class="filter-input">
<option value="">전체 작업자</option>
</select>
</div>
<div class="filter-group">
<label>프로젝트</label>
<select id="projectFilter" class="filter-input">
<option value="">전체 프로젝트</option>
</select>
</div>
<div class="filter-group">
<button type="button" id="applyFilter" class="filter-btn">필터 적용</button>
</div>
</div>
</div>
<!-- 알림 영역 -->
<div class="alerts-section">
<div class="alerts-header">
🚨 주의 필요 항목
</div>
<div id="alertsList">
<!-- 알림 항목들이 여기에 표시됩니다 -->
</div>
</div>
<!-- 메인 테이블 -->
<div class="table-section">
<div class="table-header">
<div class="table-title">작업보고서 목록</div>
<div class="table-actions">
<button class="action-btn" id="refreshBtn">🔄 새로고침</button>
<button class="action-btn" id="exportBtn">📊 내보내기</button>
</div>
</div>
<div style="overflow-x: auto;">
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>출근형태</th>
<th>기대시간</th>
<th>실제시간</th>
<th>시간상태</th>
<th>프로젝트</th>
<th>작업유형</th>
<th>상태</th>
<th>검토상태</th>
<th>액션</th>
</tr>
</thead>
<tbody id="reportsTableBody">
<!-- 데이터가 여기에 표시됩니다 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 우측 수정 패널 -->
<div class="edit-panel">
<div class="panel-header">
<div class="panel-title">빠른 수정</div>
<div class="panel-subtitle">항목을 선택하여 수정하세요</div>
</div>
<div class="panel-content" id="editPanelContent">
<div class="panel-empty">
<div class="panel-empty-icon">📝</div>
<div>수정할 항목을 선택해주세요</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
<div class="loading-spinner">
<div style="font-size: 24px; margin-bottom: 16px;"></div>
<div>데이터를 처리하는 중...</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/work-report-review.js"></script>
</body>
</html>

View File

@@ -1,733 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 보고서 입력 검증</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 날짜 범위별로 보고서 데이터 조회하는 헬퍼 함수
async function getReportsByDateRange(startDate, endDate, workerId, projectId) {
const allReports = [];
const start = new Date(startDate);
const end = new Date(endDate);
// ( API )
while (start <= end) {
const dateStr = start.toISOString().split('T')[0];
try {
const params = new URLSearchParams({
date: dateStr,
view_all: 'true' //
});
if (workerId) params.append('worker_id', workerId);
const dayReports = await API.get(`/api/daily-work-reports?${params}`);
// 프로젝트 필터링 (클라이언트 사이드에서)
let filteredReports = dayReports;
if (projectId) {
filteredReports = dayReports.filter(report =>
report.project_id == projectId
);
}
allReports.push(...filteredReports);
} catch (error) {
console.warn(`${dateStr} 데이터 조회 실패:`, error);
}
start.setDate(start.getDate() + 1);
}
return allReports;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f7fa;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.filter-section {
background: white;
padding: 25px;
border-radius: 15px;
margin-bottom: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
align-items: end;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
margin-bottom: 8px;
font-weight: 600;
color: #2d3748;
}
.filter-group input, .filter-group select {
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
.filter-group input:focus, .filter-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: transform 0.2s;
}
.btn:hover {
transform: translateY(-2px);
}
.validation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 25px;
}
.validation-card {
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.3s;
}
.validation-card:hover {
transform: translateY(-5px);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
font-size: 18px;
}
.error-icon {
background: #fed7d7;
color: #e53e3e;
}
.warning-icon {
background: #feebc8;
color: #dd6b20;
}
.info-icon {
background: #bee3f8;
color: #3182ce;
}
.success-icon {
background: #c6f6d5;
color: #38a169;
}
.card-title {
font-size: 1.3em;
font-weight: 700;
color: #2d3748;
}
.stat-number {
font-size: 2.5em;
font-weight: 900;
margin: 15px 0;
}
.error-stat { color: #e53e3e; }
.warning-stat { color: #dd6b20; }
.info-stat { color: #3182ce; }
.success-stat { color: #38a169; }
.issue-list {
max-height: 300px;
overflow-y: auto;
margin-top: 15px;
}
.issue-item {
padding: 12px;
border-left: 4px solid #e2e8f0;
margin-bottom: 10px;
background: #f7fafc;
border-radius: 0 8px 8px 0;
font-size: 14px;
}
.issue-item.error {
border-left-color: #e53e3e;
background: #fef5f5;
}
.issue-item.warning {
border-left-color: #dd6b20;
background: #fffaf0;
}
.loading {
text-align: center;
padding: 50px;
color: #718096;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid #e2e8f0;
border-top: 5px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.summary-section {
background: white;
padding: 25px;
border-radius: 15px;
margin-bottom: 25px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.summary-item {
text-align: center;
padding: 20px;
border-radius: 10px;
background: #f7fafc;
}
.summary-value {
font-size: 2em;
font-weight: 900;
margin-bottom: 5px;
}
.summary-label {
color: #718096;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 작업 보고서 입력 검증</h1>
<p>일일 작업 보고서의 데이터 품질을 확인하고 누락된 정보를 찾아보세요</p>
</div>
<div class="filter-section">
<div class="filter-grid">
<div class="filter-group">
<label for="startDate">시작 날짜</label>
<input type="date" id="startDate" value="">
</div>
<div class="filter-group">
<label for="endDate">종료 날짜</label>
<input type="date" id="endDate" value="">
</div>
<div class="filter-group">
<label for="workerFilter">작업자</label>
<select id="workerFilter">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="projectFilter">프로젝트</label>
<select id="projectFilter">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<button class="btn" onclick="validateReports()">검증 실행</button>
</div>
</div>
</div>
<div id="summarySection" class="summary-section" style="display: none;">
<h3 style="margin-bottom: 20px;">📋 검증 요약</h3>
<div class="summary-grid">
<div class="summary-item">
<div class="summary-value" id="totalReports">0</div>
<div class="summary-label">총 보고서 수</div>
</div>
<div class="summary-item">
<div class="summary-value error-stat" id="errorCount">0</div>
<div class="summary-label">오류 항목</div>
</div>
<div class="summary-item">
<div class="summary-value warning-stat" id="warningCount">0</div>
<div class="summary-label">경고 항목</div>
</div>
<div class="summary-item">
<div class="summary-value success-stat" id="validPercent">0%</div>
<div class="summary-label">정상 비율</div>
</div>
</div>
</div>
<div id="loadingSection" class="loading" style="display: none;">
<div class="loading-spinner"></div>
<p>데이터를 검증하고 있습니다...</p>
</div>
<div id="validationResults" class="validation-grid">
<!-- 검증 결과가 여기에 표시됩니다 -->
</div>
</div>
<script type="module">
// API 설정
import { API } from './js/api-config.js';
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
});
async function initializePage() {
// 기본 날짜 설정 (최근 30일)
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
// 필터 옵션 로드
await loadFilterOptions();
}
async function loadFilterOptions() {
try {
// 작업자 목록은 별도 API로 로드해야 함 (Workers 테이블)
// 임시로 하드코딩된 데이터 사용
const workerSelect = document.getElementById('workerFilter');
const workers = [
{ worker_id: 1, worker_name: '작업자1' },
{ worker_id: 2, worker_name: '작업자2' },
{ worker_id: 3, worker_name: '작업자3' }
];
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
workerSelect.appendChild(option);
});
// 프로젝트 목록도 별도 API로 로드해야 함 (Projects 테이블)
// 임시로 하드코딩된 데이터 사용
const projectSelect = document.getElementById('projectFilter');
const projects = [
{ project_id: 1, project_name: '프로젝트A' },
{ project_id: 2, project_name: '프로젝트B' },
{ project_id: 3, project_name: '프로젝트C' }
];
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.project_id;
option.textContent = project.project_name;
projectSelect.appendChild(option);
});
} catch (error) {
console.error('필터 옵션 로드 실패:', error);
}
}
async function validateReports() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const workerId = document.getElementById('workerFilter').value;
const projectId = document.getElementById('projectFilter').value;
if (!startDate || !endDate) {
alert('시작 날짜와 종료 날짜를 선택해주세요.');
return;
}
// 로딩 표시
document.getElementById('loadingSection').style.display = 'block';
document.getElementById('validationResults').innerHTML = '';
document.getElementById('summarySection').style.display = 'none';
try {
// 보고서 데이터 조회 - 백엔드 API 구조에 맞게 수정
const params = new URLSearchParams();
if (workerId && projectId) {
// 작업자와 프로젝트가 모두 선택된 경우
params.append('start_date', startDate);
params.append('end_date', endDate);
params.append('worker_id', workerId);
params.append('project_id', projectId);
params.append('view_all', 'true'); // 전체 조회 권한 요청
const reports = await API.get(`/api/daily-work-reports/search?${params}`);
const reportData = reports.reports || [];
// 날짜별로 개별 조회하여 통합
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
// 검증 실행
const validationResults = await performValidation(allReports, startDate, endDate);
// 결과 표시
displayValidationResults(validationResults);
updateSummary(validationResults, allReports.length);
} else {
// 날짜 범위로 조회
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
// 검증 실행
const validationResults = await performValidation(allReports, startDate, endDate);
// 결과 표시
displayValidationResults(validationResults);
updateSummary(validationResults, allReports.length);
}
} catch (error) {
console.error('검증 실행 실패:', error);
alert('검증 실행 중 오류가 발생했습니다.');
} finally {
document.getElementById('loadingSection').style.display = 'none';
}
}
async function performValidation(reports, startDate, endDate) {
const results = {
missingDates: [],
invalidWorkHours: [],
missingFields: [],
duplicateEntries: [],
unusualPatterns: [],
dataConsistency: []
};
// 1. 누락된 날짜 확인
const expectedDates = getDateRange(startDate, endDate);
const reportDates = [...new Set(reports.map(r => r.report_date))];
results.missingDates = expectedDates.filter(date =>
!reportDates.includes(date) && isWorkingDay(date)
);
// 2. 잘못된 작업시간 확인
results.invalidWorkHours = reports.filter(report => {
const hours = parseFloat(report.work_hours);
return isNaN(hours) || hours <= 0 || hours > 24;
});
// 3. 필수 필드 누락 확인
results.missingFields = reports.filter(report => {
return !report.worker_id || !report.project_id ||
!report.work_type_id || !report.work_status_id;
});
// 4. 중복 항목 확인
const reportKeys = new Map();
reports.forEach(report => {
const key = `${report.report_date}-${report.worker_id}-${report.project_id}`;
if (reportKeys.has(key)) {
results.duplicateEntries.push({
...report,
duplicateKey: key
});
} else {
reportKeys.set(key, report);
}
});
// 5. 비정상적인 패턴 확인
results.unusualPatterns = findUnusualPatterns(reports);
// 6. 데이터 일관성 확인
results.dataConsistency = checkDataConsistency(reports);
return results;
}
function getDateRange(startDate, endDate) {
const dates = [];
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
dates.push(current.toISOString().split('T')[0]);
current.setDate(current.getDate() + 1);
}
return dates;
}
function isWorkingDay(dateString) {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
return dayOfWeek >= 1 && dayOfWeek <= 5; // 월~금
}
function findUnusualPatterns(reports) {
const unusual = [];
// 작업자별 일일 총 작업시간이 8시간을 크게 초과하는 경우
const dailyHours = {};
reports.forEach(report => {
const key = `${report.report_date}-${report.worker_id}`;
dailyHours[key] = (dailyHours[key] || 0) + parseFloat(report.work_hours);
});
Object.entries(dailyHours).forEach(([key, hours]) => {
if (hours > 12) {
const [date, workerId] = key.split('-');
unusual.push({
type: 'excessive_hours',
date: date,
worker_id: workerId,
total_hours: hours,
message: `${date} 작업자 ${workerId}의 총 작업시간이 ${hours}시간입니다`
});
}
});
return unusual;
}
function checkDataConsistency(reports) {
const inconsistencies = [];
// 같은 프로젝트에서 완료 상태 이후 진행중 상태가 있는지 확인
const projectStatus = {};
reports.forEach(report => {
const key = `${report.project_id}-${report.worker_id}`;
if (!projectStatus[key]) {
projectStatus[key] = [];
}
projectStatus[key].push({
date: report.report_date,
status: report.work_status_id,
report: report
});
});
Object.entries(projectStatus).forEach(([key, statuses]) => {
statuses.sort((a, b) => new Date(a.date) - new Date(b.date));
// 여기서 상태 변화의 논리적 일관성을 확인할 수 있습니다
});
return inconsistencies;
}
function displayValidationResults(results) {
const container = document.getElementById('validationResults');
// 누락된 날짜
if (results.missingDates.length > 0) {
container.appendChild(createValidationCard(
'📅 누락된 작업일',
'error',
results.missingDates.length,
results.missingDates.map(date => ({
message: `${date} (${getDayName(date)}) - 작업 보고서 없음`
}))
));
}
// 잘못된 작업시간
if (results.invalidWorkHours.length > 0) {
container.appendChild(createValidationCard(
'⏰ 잘못된 작업시간',
'error',
results.invalidWorkHours.length,
results.invalidWorkHours.map(report => ({
message: `${report.report_date} - 작업자 ${report.worker_id}: ${report.work_hours}시간`
}))
));
}
// 필수 필드 누락
if (results.missingFields.length > 0) {
container.appendChild(createValidationCard(
'❗ 필수 필드 누락',
'error',
results.missingFields.length,
results.missingFields.map(report => ({
message: `${report.report_date} - ID: ${report.id} - 필수 정보 누락`
}))
));
}
// 중복 항목
if (results.duplicateEntries.length > 0) {
container.appendChild(createValidationCard(
'🔄 중복 항목',
'warning',
results.duplicateEntries.length,
results.duplicateEntries.map(report => ({
message: `${report.report_date} - 작업자 ${report.worker_id}, 프로젝트 ${report.project_id}`
}))
));
}
// 비정상적인 패턴
if (results.unusualPatterns.length > 0) {
container.appendChild(createValidationCard(
'⚠️ 비정상적인 패턴',
'warning',
results.unusualPatterns.length,
results.unusualPatterns.map(pattern => ({
message: pattern.message
}))
));
}
// 검증 완료 메시지
if (container.children.length === 0) {
container.appendChild(createValidationCard(
'✅ 검증 완료',
'success',
0,
[{ message: '모든 데이터가 정상적으로 입력되었습니다!' }]
));
}
}
function createValidationCard(title, type, count, issues) {
const card = document.createElement('div');
card.className = 'validation-card';
const iconClass = type === 'error' ? 'error-icon' :
type === 'warning' ? 'warning-icon' :
type === 'success' ? 'success-icon' : 'info-icon';
const statClass = type === 'error' ? 'error-stat' :
type === 'warning' ? 'warning-stat' :
type === 'success' ? 'success-stat' : 'info-stat';
const icon = type === 'error' ? '❌' :
type === 'warning' ? '⚠️' :
type === 'success' ? '✅' : '';
card.innerHTML = `
<div class="card-header">
<div class="card-icon ${iconClass}">${icon}</div>
<div class="card-title">${title}</div>
</div>
<div class="stat-number ${statClass}">${count}</div>
<div class="issue-list">
${issues.map(issue => `
<div class="issue-item ${type}">
${issue.message}
</div>
`).join('')}
</div>
`;
return card;
}
function updateSummary(results, totalReports) {
const errorCount = results.missingDates.length +
results.invalidWorkHours.length +
results.missingFields.length;
const warningCount = results.duplicateEntries.length +
results.unusualPatterns.length +
results.dataConsistency.length;
const totalIssues = errorCount + warningCount;
const validPercent = totalReports > 0 ?
Math.round(((totalReports - totalIssues) / totalReports) * 100) : 100;
document.getElementById('totalReports').textContent = totalReports;
document.getElementById('errorCount').textContent = errorCount;
document.getElementById('warningCount').textContent = warningCount;
document.getElementById('validPercent').textContent = validPercent + '%';
document.getElementById('summarySection').style.display = 'block';
}
function getDayName(dateString) {
const date = new Date(dateString);
const days = ['일', '월', '화', '수', '목', '금', '토'];
return days[date.getDay()];
}
</script>
</body>
</html>

View File

@@ -1,65 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>📝 작업보고서 입력</h1>
<p class="subtitle">일일 작업 내용을 입력합니다.</p>
</div>
<div class="card">
<h3>📅 날짜 선택</h3>
<div id="calendar"></div>
</div>
<div class="card">
<h3>📋 작업 내용</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">No</th>
<th width="100">작업자</th>
<th width="150">프로젝트</th>
<th width="150">작업</th>
<th width="80">잔업</th>
<th width="100">근무형태</th>
<th>메모</th>
<th width="60">삭제</th>
</tr>
</thead>
<tbody id="reportBody">
<tr><td colspan="8" class="text-center">날짜를 먼저 선택하세요</td></tr>
</tbody>
</table>
</div>
<div class="form-actions" style="margin-top: 20px;">
<button id="submitBtn" class="btn btn-primary">전체 등록</button>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/work-report-create.js"></script>
</body>
</html>

View File

@@ -1,61 +0,0 @@
<!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/main-layout.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout">
<div id="navbar-container"></div>
<div class="content-wrapper">
<div id="sidebar-container"></div>
<div id="content-container">
<div class="page-header">
<h1>📋 작업보고서 관리</h1>
<p class="subtitle">등록된 작업보고서를 수정하거나 삭제할 수 있습니다.</p>
</div>
<div class="card">
<h3>📅 날짜 선택</h3>
<div id="calendar"></div>
</div>
<div class="card">
<h3>📋 선택된 날짜 보고서</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th width="50">No</th>
<th width="100">작업자</th>
<th width="150">프로젝트</th>
<th width="150">작업</th>
<th width="80">잔업</th>
<th width="100">근무형태</th>
<th>메모</th>
<th width="120">작업</th>
</tr>
</thead>
<tbody id="reportBody">
<tr><td colspan="8" class="text-center">날짜를 선택하면 보고서가 나타납니다.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/work-report-manage.js"></script>
</body>
</html>

View File

@@ -1,164 +0,0 @@
<!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/daily-work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script type="module" src="/js/api-config.js?v=3"></script>
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-button">
← 뒤로가기
</a>
<!-- 작업자 정보 카드 -->
<div class="worker-info-card" id="workerInfoCard">
<div class="worker-avatar-large">
<span id="workerInitial"></span>
</div>
<div class="worker-info-details">
<h2 id="workerName">작업자명</h2>
<p id="workerJob">직종</p>
<p id="selectedDate">날짜</p>
</div>
<div class="worker-status-summary" id="workerStatusSummary">
<div class="status-item">
<span class="status-label">총 작업시간</span>
<span class="status-value" id="totalHours">0h</span>
</div>
<div class="status-item">
<span class="status-label">작업 건수</span>
<span class="status-value" id="workCount">0건</span>
</div>
</div>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 기존 작업 목록 -->
<div class="existing-work-section" id="existingWorkSection">
<div class="section-header">
<h3>📋 기존 작업 목록</h3>
<button class="btn btn-primary" id="addNewWorkBtn">
새 작업 추가
</button>
</div>
<div id="existingWorkList">
<!-- 기존 작업들이 여기에 표시됩니다 -->
</div>
</div>
<!-- 새 작업 추가 폼 -->
<div class="new-work-section" id="newWorkSection" style="display: none;">
<div class="section-header">
<h3> 새 작업 추가</h3>
<button class="btn btn-secondary" id="cancelNewWorkBtn">
✖️ 취소
</button>
</div>
<div class="work-entry" id="newWorkEntry">
<div class="work-entry-grid">
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">🏗️</span>
프로젝트
</label>
<select id="newProjectSelect" class="form-select" required>
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">⚙️</span>
작업 유형
</label>
<select id="newWorkTypeSelect" class="form-select" required>
<option value="">작업 유형을 선택하세요</option>
</select>
</div>
</div>
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">📊</span>
업무 상태
</label>
<select id="newWorkStatusSelect" class="form-select" required>
<option value="">업무 상태를 선택하세요</option>
</select>
</div>
<div class="error-type-section" id="newErrorTypeSection">
<label class="form-field-label">
<span class="form-field-icon">⚠️</span>
에러 유형
</label>
<select id="newErrorTypeSelect" class="form-select">
<option value="">에러 유형을 선택하세요</option>
</select>
</div>
<div class="time-input-section">
<label class="form-field-label">
<span class="form-field-icon"></span>
작업 시간 (시간)
</label>
<input type="number" id="newWorkHours" class="time-input" step="0.25" min="0.25" max="24" value="1.00" required>
<div class="quick-time-buttons">
<button type="button" class="quick-time-btn" data-hours="0.5">0.5h</button>
<button type="button" class="quick-time-btn" data-hours="1">1h</button>
<button type="button" class="quick-time-btn" data-hours="2">2h</button>
<button type="button" class="quick-time-btn" data-hours="4">4h</button>
<button type="button" class="quick-time-btn" data-hours="8">8h</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-success" id="saveNewWorkBtn">
💾 작업 저장
</button>
</div>
</div>
</div>
<!-- 휴가 처리 섹션 -->
<div class="vacation-section" id="vacationSection">
<div class="section-header">
<h3>🏖️ 휴가 처리</h3>
</div>
<div class="vacation-buttons">
<button class="btn btn-warning vacation-process-btn" data-type="full">
🏖️ 연차 (8시간)
</button>
<button class="btn btn-warning vacation-process-btn" data-type="half-half">
🌤️ 반반차 (6시간)
</button>
<button class="btn btn-warning vacation-process-btn" data-type="half">
🌅 반차 (4시간)
</button>
</div>
</div>
</main>
</div>
<!-- 스크립트 -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/worker-individual-report.js?v=3"></script>
</body>
</html>

View File

@@ -1 +0,0 @@
# Placeholder file to create admin directory

View File

@@ -1,215 +0,0 @@
<!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/common.css?v=1">
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<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>
<!-- 사용자 관리 섹션 -->
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">👥</span>
사용자 계정 관리
</h2>
<button class="btn btn-primary" id="addUserBtn">
<span class="btn-icon"></span>
새 사용자 추가
</button>
</div>
<div class="users-container">
<div class="users-header">
<div class="search-box">
<input type="text" id="userSearch" placeholder="사용자 검색..." class="search-input">
<span class="search-icon">🔍</span>
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">전체</button>
<button class="filter-btn" data-filter="admin">관리자</button>
<button class="filter-btn" data-filter="leader">그룹장</button>
<button class="filter-btn" data-filter="user">작업자</button>
</div>
</div>
<div class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>사용자명</th>
<th>아이디</th>
<th>역할</th>
<th>상태</th>
<th>최종 로그인</th>
<th>관리</th>
</tr>
</thead>
<tbody id="usersTableBody">
<!-- 사용자 목록이 여기에 동적으로 생성됩니다 -->
</tbody>
</table>
</div>
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">👥</div>
<h3>등록된 사용자가 없습니다</h3>
<p>새 사용자를 추가해보세요.</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 사용자 추가/수정 모달 -->
<div id="userModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">새 사용자 추가</h2>
<button class="modal-close-btn" onclick="closeUserModal()">×</button>
</div>
<div class="modal-body">
<form id="userForm">
<div class="form-group">
<label class="form-label">사용자명 *</label>
<input type="text" id="userName" class="form-control" required>
</div>
<div class="form-group">
<label class="form-label">아이디 *</label>
<input type="text" id="userId" class="form-control" required>
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
</div>
<div class="form-group" id="passwordGroup">
<label class="form-label">비밀번호 *</label>
<input type="password" id="userPassword" class="form-control" required>
<small class="form-help">최소 6자 이상</small>
</div>
<div class="form-group">
<label class="form-label">역할 *</label>
<select id="userRole" class="form-control" required>
<option value="">역할 선택</option>
<option value="admin">관리자</option>
<option value="user">사용자</option>
</select>
</div>
<div class="form-group">
<label class="form-label">이메일</label>
<input type="email" id="userEmail" class="form-control">
</div>
<div class="form-group">
<label class="form-label">전화번호</label>
<input type="tel" id="userPhone" class="form-control">
</div>
<!-- 페이지 권한 설정 (사용자 편집 시에만 표시) -->
<div class="form-group" id="pageAccessGroup" style="display: none;">
<label class="form-label">페이지 접근 권한</label>
<small class="form-help">관리자는 모든 페이지에 자동으로 접근 가능합니다</small>
<div id="pageAccessList" class="page-access-list">
<!-- 페이지 체크박스 목록이 동적으로 생성됩니다 -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">취소</button>
<button type="button" class="btn btn-primary" id="saveUserBtn">저장</button>
</div>
</div>
</div>
<!-- 사용자 삭제 확인 모달 -->
<div id="deleteModal" class="modal-overlay" style="display: none;">
<div class="modal-container small">
<div class="modal-header">
<h2>사용자 삭제</h2>
<button class="modal-close-btn" onclick="closeDeleteModal()">×</button>
</div>
<div class="modal-body">
<div class="delete-warning">
<div class="warning-icon">⚠️</div>
<p>정말로 이 사용자를 삭제하시겠습니까?</p>
<p class="warning-text">삭제된 사용자는 복구할 수 없습니다.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">삭제</button>
</div>
</div>
</div>
<!-- 페이지 권한 관리 모달 -->
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="pageAccessModalTitle">페이지 권한 관리</h2>
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
</div>
<div class="modal-body">
<div class="page-access-user-info">
<div class="user-avatar-small" id="pageAccessUserAvatar">U</div>
<div>
<h3 id="pageAccessUserName">사용자</h3>
<p id="pageAccessUserRole">역할</p>
</div>
</div>
<div class="form-group">
<label class="form-label">접근 가능한 페이지</label>
<small class="form-help">체크된 페이지에만 접근할 수 있습니다</small>
<div id="pageAccessModalList" class="page-access-list">
<!-- 페이지 체크박스 목록 -->
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
<!-- JavaScript -->
<script type="module" src="/js/api-config.js?v=13"></script>
<script type="module" src="/js/load-navbar.js?v=4"></script>
<script src="/js/admin-settings.js?v=8"></script>
</body>
</html>

View File

@@ -1,493 +0,0 @@
<!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>
<style>
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.comparison-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.comparison-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #111827;
}
.discrepancy-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.badge-match {
background-color: #d1fae5;
color: #065f46;
}
.badge-mismatch {
background-color: #fee2e2;
color: #991b1b;
}
.badge-missing-attendance {
background-color: #fef3c7;
color: #92400e;
}
.badge-missing-report {
background-color: #dbeafe;
color: #1e40af;
}
.filter-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
text-align: center;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
color: #111827;
}
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.detail-label {
font-weight: 500;
color: #6b7280;
}
.detail-value {
color: #111827;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<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="loadComparisonData()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 필터 섹션 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="filter-section">
<div style="flex: 1; min-width: 200px;">
<label for="startDate">시작일</label>
<input type="date" id="startDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="endDate">종료일</label>
<input type="date" id="endDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="workerFilter">작업자</label>
<select id="workerFilter" class="form-control">
<option value="">전체</option>
</select>
</div>
<div style="flex: 1; min-width: 200px;">
<label for="statusFilter">상태</label>
<select id="statusFilter" class="form-control">
<option value="">전체</option>
<option value="match">일치</option>
<option value="mismatch">불일치</option>
<option value="missing-attendance">출퇴근 누락</option>
<option value="missing-report">보고서 누락</option>
</select>
</div>
<div style="align-self: flex-end;">
<button class="btn btn-primary" onclick="loadComparisonData()">
<span>🔍 조회</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 요약 통계 -->
<div class="content-section">
<div class="summary-stats" id="summaryStats">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
<!-- 대조 결과 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">대조 결과</h2>
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
</div>
<div class="card-body">
<div id="comparisonList" class="data-table-container">
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 전역 변수
let workers = [];
let comparisonData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 기본 날짜 설정 (이번 주)
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
document.getElementById('endDate').value = today.toISOString().split('T')[0];
try {
await loadWorkers();
await loadComparisonData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerFilter');
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadComparisonData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const workerId = document.getElementById('workerFilter').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 선택해주세요.');
return;
}
try {
// 출퇴근 기록 로드
const attendanceResponse = await axios.get('/attendance/records', {
params: {
start_date: startDate,
end_date: endDate,
worker_id: workerId || undefined
}
});
// 작업 보고서 로드
const reportsResponse = await axios.get('/daily-work-reports', {
params: {
start_date: startDate,
end_date: endDate,
worker_id: workerId || undefined
}
});
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
// 데이터 비교
comparisonData = compareData(attendanceRecords, workReports);
// 필터 적용
const statusFilter = document.getElementById('statusFilter').value;
if (statusFilter) {
comparisonData = comparisonData.filter(item => item.status === statusFilter);
}
renderSummary();
renderComparisonList();
} catch (error) {
console.error('데이터 로드 오류:', error);
document.getElementById('comparisonList').innerHTML = `
<div class="empty-state">
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
}
function compareData(attendanceRecords, workReports) {
const results = [];
const dateWorkerMap = new Map();
// 출퇴근 기록 맵핑
attendanceRecords.forEach(record => {
const key = `${record.attendance_date}_${record.worker_id}`;
dateWorkerMap.set(key, {
date: record.attendance_date,
worker_id: record.worker_id,
worker_name: record.worker_name,
attendance: record,
reports: []
});
});
// 작업 보고서 맵핑
workReports.forEach(report => {
const key = `${report.report_date}_${report.worker_id}`;
if (dateWorkerMap.has(key)) {
dateWorkerMap.get(key).reports.push(report);
} else {
dateWorkerMap.set(key, {
date: report.report_date,
worker_id: report.worker_id,
worker_name: report.worker_name,
attendance: null,
reports: [report]
});
}
});
// 비교 분석
dateWorkerMap.forEach(item => {
const attendanceHours = item.attendance?.total_hours || 0;
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
let status = 'match';
let message = '일치';
if (!item.attendance && item.reports.length > 0) {
status = 'missing-attendance';
message = '출퇴근 기록 누락';
} else if (item.attendance && item.reports.length === 0) {
status = 'missing-report';
message = '작업보고서 누락';
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
status = 'mismatch';
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
}
results.push({
...item,
attendanceHours,
reportTotalHours,
status,
message
});
});
// 날짜 역순 정렬
return results.sort((a, b) => b.date.localeCompare(a.date));
}
function renderSummary() {
const summaryStats = document.getElementById('summaryStats');
const total = comparisonData.length;
const matches = comparisonData.filter(item => item.status === 'match').length;
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
summaryStats.innerHTML = `
<div class="stat-card">
<div class="stat-label">전체</div>
<div class="stat-value">${total}</div>
</div>
<div class="stat-card">
<div class="stat-label">일치</div>
<div class="stat-value" style="color: #059669;">${matches}</div>
</div>
<div class="stat-card">
<div class="stat-label">불일치</div>
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
</div>
<div class="stat-card">
<div class="stat-label">출퇴근 누락</div>
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
</div>
<div class="stat-card">
<div class="stat-label">보고서 누락</div>
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
</div>
`;
}
function renderComparisonList() {
const container = document.getElementById('comparisonList');
if (comparisonData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>비교 결과가 없습니다.</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>출퇴근 시간</th>
<th>보고서 시간</th>
<th>차이</th>
<th>상태</th>
</tr>
</thead>
<tbody>
${comparisonData.map(item => {
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
const badgeClass = `badge-${item.status}`;
return `
<tr>
<td>${item.date}</td>
<td><strong>${item.worker_name}</strong></td>
<td>${item.attendanceHours.toFixed(1)}시간</td>
<td>${item.reportTotalHours.toFixed(1)}시간</td>
<td>${diff.toFixed(1)}시간</td>
<td>
<span class="discrepancy-badge ${badgeClass}">
${item.message}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
</script>
</body>
</html>

View File

@@ -1,302 +0,0 @@
<!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">
<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 active">
<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-secondary" onclick="refreshAllCodes()">
<span class="btn-icon">🔄</span>
전체 새로고침
</button>
</div>
</div>
<!-- 코드 유형 탭 -->
<div class="code-tabs">
<button class="tab-btn active" data-tab="work-status" onclick="switchCodeTab('work-status')">
<span class="tab-icon">📊</span>
작업 상태 유형
</button>
<button class="tab-btn" data-tab="error-types" onclick="switchCodeTab('error-types')">
<span class="tab-icon">⚠️</span>
오류 유형
</button>
<button class="tab-btn" data-tab="work-types" onclick="switchCodeTab('work-types')">
<span class="tab-icon">🔧</span>
작업 유형
</button>
</div>
<!-- 작업 상태 유형 관리 -->
<div id="work-status-tab" class="code-tab-content active">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">📊</span>
작업 상태 유형 관리
</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
<span class="btn-icon"></span>
새 상태 추가
</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item">
<span class="stat-icon">📊</span>
<span id="workStatusCount">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
정상 <span id="normalStatusCount">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
오류 <span id="errorStatusCount">0</span>
</span>
</div>
<div class="code-grid" id="workStatusGrid">
<!-- 작업 상태 유형 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
<!-- 오류 유형 관리 -->
<div id="error-types-tab" class="code-tab-content">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">⚠️</span>
오류 유형 관리
</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
<span class="btn-icon"></span>
새 오류 유형 추가
</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item">
<span class="stat-icon">⚠️</span>
<span id="errorTypesCount">0</span>
</span>
<span class="stat-item critical-stat">
<span class="stat-icon">🔴</span>
심각 <span id="criticalErrorsCount">0</span>
</span>
<span class="stat-item high-stat">
<span class="stat-icon">🟠</span>
높음 <span id="highErrorsCount">0</span>
</span>
<span class="stat-item medium-stat">
<span class="stat-icon">🟡</span>
보통 <span id="mediumErrorsCount">0</span>
</span>
<span class="stat-item low-stat">
<span class="stat-icon">🟢</span>
낮음 <span id="lowErrorsCount">0</span>
</span>
</div>
<div class="code-grid" id="errorTypesGrid">
<!-- 오류 유형 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
<!-- 작업 유형 관리 -->
<div id="work-types-tab" class="code-tab-content">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">🔧</span>
작업 유형 관리
</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
<span class="btn-icon"></span>
새 작업 유형 추가
</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item">
<span class="stat-icon">🔧</span>
<span id="workTypesCount">0</span>
</span>
<span class="stat-item">
<span class="stat-icon">📁</span>
카테고리 <span id="workCategoriesCount">0</span>
</span>
</div>
<div class="code-grid" id="workTypesGrid">
<!-- 작업 유형 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</div>
</main>
<!-- 코드 추가/수정 모달 -->
<div id="codeModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">코드 추가</h2>
<button class="modal-close-btn" onclick="closeCodeModal()">×</button>
</div>
<div class="modal-body">
<form id="codeForm" onsubmit="event.preventDefault(); saveCode();">
<input type="hidden" id="codeId">
<input type="hidden" id="codeType">
<!-- 공통 필드 -->
<div class="form-group">
<label class="form-label">이름 *</label>
<input type="text" id="codeName" class="form-control" placeholder="코드명을 입력하세요" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="codeDescription" class="form-control" rows="3" placeholder="상세 설명을 입력하세요"></textarea>
</div>
<!-- 작업 상태 유형 전용 필드 -->
<div class="form-group" id="isErrorGroup" style="display: none;">
<label class="form-label">
<input type="checkbox" id="isError" class="form-checkbox">
오류 상태로 분류
</label>
<small class="form-help">체크하면 이 상태는 오류로 간주됩니다</small>
</div>
<!-- 오류 유형 전용 필드 -->
<div class="form-group" id="severityGroup" style="display: none;">
<label class="form-label">심각도 *</label>
<select id="severity" class="form-control">
<option value="low">낮음 (Low)</option>
<option value="medium" selected>보통 (Medium)</option>
<option value="high">높음 (High)</option>
<option value="critical">심각 (Critical)</option>
</select>
</div>
<div class="form-group" id="solutionGuideGroup" style="display: none;">
<label class="form-label">해결 가이드</label>
<textarea id="solutionGuide" class="form-control" rows="4" placeholder="이 오류 발생 시 해결 방법을 입력하세요"></textarea>
</div>
<!-- 작업 유형 전용 필드 -->
<div class="form-group" id="categoryGroup" style="display: none;">
<label class="form-label">카테고리</label>
<input type="text" id="category" class="form-control" placeholder="작업 카테고리 (예: PKG, Vessel)" list="categoryList">
<datalist id="categoryList">
<!-- 기존 카테고리 목록이 여기에 동적으로 생성됩니다 -->
</datalist>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCodeModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteCodeBtn" onclick="deleteCode()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveCode()">
💾 저장
</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/code-management.js?v=2"></script>
</body>
</html>

View File

@@ -1,272 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js?v=1" defer></script>
<script type="module" src="/js/api-config.js?v=3"></script>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
<div class="page-container">
<!-- 사이드바 -->
<aside class="sidebar">
<nav class="sidebar-nav">
<div class="sidebar-header">
<h3 class="sidebar-title">관리 메뉴</h3>
</div>
<ul class="sidebar-menu">
<li class="menu-item">
<a href="/pages/admin/projects.html">
<span class="menu-icon">📁</span>
<span class="menu-text">프로젝트 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workers.html">
<span class="menu-icon">👥</span>
<span class="menu-text">작업자 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/workplaces.html">
<span class="menu-icon">🏗️</span>
<span class="menu-text">작업장 관리</span>
</a>
</li>
<li class="menu-item active">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/tasks.html">
<span class="menu-icon">📋</span>
<span class="menu-text">작업 관리</span>
</a>
</li>
<li class="menu-item">
<a href="/pages/admin/codes.html">
<span class="menu-icon">🏷️</span>
<span class="menu-text">코드 관리</span>
</a>
</li>
<li class="menu-divider"></li>
<li class="menu-item">
<a href="/pages/dashboard.html">
<span class="menu-icon">🏠</span>
<span class="menu-text">대시보드로</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">
<span class="title-icon">⚙️</span>
설비 관리
</h1>
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openEquipmentModal()">
<span>+ 설비 추가</span>
</button>
</div>
</div>
<!-- 필터 영역 -->
<div class="filter-section">
<div class="filter-group">
<label for="filterWorkplace">작업장</label>
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterType">설비 유형</label>
<select id="filterType" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="filter-group">
<label for="filterStatus">상태</label>
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="filter-group">
<label for="searchInput">검색</label>
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
</div>
</div>
<!-- 설비 목록 -->
<div class="content-section">
<div id="equipmentList" class="data-table-container">
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</main>
</div>
<!-- 설비 추가/수정 모달 -->
<div id="equipmentModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2 id="modalTitle">설비 추가</h2>
<button class="btn-close" onclick="closeEquipmentModal()">&times;</button>
</div>
<div class="modal-body">
<form id="equipmentForm">
<input type="hidden" id="equipmentId">
<div class="form-row">
<div class="form-group">
<label for="equipmentCode">설비 코드 *</label>
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
</div>
<div class="form-group">
<label for="equipmentName">설비명 *</label>
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="equipmentType">설비 유형</label>
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
</div>
<div class="form-group">
<label for="workplaceId">작업장</label>
<select id="workplaceId" class="form-control">
<option value="">선택 안함</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="manufacturer">제조사</label>
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
</div>
<div class="form-group">
<label for="modelName">모델명</label>
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialNumber">시리얼 번호</label>
<input type="text" id="serialNumber" class="form-control">
</div>
<div class="form-group">
<label for="installationDate">설치일</label>
<input type="date" id="installationDate" class="form-control">
</div>
</div>
<div class="form-group">
<label for="equipmentStatus">상태</label>
<select id="equipmentStatus" class="form-control">
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="form-group">
<label for="specifications">사양 정보</label>
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
</div>
<div class="form-group">
<label for="notes">비고</label>
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
// API 설정 먼저 로드
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
// api-config.js가 로드될 때까지 대기
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
// axios 요청 인터셉터 추가 (모든 요청에 토큰 자동 추가)
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log('📤 요청:', config.method.toUpperCase(), config.url);
return config;
},
error => {
return Promise.reject(error);
}
);
// axios 응답 인터셉터 추가 (에러 처리)
axios.interceptors.response.use(
response => {
console.log('✅ 응답:', response.status, response.config.url);
return response;
},
error => {
console.error('❌ 에러:', error.response?.status, error.config?.url, error.message);
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
console.log('✅ Axios 설정 완료:', axios.defaults.baseURL);
}
}, 50);
})();
</script>
<script src="/js/equipment-management.js?v=4"></script>
</body>
</html>

View File

@@ -1,140 +0,0 @@
<!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/common.css?v=1">
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script type="module" src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<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>
<!-- 사용자 목록 섹션 -->
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">👥</span>
사용자 목록
</h2>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">전체</button>
<button class="filter-btn" data-filter="with-access">권한 있음</button>
<button class="filter-btn" data-filter="no-access">권한 없음</button>
</div>
</div>
<div class="users-container">
<div class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>사용자명</th>
<th>아이디</th>
<th>역할</th>
<th>작업자</th>
<th>접근 가능 페이지</th>
<th>관리</th>
</tr>
</thead>
<tbody id="usersTableBody">
<tr>
<td colspan="6" style="text-align: center; padding: 2rem;">
<div class="spinner"></div>
<p>사용자 목록을 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">👥</div>
<h3>등록된 사용자가 없습니다</h3>
<p>권한을 부여할 사용자 계정이 없습니다.</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 페이지 권한 설정 모달 -->
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2 id="modalTitle">페이지 권한 설정</h2>
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
</div>
<div class="modal-body">
<div class="user-info-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600;">
<span id="modalUserInitial">-</span>
</div>
<div>
<div style="font-weight: 600; font-size: 1rem; color: #111827;" id="modalUserName">사용자명</div>
<div style="font-size: 0.875rem; color: #6b7280;">
<span id="modalUsername">username</span>
<span style="margin: 0 0.5rem;"></span>
<span id="modalWorkerName">작업자</span>
</div>
</div>
</div>
</div>
<div class="page-access-list">
<h3 style="font-size: 1rem; font-weight: 600; color: #374151; margin-bottom: 1rem;">
접근 가능 페이지 선택
</h3>
<div id="pageListContainer" style="max-height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
<div style="text-align: center; padding: 2rem; color: #6b7280;">
<div class="spinner" style="margin: 0 auto 0.5rem;"></div>
페이지 목록을 불러오는 중...
</div>
</div>
</div>
<div style="margin-top: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fde047; border-radius: 0.5rem;">
<p style="margin: 0; font-size: 0.875rem; color: #92400e;">
<strong>💡 참고:</strong> Admin 및 System Admin은 모든 페이지에 자동으로 접근할 수 있습니다.
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
<!-- JavaScript -->
<script type="module" src="/js/api-config.js?v=13"></script>
<script type="module" src="/js/load-navbar.js?v=4"></script>
<script src="/js/page-access-management.js?v=1"></script>
</body>
</html>

View File

@@ -1,258 +0,0 @@
<!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=6">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></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 active">
<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">
<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="openProjectModal()">
<span class="btn-icon"></span>
새 프로젝트 등록
</button>
<button class="btn btn-secondary" onclick="refreshProjectList()">
<span class="btn-icon">🔄</span>
새로고침
</button>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-section">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="프로젝트명 또는 Job No.로 검색..." class="search-input">
<button class="search-btn" onclick="searchProjects()">
<span class="search-icon">🔍</span>
</button>
</div>
<div class="filter-options">
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
<option value="">전체 상태</option>
<option value="active">진행중</option>
<option value="completed">완료</option>
<option value="paused">중단</option>
</select>
<select id="sortBy" class="filter-select" onchange="sortProjects()">
<option value="created_at">등록일순</option>
<option value="project_name">프로젝트명순</option>
<option value="due_date">납기일순</option>
</select>
</div>
</div>
<!-- 프로젝트 목록 -->
<div class="projects-section">
<div class="section-header">
<h2 class="section-title">등록된 프로젝트</h2>
<div class="project-stats">
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 프로젝트만 보기">
<span class="stat-icon">🟢</span>
활성 <span id="activeProjects">0</span>
</span>
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 프로젝트만 보기">
<span class="stat-icon">🔴</span>
비활성 <span id="inactiveProjects">0</span>
</span>
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 프로젝트 보기">
<span class="stat-icon">📊</span>
<span id="totalProjects">0</span>
</span>
</div>
</div>
<div class="projects-grid" id="projectsGrid">
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">📁</div>
<h3>등록된 프로젝트가 없습니다</h3>
<p>새 프로젝트를 등록해보세요.</p>
<button class="btn btn-primary" onclick="openProjectModal()">
<span class="btn-icon"></span>
첫 번째 프로젝트 등록
</button>
</div>
</div>
</main>
<!-- 프로젝트 등록/수정 모달 -->
<div id="projectModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">새 프로젝트 등록</h2>
<button class="modal-close-btn" onclick="closeProjectModal()">×</button>
</div>
<div class="modal-body">
<form id="projectForm">
<input type="hidden" id="projectId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Job No. *</label>
<input type="text" id="jobNo" class="form-control" required placeholder="예: TK-2024-001">
</div>
<div class="form-group">
<label class="form-label">프로젝트명 *</label>
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명을 입력하세요">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">계약일</label>
<input type="date" id="contractDate" class="form-control">
</div>
<div class="form-group">
<label class="form-label">납기일</label>
<input type="date" id="dueDate" class="form-control">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">납품방법</label>
<select id="deliveryMethod" class="form-control">
<option value="">선택하세요</option>
<option value="직접납품">직접납품</option>
<option value="택배">택배</option>
<option value="화물">화물</option>
<option value="현장설치">현장설치</option>
</select>
</div>
<div class="form-group">
<label class="form-label">현장</label>
<input type="text" id="site" class="form-control" placeholder="현장 위치를 입력하세요">
</div>
</div>
<div class="form-group">
<label class="form-label">PM (프로젝트 매니저)</label>
<input type="text" id="pm" class="form-control" placeholder="담당 PM을 입력하세요">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">프로젝트 상태</label>
<select id="projectStatus" class="form-control">
<option value="planning">📋 계획</option>
<option value="active" selected>🚀 진행중</option>
<option value="completed">✅ 완료</option>
<option value="cancelled">❌ 취소</option>
</select>
</div>
<div class="form-group">
<label class="form-label">완료일 (납품일)</label>
<input type="date" id="completedDate" class="form-control">
</div>
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="isActive" checked style="margin: 0;">
<span>프로젝트 활성화</span>
</label>
<small style="color: #6b7280; font-size: 0.8rem;">체크 해제 시 작업보고서 입력에서 숨겨집니다</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeProjectModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveProject()">
💾 저장
</button>
</div>
</div>
</div>
</main>
</div>
<!-- JavaScript -->
<script type="module" src="/js/api-config.js?v=3"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/project-management.js?v=3"></script>
</body>
</html>

View File

@@ -1,596 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>안전 체크리스트 관리 - TK-FB</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css">
<style>
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #111827);
margin: 0;
}
.btn-add {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-add:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
}
/* 탭 메뉴 */
.tab-menu {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid #e5e7eb;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.tab-btn {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
margin-bottom: -2px;
}
.tab-btn:hover {
color: #3b82f6;
}
.tab-btn.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
font-weight: 600;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 체크리스트 카드 */
.checklist-group {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1rem;
overflow: hidden;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.group-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
color: #374151;
}
.group-icon {
font-size: 1.25rem;
}
.group-count {
background: #e5e7eb;
color: #6b7280;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 10px;
}
.checklist-items {
padding: 0;
}
.checklist-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid #f3f4f6;
}
.checklist-item:last-child {
border-bottom: none;
}
.item-info {
flex: 1;
}
.item-name {
font-weight: 500;
color: #111827;
margin-bottom: 0.25rem;
}
.item-meta {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
}
.item-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 500;
}
.badge-required {
background: #fee2e2;
color: #dc2626;
}
.badge-optional {
background: #e5e7eb;
color: #6b7280;
}
.item-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-edit {
background: #eff6ff;
color: #3b82f6;
}
.btn-edit:hover {
background: #dbeafe;
}
.btn-delete {
background: #fef2f2;
color: #dc2626;
}
.btn-delete:hover {
background: #fee2e2;
}
/* 필터/검색 */
.filter-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-select {
padding: 0.5rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 0.9rem;
min-width: 150px;
}
/* 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 1rem;
}
.modal-container {
background: white;
border-radius: 16px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
padding: 0;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
.form-radio-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form-radio {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-radio input {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-checkbox input {
width: 18px;
height: 18px;
cursor: pointer;
}
.conditional-fields {
display: none;
margin-top: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
.conditional-fields.show {
display: block;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: #e5e7eb;
color: #374151;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover {
background: #d1d5db;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #9ca3af;
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* 날씨 아이콘 */
.weather-icon {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.weather-icon.rain::before { content: '🌧️'; }
.weather-icon.snow::before { content: '❄️'; }
.weather-icon.heat::before { content: '🔥'; }
.weather-icon.cold::before { content: '🥶'; }
.weather-icon.wind::before { content: '💨'; }
.weather-icon.fog::before { content: '🌫️'; }
.weather-icon.dust::before { content: '😷'; }
.weather-icon.clear::before { content: '☀️'; }
@media (max-width: 768px) {
.checklist-item {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.item-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
</head>
<body>
<div id="navbar-container"></div>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">안전 체크리스트 관리</h1>
<button class="btn-add" onclick="openAddModal()">
<span>+</span> 항목 추가
</button>
</div>
<!-- 탭 메뉴 -->
<div class="tab-menu">
<button class="tab-btn active" data-tab="basic" onclick="switchTab('basic')">
기본 사항
</button>
<button class="tab-btn" data-tab="weather" onclick="switchTab('weather')">
날씨별
</button>
<button class="tab-btn" data-tab="task" onclick="switchTab('task')">
작업별
</button>
</div>
<!-- 기본 사항 탭 -->
<div id="basicTab" class="tab-content active">
<div id="basicChecklistContainer">
<!-- 동적 생성 -->
</div>
</div>
<!-- 날씨별 탭 -->
<div id="weatherTab" class="tab-content">
<div class="filter-bar">
<select id="weatherFilter" class="filter-select" onchange="filterByWeather()">
<option value="">모든 날씨 조건</option>
</select>
</div>
<div id="weatherChecklistContainer">
<!-- 동적 생성 -->
</div>
</div>
<!-- 작업별 탭 -->
<div id="taskTab" class="tab-content">
<div class="filter-bar">
<select id="workTypeFilter" class="filter-select" onchange="filterByWorkType()">
<option value="">공정 선택</option>
</select>
<select id="taskFilter" class="filter-select" onchange="filterByTask()">
<option value="">작업 선택</option>
</select>
</div>
<div id="taskChecklistContainer">
<!-- 동적 생성 -->
</div>
</div>
</div>
<!-- 추가/수정 모달 -->
<div id="checkModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">체크 항목 추가</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="checkForm">
<input type="hidden" id="checkId">
<div class="form-group">
<label class="form-label">유형</label>
<div class="form-radio-group">
<label class="form-radio">
<input type="radio" name="checkType" value="basic" checked onchange="toggleConditionalFields()">
<span>기본</span>
</label>
<label class="form-radio">
<input type="radio" name="checkType" value="weather" onchange="toggleConditionalFields()">
<span>날씨별</span>
</label>
<label class="form-radio">
<input type="radio" name="checkType" value="task" onchange="toggleConditionalFields()">
<span>작업별</span>
</label>
</div>
</div>
<!-- 기본 유형: 카테고리 선택 -->
<div id="basicFields" class="conditional-fields show">
<div class="form-group">
<label class="form-label">카테고리</label>
<select id="checkCategory" class="form-select">
<option value="PPE">PPE (개인보호장비)</option>
<option value="EQUIPMENT">EQUIPMENT (장비점검)</option>
<option value="ENVIRONMENT">ENVIRONMENT (작업환경)</option>
<option value="EMERGENCY">EMERGENCY (비상대응)</option>
</select>
</div>
</div>
<!-- 날씨별 유형: 날씨 조건 선택 -->
<div id="weatherFields" class="conditional-fields">
<div class="form-group">
<label class="form-label">날씨 조건</label>
<select id="weatherCondition" class="form-select">
<!-- 동적 로드 -->
</select>
</div>
</div>
<!-- 작업별 유형: 공정/작업 선택 -->
<div id="taskFields" class="conditional-fields">
<div class="form-group">
<label class="form-label">공정</label>
<select id="modalWorkType" class="form-select" onchange="loadModalTasks()">
<option value="">공정 선택</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업</label>
<select id="modalTask" class="form-select">
<option value="">작업 선택</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">체크 항목</label>
<input type="text" id="checkItem" class="form-input" placeholder="예: 안전모 착용 확인" required>
</div>
<div class="form-group">
<label class="form-label">설명 (선택)</label>
<textarea id="checkDescription" class="form-textarea" placeholder="항목에 대한 상세 설명"></textarea>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="isRequired" checked>
<span>필수 체크 항목</span>
</label>
</div>
<div class="form-group">
<label class="form-label">표시 순서</label>
<input type="number" id="displayOrder" class="form-input" value="0" min="0">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeModal()">취소</button>
<button type="button" class="btn-primary" onclick="saveCheck()">저장</button>
</div>
</div>
</div>
<script type="module" src="/js/api-config.js"></script>
<script src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/safety-checklist-manage.js"></script>
</body>
</html>

View File

@@ -1,291 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.status-tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid var(--gray-200);
}
.status-tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-weight: 600;
color: var(--gray-600);
transition: all var(--transition-fast);
}
.status-tab:hover {
color: var(--primary-600);
}
.status-tab.active {
color: var(--primary-600);
border-bottom-color: var(--primary-600);
}
.request-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.request-table th {
background: var(--gray-50);
padding: 12px;
text-align: left;
font-weight: 600;
color: var(--gray-700);
border-bottom: 2px solid var(--gray-200);
}
.request-table td {
padding: 12px;
border-bottom: 1px solid var(--gray-200);
}
.request-table tr:hover {
background: var(--gray-50);
}
.status-badge {
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
}
.status-badge.pending {
background: var(--yellow-100);
color: var(--yellow-700);
}
.status-badge.approved {
background: var(--green-100);
color: var(--green-700);
}
.status-badge.rejected {
background: var(--red-100);
color: var(--red-700);
}
.status-badge.training_completed {
background: var(--blue-100);
color: var(--blue-700);
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 6px 12px;
font-size: var(--text-sm);
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-2xl);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h2 {
margin: 0;
}
.detail-grid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin-bottom: 16px;
}
.detail-label {
font-weight: 600;
color: var(--gray-600);
}
.detail-value {
color: var(--gray-900);
}
.empty-state {
text-align: center;
padding: 64px 32px;
color: var(--gray-500);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
padding: 20px;
background: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--primary-500);
}
.stat-label {
font-size: var(--text-sm);
color: var(--gray-600);
margin-bottom: 8px;
}
.stat-value {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--gray-900);
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">안전관리</h1>
<p class="page-description">출입 신청 승인 및 안전교육 관리</p>
</div>
</div>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">승인 대기</div>
<div class="stat-value" id="statPending">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--green-500);">
<div class="stat-label">승인 완료</div>
<div class="stat-value" id="statApproved">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--blue-500);">
<div class="stat-label">교육 완료</div>
<div class="stat-value" id="statTrainingCompleted">0</div>
</div>
<div class="stat-card" style="border-left-color: var(--red-500);">
<div class="stat-label">반려</div>
<div class="stat-value" id="statRejected">0</div>
</div>
</div>
<!---->
<div class="code-section">
<div class="status-tabs">
<button class="status-tab active" data-status="pending" onclick="switchTab('pending')">
승인 대기
</button>
<button class="status-tab" data-status="approved" onclick="switchTab('approved')">
승인 완료
</button>
<button class="status-tab" data-status="training_completed" onclick="switchTab('training_completed')">
교육 완료
</button>
<button class="status-tab" data-status="rejected" onclick="switchTab('rejected')">
반려
</button>
<button class="status-tab" data-status="all" onclick="switchTab('all')">
전체
</button>
</div>
<!-- 테이블 -->
<div id="requestTableContainer">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
</main>
</div>
<!-- 상세보기 모달 -->
<div id="detailModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>출입 신청 상세</h2>
<button class="btn btn-secondary btn-sm" onclick="closeDetailModal()">닫기</button>
</div>
<div id="detailContent">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
<!-- 반려 사유 입력 모달 -->
<div id="rejectModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>반려 사유 입력</h2>
<button class="btn btn-secondary btn-sm" onclick="closeRejectModal()">취소</button>
</div>
<div>
<div class="form-group">
<label for="rejectionReason">반려 사유 *</label>
<textarea id="rejectionReason" rows="4" style="width: 100%; padding: 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-md);" placeholder="반려 사유를 입력하세요"></textarea>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<button class="btn btn-secondary" onclick="closeRejectModal()">취소</button>
<button class="btn btn-danger" onclick="confirmReject()">반려 확정</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/safety-management.js"></script>
</body>
</html>

View File

@@ -1,327 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.training-container {
max-width: 1000px;
margin: 0 auto;
}
.request-info-card {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--primary-500);
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: var(--text-sm);
color: var(--gray-600);
margin-bottom: 4px;
}
.info-value {
font-size: var(--text-base);
font-weight: 600;
color: var(--gray-900);
}
.checklist-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.checklist-item {
display: flex;
align-items: center;
padding: 16px;
margin-bottom: 12px;
background: var(--gray-50);
border-radius: var(--radius-md);
border: 2px solid transparent;
transition: all var(--transition-fast);
}
.checklist-item:hover {
border-color: var(--primary-300);
background: var(--primary-50);
}
.checklist-item input[type="checkbox"] {
width: 24px;
height: 24px;
margin-right: 16px;
cursor: pointer;
}
.checklist-item label {
flex: 1;
font-size: var(--text-base);
cursor: pointer;
user-select: none;
}
.checklist-item input[type="checkbox"]:checked + label {
color: var(--gray-500);
text-decoration: line-through;
}
.signature-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
}
.signature-canvas-container {
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
background: white;
margin-top: 16px;
position: relative;
touch-action: none;
}
.signature-canvas {
display: block;
cursor: crosshair;
touch-action: none;
}
.signature-actions {
display: flex;
justify-content: space-between;
margin-top: 12px;
}
.signature-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--gray-400);
font-size: var(--text-base);
pointer-events: none;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.warning-box {
background: var(--yellow-50);
border: 2px solid var(--yellow-300);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 24px;
display: flex;
align-items: start;
gap: 12px;
}
.warning-icon {
font-size: 24px;
}
.warning-text {
flex: 1;
color: var(--yellow-800);
font-size: var(--text-sm);
}
.saved-signature-card {
background: var(--gray-50);
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
display: flex;
gap: 16px;
align-items: center;
}
.saved-signature-card img {
max-width: 300px;
height: auto;
border: 1px solid var(--gray-300);
border-radius: var(--radius-sm);
background: white;
}
.saved-signature-info {
flex: 1;
}
.saved-signature-number {
font-size: var(--text-lg);
font-weight: 700;
color: var(--primary-600);
margin-bottom: 8px;
}
.saved-signature-date {
font-size: var(--text-sm);
color: var(--gray-600);
}
.saved-signature-actions {
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">안전교육 진행</h1>
<p class="page-description">방문자 안전교육 실시 및 서명 받기</p>
</div>
</div>
<div class="training-container">
<!-- 출입 신청 정보 -->
<div class="request-info-card">
<h2 class="section-title" style="margin-bottom: 16px;">출입 신청 정보</h2>
<div id="requestInfo" class="info-grid">
<!-- 동적으로 로드됨 -->
</div>
</div>
<!-- 안전교육 체크리스트 -->
<div class="checklist-section">
<h2 class="section-title" style="margin-bottom: 16px;">안전교육 체크리스트</h2>
<p style="color: var(--gray-600); margin-bottom: 20px;">
방문자에게 다음 안전 사항을 교육하고 체크해주세요.
</p>
<div id="checklistContainer">
<div class="checklist-item">
<input type="checkbox" id="check1" name="safety-check" value="개인보호구 착용" onchange="updateCompleteButton()">
<label for="check1">개인보호구(안전모, 안전화, 안전복) 착용 방법 교육</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check2" name="safety-check" value="작업장 위험요소" onchange="updateCompleteButton()">
<label for="check2">작업장 내 위험요소 및 주의사항 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check3" name="safety-check" value="비상대피로" onchange="updateCompleteButton()">
<label for="check3">비상대피로 및 비상연락망 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check4" name="safety-check" value="출입통제구역" onchange="updateCompleteButton()">
<label for="check4">출입통제구역 및 금지사항 안내</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check5" name="safety-check" value="사고발생시 대응" onchange="updateCompleteButton()">
<label for="check5">사고 발생 시 대응 절차 교육</label>
</div>
<div class="checklist-item">
<input type="checkbox" id="check6" name="safety-check" value="안전수칙 준수" onchange="updateCompleteButton()">
<label for="check6">현장 안전수칙 준수 서약</label>
</div>
</div>
</div>
<!-- 경고 -->
<div class="warning-box">
<div class="warning-icon">⚠️</div>
<div class="warning-text">
<strong>중요:</strong> 모든 체크리스트 항목을 완료하고 방문자의 서명을 받은 후 교육 완료 처리를 해주세요.
교육 완료 후에는 수정할 수 없습니다.
</div>
</div>
<!-- 서명 섹션 -->
<div class="signature-section">
<h2 class="section-title" style="margin-bottom: 16px;">방문자 서명 (<span id="signatureCount">0</span>명)</h2>
<p style="color: var(--gray-600); margin-bottom: 20px;">
각 방문자가 왼쪽에 이름을 쓰고 오른쪽에 서명한 후 "저장" 버튼을 눌러주세요.
</p>
<div class="signature-canvas-container" style="position: relative;">
<!-- 이름과 서명 구분선 및 라벨 -->
<div style="position: absolute; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; z-index: 1; pointer-events: none;">
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600;">이름</span>
<span style="position: absolute; left: 250px; top: 0; bottom: 0; width: 2px; background: var(--gray-300);"></span>
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600); margin-left: auto;">서명</span>
</div>
<canvas id="signatureCanvas" class="signature-canvas" width="800" height="300"></canvas>
<div id="signaturePlaceholder" class="signature-placeholder" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<div>왼쪽에 이름을 쓰고, 오른쪽에 서명해주세요</div>
<div style="font-size: var(--text-sm); color: var(--gray-400);">(마우스, 터치, 또는 Apple Pencil 사용)</div>
</div>
</div>
<div class="signature-actions">
<button type="button" class="btn btn-secondary" onclick="clearSignature()">
서명 지우기
</button>
<button type="button" class="btn btn-primary" onclick="saveSignature()">
서명 저장
</button>
</div>
<div style="font-size: var(--text-sm); color: var(--gray-600); margin-top: 12px;">
서명 날짜: <span id="signatureDate"></span>
</div>
<!-- 저장된 서명 목록 -->
<div id="savedSignatures" style="margin-top: 24px;">
<!-- 동적으로 추가됨 -->
</div>
</div>
<!-- 제출 버튼 -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="goBack()">
취소
</button>
<button type="button" class="btn btn-primary" onclick="completeTraining()" id="completeBtn" disabled>
교육 완료 처리
</button>
</div>
</div>
</div>
</main>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/safety-training-conduct.js"></script>
</body>
</html>

View File

@@ -1,236 +0,0 @@
<!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">
<a href="/pages/admin/equipments.html">
<span class="menu-icon">⚙️</span>
<span class="menu-text">설비 관리</span>
</a>
</li>
<li class="menu-item active">
<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="openWorkTypeModal()">
<span class="btn-icon">🔧</span>
공정 추가
</button>
<button class="btn btn-primary" onclick="openTaskModal()">
<span class="btn-icon"></span>
작업 추가
</button>
<button class="btn btn-secondary" onclick="refreshTasks()">
<span class="btn-icon">🔄</span>
새로고침
</button>
</div>
</div>
<!-- 공정(work_types) 탭 -->
<div class="code-tabs" id="workTypeTabs">
<button class="tab-btn active" data-work-type="" onclick="switchWorkType('')">
<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="taskStats">
<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="taskGrid">
<!-- 작업 카드들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</main>
<!-- 작업 추가/수정 모달 -->
<div id="taskModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="taskModalTitle">작업 추가</h2>
<button class="modal-close-btn" onclick="closeTaskModal()">×</button>
</div>
<div class="modal-body">
<form id="taskForm" onsubmit="event.preventDefault(); saveTask();">
<input type="hidden" id="taskId">
<div class="form-group">
<label class="form-label">소속 공정 *</label>
<select id="taskWorkTypeId" class="form-control" required>
<option value="">공정 선택...</option>
<!-- 공정 목록이 동적으로 생성됩니다 -->
</select>
<small class="form-help">이 작업이 속한 공정을 선택하세요</small>
</div>
<div class="form-group">
<label class="form-label">작업명 *</label>
<input type="text" id="taskName" class="form-control" placeholder="예: 서스 용접, 프레임 조립" required>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="taskDescription" class="form-control" rows="4" placeholder="작업에 대한 설명을 입력하세요"></textarea>
</div>
<div class="form-group">
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="taskIsActive" checked style="margin: 0;">
<span>활성화</span>
</label>
<small class="form-help">비활성화하면 TBM 입력 시 표시되지 않습니다</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveTask()">
💾 저장
</button>
</div>
</div>
</div>
<!-- 공정 추가/수정 모달 -->
<div id="workTypeModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="workTypeModalTitle">공정 추가</h2>
<button class="modal-close-btn" onclick="closeWorkTypeModal()">×</button>
</div>
<div class="modal-body">
<form id="workTypeForm" onsubmit="event.preventDefault(); saveWorkType();">
<input type="hidden" id="workTypeId">
<div class="form-group">
<label class="form-label">공정명 *</label>
<input type="text" id="workTypeName" class="form-control" placeholder="예: Base(구조물), Vessel(용기)" required>
</div>
<div class="form-group">
<label class="form-label">카테고리</label>
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작, 조립">
<small class="form-help">공정을 그룹화할 카테고리 (선택사항)</small>
</div>
<div class="form-group">
<label class="form-label">설명</label>
<textarea id="workTypeDescription" class="form-control" rows="3" placeholder="공정에 대한 설명"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkTypeModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveWorkType()">
💾 저장
</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/task-management.js?v=1"></script>
</body>
</html>

View File

@@ -1,291 +0,0 @@
<!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=6">
<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 active">
<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">
<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="openWorkerModal()">
<span class="btn-icon"></span>
새 작업자 등록
</button>
<button class="btn btn-secondary" onclick="refreshWorkerList()">
<span class="btn-icon">🔄</span>
새로고침
</button>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-section">
<div class="search-bar">
<input type="text" id="searchInput" class="search-input" placeholder="작업자명, 직책, 전화번호로 검색...">
<button class="search-btn" onclick="searchWorkers()">
<span>🔍</span>
</button>
</div>
<div class="filter-options">
<select id="jobTypeFilter" class="filter-select" onchange="filterWorkers()">
<option value="">모든 직책</option>
<option value="leader">그룹장</option>
<option value="worker">작업자</option>
<option value="admin">관리자</option>
</select>
<select id="statusFilter" class="filter-select" onchange="filterWorkers()">
<option value="">모든 상태</option>
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
<select id="sortBy" class="filter-select" onchange="sortWorkers()">
<option value="created_at">등록일순</option>
<option value="worker_name">이름순</option>
<option value="job_type">직책순</option>
</select>
</div>
</div>
<!-- 작업자 목록 -->
<div class="projects-section">
<div class="section-header">
<h2 class="section-title">등록된 작업자</h2>
<div class="project-stats">
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 작업자만 보기">
<span class="stat-icon">🟢</span>
활성 <span id="activeWorkers">0</span>
</span>
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 작업자만 보기">
<span class="stat-icon">🔴</span>
비활성 <span id="inactiveWorkers">0</span>
</span>
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 작업자 보기">
<span class="stat-icon">📊</span>
<span id="totalWorkers">0</span>
</span>
</div>
</div>
<!-- 작업자 테이블 -->
<div class="table-container">
<table class="data-table" id="workersTable">
<thead>
<tr>
<th style="width: 60px;">상태</th>
<th style="width: 100px;">이름</th>
<th style="width: 100px;">직책</th>
<th style="width: 130px;">전화번호</th>
<th style="width: 180px;">이메일</th>
<th style="width: 100px;">입사일</th>
<th style="width: 100px;">부서</th>
<th style="width: 80px;">계정</th>
<th style="width: 80px;">현장직</th>
<th style="width: 120px;">등록일</th>
<th style="width: 100px;">관리</th>
</tr>
</thead>
<tbody id="workersGrid">
<!-- 작업자 행들이 여기에 동적으로 생성됩니다 -->
</tbody>
</table>
</div>
<!-- Empty State -->
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">👥</div>
<h3>등록된 작업자가 없습니다.</h3>
<p>"새 작업자 등록" 버튼을 눌러 작업자를 등록해보세요.</p>
<button class="btn btn-primary" onclick="openWorkerModal()">
첫 작업자 등록하기
</button>
</div>
</div>
</div>
</main>
<!-- 작업자 추가/수정 모달 -->
<div id="workerModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">새 작업자 등록</h2>
<button class="modal-close-btn" onclick="closeWorkerModal()">×</button>
</div>
<div class="modal-body">
<form id="workerForm" onsubmit="event.preventDefault(); saveWorker();">
<input type="hidden" id="workerId">
<div class="form-row">
<div class="form-group">
<label class="form-label">작업자명 *</label>
<input type="text" id="workerName" class="form-control" placeholder="작업자 이름을 입력하세요" required>
</div>
<div class="form-group">
<label class="form-label">직책</label>
<select id="jobType" class="form-control">
<option value="worker">👷 작업자</option>
<option value="leader">👨‍💼 그룹장</option>
<option value="admin">👨‍💻 관리자</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">전화번호</label>
<input type="tel" id="phoneNumber" class="form-control" placeholder="010-0000-0000">
</div>
<div class="form-group">
<label class="form-label">이메일</label>
<input type="email" id="email" class="form-control" placeholder="example@company.com">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">입사일</label>
<input type="date" id="hireDate" class="form-control">
</div>
<div class="form-group">
<label class="form-label">부서</label>
<input type="text" id="department" class="form-control" placeholder="소속 부서">
</div>
</div>
<div class="form-group">
<label class="form-label">비고</label>
<textarea id="notes" class="form-control" rows="3" placeholder="추가 정보나 특이사항을 입력하세요"></textarea>
</div>
<!-- 상태 관리 섹션 -->
<div class="form-group">
<label class="form-label" style="font-weight: 600; margin-bottom: 0.75rem; display: block;">상태 관리</label>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<!-- 계정 생성/연동 -->
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="hasAccount" style="margin: 0; cursor: pointer;">
<span>🔐 계정 생성/연동</span>
</label>
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
체크 시 로그인 계정이 자동 생성됩니다 (나의 대시보드, 연차/출퇴근 관리 가능)
</small>
<!-- 현장직/사무직 구분 -->
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="isActive" checked style="margin: 0; cursor: pointer;">
<span>🏭 현장직 (활성화)</span>
</label>
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
체크: 현장직 (출퇴근 관리 필요) / 체크 해제: 사무직 (출퇴근 관리 불필요)
</small>
<!-- 퇴사 처리 -->
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="isResigned" style="margin: 0; cursor: pointer;">
<span style="color: #ef4444;">🚪 퇴사 처리</span>
</label>
<small style="color: #ef4444; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
퇴사한 작업자로 표시됩니다. 작업 보고서에서 제외됩니다
</small>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkerModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteWorkerBtn" onclick="deleteWorker()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" onclick="saveWorker()">
💾 저장
</button>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/worker-management.js?v=7"></script>
</body>
</html>

View File

@@ -1,414 +0,0 @@
<!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/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="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" 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">
<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>
<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>
</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 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=3"></script>
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
</body>
</html>

View File

@@ -1,143 +0,0 @@
<!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/annual-vacation-overview.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title">📊 연간 연차 현황</h1>
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
</div>
<!-- 필터 섹션 -->
<section class="filter-section">
<div class="card">
<div class="card-body">
<div class="filter-controls">
<div class="form-group">
<label for="yearSelect">조회 연도</label>
<select id="yearSelect" class="form-select">
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<button id="refreshBtn" class="btn btn-primary">
조회
</button>
</div>
</div>
</div>
</section>
<!-- 탭 네비게이션 -->
<section class="tabs-section">
<div class="tabs-nav">
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
</div>
</section>
<!-- 탭 1: 연간 사용 기록 -->
<section id="annualUsageTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 휴가 사용 현황</h2>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="annualUsageChart"></canvas>
</div>
</div>
</div>
</section>
<!-- 탭 2: 월별 상세 기록 -->
<section id="monthlyDetailsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 상세 기록</h2>
<div class="month-controls">
<select id="monthSelect" class="form-select">
<option value="1">1월</option>
<option value="2">2월</option>
<option value="3">3월</option>
<option value="4">4월</option>
<option value="5">5월</option>
<option value="6">6월</option>
<option value="7">7월</option>
<option value="8">8월</option>
<option value="9">9월</option>
<option value="10">10월</option>
<option value="11">11월</option>
<option value="12">12월</option>
</select>
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
📥 엑셀 다운로드
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자명</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>사용 일수</th>
<th>사유</th>
<th>상태</th>
</tr>
</thead>
<tbody id="monthlyTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
</body>
</html>

View File

@@ -1,395 +0,0 @@
<!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>
<!-- 메인 콘텐츠 -->
<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">
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 출퇴근 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">작업자 출퇴근 기록</h2>
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
</div>
<div class="card-body">
<div id="attendanceList" class="data-table-container">
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
<span>💾 전체 저장</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 전역 변수
let workers = [];
let attendanceRecords = [];
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
const attendanceTypes = [
{ value: 'on_time', label: '정시', hours: 8 },
{ value: 'half_leave', label: '반차', hours: 4 },
{ value: 'quarter_leave', label: '반반차', hours: 6 },
{ value: 'early_leave', label: '조퇴', hours: 2 },
{ value: 'weekend_work', label: '주말근무', hours: 0 },
{ value: 'annual_leave', label: '연차', hours: 0 },
{ value: 'custom', label: '특이사항', hours: 0 }
];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 오늘 날짜 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('selectedDate').value = today;
try {
await loadWorkers();
await loadAttendanceRecords();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
try {
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
const [recordsResponse, checkinResponse] = await Promise.all([
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
]);
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
attendanceRecords = checkinList.map(worker => {
const existingRecord = existingRecords.find(r => r.worker_id === worker.worker_id);
const isOnVacation = worker.vacation_status === 'approved';
// 기존 기록이 있으면 사용, 없으면 초기화
if (existingRecord) {
return existingRecord;
} else {
return {
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: isOnVacation ? 0 : 8,
overtime_hours: 0,
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
is_custom: false,
is_new: true,
is_on_vacation: isOnVacation,
vacation_type_name: worker.vacation_type_name
};
}
});
renderAttendanceList();
} catch (error) {
console.error('출퇴근 기록 로드 오류:', error);
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
}
}
function initializeAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
attendanceRecords = workers.map(worker => ({
worker_id: worker.worker_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: 8,
overtime_hours: 0,
attendance_type: 'on_time',
is_custom: false,
is_new: true
}));
renderAttendanceList();
}
function renderAttendanceList() {
const container = document.getElementById('attendanceList');
if (attendanceRecords.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
작업자 목록으로 초기화
</button>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table" style="font-size: 0.95rem;">
<thead style="background-color: #f8f9fa;">
<tr>
<th style="width: 120px;">작업자</th>
<th style="width: 180px;">근태 구분</th>
<th style="width: 100px;">근무시간</th>
<th style="width: 120px;">연장근로</th>
<th style="width: 100px;">특이사항</th>
</tr>
</thead>
<tbody>
${attendanceRecords.map((record, index) => {
const isCustom = record.is_custom || record.attendance_type === 'custom';
const isHoursReadonly = !isCustom; // 특이사항이 아니면 근무시간은 읽기 전용
const isOnVacation = record.is_on_vacation || false;
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
return `
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
<td style="padding: 0.75rem; font-weight: 600;">
${record.worker_name}
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
</td>
<td style="padding: 0.75rem;">
<select class="form-control"
onchange="updateAttendanceType(${index}, this.value)"
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
${attendanceTypes.map(type => `
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
${type.label}
</option>
`).join('')}
</select>
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="hours_${index}"
value="${record.total_hours || 0}"
min="0" max="24" step="0.5"
${isHoursReadonly ? 'readonly' : ''}
onchange="updateTotalHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="overtime_${index}"
value="${record.overtime_hours || 0}"
min="0" max="12" step="0.5"
onchange="updateOvertimeHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; background-color: white; text-align: center;">
</td>
<td style="padding: 0.75rem; text-align: center;">
${isCustom ?
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
'<span style="color: #9ca3af;">-</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
function updateTotalHours(index, value) {
attendanceRecords[index].total_hours = parseFloat(value) || 0;
}
function updateOvertimeHours(index, value) {
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
}
function updateAttendanceType(index, value) {
const record = attendanceRecords[index];
record.attendance_type = value;
// 근태 구분에 따라 자동으로 근무시간 설정
const attendanceType = attendanceTypes.find(t => t.value === value);
if (value === 'custom') {
// 특이사항 선택 시 수동 입력 가능
record.is_custom = true;
// 기존 값 유지, 수동 입력 가능
} else if (attendanceType) {
// 다른 근태 구분 선택 시 근무시간만 자동 설정
record.is_custom = false;
record.total_hours = attendanceType.hours;
// 연장근로는 유지 (별도 입력 가능)
}
// UI 다시 렌더링
renderAttendanceList();
}
async function saveAllAttendance() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
if (attendanceRecords.length === 0) {
alert('저장할 출퇴근 기록이 없습니다.');
return;
}
// 모든 기록을 API 형식에 맞게 변환
const recordsToSave = attendanceRecords.map(record => ({
worker_id: record.worker_id,
attendance_date: selectedDate,
total_hours: record.total_hours || 0,
overtime_hours: record.overtime_hours || 0,
attendance_type: record.attendance_type || 'on_time',
is_custom: record.is_custom || false
}));
try {
// 각 기록을 순차적으로 저장
let successCount = 0;
let errorCount = 0;
for (const data of recordsToSave) {
try {
const response = await axios.post('/attendance/records', data);
if (response.data.success) {
successCount++;
}
} catch (error) {
console.error(`작업자 ${data.worker_id} 저장 오류:`, error);
errorCount++;
}
}
if (errorCount === 0) {
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
await loadAttendanceRecords(); // 저장 후 새로고침
} else {
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
}
} catch (error) {
console.error('전체 저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
}
</script>
</body>
</html>

View File

@@ -1,490 +0,0 @@
<!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>
<style>
.calendar-container {
margin-top: 2rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: #e5e7eb;
border: 1px solid #e5e7eb;
}
.calendar-header {
background-color: #f3f4f6;
padding: 1rem;
text-align: center;
font-weight: 600;
color: #374151;
}
.calendar-day {
background-color: white;
padding: 0.5rem;
min-height: 100px;
position: relative;
}
.calendar-day.today {
background-color: #fef3c7;
}
.calendar-day.other-month {
background-color: #f9fafb;
color: #9ca3af;
}
.day-number {
font-weight: 600;
margin-bottom: 0.5rem;
}
.day-info {
font-size: 0.75rem;
margin-top: 0.25rem;
}
.attendance-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-normal {
background-color: #d1fae5;
color: #065f46;
}
.badge-overtime {
background-color: #fef3c7;
color: #92400e;
}
.badge-annual {
background-color: #dbeafe;
color: #1e40af;
}
.badge-half {
background-color: #e0e7ff;
color: #3730a3;
}
.badge-quarter {
background-color: #f3e8ff;
color: #5b21b6;
}
.badge-early {
background-color: #fce7f3;
color: #9f1239;
}
.worker-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.summary-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-item {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.summary-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.summary-value {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<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">
<input type="month" id="selectedMonth" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadMonthlyData()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<!-- 작업자 선택 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="worker-selector">
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
<option value="">작업자를 선택하세요</option>
</select>
</div>
</div>
</div>
</div>
<!-- 월별 요약 통계 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 요약</h2>
</div>
<div class="card-body">
<div class="summary-card" id="summarySection">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
<!-- 달력 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
</div>
<div class="card-body">
<div class="calendar-container">
<div class="calendar-grid">
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div id="calendarDays">
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 전역 변수
let workers = [];
let attendanceRecords = [];
let currentUser = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 현재 년월 설정
const now = new Date();
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
document.getElementById('selectedMonth').value = yearMonth;
// 현재 사용자 정보 가져오기
try {
const userInfo = JSON.parse(localStorage.getItem('user'));
currentUser = userInfo;
await loadWorkers();
// 관리자가 아니면 자동으로 자신 선택
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
const workerSelect = document.getElementById('workerSelect');
if (currentUser.worker_id) {
workerSelect.value = currentUser.worker_id;
}
}
await loadMonthlyData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerSelect');
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.worker_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadMonthlyData() {
const selectedMonth = document.getElementById('selectedMonth').value;
const workerId = document.getElementById('workerSelect').value;
if (!selectedMonth) {
alert('월을 선택해주세요.');
return;
}
if (!workerId) {
alert('작업자를 선택해주세요.');
return;
}
try {
// 선택한 월의 첫날과 마지막 날 계산
const [year, month] = selectedMonth.split('-');
const startDate = `${year}-${month}-01`;
const endDate = new Date(year, month, 0).getDate();
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
// 출퇴근 기록 로드
const response = await axios.get(`/attendance/records`, {
params: {
worker_id: workerId,
start_date: startDate,
end_date: endDateStr
}
});
if (response.data.success) {
attendanceRecords = response.data.data || [];
renderCalendar();
renderSummary();
}
} catch (error) {
console.error('월별 데이터 로드 오류:', error);
if (error.response?.status === 404) {
attendanceRecords = [];
renderCalendar();
renderSummary();
}
}
}
function renderCalendar() {
const selectedMonth = document.getElementById('selectedMonth').value;
const [year, month] = selectedMonth.split('-');
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const today = new Date();
// 달력 제목 업데이트
document.getElementById('calendarTitle').textContent = `${year}${month}월 출퇴근 달력`;
// 달력 그리드 생성
const calendarDays = document.getElementById('calendarDays');
calendarDays.innerHTML = '';
// 이전 달의 빈 칸
for (let i = 0; i < firstDay.getDay(); i++) {
const emptyDay = document.createElement('div');
emptyDay.className = 'calendar-day other-month';
calendarDays.appendChild(emptyDay);
}
// 현재 달의 날짜
for (let day = 1; day <= lastDay.getDate(); day++) {
const dayElement = document.createElement('div');
const currentDate = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
dayElement.className = 'calendar-day';
// 오늘 날짜 표시
if (currentDate.toDateString() === today.toDateString()) {
dayElement.classList.add('today');
}
// 날짜 번호
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = day;
dayElement.appendChild(dayNumber);
// 출퇴근 기록 찾기
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
if (record) {
const infoDiv = document.createElement('div');
infoDiv.className = 'day-info';
// 근무시간
const hoursSpan = document.createElement('div');
hoursSpan.textContent = `${record.total_hours}시간`;
infoDiv.appendChild(hoursSpan);
// 야근 표시
if (record.is_overtime) {
const overtimeBadge = document.createElement('span');
overtimeBadge.className = 'attendance-badge badge-overtime';
overtimeBadge.textContent = '야근';
infoDiv.appendChild(overtimeBadge);
}
// 근태 구분
if (record.attendance_type && record.attendance_type !== 'normal') {
const typeBadge = document.createElement('span');
typeBadge.className = 'attendance-badge';
switch (record.attendance_type) {
case 'annual_leave':
typeBadge.classList.add('badge-annual');
typeBadge.textContent = '연차';
break;
case 'half_leave':
typeBadge.classList.add('badge-half');
typeBadge.textContent = '반차';
break;
case 'quarter_leave':
typeBadge.classList.add('badge-quarter');
typeBadge.textContent = '반반차';
break;
case 'early_leave':
typeBadge.classList.add('badge-early');
typeBadge.textContent = '조퇴';
break;
default:
typeBadge.classList.add('badge-normal');
typeBadge.textContent = '정상';
}
infoDiv.appendChild(typeBadge);
}
dayElement.appendChild(infoDiv);
}
calendarDays.appendChild(dayElement);
}
}
function renderSummary() {
const summarySection = document.getElementById('summarySection');
// 통계 계산
const totalDays = attendanceRecords.length;
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
summarySection.innerHTML = `
<div class="summary-item">
<div class="summary-label">총 근무일수</div>
<div class="summary-value">${totalDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">총 근무시간</div>
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
</div>
<div class="summary-item">
<div class="summary-label">야근일수</div>
<div class="summary-value">${overtimeDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">연차 사용</div>
<div class="summary-value">${annualLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반차 사용</div>
<div class="summary-value">${halfLeaveDays}일</div>
</div>
<div class="summary-item">
<div class="summary-label">반반차 사용</div>
<div class="summary-value">${quarterLeaveDays}일</div>
</div>
`;
}
</script>
</body>
</html>

View File

@@ -1,354 +0,0 @@
<!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/vacation-allocation.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title"> 휴가 발생 입력</h1>
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<!-- 탭 네비게이션 -->
<div class="tab-navigation">
<button class="tab-button active" data-tab="individual">개별 입력</button>
<button class="tab-button" data-tab="bulk">일괄 입력</button>
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
</div>
<!-- 탭 1: 개별 입력 -->
<section id="tab-individual" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">개별 작업자 휴가 입력</h2>
</div>
<div class="card-body">
<!-- 입력 폼 -->
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
<select id="individualWorker" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualYear">연도 <span class="required">*</span></label>
<select id="individualYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
<select id="individualVacationType" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
</div>
<!-- 자동 계산 섹션 -->
<div class="auto-calculate-section">
<div class="section-header">
<h3>자동 계산 (연차만 해당)</h3>
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
🔄 입사일 기준 자동 계산
</button>
</div>
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
<!-- 계산 결과 표시 -->
</div>
</div>
<!-- 수동 입력 -->
<div class="form-row">
<div class="form-group">
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="individualUsedDays">사용 일수</label>
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
</div>
<div class="form-group full-width">
<label for="individualNotes">비고</label>
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
</div>
</div>
<div class="form-actions">
<button id="individualSubmitBtn" class="btn btn-primary">
저장
</button>
<button id="individualResetBtn" class="btn btn-secondary">
초기화
</button>
</div>
</div>
<!-- 기존 데이터 테이블 -->
<div class="existing-data-section">
<h3>기존 입력 내역</h3>
<div class="table-responsive">
<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 id="individualTableBody">
<tr>
<td colspan="8" class="loading-state">
<p>작업자를 선택하세요</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 2: 일괄 입력 -->
<section id="tab-bulk" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
</div>
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
<select id="bulkYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="bulkEmploymentStatus">재직 상태</label>
<select id="bulkEmploymentStatus" class="form-select">
<option value="employed">재직 중만</option>
<option value="all">전체</option>
</select>
</div>
</div>
<div class="form-actions">
<button id="bulkPreviewBtn" class="btn btn-secondary">
미리보기
</button>
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
일괄 생성
</button>
</div>
</div>
<!-- 미리보기 테이블 -->
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
<h3>생성 예정 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>입사일</th>
<th>근속년수</th>
<th>부여 연차</th>
<th>계산 근거</th>
<th>상태</th>
</tr>
</thead>
<tbody id="bulkPreviewTableBody">
<!-- JavaScript로 동적 생성 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 3: 특별 휴가 관리 -->
<section id="tab-special" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">특별 휴가 유형 관리</h2>
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
+ 새 휴가 유형 추가
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>유형명</th>
<th>코드</th>
<th>우선순위</th>
<th>특별 휴가</th>
<th>시스템 유형</th>
<th>설명</th>
<th>작업</th>
</tr>
</thead>
<tbody id="specialTypesTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 모달: 휴가 유형 추가/수정 -->
<div id="vacationTypeModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">휴가 유형 추가</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="vacationTypeForm">
<input type="hidden" id="modalTypeId">
<div class="form-group">
<label for="modalTypeName">유형명 <span class="required">*</span></label>
<input type="text" id="modalTypeName" class="form-input" required>
</div>
<div class="form-group">
<label for="modalTypeCode">코드 <span class="required">*</span></label>
<input type="text" id="modalTypeCode" class="form-input" required>
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="modalPriority">우선순위 <span class="required">*</span></label>
<input type="number" id="modalPriority" class="form-input" min="1" required>
<small>낮을수록 먼저 차감</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="modalIsSpecial">
특별 휴가
</label>
</div>
</div>
<div class="form-group">
<label for="modalDescription">설명</label>
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
<!-- 모달: 휴가 수정 -->
<div id="editBalanceModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>휴가 수정</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="editBalanceForm">
<input type="hidden" id="editBalanceId">
<div class="form-group">
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editNotes">비고</label>
<input type="text" id="editNotes" class="form-input">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,267 +0,0 @@
<!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>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<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>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="pendingTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
let allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'pending') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('pendingTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -1,294 +0,0 @@
<!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>
<!-- 메인 콘텐츠 -->
<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>
<!-- 휴가 직접 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/my-vacation.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadRecentInputs();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', loadRecentInputs);
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null,
auto_approve: true // 자동 승인 플래그
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>

View File

@@ -1,461 +0,0 @@
<!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>
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<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>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('input')">직접 입력</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="approvalTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 직접 입력 탭 -->
<div id="inputTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 직접 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>💾 즉시 입력 (자동 승인)</span>
</button>
</div>
</form>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
<span>🔄 새로고침</span>
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
<span>🔍 조회</span>
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
<span>🔄 전체</span>
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
let allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/common/vacation-request.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'approval') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('approvalTab').classList.add('active');
} else if (tabName === 'input') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('inputTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(3)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
worker_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
loadRecentInputs();
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
</body>
</html>

View File

@@ -1,272 +0,0 @@
<!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>
<!-- 메인 콘텐츠 -->
<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>
<!-- 휴가 잔여 현황 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 잔여 현황</h2>
</div>
<div class="card-body">
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<!-- 휴가 신청 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 신청</h2>
</div>
<div class="card-body">
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="vacationType">휴가 유형 *</label>
<select id="vacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="daysUsed">사용 일수 *</label>
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label for="startDate">시작일 *</label>
<input type="date" id="startDate" class="form-control" required>
</div>
<div class="form-group">
<label for="endDate">종료일 *</label>
<input type="date" id="endDate" class="form-control" required>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="reason">사유</label>
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
<span>📝 신청하기</span>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 내 신청 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">내 신청 내역</h2>
</div>
<div class="card-body">
<div id="myRequestsList" class="data-table-container">
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
if (!currentUser || !currentUser.worker_id) {
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
return;
}
await loadVacationTypes();
await loadVacationBalance();
await loadMyRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadVacationBalance();
loadMyRequests();
});
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadVacationBalance() {
const currentUser = getCurrentUser();
try {
const response = await axios.get(`/attendance/vacation-balance/${currentUser.worker_id}`);
if (response.data.success) {
renderVacationBalance(response.data.data);
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
document.getElementById('vacationBalance').innerHTML = `
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
`;
}
}
function renderVacationBalance(balance) {
const container = document.getElementById('vacationBalance');
if (!balance || Object.keys(balance).length === 0) {
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
${info.remaining || 0}
</div>
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
사용: ${info.used || 0}일 / 전체: ${info.total || 0}
</div>
</div>
`;
}).join('');
container.innerHTML = balanceHTML;
}
async function submitVacationRequest(event) {
event.preventDefault();
const currentUser = getCurrentUser();
const data = {
worker_id: currentUser.worker_id,
vacation_type_id: parseInt(document.getElementById('vacationType').value),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
days_used: parseFloat(document.getElementById('daysUsed').value),
reason: document.getElementById('reason').value || null
};
try {
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
alert('휴가 신청이 완료되었습니다.');
document.getElementById('vacationRequestForm').reset();
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (error) {
console.error('휴가 신청 오류:', error);
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
}
}
async function loadMyRequests() {
const currentUser = getCurrentUser();
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 내 신청만 필터링
const myRequests = response.data.data.filter(req =>
req.requested_by === currentUser.user_id || req.worker_id === currentUser.worker_id
);
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
}
} catch (error) {
console.error('내 신청 내역 로드 오류:', error);
document.getElementById('myRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
</script>
</body>
</html>

View File

@@ -1,277 +0,0 @@
<!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/modern-dashboard.css?v=2">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 (순서 중요: api-config.js가 먼저 로드되어야 함) -->
<script type="module" src="/js/api-config.js"></script>
<script type="module" src="/js/auth-check.js" defer></script>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js" defer></script>
</head>
<body>
<!-- 메인 컨테이너 -->
<div class="dashboard-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="dashboard-main">
<!-- 빠른 작업 섹션 -->
<section class="quick-actions-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">빠른 작업</h2>
</div>
<div class="card-body">
<div class="quick-actions-grid-full">
<!-- TBM 관리 (페이지 권한 체크) -->
<a href="/pages/work/tbm.html" class="quick-action-card" id="tbmQuickAction" style="display: none; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">🛠️ TBM 관리</h3>
<p style="color: rgba(255, 255, 255, 0.9);">아침 안전 회의 및 팀 구성을 관리합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">🚪 출입 신청</h3>
<p style="color: rgba(255, 255, 255, 0.9);">작업장 출입 및 안전교육을 신청합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">🛡️ 안전관리</h3>
<p style="color: rgba(255, 255, 255, 0.9);">출입 신청 승인 및 안전교육 관리</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/admin/safety-checklist-manage.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">📋 안전 체크리스트 관리</h3>
<p style="color: rgba(255, 255, 255, 0.9);">TBM 안전 체크 항목 관리 (기본/날씨/작업별)</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/issue-report.html" class="quick-action-card" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">⚠️ 문제 신고</h3>
<p style="color: rgba(255, 255, 255, 0.9);">작업 중 발생한 문제를 신고합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/issue-list.html" class="quick-action-card" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">📋 신고 현황</h3>
<p style="color: rgba(255, 255, 255, 0.9);">신고 목록 및 처리 현황을 확인합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/work/report-create.html" class="quick-action-card">
<div class="action-content">
<h3>작업 보고서 작성</h3>
<p>오늘의 작업 내용을 입력하고 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/work/report-view.html" class="quick-action-card">
<div class="action-content">
<h3>작업 현황 확인</h3>
<p>팀원들의 작업 현황을 실시간으로 조회합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/work/analysis.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>작업 분석</h3>
<p>작업 효율성 및 통계를 분석합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/admin/projects.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>기본 정보 관리</h3>
<p>프로젝트, 작업자, 코드를 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/daily-attendance.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
<div class="action-content">
<h3 style="color: white;">📅 일일 출퇴근 입력</h3>
<p style="color: rgba(255, 255, 255, 0.9);">오늘의 출퇴근 기록을 입력합니다</p>
</div>
<div class="action-arrow" style="color: white;"></div>
</a>
<a href="/pages/common/monthly-attendance.html" class="quick-action-card">
<div class="action-content">
<h3>📆 월별 출퇴근 현황</h3>
<p>이번 달 출퇴근 현황을 조회합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-request.html" class="quick-action-card">
<div class="action-content">
<h3>📝 휴가 신청</h3>
<p>휴가를 신청하고 신청 내역을 확인합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-management.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>🏖️ 휴가 관리</h3>
<p>휴가 승인, 직접 입력, 전체 내역을 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/annual-vacation-overview.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>📊 연간 연차 현황</h3>
<p>모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/common/vacation-allocation.html" class="quick-action-card admin-only">
<div class="action-content">
<h3> 휴가 발생 입력</h3>
<p>작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<div class="action-arrow"></div>
</a>
<a href="/pages/admin/attendance-report-comparison.html" class="quick-action-card admin-only">
<div class="action-content">
<h3>🔍 출퇴근-작업보고서 대조</h3>
<p>출퇴근 기록과 작업보고서를 비교 분석합니다</p>
</div>
<div class="action-arrow"></div>
</a>
</div>
</div>
</div>
</section>
<!-- 작업장 현황 -->
<section class="workplace-status-section">
<div class="card">
<div class="card-header">
<div class="flex justify-between items-center">
<h2 class="card-title">작업장 현황</h2>
<div class="flex items-center" style="gap: 12px;">
<select id="categorySelect" class="form-select" style="width: 200px;">
<option value="">공장을 선택하세요</option>
</select>
<button class="btn btn-primary btn-sm" id="refreshMapBtn">
새로고침
</button>
</div>
</div>
</div>
<div class="card-body">
<!-- 지도 영역 -->
<div id="workplaceMapContainer" style="position: relative; min-height: 500px; display: none;">
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas>
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);">
<h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(59, 130, 246, 0.3); border: 2px solid rgb(59, 130, 246); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">작업 중 (내부 작업자)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(168, 85, 247, 0.3); border: 2px solid rgb(168, 85, 247); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">방문 예정 (외부 인원)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(34, 197, 94, 0.3); border: 2px solid rgb(34, 197, 94); border-radius: 4px;"></div>
<span style="font-size: var(--text-sm);">작업 + 방문</span>
</div>
</div>
</div>
</div>
<!-- 안내 메시지 -->
<div id="mapPlaceholder" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: var(--gray-500);">
<div style="font-size: 48px; margin-bottom: 16px;">🏭</div>
<h3 style="margin-bottom: 8px;">공장을 선택하세요</h3>
<p style="font-size: var(--text-sm);">위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.</p>
</div>
</div>
</div>
</section>
</main>
<!-- 푸터 -->
<footer class="dashboard-footer">
<div class="footer-content">
<p class="footer-text">
© 2025 (주)테크니컬코리아. 모든 권리 보유.
</p>
<div class="footer-links">
<a href="#" class="footer-link">도움말</a>
<a href="#" class="footer-link">문의하기</a>
<a href="#" class="footer-link">개인정보처리방침</a>
</div>
</div>
</footer>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 작업장 상세 정보 모달 -->
<div id="workplaceDetailModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: var(--shadow-2xl);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 id="modalWorkplaceName" style="margin: 0; font-size: var(--text-2xl); font-weight: 700;"></h2>
<button class="btn btn-secondary btn-sm" onclick="closeWorkplaceModal()">닫기</button>
</div>
<!-- 내부 작업자 -->
<div id="internalWorkersSection" style="margin-bottom: 24px;">
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--primary-600);">👷 내부 작업자</h3>
<div id="internalWorkersList"></div>
</div>
<!-- 외부 방문자 -->
<div id="externalVisitorsSection">
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--purple-600);">🚪 외부 방문자</h3>
<div id="externalVisitorsList"></div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,317 +0,0 @@
<!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/main-layout.css">
<script src="/js/auth-check.js" defer></script>
<style>
.profile-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.profile-header {
text-align: center;
margin-bottom: 40px;
}
.profile-avatar {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: white;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.profile-name {
font-size: 2rem;
font-weight: 700;
color: #333;
margin-bottom: 8px;
}
.profile-role {
font-size: 1.2rem;
color: #666;
font-weight: 500;
}
.profile-cards {
display: grid;
gap: 24px;
}
.profile-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
padding: 28px;
}
.card-title {
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-label {
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
.info-value {
font-size: 1.05rem;
color: #333;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}
.action-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
font-family: inherit;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover {
background: #1565c0;
transform: translateY(-1px);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-warning {
background: #ff9800;
color: white;
}
.btn-warning:hover {
background: #f57c00;
transform: translateY(-1px);
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stat-box {
background: #f5f5f5;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #1976d2;
display: block;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
@media (max-width: 768px) {
.profile-page {
padding: 20px 16px;
}
.profile-avatar {
width: 100px;
height: 100px;
font-size: 2.5rem;
}
.profile-name {
font-size: 1.6rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="main-layout-no-sidebar">
<div id="navbar-container"></div>
<div class="profile-page">
<div class="profile-header">
<div class="profile-avatar" id="profileAvatar">👤</div>
<h1 class="profile-name" id="profileName">사용자</h1>
<p class="profile-role" id="profileRole">역할</p>
</div>
<div class="profile-cards">
<!-- 기본 정보 -->
<div class="profile-card">
<h2 class="card-title">
<span>📋</span>
<span>기본 정보</span>
</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">사용자 ID</span>
<span class="info-value" id="userId">-</span>
</div>
<div class="info-item">
<span class="info-label">사용자명</span>
<span class="info-value" id="username">-</span>
</div>
<div class="info-item">
<span class="info-label">이름</span>
<span class="info-value" id="fullName">-</span>
</div>
<div class="info-item">
<span class="info-label">권한 레벨</span>
<span class="info-value" id="accessLevel">-</span>
</div>
<div class="info-item">
<span class="info-label">작업자 ID</span>
<span class="info-value" id="workerId">-</span>
</div>
<div class="info-item">
<span class="info-label">가입일</span>
<span class="info-value" id="createdAt">-</span>
</div>
</div>
</div>
<!-- 활동 정보 -->
<div class="profile-card">
<h2 class="card-title">
<span>📊</span>
<span>활동 정보</span>
</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">마지막 로그인</span>
<span class="info-value" id="lastLogin">-</span>
</div>
<div class="info-item">
<span class="info-label">이메일</span>
<span class="info-value" id="email">-</span>
</div>
</div>
<!-- 간단한 통계 (준비중) -->
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">작업 보고서</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">이번 달 활동</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">팀 기여도</span>
</div>
</div>
</div>
<!-- 빠른 작업 -->
<div class="profile-card">
<h2 class="card-title">
<span></span>
<span>빠른 작업</span>
</h2>
<div class="action-buttons">
<a href="/pages/profile/change-password.html" class="action-btn btn-warning">
<span>🔐</span>
<span>비밀번호 변경</span>
</a>
<button class="action-btn btn-secondary" disabled>
<span>✏️</span>
<span>프로필 수정 (준비중)</span>
</button>
<button class="action-btn btn-secondary" disabled>
<span>⚙️</span>
<span>설정 (준비중)</span>
</button>
<a href="javascript:history.back()" class="action-btn btn-secondary">
<span></span>
<span>돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/my-profile.js"></script>
</body>
</html>

View File

@@ -1,391 +0,0 @@
<!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/main-layout.css">
<script src="/js/auth-check.js" defer></script>
<style>
/* 페이지 전용 스타일 */
.password-page {
max-width: 600px;
margin: 0 auto;
padding: 40px 20px;
}
.page-title {
text-align: center;
margin-bottom: 40px;
}
.page-title h1 {
font-size: 2rem;
color: #333;
margin-bottom: 12px;
}
.page-title p {
color: #666;
font-size: 1.1rem;
}
/* 카드 스타일 */
.password-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
color: white;
padding: 24px 32px;
}
.card-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.card-body {
padding: 32px;
}
/* 알림 박스 */
.info-box {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 16px;
margin-bottom: 28px;
}
.info-box h4 {
margin: 0 0 12px 0;
color: #1565c0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.info-box ul {
margin: 0;
padding-left: 24px;
color: #0d47a1;
}
.info-box li {
margin-bottom: 6px;
}
/* 폼 스타일 */
.password-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.input-wrapper {
position: relative;
}
.form-control {
width: 100%;
padding: 14px 48px 14px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: #ff9800;
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 8px;
font-size: 1.2rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.password-toggle:hover {
opacity: 1;
}
/* 버튼 */
.form-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-primary {
background: #ff9800;
color: white;
flex: 1;
}
.btn-primary:hover {
background: #f57c00;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
/* 메시지 */
.message-box {
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
animation: slideDown 0.3s ease-out;
}
.message-box.error {
background: #ffebee;
border: 1px solid #ffcdd2;
color: #c62828;
}
.message-box.success {
background: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 하단 링크 */
.back-link {
text-align: center;
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid #e0e0e0;
}
.back-link a {
color: #1976d2;
text-decoration: none;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.back-link a:hover {
color: #1565c0;
text-decoration: underline;
}
/* 반응형 */
@media (max-width: 768px) {
.password-page {
padding: 20px 16px;
}
.card-body {
padding: 24px;
}
.form-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="main-layout-no-sidebar">
<div id="navbar-container"></div>
<div class="password-page">
<div class="page-title">
<h1>🔐 비밀번호 변경</h1>
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
</div>
<div class="password-card">
<div class="card-header">
<h2>
<span>🔑</span>
<span>새 비밀번호 설정</span>
</h2>
</div>
<div class="card-body">
<!-- 메시지 영역 -->
<div id="message-area"></div>
<!-- 안내 정보 -->
<div class="info-box">
<h4>
<span></span>
<span>비밀번호 요구사항</span>
</h4>
<ul>
<li>최소 6자 이상 입력해주세요</li>
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
<li>개인정보나 쉬운 단어는 피해주세요</li>
<li>이전 비밀번호와 다르게 설정해주세요</li>
</ul>
</div>
<!-- 비밀번호 변경 폼 -->
<form id="changePasswordForm" class="password-form">
<div class="form-group">
<label for="currentPassword">
<span>🔓</span>
<span>현재 비밀번호</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="currentPassword"
class="form-control"
placeholder="현재 비밀번호를 입력하세요"
required
autocomplete="current-password"
/>
<button type="button" class="password-toggle" data-target="currentPassword">👁️</button>
</div>
</div>
<div class="form-group">
<label for="newPassword">
<span>🔐</span>
<span>새 비밀번호</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="newPassword"
class="form-control"
placeholder="새 비밀번호를 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="newPassword">👁️</button>
</div>
<div id="passwordStrength"></div>
</div>
<div class="form-group">
<label for="confirmPassword">
<span></span>
<span>새 비밀번호 확인</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="confirmPassword"
class="form-control"
placeholder="새 비밀번호를 다시 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="confirmPassword">👁️</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="submitBtn">
<span>🔑</span>
<span>비밀번호 변경</span>
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
초기화
</button>
</div>
</form>
<div class="back-link">
<a href="javascript:history.back()">
<span></span>
<span>이전 페이지로 돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/change-password.js"></script>
</body>
</html>

View File

@@ -1 +0,0 @@
# Placeholder file to create work directory

File diff suppressed because it is too large Load Diff

View File

@@ -1,946 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.detail-container {
max-width: 900px;
margin: 0 auto;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.detail-title {
font-size: 24px;
font-weight: 600;
}
.detail-id {
font-size: var(--text-sm);
color: var(--gray-500);
margin-bottom: 8px;
}
.status-badge {
padding: 8px 20px;
border-radius: 9999px;
font-size: var(--text-sm);
font-weight: 600;
}
.status-badge.reported { background: var(--blue-100); color: var(--blue-700); }
.status-badge.received { background: var(--orange-100); color: var(--orange-700); }
.status-badge.in_progress { background: var(--purple-100); color: var(--purple-700); }
.status-badge.completed { background: var(--green-100); color: var(--green-700); }
.status-badge.closed { background: var(--gray-100); color: var(--gray-700); }
.detail-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-200);
}
.section-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-200);
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.info-item {
padding: 12px;
background: var(--gray-50);
border-radius: var(--radius-md);
}
.info-label {
font-size: var(--text-xs);
color: var(--gray-500);
margin-bottom: 4px;
text-transform: uppercase;
}
.info-value {
font-size: var(--text-base);
font-weight: 500;
}
.type-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: var(--text-sm);
font-weight: 500;
}
.type-badge.nonconformity {
background: var(--orange-100);
color: var(--orange-700);
}
.type-badge.safety {
background: var(--red-100);
color: var(--red-700);
}
.severity-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: var(--text-xs);
font-weight: 600;
}
.severity-badge.critical { background: var(--red-100); color: var(--red-700); }
.severity-badge.high { background: var(--orange-100); color: var(--orange-700); }
.severity-badge.medium { background: var(--yellow-100); color: var(--yellow-700); }
.severity-badge.low { background: var(--gray-100); color: var(--gray-700); }
.photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.photo-item {
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
border: 1px solid var(--gray-200);
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.description-text {
padding: 16px;
background: var(--gray-50);
border-radius: var(--radius-md);
white-space: pre-wrap;
line-height: 1.6;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}
.action-btn {
padding: 12px 24px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: 1px solid var(--gray-300);
background: white;
transition: all var(--transition-fast);
}
.action-btn:hover {
background: var(--gray-50);
}
.action-btn.primary {
background: var(--primary-500);
color: white;
border-color: var(--primary-500);
}
.action-btn.primary:hover {
background: var(--primary-600);
}
.action-btn.success {
background: var(--green-500);
color: white;
border-color: var(--green-500);
}
.action-btn.success:hover {
background: var(--green-600);
}
.action-btn.danger {
background: var(--red-500);
color: white;
border-color: var(--red-500);
}
.action-btn.danger:hover {
background: var(--red-600);
}
/* 상태 변경 이력 */
.status-timeline {
position: relative;
padding-left: 24px;
}
.status-timeline::before {
content: '';
position: absolute;
left: 6px;
top: 0;
bottom: 0;
width: 2px;
background: var(--gray-200);
}
.timeline-item {
position: relative;
padding-bottom: 16px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -18px;
top: 4px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--primary-500);
}
.timeline-status {
font-weight: 600;
margin-bottom: 4px;
}
.timeline-meta {
font-size: var(--text-sm);
color: var(--gray-500);
}
/* 담당자 배정 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.visible {
display: flex;
}
.modal-content {
background: white;
padding: 24px;
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 20px;
}
.modal-form-group {
margin-bottom: 16px;
}
.modal-form-group label {
display: block;
font-weight: 500;
margin-bottom: 8px;
}
.modal-form-group input,
.modal-form-group select,
.modal-form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
/* 사진 확대 모달 */
.photo-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 1001;
align-items: center;
justify-content: center;
}
.photo-modal.visible {
display: flex;
}
.photo-modal img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
.photo-modal-close {
position: absolute;
top: 20px;
right: 20px;
color: white;
font-size: 32px;
cursor: pointer;
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
.detail-header {
flex-direction: column;
gap: 16px;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<div id="navbar-container"></div>
<main class="main-content">
<div class="detail-container">
<a href="/pages/work/issue-list.html" style="color: var(--primary-600); text-decoration: none; margin-bottom: 16px; display: inline-block;">
&#8592; 목록으로
</a>
<div class="detail-header">
<div>
<div class="detail-id" id="reportId"></div>
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
</div>
<span class="status-badge" id="statusBadge"></span>
</div>
<!-- 기본 정보 -->
<div class="detail-section">
<h2 class="section-title">신고 정보</h2>
<div class="info-grid" id="basicInfo"></div>
</div>
<!-- 신고 내용 -->
<div class="detail-section">
<h2 class="section-title">신고 내용</h2>
<div id="issueContent"></div>
</div>
<!-- 사진 -->
<div class="detail-section" id="photoSection" style="display: none;">
<h2 class="section-title">첨부 사진</h2>
<div class="photo-gallery" id="photoGallery"></div>
</div>
<!-- 처리 정보 (담당자 배정 시) -->
<div class="detail-section" id="processSection" style="display: none;">
<h2 class="section-title">처리 정보</h2>
<div id="processInfo"></div>
</div>
<!-- 상태 이력 -->
<div class="detail-section">
<h2 class="section-title">상태 변경 이력</h2>
<div class="status-timeline" id="statusTimeline"></div>
</div>
<!-- 액션 버튼 -->
<div class="action-buttons" id="actionButtons"></div>
</div>
</main>
<!-- 담당자 배정 모달 -->
<div class="modal-overlay" id="assignModal">
<div class="modal-content">
<h3 class="modal-title">담당자 배정</h3>
<div class="modal-form-group">
<label>담당 부서</label>
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
</div>
<div class="modal-form-group">
<label>담당자</label>
<select id="assignUser">
<option value="">담당자 선택</option>
</select>
</div>
<div class="modal-actions">
<button class="action-btn" onclick="closeAssignModal()">취소</button>
<button class="action-btn primary" onclick="submitAssign()">배정</button>
</div>
</div>
</div>
<!-- 처리 완료 모달 -->
<div class="modal-overlay" id="completeModal">
<div class="modal-content">
<h3 class="modal-title">처리 완료</h3>
<div class="modal-form-group">
<label>처리 내용</label>
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
</div>
<div class="modal-actions">
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
</div>
</div>
</div>
<!-- 사진 확대 모달 -->
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
<span class="photo-modal-close">&times;</span>
<img id="photoModalImg" src="" alt="">
</div>
<script src="/js/load-navbar.js?v=1"></script>
<script>
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
let currentReport = null;
let currentUserLevel = null;
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
const TYPE_LABELS = {
nonconformity: '부적합',
safety: '안전'
};
const SEVERITY_LABELS = {
critical: '심각',
high: '높음',
medium: '보통',
low: '낮음'
};
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const reportId = urlParams.get('id');
if (!reportId) {
alert('잘못된 접근입니다.');
window.location.href = '/pages/work/issue-list.html';
return;
}
// 사용자 정보 확인
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
currentUserLevel = payload.access_level;
} catch (e) {}
}
await loadReport(reportId);
await loadStatusLogs(reportId);
await loadUsers();
});
// 신고 상세 로드
async function loadReport(reportId) {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) {
if (response.status === 404) {
alert('신고를 찾을 수 없습니다.');
} else if (response.status === 403) {
alert('권한이 없습니다.');
}
window.location.href = '/pages/work/issue-list.html';
return;
}
const data = await response.json();
if (data.success) {
currentReport = data.data;
renderReport(currentReport);
}
} catch (error) {
console.error('신고 로드 실패:', error);
alert('데이터를 불러올 수 없습니다.');
}
}
// 신고 렌더링
function renderReport(report) {
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
// 헤더
document.getElementById('reportId').textContent = `#${report.report_id}`;
document.getElementById('reportTitle').textContent = report.issue_item_name || report.issue_category_name || '신고';
const statusBadge = document.getElementById('statusBadge');
statusBadge.textContent = STATUS_LABELS[report.status] || report.status;
statusBadge.className = `status-badge ${report.status}`;
// 기본 정보
const reportDate = new Date(report.report_date).toLocaleString('ko-KR');
let location = report.custom_location || '';
if (report.factory_name) {
location = report.factory_name;
if (report.workplace_name) {
location += ` - ${report.workplace_name}`;
}
}
document.getElementById('basicInfo').innerHTML = `
<div class="info-item">
<div class="info-label">신고자</div>
<div class="info-value">${report.reporter_full_name || report.reporter_name}</div>
</div>
<div class="info-item">
<div class="info-label">신고 일시</div>
<div class="info-value">${reportDate}</div>
</div>
<div class="info-item">
<div class="info-label">발생 위치</div>
<div class="info-value">${location || '-'}</div>
</div>
<div class="info-item">
<div class="info-label">문제 유형</div>
<div class="info-value">
<span class="type-badge ${report.category_type}">${TYPE_LABELS[report.category_type]}</span>
</div>
</div>
`;
// 신고 내용
let contentHtml = `
<div class="info-grid" style="margin-bottom: 16px;">
<div class="info-item">
<div class="info-label">카테고리</div>
<div class="info-value">${report.issue_category_name}</div>
</div>
${report.issue_item_name ? `
<div class="info-item">
<div class="info-label">신고 항목</div>
<div class="info-value">
${report.issue_item_name}
${report.severity ? `<span class="severity-badge ${report.severity}">${SEVERITY_LABELS[report.severity]}</span>` : ''}
</div>
</div>
` : ''}
</div>
`;
if (report.additional_description) {
contentHtml += `
<div class="info-label" style="margin-bottom: 8px;">추가 설명</div>
<div class="description-text">${report.additional_description}</div>
`;
}
document.getElementById('issueContent').innerHTML = contentHtml;
// 사진
const photos = [
report.photo_path1,
report.photo_path2,
report.photo_path3,
report.photo_path4,
report.photo_path5
].filter(Boolean);
if (photos.length > 0) {
document.getElementById('photoSection').style.display = 'block';
document.getElementById('photoGallery').innerHTML = photos.map(p => `
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
</div>
`).join('');
}
// 처리 정보
if (report.assigned_user_id || report.resolution_notes) {
document.getElementById('processSection').style.display = 'block';
let processHtml = '<div class="info-grid">';
if (report.assigned_full_name) {
processHtml += `
<div class="info-item">
<div class="info-label">담당자</div>
<div class="info-value">${report.assigned_full_name}</div>
</div>
`;
}
if (report.assigned_department) {
processHtml += `
<div class="info-item">
<div class="info-label">담당 부서</div>
<div class="info-value">${report.assigned_department}</div>
</div>
`;
}
processHtml += '</div>';
if (report.resolution_notes) {
processHtml += `
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 내용</div>
<div class="description-text">${report.resolution_notes}</div>
`;
}
// 처리 완료 사진
const resolutionPhotos = [
report.resolution_photo_path1,
report.resolution_photo_path2
].filter(Boolean);
if (resolutionPhotos.length > 0) {
processHtml += `
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 완료 사진</div>
<div class="photo-gallery">
${resolutionPhotos.map(p => `
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
<img src="${baseUrl}${p}" alt="처리 완료 사진" loading="lazy">
</div>
`).join('')}
</div>
`;
}
document.getElementById('processInfo').innerHTML = processHtml;
}
// 액션 버튼
renderActionButtons(report);
}
// 액션 버튼 렌더링
function renderActionButtons(report) {
const buttons = [];
const isManager = ['admin', 'system', 'support_team'].includes(currentUserLevel);
switch (report.status) {
case 'reported':
if (isManager) {
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수</button>`);
}
break;
case 'received':
if (isManager) {
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 배정</button>`);
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
}
break;
case 'in_progress':
buttons.push(`<button class="action-btn success" onclick="showCompleteModal()">처리 완료</button>`);
if (isManager) {
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 변경</button>`);
}
break;
case 'completed':
if (['admin', 'system'].includes(currentUserLevel)) {
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
}
break;
}
document.getElementById('actionButtons').innerHTML = buttons.join('');
}
// 상태 이력 로드
async function loadStatusLogs(reportId) {
try {
const response = await fetch(`${API_BASE}/work-issues/${reportId}/logs`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
renderStatusLogs(data.data);
}
} catch (error) {
console.error('상태 이력 로드 실패:', error);
}
}
// 상태 이력 렌더링
function renderStatusLogs(logs) {
const timeline = document.getElementById('statusTimeline');
if (logs.length === 0) {
timeline.innerHTML = '<p style="color: var(--gray-500);">이력이 없습니다.</p>';
return;
}
timeline.innerHTML = logs.map(log => {
const date = new Date(log.changed_at).toLocaleString('ko-KR');
const prevStatus = log.previous_status ? STATUS_LABELS[log.previous_status] : '(없음)';
const newStatus = STATUS_LABELS[log.new_status] || log.new_status;
return `
<div class="timeline-item">
<div class="timeline-status">${prevStatus}${newStatus}</div>
<div class="timeline-meta">
${log.changed_by_full_name || log.changed_by_name} | ${date}
${log.change_reason ? `<br><span style="color: var(--gray-600);">${log.change_reason}</span>` : ''}
</div>
</div>
`;
}).join('');
}
// 사용자 목록 로드 (담당자 배정용)
async function loadUsers() {
try {
const response = await fetch(`${API_BASE}/users`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!response.ok) return;
const data = await response.json();
if (data.success && data.data) {
const select = document.getElementById('assignUser');
data.data.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = `${user.name} (${user.username})`;
select.appendChild(option);
});
}
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
}
}
// 접수
async function receiveReport() {
if (!confirm('이 신고를 접수하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/receive`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('접수되었습니다.');
location.reload();
} else {
throw new Error(data.error);
}
} catch (error) {
alert('접수 실패: ' + error.message);
}
}
// 처리 시작
async function startProcessing() {
if (!confirm('처리를 시작하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/start`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('처리가 시작되었습니다.');
location.reload();
} else {
throw new Error(data.error);
}
} catch (error) {
alert('처리 시작 실패: ' + error.message);
}
}
// 담당자 배정 모달
function showAssignModal() {
document.getElementById('assignModal').classList.add('visible');
}
function closeAssignModal() {
document.getElementById('assignModal').classList.remove('visible');
}
async function submitAssign() {
const department = document.getElementById('assignDepartment').value;
const userId = document.getElementById('assignUser').value;
if (!userId) {
alert('담당자를 선택하세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/assign`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
assigned_department: department,
assigned_user_id: parseInt(userId)
})
});
const data = await response.json();
if (data.success) {
alert('담당자가 배정되었습니다.');
location.reload();
} else {
throw new Error(data.error);
}
} catch (error) {
alert('담당자 배정 실패: ' + error.message);
}
}
// 처리 완료 모달
function showCompleteModal() {
document.getElementById('completeModal').classList.add('visible');
}
function closeCompleteModal() {
document.getElementById('completeModal').classList.remove('visible');
}
async function submitComplete() {
const resolutionNotes = document.getElementById('resolutionNotes').value;
if (!resolutionNotes.trim()) {
alert('처리 내용을 입력하세요.');
return;
}
try {
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/complete`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
resolution_notes: resolutionNotes
})
});
const data = await response.json();
if (data.success) {
alert('처리가 완료되었습니다.');
location.reload();
} else {
throw new Error(data.error);
}
} catch (error) {
alert('처리 완료 실패: ' + error.message);
}
}
// 종료
async function closeReport() {
if (!confirm('이 신고를 종료하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/close`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
const data = await response.json();
if (data.success) {
alert('종료되었습니다.');
location.reload();
} else {
throw new Error(data.error);
}
} catch (error) {
alert('종료 실패: ' + error.message);
}
}
// 사진 확대
function showPhoto(src) {
document.getElementById('photoModalImg').src = src;
document.getElementById('photoModal').classList.add('visible');
}
function closePhotoModal() {
document.getElementById('photoModal').classList.remove('visible');
}
</script>
</body>
</html>

View File

@@ -1,301 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-align: center;
border: 1px solid var(--gray-200);
}
.stat-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-label {
font-size: var(--text-sm);
color: var(--gray-500);
}
.stat-card.reported .stat-number { color: var(--blue-600); }
.stat-card.received .stat-number { color: var(--orange-600); }
.stat-card.in_progress .stat-number { color: var(--purple-600); }
.stat-card.completed .stat-number { color: var(--green-600); }
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
padding: 16px;
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.filter-bar select,
.filter-bar input {
padding: 10px 14px;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: var(--primary-500);
}
.btn-new-report {
margin-left: auto;
padding: 10px 20px;
background: var(--primary-500);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-new-report:hover {
background: var(--primary-600);
}
.issue-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.issue-card {
background: white;
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-200);
cursor: pointer;
transition: all var(--transition-fast);
}
.issue-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--primary-300);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.issue-id {
font-size: var(--text-sm);
color: var(--gray-500);
}
.issue-status {
padding: 4px 12px;
border-radius: 9999px;
font-size: var(--text-xs);
font-weight: 600;
}
.issue-status.reported {
background: var(--blue-100);
color: var(--blue-700);
}
.issue-status.received {
background: var(--orange-100);
color: var(--orange-700);
}
.issue-status.in_progress {
background: var(--purple-100);
color: var(--purple-700);
}
.issue-status.completed {
background: var(--green-100);
color: var(--green-700);
}
.issue-status.closed {
background: var(--gray-100);
color: var(--gray-700);
}
.issue-title {
font-size: var(--text-base);
font-weight: 600;
margin-bottom: 8px;
}
.issue-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: var(--text-xs);
font-weight: 500;
margin-right: 8px;
}
.issue-type-badge.nonconformity {
background: var(--orange-100);
color: var(--orange-700);
}
.issue-type-badge.safety {
background: var(--red-100);
color: var(--red-700);
}
.issue-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: var(--text-sm);
color: var(--gray-500);
}
.issue-meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.issue-photos {
display: flex;
gap: 8px;
margin-top: 12px;
}
.issue-photos img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--gray-200);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--gray-500);
}
.empty-state-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 8px;
}
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
}
.btn-new-report {
width: 100%;
justify-content: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<div id="navbar-container"></div>
<main class="main-content">
<div class="page-header">
<h1 class="page-title">문제 신고 목록</h1>
</div>
<!-- 통계 카드 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card reported">
<div class="stat-number" id="statReported">-</div>
<div class="stat-label">신고</div>
</div>
<div class="stat-card received">
<div class="stat-number" id="statReceived">-</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress">
<div class="stat-number" id="statProgress">-</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed">
<div class="stat-number" id="statCompleted">-</div>
<div class="stat-label">완료</div>
</div>
</div>
<!-- 필터 바 -->
<div class="filter-bar">
<select id="filterStatus">
<option value="">전체 상태</option>
<option value="reported">신고</option>
<option value="received">접수</option>
<option value="in_progress">처리중</option>
<option value="completed">완료</option>
<option value="closed">종료</option>
</select>
<select id="filterType">
<option value="">전체 유형</option>
<option value="nonconformity">부적합</option>
<option value="safety">안전</option>
</select>
<input type="date" id="filterStartDate" title="시작일">
<input type="date" id="filterEndDate" title="종료일">
<a href="/pages/work/issue-report.html" class="btn-new-report">
+ 새 신고
</a>
</div>
<!-- 신고 목록 -->
<div class="issue-list" id="issueList">
<div class="empty-state">
<div class="empty-state-title">로딩 중...</div>
</div>
</div>
</main>
<script src="/js/load-navbar.js?v=1"></script>
<script src="/js/work-issue-list.js?v=1"></script>
</body>
</html>

View File

@@ -1,618 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.issue-form-container {
max-width: 900px;
margin: 0 auto;
}
.step-indicator {
display: flex;
justify-content: space-between;
margin-bottom: 32px;
padding: 16px;
background: var(--gray-50);
border-radius: var(--radius-lg);
}
.step {
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-400);
font-size: var(--text-sm);
}
.step.active {
color: var(--primary-600);
font-weight: 600;
}
.step.completed {
color: var(--green-600);
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--gray-200);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.step.active .step-number {
background: var(--primary-500);
color: white;
}
.step.completed .step-number {
background: var(--green-500);
color: white;
}
.form-section {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--gray-200);
}
.form-section-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-200);
}
/* 지도 선택 영역 */
.map-container {
position: relative;
min-height: 400px;
background: var(--gray-100);
border-radius: var(--radius-md);
overflow: hidden;
}
#issueMapCanvas {
width: 100%;
height: 400px;
cursor: crosshair;
}
.selected-location-info {
margin-top: 16px;
padding: 16px;
background: var(--primary-50);
border-radius: var(--radius-md);
border-left: 4px solid var(--primary-500);
}
.selected-location-info.empty {
background: var(--gray-50);
border-left-color: var(--gray-300);
color: var(--gray-500);
text-align: center;
}
.custom-location-toggle {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.custom-location-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
}
.custom-location-input {
margin-top: 12px;
display: none;
}
.custom-location-input.visible {
display: block;
}
/* 유형 선택 버튼 */
.type-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.type-btn {
padding: 24px;
border: 2px solid var(--gray-200);
border-radius: var(--radius-lg);
background: white;
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.type-btn:hover {
border-color: var(--primary-300);
background: var(--primary-50);
}
.type-btn.selected {
border-color: var(--primary-500);
background: var(--primary-50);
}
.type-btn.nonconformity.selected {
border-color: var(--orange-500);
background: var(--orange-50);
}
.type-btn.safety.selected {
border-color: var(--red-500);
background: var(--red-50);
}
.type-btn-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 8px;
}
.type-btn-desc {
font-size: var(--text-sm);
color: var(--gray-500);
}
/* 카테고리 선택 */
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.category-btn {
padding: 16px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
background: white;
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
font-size: var(--text-sm);
}
.category-btn:hover {
border-color: var(--primary-300);
background: var(--gray-50);
}
.category-btn.selected {
border-color: var(--primary-500);
background: var(--primary-50);
font-weight: 600;
}
/* 사전 정의 항목 선택 */
.item-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.item-btn {
padding: 12px 20px;
border: 1px solid var(--gray-200);
border-radius: 9999px;
background: white;
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--text-sm);
}
.item-btn:hover {
border-color: var(--primary-300);
background: var(--gray-50);
}
.item-btn.selected {
border-color: var(--primary-500);
background: var(--primary-500);
color: white;
}
.item-btn[data-severity="critical"] {
border-color: var(--red-300);
}
.item-btn[data-severity="critical"].selected {
background: var(--red-500);
border-color: var(--red-500);
}
.item-btn[data-severity="high"] {
border-color: var(--orange-300);
}
.item-btn[data-severity="high"].selected {
background: var(--orange-500);
border-color: var(--orange-500);
}
/* 사진 업로드 */
.photo-upload-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.photo-slot {
aspect-ratio: 1;
border: 2px dashed var(--gray-300);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
background: var(--gray-50);
}
.photo-slot:hover {
border-color: var(--primary-500);
background: var(--primary-50);
}
.photo-slot.has-photo {
border-style: solid;
border-color: var(--green-500);
}
.photo-slot img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-slot .add-icon {
font-size: 24px;
color: var(--gray-400);
}
.photo-slot .remove-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--red-500);
color: white;
border: none;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 14px;
}
.photo-slot.has-photo .remove-btn {
display: flex;
}
.photo-slot .add-icon {
display: block;
}
.photo-slot.has-photo .add-icon {
display: none;
}
/* 추가 설명 */
.additional-textarea {
width: 100%;
min-height: 100px;
padding: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--text-base);
resize: vertical;
}
.additional-textarea:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
/* 제출 버튼 */
.form-actions {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-top: 32px;
}
.btn-submit {
padding: 16px 48px;
background: var(--primary-500);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-submit:hover {
background: var(--primary-600);
}
.btn-submit:disabled {
background: var(--gray-300);
cursor: not-allowed;
}
.btn-cancel {
padding: 16px 32px;
background: white;
color: var(--gray-600);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--text-base);
cursor: pointer;
}
.btn-cancel:hover {
background: var(--gray-50);
}
/* 작업 선택 모달 */
.work-selection-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.work-selection-modal.visible {
display: flex;
}
.work-selection-content {
background: white;
padding: 24px;
border-radius: var(--radius-lg);
max-width: 500px;
width: 90%;
}
.work-selection-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 16px;
}
.work-option {
padding: 16px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
margin-bottom: 12px;
cursor: pointer;
transition: all var(--transition-fast);
}
.work-option:hover {
border-color: var(--primary-500);
background: var(--primary-50);
}
.work-option-title {
font-weight: 600;
margin-bottom: 4px;
}
.work-option-desc {
font-size: var(--text-sm);
color: var(--gray-500);
}
/* 반응형 */
@media (max-width: 768px) {
.type-buttons {
grid-template-columns: 1fr;
}
.photo-upload-grid {
grid-template-columns: repeat(3, 1fr);
}
.step-indicator {
flex-wrap: wrap;
gap: 12px;
}
.step-text {
display: none;
}
}
</style>
</head>
<body>
<div id="navbar-container"></div>
<main class="main-content">
<div class="page-header">
<h1 class="page-title">문제 신고</h1>
<p class="page-description">작업 중 발견된 부적합 사항 또는 안전 문제를 신고합니다.</p>
</div>
<div class="issue-form-container">
<!-- 단계 표시 -->
<div class="step-indicator">
<div class="step active" data-step="1">
<span class="step-number">1</span>
<span class="step-text">위치 선택</span>
</div>
<div class="step" data-step="2">
<span class="step-number">2</span>
<span class="step-text">유형 선택</span>
</div>
<div class="step" data-step="3">
<span class="step-number">3</span>
<span class="step-text">항목 선택</span>
</div>
<div class="step" data-step="4">
<span class="step-number">4</span>
<span class="step-text">사진/설명</span>
</div>
</div>
<!-- Step 1: 위치 선택 -->
<div class="form-section" id="step1Section">
<h2 class="form-section-title">1. 발생 위치 선택</h2>
<div class="form-group">
<label for="factorySelect">공장 선택</label>
<select id="factorySelect">
<option value="">공장을 선택하세요</option>
</select>
</div>
<div class="map-container">
<canvas id="issueMapCanvas"></canvas>
</div>
<div class="selected-location-info empty" id="selectedLocationInfo">
지도에서 작업장을 클릭하여 위치를 선택하세요
</div>
<div class="custom-location-toggle">
<input type="checkbox" id="useCustomLocation">
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
</div>
<div class="custom-location-input" id="customLocationInput">
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
</div>
</div>
<!-- Step 2: 문제 유형 선택 -->
<div class="form-section" id="step2Section">
<h2 class="form-section-title">2. 문제 유형 선택</h2>
<div class="type-buttons">
<div class="type-btn nonconformity" data-type="nonconformity">
<div class="type-btn-title">부적합 사항</div>
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
</div>
<div class="type-btn safety" data-type="safety">
<div class="type-btn-title">안전 관련</div>
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
</div>
</div>
<div id="categoryContainer" style="display: none;">
<label style="font-weight: 600; margin-bottom: 12px; display: block;">세부 카테고리</label>
<div class="category-grid" id="categoryGrid"></div>
</div>
</div>
<!-- Step 3: 신고 항목 선택 -->
<div class="form-section" id="step3Section">
<h2 class="form-section-title">3. 신고 항목 선택</h2>
<p style="color: var(--gray-500); margin-bottom: 16px;">해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.</p>
<div class="item-grid" id="itemGrid">
<p style="color: var(--gray-400);">먼저 카테고리를 선택하세요</p>
</div>
</div>
<!-- Step 4: 사진 및 추가 설명 -->
<div class="form-section" id="step4Section">
<h2 class="form-section-title">4. 사진 및 추가 설명</h2>
<div class="form-group">
<label>사진 첨부 (최대 5장)</label>
<div class="photo-upload-grid">
<div class="photo-slot" data-index="0">
<span class="add-icon">+</span>
<button class="remove-btn" type="button">&times;</button>
</div>
<div class="photo-slot" data-index="1">
<span class="add-icon">+</span>
<button class="remove-btn" type="button">&times;</button>
</div>
<div class="photo-slot" data-index="2">
<span class="add-icon">+</span>
<button class="remove-btn" type="button">&times;</button>
</div>
<div class="photo-slot" data-index="3">
<span class="add-icon">+</span>
<button class="remove-btn" type="button">&times;</button>
</div>
<div class="photo-slot" data-index="4">
<span class="add-icon">+</span>
<button class="remove-btn" type="button">&times;</button>
</div>
</div>
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
</div>
<div class="form-group">
<label for="additionalDescription">추가 설명 (선택)</label>
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
</div>
</div>
<!-- 제출 버튼 -->
<div class="form-actions">
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
</div>
</div>
</main>
<!-- 작업 선택 모달 -->
<div class="work-selection-modal" id="workSelectionModal">
<div class="work-selection-content">
<h3 class="work-selection-title">작업 선택</h3>
<p style="margin-bottom: 16px; color: var(--gray-600);">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
<div id="workOptionsList"></div>
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 12px; margin-top: 8px; background: var(--gray-100); border: none; border-radius: var(--radius-md); cursor: pointer;">
작업 연결 없이 진행
</button>
</div>
</div>
<script src="/js/load-navbar.js?v=1"></script>
<script src="/js/work-issue-report.js?v=1"></script>
</body>
</html>

View File

@@ -1,180 +0,0 @@
<!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/daily-work-report.css?v=11">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<!-- 탭 메뉴 -->
<div class="tab-menu" style="margin-bottom: var(--space-6);">
<button class="tab-btn active" id="tbmReportTab" onclick="switchTab('tbm')">
작업보고서 작성
</button>
<button class="tab-btn" id="completedReportTab" onclick="switchTab('completed')">
작성 완료 보고서
</button>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- TBM 작업보고 섹션 -->
<div id="tbmReportSection" class="step-section active">
<!-- TBM 작업 목록 -->
<div id="tbmWorkList">
<!-- TBM 작업 항목들이 여기에 동적으로 추가됩니다 -->
</div>
</div>
<!-- 작성 완료 보고서 섹션 -->
<div id="completedReportSection" class="step-section" style="display: none;">
<!-- 날짜 선택 필터 -->
<div class="form-group" style="max-width: 300px; margin-bottom: var(--space-5);">
<label for="completedReportDate" class="form-label">조회 날짜</label>
<input type="date" id="completedReportDate" class="form-input" onchange="loadCompletedReports()">
</div>
<!-- 완료된 보고서 목록 -->
<div id="completedReportsList">
<!-- 완료된 보고서들이 여기에 동적으로 추가됩니다 -->
</div>
</div>
</main>
</div>
<!-- 저장 결과 모달 -->
<div id="saveResultModal" class="modal-overlay" style="display: none;">
<div class="modal-container result-modal">
<div class="modal-header">
<h2 id="resultModalTitle">저장 결과</h2>
<button class="modal-close-btn" onclick="closeSaveResultModal()">×</button>
</div>
<div class="modal-body">
<div id="resultModalContent" class="result-content">
<!-- 결과 내용이 여기에 동적으로 추가됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="closeSaveResultModal()">
확인
</button>
</div>
</div>
</div>
<!-- 작업장소 선택 모달 (지도 기반) -->
<div id="workplaceModal" class="modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1002; align-items: center; justify-content: center; overflow-y: auto; padding: 2rem 0;">
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 1000px; width: 90%; max-height: none; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column;">
<div class="modal-header" style="padding: 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
<h2 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0;">
<span style="margin-right: 0.5rem;">🗺️</span>작업장소 선택
</h2>
<button class="modal-close" onclick="closeWorkplaceModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280; padding: 0; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; border-radius: 4px;">&times;</button>
</div>
<div class="modal-body" style="padding: 1.5rem; flex: 1; overflow-y: visible;">
<!-- 1단계: 카테고리 선택 -->
<div id="categorySelectionArea">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">🏭</span>공장 선택
</h3>
<div id="workplaceCategoryList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
<!-- 카테고리 버튼들 -->
</div>
</div>
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
<div id="workplaceSelectionArea" style="display: none; margin-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">📍</span>
<span id="selectedCategoryTitle">작업장 선택</span>
</h3>
<!-- 지도 기반 선택 영역 -->
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
<span style="margin-right: 0.25rem;">🗺️</span>
지도에서 작업장을 클릭하여 선택하세요
</div>
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</div>
</div>
<!-- 리스트 선택 영역 -->
<div style="margin-bottom: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-size: 0.875rem; color: #6b7280;">
<span style="margin-right: 0.25rem;">📋</span>
리스트에서 선택
</span>
</div>
<div id="workplaceListArea" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
<!-- 작업장소 목록 -->
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
<button type="button" class="btn btn-primary" id="confirmWorkplaceBtn" onclick="confirmWorkplaceSelection()" disabled>선택 완료</button>
</div>
</div>
</div>
</div>
</div>
<!-- 시간 선택 팝오버 -->
<div id="timePickerOverlay" class="time-picker-overlay" style="display: none;" onclick="closeTimePicker()">
<div class="time-picker-popup" onclick="event.stopPropagation()">
<div class="time-picker-header">
<h3 id="timePickerTitle">작업시간 선택</h3>
<button class="time-picker-close" onclick="closeTimePicker()">&times;</button>
</div>
<div class="quick-time-grid">
<button type="button" class="time-btn" onclick="setTimeValue(0.5)">
<span class="time-value">30분</span>
</button>
<button type="button" class="time-btn" onclick="setTimeValue(1)">
<span class="time-value">1시간</span>
</button>
<button type="button" class="time-btn" onclick="setTimeValue(2)">
<span class="time-value">2시간</span>
</button>
<button type="button" class="time-btn" onclick="setTimeValue(4)">
<span class="time-value">4시간</span>
</button>
<button type="button" class="time-btn" onclick="setTimeValue(8)">
<span class="time-value">8시간</span>
</button>
</div>
<div class="time-adjust-area">
<span class="current-time-label">현재:</span>
<strong id="currentTimeDisplay" class="current-time-value">0시간</strong>
<div class="adjust-buttons">
<button type="button" class="adjust-btn" onclick="adjustTime(-0.5)">-30분</button>
<button type="button" class="adjust-btn" onclick="adjustTime(0.5)">+30분</button>
</div>
</div>
<button type="button" class="confirm-btn" onclick="confirmTimeSelection()">확인</button>
</div>
</div>
<!-- 스크립트 -->
<script type="module" src="/js/api-config.js?v=3"></script>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/daily-work-report.js?v=25"></script>
</body>
</html>

View File

@@ -1,294 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업 현황 확인 - TK 건설</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=13">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=14">
<link rel="stylesheet" href="/css/work-report-calendar.css?v=29">
</head>
<body>
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="dashboard-main">
<div class="calendar-page-container">
<!-- 페이지 제목 -->
<div class="page-title-section">
<h2 class="page-title">📅 작업 현황 확인</h2>
<p class="page-subtitle">월별 작업자 현황을 한눈에 확인하세요</p>
</div>
<!-- 캘린더 카드 -->
<div class="calendar-card">
<!-- 월 네비게이션 -->
<div class="calendar-nav">
<button id="prevMonthBtn" class="nav-btn prev-btn">
<span class="nav-icon"></span>
<span class="nav-text">이전</span>
</button>
<div class="calendar-title">
<h3 id="monthYearTitle">2025년 11월</h3>
<button id="todayBtn" class="today-btn">오늘</button>
</div>
<button id="nextMonthBtn" class="nav-btn next-btn">
<span class="nav-text">다음</span>
<span class="nav-icon"></span>
</button>
</div>
<!-- 범례 -->
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-dot has-overtime-warning"></div>
<span>확인필요</span>
</div>
<div class="legend-item">
<div class="legend-dot has-errors"></div>
<span>미입력</span>
</div>
<div class="legend-item">
<div class="legend-dot has-issues"></div>
<span>부분입력</span>
</div>
<div class="legend-item">
<div class="legend-dot has-normal"></div>
<span>이상 없음</span>
</div>
</div>
<!-- 캘린더 -->
<div class="calendar-grid">
<div class="calendar-header">
<div class="day-header sunday"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header saturday"></div>
</div>
<div class="calendar-days" id="calendarDays">
<!-- 캘린더 날짜들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</div>
</main>
<!-- 로딩 스피너 -->
<div id="loadingSpinner" class="loading-overlay" style="display: none;">
<div class="loading-content">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</div>
</div>
<!-- 일일 작업 현황 모달 -->
<div id="dailyWorkModal" class="modal-overlay" style="display: none;">
<div class="modal-container large-modal">
<div class="modal-header">
<h2 id="modalTitle">2025년 11월 3일 작업 현황</h2>
<button class="modal-close-btn" onclick="closeDailyWorkModal()">×</button>
</div>
<div class="modal-body">
<!-- 요약 정보 -->
<div class="daily-summary">
<div class="summary-card">
<div class="summary-icon success">👥</div>
<div class="summary-content">
<div class="summary-label">총 작업자</div>
<div class="summary-value" id="modalTotalWorkers">0명</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon primary"></div>
<div class="summary-content">
<div class="summary-label">총 작업시간</div>
<div class="summary-value" id="modalTotalHours">0.0h</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon warning">📝</div>
<div class="summary-content">
<div class="summary-label">작업 건수</div>
<div class="summary-value" id="modalTotalTasks">0건</div>
</div>
</div>
<div class="summary-card">
<div class="summary-icon error">⚠️</div>
<div class="summary-content">
<div class="summary-label">오류 건수</div>
<div class="summary-value" id="modalErrorCount">0건</div>
</div>
</div>
</div>
<!-- 작업자 현황 리스트 -->
<div class="modal-work-status">
<div class="work-status-header">
<h3>작업자별 현황</h3>
<div class="status-filter">
<select id="statusFilter">
<option value="all">전체</option>
<option value="incomplete">미입력</option>
<option value="partial">부분입력</option>
<option value="complete">완료</option>
<option value="overtime">연장근로</option>
<option value="error">오류</option>
</select>
</div>
</div>
<div id="modalWorkersList" class="worker-status-list">
<!-- 작업자 리스트가 여기에 동적으로 생성됩니다 -->
</div>
<div id="modalNoData" class="empty-state" style="display: none;">
<div class="empty-icon">📭</div>
<h3>해당 날짜의 작업 보고서가 없습니다</h3>
<p>다른 날짜를 선택해 주세요.</p>
</div>
</div>
</div>
</div>
</div>
<!-- 작업 입력/수정 모달 -->
<div id="workEntryModal" class="modal-overlay" style="display: none;">
<div class="modal-container large-modal">
<div class="modal-header">
<h2 id="workEntryModalTitle">작업 관리</h2>
<button class="modal-close-btn" onclick="closeWorkEntryModal()">×</button>
</div>
<div class="modal-body">
<!-- 탭 네비게이션 -->
<div class="modal-tabs">
<button class="tab-btn active" data-tab="existing" onclick="switchTab('existing')">
📋 기존 작업 (0건)
</button>
<button class="tab-btn" data-tab="new" onclick="switchTab('new')">
새 작업 추가
</button>
</div>
<!-- 기존 작업 목록 탭 -->
<div id="existingWorkTab" class="tab-content active">
<div class="existing-work-header">
<h3>등록된 작업 목록</h3>
<div class="work-summary" id="workSummary">
<span id="totalWorkCount">0</span>건 | 총 <span id="totalWorkHours">0</span>시간
</div>
</div>
<div id="existingWorkList" class="existing-work-list">
<!-- 기존 작업들이 여기에 동적으로 생성됩니다 -->
</div>
<div id="noExistingWork" class="empty-state" style="display: none;">
<div class="empty-icon">📝</div>
<h3>등록된 작업이 없습니다</h3>
<p>"새 작업 추가" 탭에서 작업을 등록해보세요.</p>
</div>
</div>
<!-- 새 작업 추가 탭 -->
<div id="newWorkTab" class="tab-content">
<form id="workEntryForm">
<!-- 작업자 정보 -->
<div class="form-section">
<h3>작업자 정보</h3>
<div class="form-group">
<label class="form-label">작업자</label>
<input type="text" id="workerNameDisplay" class="form-control" readonly>
<input type="hidden" id="workerId">
<input type="hidden" id="editingWorkId">
</div>
<div class="form-group">
<label class="form-label">작업 날짜</label>
<input type="date" id="workDate" class="form-control" readonly>
</div>
</div>
<!-- 작업 내용 -->
<div class="form-section">
<h3 id="workContentTitle">작업 내용</h3>
<div class="form-group">
<label class="form-label">프로젝트 *</label>
<select id="projectSelect" class="form-control" required>
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 유형 *</label>
<select id="workTypeSelect" class="form-control" required>
<option value="">작업 유형을 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 시간 (시간) *</label>
<input type="number" id="workHours" class="form-control" min="0" max="24" step="0.5" required>
</div>
<div class="form-group">
<label class="form-label">작업 상태 *</label>
<select id="workStatusSelect" class="form-control" required>
<option value="">상태를 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label">오류 유형</label>
<select id="errorTypeSelect" class="form-control">
<option value="">오류 유형 (선택사항)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 설명</label>
<textarea id="workDescription" class="form-control" rows="3" placeholder="작업 내용을 상세히 입력하세요"></textarea>
</div>
</div>
<!-- 휴가 처리 -->
<div class="form-section" id="vacationSection">
<h3>휴가 처리</h3>
<div class="vacation-buttons">
<button type="button" class="btn-vacation" onclick="handleVacation('full')">연차 (8시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('half')">반차 (4시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('quarter')">반반차 (2시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('early')">조퇴 (6시간)</button>
</div>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkEntryModal()">취소</button>
<div class="footer-actions">
<button type="button" class="btn btn-danger" id="deleteWorkBtn" onclick="deleteWork()" style="display: none;">
🗑️ 삭제
</button>
<button type="button" class="btn btn-primary" id="saveWorkBtn" onclick="saveWorkEntry()">
💾 저장
</button>
</div>
</div>
</div>
</div>
<!-- JavaScript -->
<script type="module" src="/js/api-config.js?v=13"></script>
<script type="module" src="/js/auth-check.js?v=13"></script>
<script type="module" src="/js/load-navbar.js"></script>
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
<script src="/js/work-report-calendar.js?v=41"></script>
</body>
</html>

View File

@@ -1,650 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TBM 관리 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.date-group {
margin-bottom: 2rem;
}
.date-group-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 0.5rem;
margin-bottom: 1rem;
border-left: 4px solid var(--primary-500);
}
.date-group-header.today {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-left-color: #3b82f6;
}
.date-group-date {
font-size: 1.1rem;
font-weight: 700;
color: #1f2937;
}
.date-group-day {
font-size: 0.875rem;
color: #6b7280;
padding: 0.25rem 0.5rem;
background: white;
border-radius: 0.25rem;
}
.date-group-count {
margin-left: auto;
font-size: 0.875rem;
color: #6b7280;
}
.date-group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
@media (max-width: 768px) {
.date-group-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">
<span class="title-icon">🛠️</span>
TBM (Tool Box Meeting)
</h1>
<p class="page-description">아침 안전 회의 및 팀 구성 관리</p>
</div>
<div class="page-actions" id="headerActions">
<!-- 탭에 따라 동적으로 변경됩니다 -->
</div>
</div>
<!-- TBM 탭 -->
<div class="code-tabs">
<button class="tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
<span class="tab-icon"></span>
TBM 입력
</button>
<button class="tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
<span class="tab-icon">📋</span>
TBM 관리
</button>
</div>
<!-- TBM 입력 탭 -->
<div id="tbm-input-tab" class="code-tab-content active">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">🌅</span>
오늘의 TBM
</h2>
<div class="section-actions">
<button class="btn btn-primary" onclick="openNewTbmModal()">
<span class="btn-icon"></span>
새 TBM 시작
</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item">
<span class="stat-icon">📋</span>
오늘 등록 <span id="todayTotalSessions">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
완료 <span id="todayCompletedSessions">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
진행중 <span id="todayActiveSessions">0</span>
</span>
</div>
<div class="code-grid" id="todayTbmGrid">
<!-- 오늘의 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- Empty State -->
<div class="empty-state" id="todayEmptyState" style="display: none;">
<div class="empty-icon">🛠️</div>
<h3>오늘 등록된 TBM이 없습니다</h3>
<p>"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
<button class="btn btn-primary" onclick="openNewTbmModal()">
첫 TBM 시작하기
</button>
</div>
</div>
</div>
<!-- TBM 관리 탭 -->
<div id="tbm-manage-tab" class="code-tab-content">
<div class="code-section">
<div class="section-header">
<h2 class="section-title">
<span class="section-icon">📚</span>
TBM 기록
</h2>
<div class="section-actions">
<button class="btn btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
<span class="btn-icon">📅</span>
더 보기
</button>
</div>
</div>
<div class="code-stats">
<span class="stat-item">
<span class="stat-icon">📋</span>
<span id="totalSessions">0</span>
</span>
<span class="stat-item">
<span class="stat-icon"></span>
완료 <span id="completedSessions">0</span>
</span>
<span class="stat-item" id="viewModeIndicator" style="display: none;">
<span class="stat-icon">👤</span>
<span id="viewModeText">내 TBM만</span>
</span>
</div>
<!-- 날짜별 그룹 컨테이너 -->
<div id="tbmDateGroupsContainer">
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
</div>
<!-- Empty State -->
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">🛠️</div>
<h3>등록된 TBM 세션이 없습니다</h3>
<p>TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
</div>
</div>
</div>
</div>
</main>
<!-- TBM 생성/수정 모달 -->
<div id="tbmModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 1000px;">
<div class="modal-header">
<h2 id="modalTitle">새 TBM 시작</h2>
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
</div>
<div class="modal-body">
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
<input type="hidden" id="sessionId">
<!-- 고정 정보 섹션 -->
<div class="form-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<div class="form-row">
<div class="form-group">
<label class="form-label">TBM 날짜 *</label>
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
</div>
<div class="form-group">
<label class="form-label">입력자 *</label>
<input type="text" id="leaderName" class="form-control" readonly style="background: #e5e7eb;">
<input type="hidden" id="leaderId">
</div>
</div>
</div>
<!-- 작업자 및 작업 정보 섹션 -->
<div class="form-section">
<div class="section-header" style="margin-bottom: 1rem;">
<h3 style="font-size: 1.1rem; font-weight: 600; color: #1f2937;">
<span style="margin-right: 0.5rem;">👷</span>
작업자 및 작업 정보
</h3>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="openBulkSettingModal()" style="display: flex; align-items: center; gap: 0.25rem;">
<span>📋</span>
일괄 설정
</button>
<button type="button" class="btn btn-sm btn-primary" onclick="openWorkerSelectionModal()">
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
작업자 선택
</button>
</div>
</div>
<!-- 작업자 카드 리스트 -->
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 0.75rem; min-height: 100px;">
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
<div class="empty-state-small" id="workerListEmpty" style="display: flex; align-items: center; justify-content: center; padding: 2rem; border: 2px dashed #d1d5db; border-radius: 0.5rem; color: #6b7280;">
<div style="text-align: center;">
<div style="font-size: 2rem; margin-bottom: 0.5rem;">👷‍♂️</div>
<p>작업자를 선택해주세요</p>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">
💾 저장하기
</button>
</div>
</div>
</div>
<!-- 일괄 설정 모달 -->
<div id="bulkSettingModal" class="modal-overlay" style="display: none; z-index: 1001;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2>일괄 설정</h2>
<button class="modal-close-btn" onclick="closeBulkSettingModal()">×</button>
</div>
<div class="modal-body">
<div style="background: #dbeafe; border: 1px solid #3b82f6; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
<div style="font-weight: 600; color: #1e40af; margin-bottom: 0.25rem;">💡 일괄 설정</div>
<div style="color: #1e40af; font-size: 0.9rem;">
선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.
</div>
</div>
<!-- 작업자 선택 -->
<div class="form-group">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<label class="form-label" style="margin-bottom: 0;">적용할 작업자 선택 *</label>
<div style="display: flex; gap: 0.25rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">전체</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">해제</button>
</div>
</div>
<div id="bulkWorkerSelection" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; background: #f9fafb;">
<!-- 작업자 체크박스들이 여기에 생성됩니다 -->
</div>
</div>
<div style="border-top: 1px solid #e5e7eb; margin: 1.5rem 0; padding-top: 1.5rem;">
<h4 style="font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">적용할 작업 정보</h4>
<div class="form-group">
<label class="form-label">프로젝트</label>
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
📁 프로젝트 선택
</button>
<input type="hidden" id="bulkProjectId">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">공정 *</label>
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
⚙️ 공정 선택
</button>
<input type="hidden" id="bulkWorkTypeId">
</div>
<div class="form-group">
<label class="form-label">작업 *</label>
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;" disabled>
🔧 작업 선택
</button>
<input type="hidden" id="bulkTaskId">
</div>
</div>
<div class="form-group">
<label class="form-label">작업장 *</label>
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
📍 작업장 선택
</button>
<input type="hidden" id="bulkWorkplaceCategoryId">
<input type="hidden" id="bulkWorkplaceId">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeBulkSettingModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="applyBulkSettings()">
✓ 선택한 작업자에 적용
</button>
</div>
</div>
</div>
<!-- 작업자 선택 모달 -->
<div id="workerSelectionModal" class="modal-overlay" style="display: none; z-index: 1001;">
<div class="modal-container" style="max-width: 800px;">
<div class="modal-header">
<h2>작업자 선택</h2>
<button class="modal-close-btn" onclick="closeWorkerSelectionModal()">×</button>
</div>
<div class="modal-body">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllWorkersInModal()">전체 선택</button>
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllWorkersInModal()">전체 해제</button>
</div>
<div id="workerCardGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 500px; overflow-y: auto; padding: 0.5rem;">
<!-- 작업자 카드들이 여기에 생성됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="confirmWorkerSelection()">
✓ 선택 완료
</button>
</div>
</div>
</div>
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
<div id="itemSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
<div class="modal-container" style="max-width: 600px;">
<div class="modal-header">
<h2 id="itemSelectModalTitle">항목 선택</h2>
<button class="modal-close-btn" onclick="closeItemSelectModal()">×</button>
</div>
<div class="modal-body">
<div id="itemSelectList" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; padding: 0.5rem;">
<!-- 선택 항목들이 여기에 생성됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeItemSelectModal()">취소</button>
</div>
</div>
</div>
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
<div id="workplaceSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
<div class="modal-container" style="max-width: 1000px; max-height: 90vh;">
<div class="modal-header">
<h2>작업장 선택</h2>
<button class="modal-close-btn" onclick="closeWorkplaceSelectModal()">×</button>
</div>
<div class="modal-body" style="overflow-y: auto;">
<!-- 1단계: 공장 선택 -->
<div style="margin-bottom: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">🏭</span>
1. 공장 선택
</h3>
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: #f9fafb;">
<!-- 공장 카테고리 버튼들이 여기에 생성됩니다 -->
</div>
</div>
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
<div id="workplaceSelectionArea" style="display: none;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span style="margin-right: 0.5rem;">📍</span>
2. 작업장 선택
</h3>
<!-- 지도 기반 선택 영역 -->
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
<span style="margin-right: 0.25rem;">🗺️</span>
지도에서 작업장을 클릭하여 선택하세요
</div>
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</div>
</div>
<!-- 리스트 기반 선택 (오류 대비용) -->
<div>
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
<span>
<span style="margin-right: 0.25rem;">📋</span>
리스트에서 선택 (지도 오류 시)
</span>
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleWorkplaceList()" id="toggleListBtn">
<span id="toggleListIcon"></span>
리스트 보기
</button>
</div>
<div id="workplaceList" style="display: none; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
<div style="color: #9ca3af; text-align: center; padding: 2rem;">
공장을 먼저 선택해주세요
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
✓ 선택 완료
</button>
</div>
</div>
</div>
<!-- 팀 구성 모달 -->
<div id="teamModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 900px;">
<div class="modal-header">
<h2>팀 구성</h2>
<button class="modal-close-btn" onclick="closeTeamModal()">×</button>
</div>
<div class="modal-body">
<div class="section-header" style="margin-bottom: 1rem;">
<h3 style="font-size: 1rem; font-weight: 600;">작업자 선택</h3>
<div>
<button class="btn btn-sm btn-secondary" onclick="selectAllWorkers()">전체 선택</button>
<button class="btn btn-sm btn-secondary" onclick="deselectAllWorkers()">전체 해제</button>
</div>
</div>
<div id="workerSelectionGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 400px; overflow-y: auto; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
</div>
<div style="margin-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem;">선택된 팀원 <span id="selectedCount">0</span></h3>
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem;">
<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTeamModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveTeamComposition()">
👥 팀 구성 완료
</button>
</div>
</div>
</div>
<!-- 안전 체크리스트 모달 -->
<div id="safetyModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 700px;">
<div class="modal-header">
<h2>안전 체크리스트</h2>
<button class="modal-close-btn" onclick="closeSafetyModal()">×</button>
</div>
<div class="modal-body">
<div id="safetyChecklistContainer" style="max-height: 500px; overflow-y: auto;">
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeSafetyModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveSafetyChecklist()">
✅ 안전 체크 완료
</button>
</div>
</div>
</div>
<!-- TBM 완료 모달 -->
<div id="completeModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 500px;">
<div class="modal-header">
<h2>TBM 완료</h2>
<button class="modal-close-btn" onclick="closeCompleteModal()">×</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 1rem;">이 TBM 세션을 완료 처리하시겠습니까?</p>
<p style="color: #6b7280; font-size: 0.875rem;">완료 후에는 수정할 수 없습니다.</p>
<div class="form-group" style="margin-top: 1.5rem;">
<label class="form-label">종료 시간</label>
<input type="time" id="endTime" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCompleteModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="completeTbmSession()">
✅ 완료
</button>
</div>
</div>
</div>
<!-- 작업 인계 모달 -->
<div id="handoverModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 600px;">
<div class="modal-header">
<h2>작업 인계</h2>
<button class="modal-close-btn" onclick="closeHandoverModal()">×</button>
</div>
<div class="modal-body">
<form id="handoverForm">
<input type="hidden" id="handoverSessionId">
<div class="form-group">
<label class="form-label">인계 사유 *</label>
<select id="handoverReason" class="form-control" required>
<option value="">사유 선택...</option>
<option value="half_day">반차</option>
<option value="early_leave">조퇴</option>
<option value="emergency">긴급 상황</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label class="form-label">인수자 (다음 팀장) *</label>
<select id="toLeaderId" class="form-control" required>
<option value="">인수자 선택...</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">인계 날짜 *</label>
<input type="date" id="handoverDate" class="form-control" required>
</div>
<div class="form-group">
<label class="form-label">인계 시간</label>
<input type="time" id="handoverTime" class="form-control">
</div>
</div>
<div class="form-group">
<label class="form-label">인계 내용</label>
<textarea id="handoverNotes" class="form-control" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요"></textarea>
</div>
<div class="form-group">
<label class="form-label" style="margin-bottom: 0.75rem; display: block;">인계할 팀원 선택</label>
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
<!-- 팀원 체크박스 목록 -->
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeHandoverModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveHandover()">
📤 인계 요청
</button>
</div>
</div>
</div>
<!-- TBM 상세보기 모달 -->
<div id="detailModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 900px;">
<div class="modal-header">
<h2>TBM 상세 정보</h2>
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
<!-- 세션 기본 정보 -->
<div class="section" style="margin-bottom: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">기본 정보</h3>
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem;">
<!-- 동적 생성 -->
</div>
</div>
<!-- 팀 구성 -->
<div class="section" style="margin-bottom: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">팀 구성</h3>
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
<!-- 동적 생성 -->
</div>
</div>
<!-- 안전 체크 -->
<div class="section">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">안전 체크리스트</h3>
<div id="detailSafetyChecks">
<!-- 동적 생성 -->
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeDetailModal()">닫기</button>
</div>
</div>
</div>
<!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div>
</div>
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script type="module" src="/js/tbm.js?v=3"></script>
</body>
</html>

View File

@@ -1,371 +0,0 @@
<!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/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<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>
<style>
.visit-form-container {
max-width: 800px;
margin: 0 auto;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: var(--gray-700);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--text-base);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-500);
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.workplace-selection {
padding: 20px;
background: var(--gray-50);
border-radius: var(--radius-md);
border: 2px dashed var(--gray-300);
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.workplace-selection:hover {
border-color: var(--primary-500);
background: var(--primary-50);
}
.workplace-selection.selected {
border-color: var(--primary-500);
border-style: solid;
background: white;
}
.workplace-selection .icon {
font-size: 48px;
margin-bottom: 12px;
}
.workplace-selection .text {
font-size: var(--text-base);
color: var(--gray-600);
text-align: center;
}
.workplace-selection.selected .text {
color: var(--primary-600);
font-weight: 600;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
}
/* 지도 모달 스타일 */
.map-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.map-modal-content {
background: white;
border-radius: var(--radius-lg);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
padding: 32px;
box-shadow: var(--shadow-2xl);
}
.map-canvas-container {
position: relative;
margin-top: 20px;
border: 2px solid var(--gray-300);
border-radius: var(--radius-md);
overflow: hidden;
}
.map-canvas {
cursor: pointer;
display: block;
max-width: 100%;
}
.workplace-info-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--primary-50);
border: 2px solid var(--primary-200);
border-radius: var(--radius-md);
margin-top: 12px;
}
.workplace-info-card .icon {
font-size: 32px;
}
.workplace-info-card .details {
flex: 1;
}
.workplace-info-card .name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--primary-700);
}
.workplace-info-card .category {
font-size: var(--text-sm);
color: var(--gray-600);
margin-top: 4px;
}
.my-requests-section {
margin-top: 48px;
}
.request-card {
padding: 20px;
background: white;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
margin-bottom: 16px;
box-shadow: var(--shadow-sm);
}
.request-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.request-status {
padding: 6px 12px;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
}
.request-status.pending {
background: var(--yellow-100);
color: var(--yellow-700);
}
.request-status.approved {
background: var(--green-100);
color: var(--green-700);
}
.request-status.rejected {
background: var(--red-100);
color: var(--red-700);
}
.request-status.training_completed {
background: var(--blue-100);
color: var(--blue-700);
}
.request-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
color: var(--gray-700);
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: var(--text-sm);
color: var(--gray-500);
margin-bottom: 4px;
}
.info-value {
font-weight: 600;
}
</style>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<div class="dashboard-main">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">출입 신청</h1>
<p class="page-description">작업장 출입 및 안전교육 신청</p>
</div>
</div>
<!-- 출입 신청 폼 -->
<div class="visit-form-container">
<div class="code-section">
<h2 class="section-title">출입 정보 입력</h2>
<form id="visitRequestForm">
<!-- 방문자 정보 -->
<div class="form-row">
<div class="form-group">
<label for="visitorCompany">방문자 소속 *</label>
<input type="text" id="visitorCompany" placeholder="예: (주)협력업체, 일용직 등" required>
</div>
<div class="form-group">
<label for="visitorCount">방문 인원 *</label>
<input type="number" id="visitorCount" value="1" min="1" required>
</div>
</div>
<!-- 작업장 선택 -->
<div class="form-group">
<label>방문 작업장 *</label>
<div id="workplaceSelection" class="workplace-selection" onclick="openMapModal()">
<div class="icon">📍</div>
<div class="text">지도에서 작업장을 선택하세요</div>
</div>
<div id="selectedWorkplaceInfo" style="display: none;"></div>
</div>
<!-- 방문 일시 -->
<div class="form-row">
<div class="form-group">
<label for="visitDate">방문 날짜 *</label>
<input type="date" id="visitDate" required>
</div>
<div class="form-group">
<label for="visitTime">방문 시간 *</label>
<input type="time" id="visitTime" required>
</div>
</div>
<!-- 방문 목적 -->
<div class="form-group">
<label for="visitPurpose">방문 목적 *</label>
<select id="visitPurpose" required>
<option value="">선택하세요</option>
<!-- 동적으로 로드됨 -->
</select>
</div>
<!-- 비고 -->
<div class="form-group">
<label for="notes">비고 (선택)</label>
<textarea id="notes" placeholder="추가 전달 사항이 있다면 입력하세요"></textarea>
</div>
<!-- 버튼 -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="resetForm()">초기화</button>
<button type="submit" class="btn btn-primary">출입 신청 및 안전교육 신청</button>
</div>
</form>
</div>
<!-- 내 신청 목록 -->
<div class="my-requests-section">
<div class="code-section">
<h2 class="section-title">내 출입 신청 현황</h2>
<div id="myRequestsList">
<!-- 동적으로 로드됨 -->
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 작업장 지도 모달 -->
<div id="mapModal" class="map-modal">
<div class="map-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h2 style="margin: 0;">작업장 선택</h2>
<button class="btn btn-secondary" onclick="closeMapModal()">닫기</button>
</div>
<!-- 구역(공장) 선택 -->
<div class="form-group">
<label for="categorySelect">구역(공장) 선택</label>
<select id="categorySelect" onchange="loadWorkplaceMap()">
<option value="">구역을 선택하세요</option>
<!-- 동적으로 로드됨 -->
</select>
</div>
<!-- 지도 캔버스 -->
<div id="mapCanvasContainer" style="display: none;">
<div class="map-canvas-container">
<canvas id="workplaceMapCanvas" class="map-canvas"></canvas>
</div>
<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);">
지도에서 방문할 작업장을 클릭하세요
</p>
</div>
</div>
</div>
<!-- Scripts -->
<script type="module" src="/js/load-navbar.js?v=5"></script>
<script src="/js/visit-request.js"></script>
</body>
</html>