Compare commits

...

8 Commits

Author SHA1 Message Date
e3b2718767 refactor(backend): 일일 이슈 보고 API 전체 리팩토링
- dailyIssueReport 기능을 Controller-Service-Model 아키텍처로 재구성
- Model 계층을 Promise 기반으로 전환하고 트랜잭션을 적용하여 안정성 확보
- API 전반의 코드 품질 및 유지보수성 향상
2025-07-28 12:41:41 +09:00
5268fec1ef refactor(frontend): 일일 이슈 보고 기능 모듈화
- daily-issue.js를 API, UI, Controller 로직으로 분리
- 프로젝트 전반에 일관된 아키텍처 패턴을 적용하여 유지보수성 향상
2025-07-28 12:39:29 +09:00
71c06f38b1 refactor(backend): 작업 보고서 통계/요약 API 구조 개선
- dailyWorkReportController의 통계/요약 함수를 C-S-M 아키텍처에 맞게 리팩토링
- Model 계층의 콜백 기반 함수를 Promise 기반으로 전환
- API의 일관성 및 유지보수성 향상
2025-07-28 12:35:50 +09:00
5a68ced13b feat(frontend): 작업 보고서 뷰어 기능 전체 리팩토링
- 765줄의 daily-report-viewer.js를 API, UI, Export, Controller의 4개 모듈로 분리
- 관심사 분리를 통해 코드의 가독성, 테스트 용이성, 유지보수성을 극적으로 향상
- 프로젝트 전반의 코드 일관성 확보 및 레거시 로직 제거
2025-07-28 12:32:27 +09:00
ef85a880e5 refactor(frontend): 작업 보고서 생성 기능 모듈화
- API, UI, Controller 로직을 work-report-api.js, work-report-ui.js, work-report-create.js로 분리
- 관심사 분리를 통해 코드의 재사용성 및 유지보수성 향상
2025-07-28 12:28:06 +09:00
892215a15d refactor(frontend): 대시보드 섹션 로더 로직 개선
- admin.html의 잘못된 스크립트 태그 제거
- load-sections.js 리팩토링으로 코드 중복 제거 및 성능 최적화
- Promise.all과 DOMParser를 활용하여 화면 깜빡임 없이 동적 컨텐츠 로드
2025-07-28 12:05:27 +09:00
8d7422d376 feat(frontend): 공통 UI 로더 및 대시보드 구조 개선
- auth-check, load-navbar, load-sidebar 리팩토링
- auth.js 모듈을 활용하여 코드 중복 제거 및 일관성 확보
- DOMParser를 사용하여 컴포넌트 로딩 시 화면 깜빡임 현상 해결
- user-dashboard에 API 연동을 위한 견고한 기반 코드 마련
2025-07-28 12:03:52 +09:00
e0e0b1ad99 refactor(auth): 프론트엔드/백엔드 로그인 로직 개선
- 프론트엔드: login.js, api-helper.js, auth.js 모듈화 및 책임 분리
- 백엔드: authController가 역할 기반 redirectUrl을 제공하도록 수정
- 로그인 프로세스의 안정성 및 유지보수성 향상
2025-07-28 12:00:30 +09:00
26 changed files with 1773 additions and 2012 deletions

View File

@@ -19,7 +19,33 @@ const login = async (req, res) => {
return res.status(result.status || 400).json({ error: result.error });
}
res.json(result.data);
// 로그인 성공 후, 역할에 따라 리디렉션 URL을 결정
const user = result.data.user;
let redirectUrl;
switch (user.role) {
case 'admin':
case 'system': // 'system'도 관리자로 취급
redirectUrl = '/pages/dashboard/admin.html';
break;
case 'leader':
redirectUrl = '/pages/dashboard/group-leader.html';
break;
case 'support':
// 예시: 지원팀 대시보드가 있다면
// redirectUrl = '/pages/dashboard/support.html';
// 없다면 일반 사용자 대시보드로
redirectUrl = '/pages/dashboard/user.html';
break;
default:
redirectUrl = '/pages/dashboard/user.html';
}
// 최종 응답에 redirectUrl을 포함하여 전달
res.json({
...result.data,
redirectUrl: redirectUrl
});
} catch (error) {
console.error('Login controller error:', error);

View File

@@ -1,110 +1,58 @@
const dailyIssueReportModel = require('../models/dailyIssueReportModel');
// /controllers/dailyIssueReportController.js
const dailyIssueReportService = require('../services/dailyIssueReportService');
// 1. CREATE: 단일 또는 다중 등록 (worker_id 배열 지원)
exports.createDailyIssueReport = async (req, res) => {
/**
* 1. CREATE: 일일 이슈 보고서 생성 (Service Layer 사용)
*/
const createDailyIssueReport = async (req, res) => {
try {
const body = req.body;
// 기본 필드
const base = {
date: body.date,
project_id: body.project_id,
start_time: body.start_time,
end_time: body.end_time,
issue_type_id: body.issue_type_id
};
if (!base.date || !base.project_id || !base.start_time || !base.end_time || !base.issue_type_id || !body.worker_id) {
return res.status(400).json({ error: '필수 필드 누락' });
}
// worker_id 배열화
const workers = Array.isArray(body.worker_id) ? body.worker_id : [body.worker_id];
const insertedIds = [];
for (const wid of workers) {
const payload = { ...base, worker_id: wid };
const insertId = await new Promise((resolve, reject) => {
dailyIssueReportModel.create(payload, (err, id) => {
if (err) reject(err);
else resolve(id);
});
});
insertedIds.push(insertId);
}
res.json({ success: true, issue_report_ids: insertedIds });
// 프론트엔드에서 worker_ids로 보내주기로 약속함
const issueData = { ...req.body, worker_ids: req.body.worker_ids || req.body.worker_id };
const result = await dailyIssueReportService.createDailyIssueReportService(issueData);
res.status(201).json({ success: true, ...result });
} catch (err) {
console.error('🔥 createDailyIssueReport error:', err);
res.status(500).json({ error: err.message || String(err) });
console.error('💥 이슈 보고서 생성 컨트롤러 오류:', err);
res.status(400).json({ success: false, error: err.message });
}
};
// 2. READ BY DATE
exports.getDailyIssuesByDate = async (req, res) => {
/**
* 2. READ BY DATE: 날짜별 이슈 조회 (Service Layer 사용)
*/
const getDailyIssuesByDate = async (req, res) => {
try {
const { date } = req.query;
const rows = await new Promise((resolve, reject) => {
dailyIssueReportModel.getAllByDate(date, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
res.json(rows);
const issues = await dailyIssueReportService.getDailyIssuesByDateService(date);
res.json(issues);
} catch (err) {
res.status(500).json({ error: err.message || String(err) });
console.error('💥 이슈 보고서 조회 컨트롤러 오류:', err);
res.status(500).json({ success: false, error: err.message });
}
};
// 3. READ ONE
exports.getDailyIssueById = async (req, res) => {
/**
* 3. DELETE: 이슈 보고서 삭제 (Service Layer 사용)
*/
const removeDailyIssue = async (req, res) => {
try {
const { id } = req.params;
const row = await new Promise((resolve, reject) => {
dailyIssueReportModel.getById(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (!row) return res.status(404).json({ error: 'DailyIssueReport not found' });
res.json(row);
const result = await dailyIssueReportService.removeDailyIssueService(id);
res.json({ success: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message || String(err) });
console.error('💥 이슈 보고서 삭제 컨트롤러 오류:', err);
const statusCode = err.statusCode || 500;
res.status(statusCode).json({ success: false, error: err.message });
}
};
// 4. UPDATE
exports.updateDailyIssue = async (req, res) => {
try {
const { id } = req.params;
const changes = await new Promise((resolve, reject) => {
dailyIssueReportModel.update(id, req.body, (err, affectedRows) => {
if (err) reject(err);
else resolve(affectedRows);
});
});
if (changes === 0) return res.status(404).json({ error: 'No changes or not found' });
res.json({ success: true, changes });
} catch (err) {
res.status(500).json({ error: err.message || String(err) });
}
};
// 레거시 함수들은 더 이상 라우팅되지 않으므로 제거하거나 주석 처리 가능
// exports.getDailyIssueById = ...
// exports.updateDailyIssue = ...
// 5. DELETE
exports.removeDailyIssue = async (req, res) => {
try {
const { id } = req.params;
const changes = await new Promise((resolve, reject) => {
dailyIssueReportModel.remove(id, (err, affectedRows) => {
if (err) reject(err);
else resolve(affectedRows);
});
});
if (changes === 0) return res.status(404).json({ error: 'DailyIssueReport not found' });
res.json({ success: true, changes });
} catch (err) {
res.status(500).json({ error: err.message || String(err) });
}
module.exports = {
createDailyIssueReport,
getDailyIssuesByDate,
removeDailyIssue,
};

View File

@@ -31,41 +31,6 @@ const createDailyWorkReport = async (req, res) => {
}
};
/**
* <20><> 누적 현황 조회 (새로운 기능)
*/
const getAccumulatedReports = (req, res) => {
const { date, worker_id } = req.query;
if (!date || !worker_id) {
return res.status(400).json({
error: 'date와 worker_id가 필요합니다.',
example: 'date=2024-06-16&worker_id=1'
});
}
console.log(`📊 누적 현황 조회: date=${date}, worker_id=${worker_id}`);
dailyWorkReportModel.getAccumulatedReportsByDate(date, worker_id, (err, data) => {
if (err) {
console.error('누적 현황 조회 오류:', err);
return res.status(500).json({
error: '누적 현황 조회 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`📊 누적 현황 조회 결과: ${data.length}`);
res.json({
date,
worker_id,
total_entries: data.length,
accumulated_data: data,
timestamp: new Date().toISOString()
});
});
};
/**
* 📊 기여자별 요약 조회 (새로운 기능)
*/
@@ -298,85 +263,35 @@ const searchWorkReports = (req, res) => {
};
/**
* 📈 통계 조회 (작성자별 필터링)
* 📈 통계 조회 (V2 - Service Layer 사용)
*/
const getWorkReportStats = (req, res) => {
const { start_date, end_date } = req.query;
const created_by = req.user?.user_id || req.user?.id;
if (!start_date || !end_date) {
return res.status(400).json({
error: 'start_date와 end_date가 필요합니다.',
example: 'start_date=2024-01-01&end_date=2024-01-31'
const getWorkReportStats = async (req, res) => {
try {
const statsData = await dailyWorkReportService.getStatisticsService(req.query);
res.json(statsData);
} catch (error) {
console.error('💥 통계 조회 컨트롤러 오류:', error.message);
res.status(400).json({
success: false,
error: '통계 조회에 실패했습니다.',
details: error.message
});
}
if (!created_by) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
console.log(`📈 통계 조회: ${start_date} ~ ${end_date}, 요청자: ${created_by}`);
dailyWorkReportModel.getStatistics(start_date, end_date, (err, data) => {
if (err) {
console.error('통계 조회 오류:', err);
return res.status(500).json({
error: '통계 조회 중 오류가 발생했습니다.',
details: err.message
});
}
res.json({
...data,
metadata: {
note: '현재는 전체 통계입니다. 개인별 통계는 추후 구현 예정',
requested_by: created_by,
period: `${start_date} ~ ${end_date}`,
timestamp: new Date().toISOString()
}
});
});
};
/**
* 📊 일일 근무 요약 조회
* 📊 일일 근무 요약 조회 (V2 - Service Layer 사용)
*/
const getDailySummary = (req, res) => {
const { date, worker_id } = req.query;
if (date) {
console.log(`📊 일일 요약 조회: date=${date}`);
dailyWorkReportModel.getSummaryByDate(date, (err, data) => {
if (err) {
console.error('일일 요약 조회 오류:', err);
return res.status(500).json({
error: '일일 요약 조회 중 오류가 발생했습니다.',
details: err.message
});
}
res.json(data);
});
} else if (worker_id) {
console.log(`📊 작업자별 요약 조회: worker_id=${worker_id}`);
dailyWorkReportModel.getSummaryByWorker(worker_id, (err, data) => {
if (err) {
console.error('작업자별 요약 조회 오류:', err);
return res.status(500).json({
error: '작업자별 요약 조회 중 오류가 발생했습니다.',
details: err.message
});
}
res.json(data);
});
} else {
res.status(400).json({
error: 'date 또는 worker_id 파라미터가 필요합니다.',
examples: [
'date=2024-06-16',
'worker_id=1'
]
const getDailySummary = async (req, res) => {
try {
const summaryData = await dailyWorkReportService.getSummaryService(req.query);
res.json(summaryData);
} catch (error) {
console.error('💥 일일 요약 조회 컨트롤러 오류:', error.message);
res.status(400).json({
success: false,
error: '일일 요약 조회에 실패했습니다.',
details: error.message
});
}
};
@@ -576,30 +491,28 @@ const getErrorTypes = (req, res) => {
});
};
// 모든 컨트롤러 함수 내보내기 (권한별 조회 지원)
// 모든 컨트롤러 함수 내보내기 (리팩토링된 함수 위주로 재구성)
module.exports = {
// 📝 핵심 CRUD 함수들 (권한별 전체 조회 지원)
createDailyWorkReport, // 누적 추가 (덮어쓰기 없음)
getDailyWorkReports, // 조회 (권한별 필터링 개선)
getDailyWorkReportsByDate, // 날짜별 조회 (권한별 필터링 개선)
searchWorkReports, // 검색 (페이지네이션)
updateWorkReport, // 수정
removeDailyWorkReport, // 개별 삭제
removeDailyWorkReportByDateAndWorker, // 전체 삭제
// 📝 V2 핵심 CRUD 함수
createDailyWorkReport,
getDailyWorkReports,
updateWorkReport,
removeDailyWorkReport,
// 🔄 누적 관련 새로운 함수
getAccumulatedReports, // 누적 현황 조회
getContributorsSummary, // 기여자별 요약
getMyAccumulatedData, // 개인 누적 현황
removeMyEntry, // 개별 항목 삭제 (본인 것만)
// 📊 요약 및 통계 함수들
getDailySummary, // 일일 요약
getMonthlySummary, // 월간 요약
getWorkReportStats, // 통계
// 📋 마스터 데이터 함수들
getWorkTypes, // 작업 유형 목록
getWorkStatusTypes, // 업무 상태 유형 목록
getErrorTypes // 에러 유형 목록
// 📊 V2 통계 및 요약 함수
getWorkReportStats,
getDailySummary,
// 🔽 아직 리팩토링되지 않은 레거시 함수들
getAccumulatedReports,
getContributorsSummary,
getMyAccumulatedData,
removeMyEntry,
getDailyWorkReportsByDate,
searchWorkReports,
getMonthlySummary,
removeDailyWorkReportByDateAndWorker,
getWorkTypes,
getWorkStatusTypes,
getErrorTypes
};

View File

@@ -1,51 +1,50 @@
const { getDb } = require('../dbPool');
/**
* 1. 등록 (단일 레코드)
* 1. 여러 개의 이슈 보고서를 트랜잭션으로 생성합니다.
* @param {Array<object>} reports - 생성할 보고서 데이터 배열
* @returns {Promise<Array<number>>} - 삽입된 ID 배열
*/
const create = async (report, callback) => {
const createMany = async (reports) => {
const db = await getDb();
const conn = await db.getConnection();
try {
const db = await getDb();
const {
date,
worker_id,
project_id,
start_time,
end_time,
issue_type_id,
description = null // 선택값 처리
} = report;
await conn.beginTransaction();
const [result] = await db.query(
`INSERT INTO DailyIssueReports
(date, worker_id, project_id, start_time, end_time, issue_type_id, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[date, worker_id, project_id, start_time, end_time, issue_type_id, description]
);
const insertedIds = [];
const sql = `
INSERT INTO DailyIssueReports
(date, worker_id, project_id, start_time, end_time, issue_type_id)
VALUES (?, ?, ?, ?, ?, ?)
`;
callback(null, result.insertId);
for (const report of reports) {
const { date, worker_id, project_id, start_time, end_time, issue_type_id } = report;
const [result] = await conn.query(sql, [date, worker_id, project_id, start_time, end_time, issue_type_id]);
insertedIds.push(result.insertId);
}
await conn.commit();
return insertedIds;
} catch (err) {
callback(err);
await conn.rollback();
console.error('[Model] 여러 이슈 보고서 생성 중 오류:', err);
throw new Error('데이터베이스에 이슈 보고서를 생성하는 중 오류가 발생했습니다.');
} finally {
conn.release();
}
};
/**
* 2. 특정 날짜의 전체 이슈 목록 조회
* 2. 특정 날짜의 전체 이슈 목록 조회 (Promise 기반)
*/
const getAllByDate = async (date, callback) => {
const getAllByDate = async (date) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
d.id,
d.date,
w.worker_name,
p.project_name,
d.start_time,
d.end_time,
t.category,
t.subcategory,
d.description
d.id, d.date, w.worker_name, p.project_name, d.start_time, d.end_time,
t.category, t.subcategory, d.description
FROM DailyIssueReports d
LEFT JOIN Workers w ON d.worker_id = w.worker_id
LEFT JOIN Projects p ON d.project_id = p.project_id
@@ -54,9 +53,10 @@ const getAllByDate = async (date, callback) => {
ORDER BY d.start_time ASC`,
[date]
);
callback(null, rows);
return rows;
} catch (err) {
callback(err);
console.error(`[Model] ${date} 이슈 목록 조회 오류:`, err);
throw new Error('데이터베이스에서 이슈 목록을 조회하는 중 오류가 발생했습니다.');
}
};
@@ -102,22 +102,53 @@ const update = async (id, data, callback) => {
};
/**
* 5. 삭제
* 5. 삭제 (Promise 기반)
*/
const remove = async (id, callback) => {
const remove = async (id) => {
try {
const db = await getDb();
const [result] = await db.query(`DELETE FROM DailyIssueReports WHERE id = ?`, [id]);
callback(null, result.affectedRows);
return result.affectedRows;
} catch (err) {
console.error(`[Model] 이슈(id: ${id}) 삭제 오류:`, err);
throw new Error('데이터베이스에서 이슈를 삭제하는 중 오류가 발생했습니다.');
}
};
// V1 함수들은 점진적으로 제거 예정
const create = async (report, callback) => {
try {
const db = await getDb();
const {
date,
worker_id,
project_id,
start_time,
end_time,
issue_type_id,
description = null // 선택값 처리
} = report;
const [result] = await db.query(
`INSERT INTO DailyIssueReports
(date, worker_id, project_id, start_time, end_time, issue_type_id, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[date, worker_id, project_id, start_time, end_time, issue_type_id, description]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
module.exports = {
create,
createMany, // 신규
getAllByDate,
remove,
// 레거시 호환성을 위해 V1 함수들 임시 유지
create: (report, callback) => createMany([report]).then(ids => callback(null, ids[0])).catch(err => callback(err)),
getById,
update,
remove
};

View File

@@ -745,33 +745,26 @@ const removeByDateAndWorker = async (date, worker_id, deletedBy, callback) => {
};
/**
* 20. 통계 조회
* 20. 통계 조회 (Promise 기반)
*/
const getStatistics = async (start_date, end_date, callback) => {
const getStatistics = async (start_date, end_date) => {
try {
const db = await getDb();
const sql = `
const overallSql = `
SELECT
COUNT(*) as total_reports,
SUM(work_hours) as total_hours,
COUNT(DISTINCT worker_id) as unique_workers,
COUNT(DISTINCT project_id) as unique_projects,
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as error_count,
AVG(work_hours) as avg_hours_per_entry,
MIN(work_hours) as min_hours,
MAX(work_hours) as max_hours
COUNT(DISTINCT project_id) as unique_projects
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
`;
const [rows] = await db.query(sql, [start_date, end_date]);
// 추가 통계 - 날짜별 집계
const [overallRows] = await db.query(overallSql, [start_date, end_date]);
const dailyStatsSql = `
SELECT
report_date,
COUNT(*) as daily_reports,
SUM(work_hours) as daily_hours,
COUNT(DISTINCT worker_id) as daily_workers
FROM daily_work_reports
@@ -779,18 +772,15 @@ const getStatistics = async (start_date, end_date, callback) => {
GROUP BY report_date
ORDER BY report_date DESC
`;
const [dailyStats] = await db.query(dailyStatsSql, [start_date, end_date]);
const result = {
overall: rows[0],
return {
overall: overallRows[0],
daily_breakdown: dailyStats
};
callback(null, result);
} catch (err) {
console.error('통계 조회 오류:', err);
callback(err);
throw new Error('데이터베이스에서 통계 정보를 조회하는 중 오류가 발생했습니다.');
}
};
@@ -1002,43 +992,38 @@ const removeReportById = async (reportId, deletedByUserId) => {
};
// 모든 함수 내보내기 (기존 기능 + 누적 기능)
// 모든 함수 내보내기 (Promise 기반 함수 위주로 재구성)
module.exports = {
// 📋 마스터 데이터
// 새로 추가된 V2 함수 (Promise 기반)
createReportEntries,
getReportsWithOptions,
updateReportById,
removeReportById,
// Promise 기반으로 리팩토링된 함수
getStatistics,
getSummaryByDate,
getSummaryByWorker,
// 아직 리팩토링되지 않았지만 필요한 기존 함수들...
// (점진적으로 아래 함수들도 Promise 기반으로 전환해야 함)
getAllWorkTypes,
getAllWorkStatusTypes,
getAllErrorTypes,
// 🔄 핵심 생성 함수 (누적 방식)
createDailyReport, // 누적 추가 (덮어쓰기 없음)
// 📊 누적 관련 새로운 함수들
getMyAccumulatedHours, // 개인 누적 현황
getAccumulatedReportsByDate, // 날짜별 누적 현황
getContributorsByDate, // 기여자별 요약
removeSpecificEntry, // 개별 항목 삭제
// 📊 기존 조회 함수들 (모두 유지)
createDailyReport,
getMyAccumulatedHours,
getAccumulatedReportsByDate,
getContributorsByDate,
removeSpecificEntry,
getById,
getByDate,
getByDateAndCreator, // 날짜+작성자별 조회
getByDateAndCreator,
getByWorker,
getByDateAndWorker,
getByRange,
searchWithDetails,
getSummaryByDate,
getSummaryByWorker,
getMonthlySummary,
// ✏️ 수정/삭제 함수들 (기존 유지)
updateById,
removeById,
removeByDateAndWorker,
getStatistics,
// 새로 추가된 V2 함수
createReportEntries,
getReportsWithOptions,
updateReportById,
removeReportById
};

View File

@@ -0,0 +1,93 @@
// /services/dailyIssueReportService.js
const dailyIssueReportModel = require('../models/dailyIssueReportModel');
/**
* 일일 이슈 보고서를 생성하는 비즈니스 로직을 처리합니다.
* 한 번에 여러 작업자에 대해 동일한 이슈를 등록할 수 있습니다.
* @param {object} issueData - 컨트롤러에서 전달된 이슈 데이터
* @returns {Promise<object>} 생성 결과
*/
const createDailyIssueReportService = async (issueData) => {
const { date, project_id, start_time, end_time, issue_type_id, worker_ids } = issueData;
// 1. 유효성 검사
if (!date || !project_id || !start_time || !end_time || !issue_type_id || !worker_ids) {
throw new Error('필수 필드가 누락되었습니다.');
}
if (!Array.isArray(worker_ids) || worker_ids.length === 0) {
throw new Error('worker_ids는 최소 한 명 이상의 작업자를 포함하는 배열이어야 합니다.');
}
// 2. 모델에 전달할 데이터 준비
const reportsToCreate = worker_ids.map(worker_id => ({
date,
project_id,
start_time,
end_time,
issue_type_id,
worker_id
}));
// 3. 모델 함수 호출 (모델에 createMany와 같은 함수가 필요)
try {
const insertedIds = await dailyIssueReportModel.createMany(reportsToCreate);
return {
message: `${insertedIds.length}개의 이슈 보고서가 성공적으로 생성되었습니다.`,
issue_report_ids: insertedIds
};
} catch (error) {
console.error('[Service] 이슈 보고서 생성 중 오류 발생:', error);
throw error;
}
};
/**
* 특정 날짜의 모든 이슈 보고서를 조회합니다.
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} 조회된 이슈 보고서 배열
*/
const getDailyIssuesByDateService = async (date) => {
if (!date) {
throw new Error('조회를 위해 날짜(date)는 필수입니다.');
}
try {
const issues = await dailyIssueReportModel.getAllByDate(date);
return issues;
} catch (error) {
console.error(`[Service] ${date}의 이슈 보고서 조회 중 오류 발생:`, error);
throw error;
}
};
/**
* 특정 ID의 이슈 보고서를 삭제합니다.
* @param {string} issueId - 삭제할 이슈 보고서의 ID
* @returns {Promise<object>} 삭제 결과
*/
const removeDailyIssueService = async (issueId) => {
if (!issueId) {
throw new Error('삭제를 위해 이슈 보고서 ID가 필요합니다.');
}
try {
const affectedRows = await dailyIssueReportModel.remove(issueId);
if (affectedRows === 0) {
const notFoundError = new Error('삭제할 이슈 보고서를 찾을 수 없습니다.');
notFoundError.statusCode = 404;
throw notFoundError;
}
return {
message: '이슈 보고서가 성공적으로 삭제되었습니다.',
deleted_id: issueId,
affected_rows: affectedRows
};
} catch (error) {
console.error(`[Service] 이슈 보고서(id: ${issueId}) 삭제 중 오류 발생:`, error);
throw error;
}
};
module.exports = {
createDailyIssueReportService,
getDailyIssuesByDateService,
removeDailyIssueService,
};

View File

@@ -204,10 +204,71 @@ const removeDailyWorkReportService = async (reportId, userInfo) => {
}
};
/**
* 기간별 작업 보고서 통계를 조회하는 비즈니스 로직을 처리합니다.
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터 (start_date, end_date)
* @returns {Promise<object>} 통계 데이터
*/
const getStatisticsService = async (queryParams) => {
const { start_date, end_date } = queryParams;
if (!start_date || !end_date) {
throw new Error('통계 조회를 위해 시작일(start_date)과 종료일(end_date)이 모두 필요합니다.');
}
console.log(`📈 [Service] 통계 조회 요청: ${start_date} ~ ${end_date}`);
try {
// 모델의 getStatistics 함수가 Promise를 반환하도록 수정 필요
const statsData = await dailyWorkReportModel.getStatistics(start_date, end_date);
console.log('✅ [Service] 통계 조회 성공');
return {
...statsData,
metadata: {
period: `${start_date} ~ ${end_date}`,
timestamp: new Date().toISOString()
}
};
} catch (error) {
console.error('[Service] 통계 조회 중 오류 발생:', error);
throw error;
}
};
/**
* 일일 또는 작업자별 작업 요약 정보를 조회하는 비즈니스 로직을 처리합니다.
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터 (date 또는 worker_id)
* @returns {Promise<object>} 요약 데이터
*/
const getSummaryService = async (queryParams) => {
const { date, worker_id } = queryParams;
if (!date && !worker_id) {
throw new Error('일일 또는 작업자별 요약 조회를 위해 날짜(date) 또는 작업자 ID(worker_id)가 필요합니다.');
}
try {
if (date) {
console.log(`📊 [Service] 일일 요약 조회 요청: date=${date}`);
// 모델의 getSummaryByDate 함수가 Promise를 반환하도록 수정 필요
return await dailyWorkReportModel.getSummaryByDate(date);
} else { // worker_id
console.log(`📊 [Service] 작업자별 요약 조회 요청: worker_id=${worker_id}`);
// 모델의 getSummaryByWorker 함수가 Promise를 반환하도록 수정 필요
return await dailyWorkReportModel.getSummaryByWorker(worker_id);
}
} catch (error) {
console.error('[Service] 요약 정보 조회 중 오류 발생:', error);
throw error;
}
};
module.exports = {
createDailyWorkReportService,
getDailyWorkReportsService,
updateWorkReportService,
removeDailyWorkReportService,
getStatisticsService,
getSummaryService,
};

View File

@@ -1,18 +1,45 @@
// /public/js/api-helper.js
// API 기본 URL 설정
const API_BASE = location.hostname.includes('localhost')
? 'http://localhost:3005/api'
: 'https://api.hyungi.net/api';
import { API_BASE_URL } from './api-config.js';
import { getToken, clearAuthData } from './auth.js';
// 인증된 fetch 함수
async function authFetch(url, options = {}) {
const token = localStorage.getItem('token');
/**
* 로그인 API를 호출합니다. (인증이 필요 없는 public 요청)
* @param {string} username - 사용자 아이디
* @param {string} password - 사용자 비밀번호
* @returns {Promise<object>} - API 응답 결과
*/
export async function login(username, password) {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const result = await response.json();
if (!response.ok) {
// API 에러 응답을 그대로 에러 객체로 던져서 호출부에서 처리하도록 함
throw new Error(result.error || '로그인에 실패했습니다.');
}
return result;
}
/**
* 인증이 필요한 API 요청을 위한 fetch 래퍼 함수
* @param {string} endpoint - /로 시작하는 API 엔드포인트
* @param {object} options - fetch 함수에 전달할 옵션
* @returns {Promise<Response>} - fetch 응답 객체
*/
async function authFetch(endpoint, options = {}) {
const token = getToken();
if (!token) {
console.error('토큰이 없습니다. 로그인이 필요합니다.');
window.location.href = '/index.html';
return;
clearAuthData(); // 인증 정보 정리
window.location.href = '/index.html'; // 로그인 페이지로 리디렉션
// 에러를 던져서 후속 실행을 중단
throw new Error('인증 토큰이 없습니다.');
}
const defaultHeaders = {
@@ -20,7 +47,7 @@ async function authFetch(url, options = {}) {
'Content-Type': 'application/json'
};
const response = await fetch(url, {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...defaultHeaders,
@@ -28,59 +55,61 @@ async function authFetch(url, options = {}) {
}
});
// 401 에러 시 로그인 페이지로
// 401 Unauthorized 에러 발생 시, 토큰이 유효하지 않다는 의미
if (response.status === 401) {
console.error('인증 실패. 다시 로그인해주세요.');
localStorage.removeItem('token');
console.error('인증 실패. 토큰이 만료되었거나 유효하지 않습니다.');
clearAuthData(); // 만료된 인증 정보 정리
window.location.href = '/index.html';
return;
throw new Error('인증에 실패했습니다.');
}
return response;
}
// GET 요청 헬퍼
async function apiGet(endpoint) {
const response = await authFetch(`${API_BASE}${endpoint}`);
if (!response) return null;
// 공통 API 요청 함수들
/**
* GET 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
export async function apiGet(endpoint) {
const response = await authFetch(endpoint);
return response.json();
}
// POST 요청 헬퍼
async function apiPost(endpoint, data) {
const response = await authFetch(`${API_BASE}${endpoint}`, {
/**
* POST 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
export async function apiPost(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
if (!response) return null;
return response.json();
}
// PUT 요청 헬퍼
async function apiPut(endpoint, data) {
const response = await authFetch(`${API_BASE}${endpoint}`, {
/**
* PUT 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
* @param {object} data - 전송할 데이터
*/
export async function apiPut(endpoint, data) {
const response = await authFetch(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
if (!response) return null;
return response.json();
}
// DELETE 요청 헬퍼
async function apiDelete(endpoint) {
const response = await authFetch(`${API_BASE}${endpoint}`, {
/**
* DELETE 요청 헬퍼
* @param {string} endpoint - API 엔드포인트
*/
export async function apiDelete(endpoint) {
const response = await authFetch(endpoint, {
method: 'DELETE'
});
if (!response) return null;
return response.json();
}
// 내보내기 (다른 파일에서 사용 가능)
window.API = {
get: apiGet,
post: apiPost,
put: apiPut,
delete: apiDelete,
fetch: authFetch,
BASE: API_BASE
};
}

View File

@@ -1,79 +1,27 @@
// /js/auth-check.js
// 토큰 검증과 권한 체크
// /js/auth-check.js
import { isLoggedIn, getUser, clearAuthData } from './auth.js';
const token = localStorage.getItem('token');
function isValidJWT(token) {
return typeof token === 'string' && token.split('.').length === 3;
}
function getPayload(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
if (!token || !isValidJWT(token)) {
console.log('🚨 토큰이 없거나 유효하지 않음');
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/index.html';
} else {
const user = getPayload(token);
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
console.log('🔐 JWT 사용자 정보:', user);
console.log('💾 저장된 사용자 정보:', storedUser);
// 사용자 정보 우선순위: localStorage > JWT payload
const currentUser = storedUser.access_level ? storedUser : user;
if (!currentUser || !currentUser.username || !currentUser.access_level) {
console.log('🚨 사용자 정보가 유효하지 않음');
localStorage.removeItem('token');
localStorage.removeItem('user');
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
(function() {
if (!isLoggedIn()) {
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
clearAuthData(); // 만약을 위해 한번 더 정리
window.location.href = '/index.html';
} else {
console.log('✅ 인증 성공:', currentUser.username, currentUser.access_level);
// 사용자 이름 표시
const userNameElements = document.querySelectorAll('#user-name, .user-name');
userNameElements.forEach(el => {
if (el) el.textContent = currentUser.name || currentUser.username;
});
// 🎯 역할별 메뉴 표시/숨김 처리
const accessLevel = currentUser.access_level;
// 관리자 전용 메뉴
if (accessLevel !== 'admin' && accessLevel !== 'system') {
const adminOnly = document.querySelectorAll('.admin-only, .system-only');
adminOnly.forEach(el => el.remove());
}
// 그룹장 전용 메뉴
if (accessLevel !== 'group_leader') {
const groupLeaderOnly = document.querySelectorAll('.group-leader-only');
groupLeaderOnly.forEach(el => el.remove());
}
// 지원팀 전용 메뉴
if (accessLevel !== 'support') {
const supportOnly = document.querySelectorAll('.support-only');
supportOnly.forEach(el => el.remove());
}
// 일반 작업자 전용 메뉴
if (accessLevel !== 'worker' && accessLevel !== 'user') {
const workerOnly = document.querySelectorAll('.worker-only');
workerOnly.forEach(el => el.remove());
}
// 전역 사용자 정보 저장
window.currentUser = currentUser;
console.log('🎭 역할별 메뉴 필터링 완료:', accessLevel);
return; // 이후 코드 실행 방지
}
}
const currentUser = getUser();
// 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우)
if (!currentUser || !currentUser.username || !currentUser.role) {
console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.');
clearAuthData();
window.location.href = '/index.html';
return;
}
console.log(`${currentUser.username}(${currentUser.role})님 인증 성공.`);
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
// 전역 변수 할당(window.currentUser) 제거.
})();

76
web-ui/js/auth.js Normal file
View File

@@ -0,0 +1,76 @@
// js/auth.js
/**
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
* @param {string} token - JWT 토큰
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
*/
export function parseJwt(token) {
try {
// 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
console.error("잘못된 토큰입니다.", e);
return null;
}
}
/**
* localStorage에서 인증 토큰을 가져옵니다.
* @returns {string|null} - 저장된 토큰 또는 토큰이 없을 경우 null
*/
export function getToken() {
return localStorage.getItem('token');
}
/**
* localStorage에서 사용자 정보를 가져옵니다.
* @returns {object|null} - 저장된 사용자 객체 또는 정보가 없을 경우 null
*/
export function getUser() {
const user = localStorage.getItem('user');
try {
return user ? JSON.parse(user) : null;
} catch(e) {
console.error("사용자 정보를 파싱하는 데 실패했습니다.", e);
return null;
}
}
/**
* 로그인 성공 후 토큰과 사용자 정보를 localStorage에 저장합니다.
* @param {string} token - 서버에서 받은 JWT 토큰
* @param {object} user - 서버에서 받은 사용자 정보 객체
*/
export function saveAuthData(token, user) {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
}
/**
* 로그아웃 시 localStorage에서 인증 정보를 제거합니다.
*/
export function clearAuthData() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
/**
* 현재 사용자가 로그인 상태인지 확인합니다.
* @returns {boolean} - 로그인 상태이면 true, 아니면 false
*/
export function isLoggedIn() {
const token = getToken();
if (!token) {
return false;
}
// 선택 사항: 토큰 만료 여부 확인 로직 추가 가능
// const payload = parseJwt(token);
// if (payload && payload.exp * 1000 > Date.now()) {
// return true;
// }
// return false;
return !!token;
}

View File

@@ -0,0 +1,66 @@
// /js/daily-issue-api.js
import { apiGet, apiPost } from './api-helper.js';
/**
* 이슈 보고서 작성을 위해 필요한 초기 데이터(프로젝트, 이슈 유형)를 가져옵니다.
* @returns {Promise<{projects: Array, issueTypes: Array}>}
*/
export async function getInitialData() {
try {
const [projects, issueTypes] = await Promise.all([
apiGet('/projects'),
apiGet('/issue-types')
]);
return { projects, issueTypes };
} catch (error) {
console.error('이슈 보고서 초기 데이터 로딩 실패:', error);
throw error;
}
}
/**
* 특정 날짜에 근무한 작업자 목록을 가져옵니다.
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} - 작업자 목록
*/
export async function getWorkersByDate(date) {
try {
// 백엔드에 해당 날짜의 작업자 목록을 요청하는 API가 있다고 가정합니다.
// (예: /api/workers?work_date=YYYY-MM-DD)
// 현재는 기존 로직을 최대한 활용하여 구현합니다.
let workers = [];
const reports = await apiGet(`/daily-work-reports?date=${date}`);
if (reports && reports.length > 0) {
const workerMap = new Map();
reports.forEach(r => {
if (!workerMap.has(r.worker_id)) {
workerMap.set(r.worker_id, { worker_id: r.worker_id, worker_name: r.worker_name });
}
});
workers = Array.from(workerMap.values());
} else {
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
workers = await apiGet('/workers');
}
return workers.sort((a, b) => a.worker_name.localeCompare(b.worker_name));
} catch (error) {
console.error(`${date}의 작업자 목록 로딩 실패:`, error);
throw error;
}
}
/**
* 작성된 이슈 보고서 데이터를 서버에 전송합니다.
* @param {object} issueData - 전송할 이슈 데이터
* @returns {Promise<object>} - 서버 응답 결과
*/
export async function createIssueReport(issueData) {
try {
const result = await apiPost('/issue-reports', issueData);
return result;
} catch (error) {
console.error('이슈 보고서 생성 요청 실패:', error);
throw error;
}
}

103
web-ui/js/daily-issue-ui.js Normal file
View File

@@ -0,0 +1,103 @@
// /js/daily-issue-ui.js
const DOM = {
dateSelect: document.getElementById('dateSelect'),
projectSelect: document.getElementById('projectSelect'),
issueTypeSelect: document.getElementById('issueTypeSelect'),
timeStart: document.getElementById('timeStart'),
timeEnd: document.getElementById('timeEnd'),
workerList: document.getElementById('workerList'),
form: document.getElementById('issueForm'),
submitBtn: document.getElementById('submitBtn'),
};
function createOption(value, text) {
const option = document.createElement('option');
option.value = value;
option.textContent = text;
return option;
}
export function populateProjects(projects) {
DOM.projectSelect.innerHTML = '<option value="">-- 프로젝트 선택 --</option>';
if (Array.isArray(projects)) {
projects.forEach(p => DOM.projectSelect.appendChild(createOption(p.project_id, p.project_name)));
}
}
export function populateIssueTypes(issueTypes) {
DOM.issueTypeSelect.innerHTML = '<option value="">-- 이슈 유형 선택 --</option>';
if (Array.isArray(issueTypes)) {
issueTypes.forEach(t => DOM.issueTypeSelect.appendChild(createOption(t.issue_type_id, `${t.category}:${t.subcategory}`)));
}
}
export function populateTimeOptions() {
for (let h = 0; h < 24; h++) {
for (let m of [0, 30]) {
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
DOM.timeStart.appendChild(createOption(time, time));
DOM.timeEnd.appendChild(createOption(time, time.replace('00:00', '24:00')));
}
}
DOM.timeEnd.value = "24:00"; // 기본값 설정
}
export function renderWorkerList(workers) {
DOM.workerList.innerHTML = '';
if (!Array.isArray(workers) || workers.length === 0) {
DOM.workerList.textContent = '선택 가능한 작업자가 없습니다.';
return;
}
workers.forEach(worker => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn';
btn.textContent = worker.worker_name;
btn.dataset.id = worker.worker_id;
btn.addEventListener('click', () => btn.classList.toggle('selected'));
DOM.workerList.appendChild(btn);
});
}
export function getFormData() {
const selectedWorkers = [...DOM.workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
if (selectedWorkers.length === 0) {
alert('작업자를 한 명 이상 선택해주세요.');
return null;
}
if (DOM.timeEnd.value <= DOM.timeStart.value) {
alert('종료 시간은 시작 시간보다 이후여야 합니다.');
return null;
}
const formData = new FormData(DOM.form);
const data = {
date: formData.get('dateSelect'), // input name 속성이 없어 직접 가져옴
project_id: DOM.projectSelect.value,
issue_type_id: DOM.issueTypeSelect.value,
start_time: DOM.timeStart.value,
end_time: DOM.timeEnd.value,
worker_ids: selectedWorkers, // worker_id -> worker_ids 로 명확하게 변경
};
for (const key in data) {
if (!data[key] || (Array.isArray(data[key]) && data[key].length === 0)) {
alert('모든 필수 항목을 입력해주세요.');
return null;
}
}
return data;
}
export function setSubmitButtonState(isLoading) {
if (isLoading) {
DOM.submitBtn.disabled = true;
DOM.submitBtn.textContent = '등록 중...';
} else {
DOM.submitBtn.disabled = false;
DOM.submitBtn.textContent = '등록';
}
}

View File

@@ -1,154 +1,89 @@
// /js/daily-issue.js
import { API, getAuthHeaders } from '/js/api-config.js';
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
import {
populateProjects,
populateIssueTypes,
populateTimeOptions,
renderWorkerList,
getFormData,
setSubmitButtonState
} from './daily-issue-ui.js';
const dateInput = document.getElementById('dateSelect');
const projectSel = document.getElementById('projectSelect');
const issueTypeSel = document.getElementById('issueTypeSelect');
const timeStartSel = document.getElementById('timeStart');
const timeEndSel = document.getElementById('timeEnd');
const workerList = document.getElementById('workerList');
const dateSelect = document.getElementById('dateSelect');
const form = document.getElementById('issueForm');
// 오늘 날짜 기본 설정
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
// 시간 옵션 생성
function populateTimeOptions(startEl, endEl) {
for (let h = 0; h < 24; h++) {
for (let m of [0, 30]) {
const time = `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`;
const option = new Option(time, time);
startEl.appendChild(option);
endEl.appendChild(option.cloneNode(true));
}
/**
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
*/
async function handleDateChange() {
const selectedDate = dateSelect.value;
if (!selectedDate) {
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
return;
}
}
populateTimeOptions(timeStartSel, timeEndSel);
// 📌 프로젝트 목록
async function loadProjects() {
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
try {
const res = await fetch(`${API}/projects`, {
headers: getAuthHeaders()
});
const data = await res.json();
if (Array.isArray(data)) {
data.forEach(p => {
projectSel.appendChild(new Option(p.project_name, p.project_id));
});
}
} catch (err) {
console.error('프로젝트 로딩 오류:', err);
const workers = await getWorkersByDate(selectedDate);
renderWorkerList(workers);
} catch (error) {
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
}
}
// 📌 이슈 유형 목록
async function loadIssueTypes() {
/**
* 폼 제출 이벤트를 처리합니다.
*/
async function handleSubmit(event) {
event.preventDefault();
const issueData = getFormData();
if (!issueData) return; // 유효성 검사 실패
setSubmitButtonState(true);
try {
const res = await fetch(`${API}/issue-types`, {
headers: getAuthHeaders()
});
const data = await res.json();
if (Array.isArray(data)) {
data.forEach(t => {
issueTypeSel.appendChild(new Option(`${t.category}:${t.subcategory}`, t.issue_type_id));
});
const result = await createIssueReport(issueData);
if (result.success) {
alert('✅ 이슈가 성공적으로 등록되었습니다.');
form.reset(); // 폼 초기화
dateSelect.value = new Date().toISOString().split('T')[0]; // 날짜 오늘로 리셋
handleDateChange(); // 작업자 목록 새로고침
} else {
throw new Error(result.error || '알 수 없는 오류가 발생했습니다.');
}
} catch (err) {
console.error('이슈 타입 로딩 오류:', err);
} catch (error) {
alert(`🚨 등록 실패: ${error.message}`);
} finally {
setSubmitButtonState(false);
}
}
// 📌 작업자 목록
async function loadWorkers() {
const d = dateInput.value;
workerList.textContent = '로딩 중...';
/**
* 페이지 초기화 함수
*/
async function initializePage() {
// 오늘 날짜 기본 설정
dateSelect.value = new Date().toISOString().split('T')[0];
populateTimeOptions();
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
try {
let res = await fetch(`${API}/workreports/date/${d}`, {
headers: getAuthHeaders()
});
let reports = await res.json();
if (!reports.length) {
const wRes = await fetch(`${API}/workers`, {
headers: getAuthHeaders()
});
const allWorkers = await wRes.json();
if (Array.isArray(allWorkers)) {
reports = allWorkers.map(w => ({
worker_id: w.worker_id,
worker_name: w.worker_name
}));
}
}
const seen = new Set();
workerList.innerHTML = '';
reports.forEach(r => {
if (!seen.has(r.worker_id)) {
seen.add(r.worker_id);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn';
btn.textContent = r.worker_name;
btn.dataset.id = r.worker_id;
btn.addEventListener('click', () => btn.classList.toggle('selected'));
workerList.appendChild(btn);
}
});
} catch (err) {
console.error('👿 작업자 로딩 오류:', err);
workerList.textContent = '작업자 로딩 실패';
const [initialData] = await Promise.all([
getInitialData(),
handleDateChange() // 초기 작업자 목록 로드
]);
populateProjects(initialData.projects);
populateIssueTypes(initialData.issueTypes);
} catch (error) {
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
}
// 이벤트 리스너 설정
dateSelect.addEventListener('change', handleDateChange);
form.addEventListener('submit', handleSubmit);
}
// 📌 초기 실행
document.addEventListener('DOMContentLoaded', () => {
loadProjects();
loadIssueTypes();
loadWorkers();
dateInput.addEventListener('change', loadWorkers);
form.addEventListener('submit', async e => {
e.preventDefault();
const workerIds = [...workerList.querySelectorAll('.btn.selected')].map(b => b.dataset.id);
if (!workerIds.length) return alert('작업자를 선택하세요.');
const projectId = projectSel.value;
const issueTypeId = issueTypeSel.value;
const start = timeStartSel.value;
const end = timeEndSel.value;
if (!projectId || !issueTypeId || !start || !end) return alert('모든 값을 입력하세요.');
if (end <= start) return alert('종료 시간은 시작 시간 이후여야 합니다.');
const payload = {
date: dateInput.value,
worker_id: workerIds,
project_id: projectId,
start_time: timeStartSel.value,
end_time: timeEndSel.value,
issue_type_id: issueTypeId
};
try {
const res = await fetch(`${API}/issue-reports`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const json = await res.json();
if (res.ok && json.success) {
alert('✅ 등록 완료!');
loadWorkers();
} else {
alert(json.error || '등록 실패');
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
});
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,765 +1,81 @@
// daily-report-viewer.js - 통합 API 설정 적용 버전
// /js/daily-report-viewer.js
// =================================================================
// 🌐 통합 API 설정 import
// =================================================================
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
import { fetchReportData } from './report-viewer-api.js';
import { renderReport, processReportData, showLoading, showError } from './report-viewer-ui.js';
import { exportToExcel, printReport } from './report-viewer-export.js';
import { getUser } from './auth.js';
// =================================================================
// 🌐 전역 변수 및 기본 설정
// =================================================================
let currentReportData = null;
let workTypes = [];
let workStatusTypes = [];
let errorTypes = [];
// 전역 상태: 현재 화면에 표시된 데이터
let currentProcessedData = null;
// =================================================================
// 🔧 유틸리티 함수들 (입력 페이지와 동일)
// =================================================================
/**
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
*/
async function searchReports() {
const dateInput = document.getElementById('reportDate');
const selectedDate = dateInput.value;
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
console.log('토큰에서 추출한 사용자 정보:', payload);
return payload;
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
const parsed = JSON.parse(userInfo);
console.log('localStorage에서 가져온 사용자 정보:', parsed);
return parsed;
}
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
if (!selectedDate) {
showError('날짜를 선택해주세요.');
return;
}
showLoading(true);
currentProcessedData = null; // 새 검색이 시작되면 이전 데이터 초기화
try {
const rawData = await fetchReportData(selectedDate);
currentProcessedData = processReportData(rawData, selectedDate);
renderReport(currentProcessedData);
} catch (error) {
showError(error.message);
renderReport(null); // 에러 발생 시 데이터 없는 화면 표시
} finally {
showLoading(false);
}
}
// 한국 시간 기준 오늘 날짜 가져기기
function getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
/**
* 페이지의 모든 이벤트 리스너를 설정합니다.
*/
function setupEventListeners() {
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
document.getElementById('todayBtn')?.addEventListener('click', () => {
const today = new Date().toISOString().split('T')[0];
document.getElementById('reportDate').value = today;
searchReports();
});
document.getElementById('reportDate')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchReports();
});
document.getElementById('exportExcelBtn')?.addEventListener('click', () => {
exportToExcel(currentProcessedData);
});
document.getElementById('printBtn')?.addEventListener('click', printReport);
}
// 권한 확인 함수 (수정된 버전)
function checkUserPermission(user) {
if (!user || !user.access_level) {
return { level: 'none', canViewAll: false, description: '권한 없음' };
}
const accessLevel = user.access_level.toLowerCase();
// 🎯 권한 레벨 정의 (더 유연하게)
if (accessLevel === 'system' || accessLevel === 'admin') {
return {
level: 'admin',
canViewAll: true,
description: '시스템/관리자 (전체 조회 시도 → 실패 시 본인 데이터)'
};
} else if (accessLevel === 'manager' || accessLevel === 'group_leader' || accessLevel === '그룹장') {
return {
level: 'manager',
canViewAll: false,
description: '그룹장 (본인 입력 데이터만)'
};
} else {
return {
level: 'user',
canViewAll: false,
description: '일반 사용자 (본인 입력 데이터만)'
};
}
}
// =================================================================
// 🚀 초기화 및 이벤트 설정
// =================================================================
document.addEventListener('DOMContentLoaded', async function() {
console.log('🔥 ===== 통합 API 설정 적용 일일보고서 뷰어 시작 =====');
// 사용자 정보 및 권한 확인
const userInfo = getCurrentUser();
const permission = checkUserPermission(userInfo);
console.log('👤 사용자 정보:', userInfo);
console.log('🔐 권한 정보:', permission);
// 토큰 확인
const mainToken = localStorage.getItem('token');
if (!mainToken) {
console.error('❌ 토큰이 없습니다.');
alert('로그인이 필요합니다.');
/**
* 페이지가 처음 로드될 때 실행되는 초기화 함수
*/
function initializePage() {
// auth.js를 사용하여 인증 상태 확인
const user = getUser();
if (!user) {
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
setTimeout(() => window.location.href = '/index.html', 2000);
return;
}
try {
showMessage('시스템을 초기화하는 중...', 'loading');
// 기본 설정
setupEventListeners();
setTodayDate();
// 마스터 데이터 로드
await loadMasterData();
// 권한 표시
displayUserPermission(permission);
hideMessage();
console.log('✅ 초기화 완료!');
} catch (error) {
console.error('❌ 초기화 실패:', error);
showError(`초기화 오류: ${error.message}`);
}
});
function setupEventListeners() {
document.getElementById('searchBtn')?.addEventListener('click', searchReports);
document.getElementById('todayBtn')?.addEventListener('click', setTodayDate);
document.getElementById('reportDate')?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchReports();
}
});
document.getElementById('exportExcelBtn')?.addEventListener('click', exportToExcel);
document.getElementById('printBtn')?.addEventListener('click', printReport);
}
setupEventListeners();
function setTodayDate() {
const today = getKoreaToday();
// 페이지 로드 시 오늘 날짜로 자동 검색
const dateInput = document.getElementById('reportDate');
if (dateInput) {
dateInput.value = today;
searchReports();
}
dateInput.value = new Date().toISOString().split('T')[0];
searchReports();
}
// 권한 표시 함수 (더 상세하게)
function displayUserPermission(permission) {
// 권한 정보를 UI에 표시
const headerElement = document.querySelector('h1');
if (headerElement) {
headerElement.innerHTML += ` <small style="color: #666; font-size: 0.6em;">(${permission.description})</small>`;
}
console.log(`🔐 현재 권한: ${permission.description}`);
}
// =================================================================
// 📊 마스터 데이터 로드 (통합 API 사용)
// =================================================================
async function loadMasterData() {
try {
console.log('📋 마스터 데이터 로딩...');
await loadWorkTypes();
await loadWorkStatusTypes();
await loadErrorTypes();
console.log('✅ 마스터 데이터 로드 완료');
} catch (error) {
console.error('❌ 마스터 데이터 로드 실패:', error);
}
}
async function loadWorkTypes() {
try {
const data = await apiCall(`${API}/daily-work-reports/work-types`);
if (Array.isArray(data) && data.length > 0) {
workTypes = data;
return;
}
throw new Error('API 실패');
} catch (error) {
workTypes = [
{id: 1, name: 'Base'},
{id: 2, name: 'Vessel'},
{id: 3, name: 'Piping'}
];
}
}
async function loadWorkStatusTypes() {
try {
const data = await apiCall(`${API}/daily-work-reports/work-status-types`);
if (Array.isArray(data) && data.length > 0) {
workStatusTypes = data;
return;
}
throw new Error('API 실패');
} catch (error) {
workStatusTypes = [
{id: 1, name: '정규'},
{id: 2, name: '에러'}
];
}
}
async function loadErrorTypes() {
try {
const data = await apiCall(`${API}/daily-work-reports/error-types`);
if (Array.isArray(data) && data.length > 0) {
errorTypes = data;
return;
}
throw new Error('API 실패');
} catch (error) {
errorTypes = [
{id: 1, name: '설계미스'},
{id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'},
{id: 4, name: '작업 불량'}
];
}
}
// =================================================================
// 🔍 스마트 권한별 데이터 조회 시스템 (통합 API 사용)
// =================================================================
async function searchReports() {
const selectedDate = document.getElementById('reportDate')?.value;
if (!selectedDate) {
showError('날짜를 선택해 주세요.');
return;
}
console.log(`\n🔍 ===== ${selectedDate} 스마트 권한별 조회 시작 =====`);
try {
hideAllMessages();
showLoading(true);
const currentUser = getCurrentUser();
const permission = checkUserPermission(currentUser);
console.log('🔐 권한 확인:', permission);
let data = [];
let queryMethod = '';
if (permission.canViewAll) {
// 🌍 관리자/시스템: 전체 데이터 조회 시도 → 실패 시 본인 데이터로 폴백
console.log('🌍 관리자 권한으로 전체 데이터 조회 시도');
data = await fetchAllDataWithFallback(selectedDate, currentUser);
queryMethod = '관리자 권한 (폴백 포함)';
} else {
// 🔒 일반 사용자/그룹장: 처음부터 본인 데이터만 조회
console.log('🔒 제한 권한으로 본인 데이터만 조회');
data = await fetchMyData(selectedDate, currentUser);
queryMethod = '제한 권한 (본인 데이터만)';
}
console.log(`📊 최종 조회된 데이터: ${data.length}`);
if (data.length > 0) {
const processedData = processRawData(data, selectedDate);
currentReportData = processedData;
displayReportData(processedData);
showExportSection(true);
showMessage(`${queryMethod}으로 ${data.length}개 데이터를 표시했습니다.`, 'success');
} else {
const helpMessage = permission.canViewAll ?
'전체 조회 및 본인 데이터 조회 모두 실패했습니다.' :
'해당 날짜에 본인이 입력한 데이터가 없습니다.';
showNoDataWithHelp(selectedDate, helpMessage);
showExportSection(false);
}
} catch (error) {
console.error('❌ 조회 오류:', error);
showError(`데이터 조회 오류: ${error.message}`);
showExportSection(false);
} finally {
showLoading(false);
console.log('🔍 ===== 조회 완료 =====\n');
}
}
// 전체 데이터 조회 + 본인 데이터 폴백 (시스템/관리자용) - 통합 API 사용
async function fetchAllDataWithFallback(selectedDate, currentUser) {
console.log('📡 전체 데이터 조회 시도 (폴백 지원)');
// 1단계: 전체 데이터 조회 시도
const allData = await fetchAllData(selectedDate);
if (allData.length > 0) {
console.log(`✅ 전체 데이터 조회 성공: ${allData.length}`);
return allData;
}
// 2단계: 전체 조회 실패 시 본인 데이터로 폴백
console.log('⚠️ 전체 조회 실패, 본인 데이터로 폴백');
const myData = await fetchMyData(selectedDate, currentUser);
if (myData.length > 0) {
console.log(`✅ 폴백 성공: 본인 데이터 ${myData.length}`);
showMessage('⚠️ 전체 조회 권한이 없어 본인 입력 데이터만 표시합니다.', 'warning');
return myData;
}
console.log('❌ 전체 조회 및 폴백 모두 실패');
return [];
}
// 전체 데이터 조회 (시스템/관리자용) - 통합 API 사용
async function fetchAllData(selectedDate) {
console.log('📡 전체 데이터 API 호출');
// 여러 방법으로 시도
const endpoints = [
`/daily-work-reports?date=${selectedDate}`,
`/daily-work-reports/date/${selectedDate}`
];
for (const endpoint of endpoints) {
try {
console.log(`🔍 시도: ${API}${endpoint}`);
const rawData = await apiCall(`${API}${endpoint}`);
let data = Array.isArray(rawData) ? rawData : (rawData?.data || []);
if (data.length > 0) {
console.log(`✅ 전체 조회 성공: ${data.length}개 데이터`);
return data;
}
} catch (error) {
console.log(`❌ 오류: ${error.message}`);
continue;
}
}
console.log('❌ 모든 전체 조회 방법 실패');
return [];
}
// 본인 데이터 조회 (모든 사용자 공통) - 통합 API 사용
async function fetchMyData(selectedDate, currentUser) {
console.log('📡 본인 데이터 API 호출');
if (!currentUser?.user_id && !currentUser?.id) {
console.error('❌ 사용자 ID가 없습니다');
return [];
}
const userId = currentUser.user_id || currentUser.id;
console.log(`🔍 본인 데이터 URL: ${API}/daily-work-reports?date=${selectedDate}&created_by=${userId}`);
try {
const rawData = await apiCall(`${API}/daily-work-reports?date=${selectedDate}&created_by=${userId}`);
let data = Array.isArray(rawData) ? rawData : (rawData?.data || []);
console.log(`✅ 본인 데이터: ${data.length}`);
return data;
} catch (error) {
console.error('❌ 본인 데이터 조회 오류:', error);
return [];
}
}
// 원시 데이터를 구조화된 형태로 변환
function processRawData(rawData, selectedDate) {
console.log('🔄 데이터 구조 변환 시작');
if (!Array.isArray(rawData) || rawData.length === 0) {
return {
summary: {
date: selectedDate,
total_workers: 0,
total_hours: 0,
total_entries: 0,
error_count: 0
},
workers: []
};
}
// 작업자별로 그룹화
const workerGroups = {};
let totalHours = 0;
let errorCount = 0;
rawData.forEach(item => {
const workerName = item.worker_name || '미지정';
const workHours = parseFloat(item.work_hours || 0);
totalHours += workHours;
if (item.work_status_id === 2) {
errorCount++;
}
if (!workerGroups[workerName]) {
workerGroups[workerName] = {
worker_name: workerName,
worker_id: item.worker_id,
total_hours: 0,
work_entries: []
};
}
workerGroups[workerName].total_hours += workHours;
workerGroups[workerName].work_entries.push({
project_name: item.project_name,
work_type_name: item.work_type_name,
work_status_name: item.work_status_name,
error_type_name: item.error_type_name,
work_hours: workHours,
work_status_id: item.work_status_id,
created_by_name: item.created_by_name || '입력자 미지정'
});
});
const processedData = {
summary: {
date: selectedDate,
total_workers: Object.keys(workerGroups).length,
total_hours: totalHours,
total_entries: rawData.length,
error_count: errorCount
},
workers: Object.values(workerGroups)
};
console.log('✅ 데이터 변환 완료:', {
작업자수: processedData.workers.length,
총항목수: rawData.length,
총시간: totalHours,
에러수: errorCount
});
return processedData;
}
// =================================================================
// 🎨 UI 표시 함수들 (기존과 동일)
// =================================================================
function displayReportData(data) {
console.log('🎨 리포트 데이터 표시');
displaySummary(data.summary);
displayWorkersDetails(data.workers);
document.getElementById('reportSummary').style.display = 'block';
document.getElementById('workersReport').style.display = 'block';
}
function displaySummary(summary) {
const elements = {
totalWorkers: summary?.total_workers || 0,
totalHours: `${summary?.total_hours || 0}시간`,
totalEntries: `${summary?.total_entries || 0}`,
errorCount: `${summary?.error_count || 0}`
};
Object.entries(elements).forEach(([id, value]) => {
const element = document.getElementById(id);
if (element) element.textContent = value;
});
// 에러 카드 스타일링
const errorCard = document.querySelector('.summary-card.error-card');
if (errorCard) {
const hasErrors = (summary?.error_count || 0) > 0;
errorCard.style.borderLeftColor = hasErrors ? '#e74c3c' : '#28a745';
errorCard.style.backgroundColor = hasErrors ? '#fff5f5' : '#f8fff9';
}
}
function displayWorkersDetails(workers) {
const workersList = document.getElementById('workersList');
if (!workersList) return;
workersList.innerHTML = '';
workers.forEach(worker => {
const workerCard = createWorkerCard(worker);
workersList.appendChild(workerCard);
});
}
function createWorkerCard(worker) {
const workerDiv = document.createElement('div');
workerDiv.className = 'worker-card';
const workerHeader = document.createElement('div');
workerHeader.className = 'worker-header';
workerHeader.innerHTML = `
<div class="worker-name">👤 ${worker.worker_name || '미지정'}</div>
<div class="worker-total-hours">총 ${worker.total_hours || 0}시간</div>
`;
const workEntries = document.createElement('div');
workEntries.className = 'work-entries';
if (worker.work_entries && Array.isArray(worker.work_entries)) {
worker.work_entries.forEach(entry => {
const entryDiv = createWorkEntryCard(entry);
workEntries.appendChild(entryDiv);
});
}
workerDiv.appendChild(workerHeader);
workerDiv.appendChild(workEntries);
return workerDiv;
}
function createWorkEntryCard(entry) {
const entryDiv = document.createElement('div');
entryDiv.className = 'work-entry';
if (entry.work_status_id === 2) {
entryDiv.classList.add('error-entry');
}
const entryHeader = document.createElement('div');
entryHeader.className = 'entry-header';
entryHeader.innerHTML = `
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
<div class="work-hours">${entry.work_hours || 0}시간</div>
`;
const entryDetails = document.createElement('div');
entryDetails.className = 'entry-details';
const details = [
['작업 유형', entry.work_type_name || '-'],
['작업 상태', entry.work_status_name || '정상'],
['입력자', entry.created_by_name || '미지정']
];
if (entry.work_status_id === 2 && entry.error_type_name) {
details.push(['에러 유형', entry.error_type_name, 'error-type']);
}
details.forEach(([label, value, valueClass]) => {
const detailRow = createDetailRow(label, value, valueClass);
entryDetails.appendChild(detailRow);
});
entryDiv.appendChild(entryHeader);
entryDiv.appendChild(entryDetails);
return entryDiv;
}
function createDetailRow(label, value, valueClass = '') {
const detailDiv = document.createElement('div');
detailDiv.className = 'entry-detail';
detailDiv.innerHTML = `
<span class="detail-label">${label}:</span>
<span class="detail-value ${valueClass}">${value}</span>
`;
return detailDiv;
}
// =================================================================
// 🎭 UI 상태 관리
// =================================================================
function showLoading(show) {
const spinner = document.getElementById('loadingSpinner');
if (spinner) {
spinner.style.display = show ? 'flex' : 'none';
}
}
function showError(message) {
const errorDiv = document.getElementById('errorMessage');
if (errorDiv) {
const errorText = errorDiv.querySelector('.error-text');
if (errorText) errorText.textContent = message;
errorDiv.style.display = 'block';
}
}
function showMessage(message, type = 'info') {
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
messageContainer.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success' || type === 'info') {
setTimeout(() => {
messageContainer.innerHTML = '';
}, 5000);
}
} else {
console.log(`📢 ${type.toUpperCase()}: ${message}`);
}
}
function hideMessage() {
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
messageContainer.innerHTML = '';
}
}
function showNoDataWithHelp(selectedDate, helpMessage = '해당 날짜에 데이터가 없습니다.') {
const noDataDiv = document.getElementById('noDataMessage');
if (noDataDiv) {
noDataDiv.innerHTML = `
<div class="no-data-content">
<span class="no-data-icon">📭</span>
<h3>${selectedDate} 작업보고서가 없습니다</h3>
<div class="help-section">
<p><strong>💡 ${helpMessage}</strong></p>
<ul style="text-align: left; margin: 10px 0;">
<li>다른 날짜를 선택해보세요 (예: ${getKoreaToday()})</li>
<li><a href="/pages/common/daily-work-report.html" target="_blank" style="color: #3498db;">📝 작업보고서 입력 페이지</a>에서 데이터를 먼저 입력해보세요</li>
<li>입력 후 잠시 기다린 다음 다시 시도해보세요</li>
</ul>
<p style="margin-top: 15px;">
<button onclick="window.location.reload()" style="padding: 8px 16px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer;">
🔄 새로고침
</button>
</p>
</div>
</div>
`;
noDataDiv.style.display = 'block';
}
}
function showExportSection(show) {
const exportSection = document.getElementById('exportSection');
if (exportSection) {
exportSection.style.display = show ? 'block' : 'none';
}
}
function hideAllMessages() {
const elements = [
'errorMessage',
'noDataMessage',
'reportSummary',
'workersReport'
];
elements.forEach(id => {
const element = document.getElementById(id);
if (element) element.style.display = 'none';
});
}
// =================================================================
// 📤 내보내기 기능
// =================================================================
function exportToExcel() {
if (!currentReportData?.workers?.length) {
alert('내보낼 데이터가 없습니다.');
return;
}
console.log('📊 Excel 내보내기 시작');
try {
let csvContent = "\uFEFF작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
currentReportData.workers.forEach(worker => {
if (worker.work_entries && Array.isArray(worker.work_entries)) {
worker.work_entries.forEach(entry => {
const row = [
worker.worker_name || '',
entry.project_name || '',
entry.work_type_name || '',
entry.work_status_name || '',
entry.error_type_name || '',
entry.work_hours || 0,
entry.created_by_name || ''
].map(field => `"${String(field).replace(/"/g, '""')}"`).join(',');
csvContent += row + "\n";
});
}
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const fileName = `작업보고서_${currentReportData.summary?.date || '날짜미지정'}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
console.log('✅ Excel 내보내기 완료');
showMessage('Excel 파일이 다운로드되었습니다.', 'success');
} catch (error) {
console.error('❌ Excel 내보내기 실패:', error);
showError('Excel 내보내기 중 오류가 발생했습니다.');
}
}
function printReport() {
console.log('🖨️ 인쇄 시작');
if (!currentReportData?.workers?.length) {
alert('인쇄할 데이터가 없습니다.');
return;
}
try {
window.print();
console.log('✅ 인쇄 대화상자 표시');
} catch (error) {
console.error('❌ 인쇄 실패:', error);
showError('인쇄 중 오류가 발생했습니다.');
}
}
// =================================================================
// 🔄 전역 함수 및 디버깅
// =================================================================
// 개발 모드 디버깅
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('🐛 개발 모드 활성화');
window.DEBUG = {
currentReportData,
getCurrentUser,
checkUserPermission,
fetchAllData,
fetchMyData,
fetchAllDataWithFallback,
searchReports
};
}
// 전역 함수 노출
window.searchReports = searchReports;
window.exportToExcel = exportToExcel;
window.printReport = printReport;
// 페이지 정리
window.addEventListener('beforeunload', function() {
console.log('📋 페이지 종료');
});
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,252 +1,144 @@
// js/load-navbar.js
// 네비게이션바 로드 및 프로필 드롭다운 기능 구현
import { getUser, clearAuthData } from './auth.js';
// 역할 이름을 한글로 변환하는 맵
const ROLE_NAMES = {
admin: '관리자',
system: '시스템 관리자',
leader: '그룹장',
user: '작업자',
support: '지원팀',
default: '사용자',
};
/**
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {string} userRole - 현재 사용자의 역할
*/
function filterMenuByRole(doc, userRole) {
const selectors = [
{ role: 'admin', selector: '.admin-only' },
{ role: 'system', selector: '.system-only' },
{ role: 'leader', selector: '.leader-only' },
];
selectors.forEach(({ role, selector }) => {
// 사용자가 해당 역할을 가지고 있지 않으면 메뉴 항목을 제거
if (userRole !== role) {
doc.querySelectorAll(selector).forEach(el => el.remove());
}
});
}
/**
* 네비게이션 바에 사용자 정보를 채웁니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {object} user - 현재 사용자 객체
*/
function populateUserInfo(doc, user) {
const displayName = user.name || user.username;
const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default;
// 상단 바 사용자 이름
const userNameEl = doc.getElementById('user-name');
if (userNameEl) userNameEl.textContent = displayName;
// 상단 바 사용자 역할
const userRoleEl = doc.getElementById('user-role');
if (userRoleEl) userRoleEl.textContent = roleName;
// 드롭다운 메뉴 사용자 이름
const dropdownNameEl = doc.getElementById('dropdown-user-fullname');
if (dropdownNameEl) dropdownNameEl.textContent = displayName;
// 드롭다운 메뉴 사용자 아이디
const dropdownIdEl = doc.getElementById('dropdown-user-id');
if (dropdownIdEl) dropdownIdEl.textContent = `@${user.username}`;
}
/**
* 네비게이션 바와 관련된 모든 이벤트를 설정합니다.
*/
function setupNavbarEvents() {
const userInfoDropdown = document.getElementById('user-info-dropdown');
const profileDropdownMenu = document.getElementById('profile-dropdown-menu');
// 드롭다운 토글
if (userInfoDropdown && profileDropdownMenu) {
userInfoDropdown.addEventListener('click', (e) => {
e.stopPropagation();
profileDropdownMenu.classList.toggle('show');
userInfoDropdown.classList.toggle('active');
});
}
// 로그아웃 버튼
const logoutButton = document.getElementById('dropdown-logout');
if (logoutButton) {
logoutButton.addEventListener('click', () => {
if (confirm('로그아웃 하시겠습니까?')) {
clearAuthData();
window.location.href = '/index.html';
}
});
}
// 외부 클릭 시 드롭다운 닫기
document.addEventListener('click', (e) => {
if (profileDropdownMenu && !userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) {
profileDropdownMenu.classList.remove('show');
userInfoDropdown.classList.remove('active');
}
});
}
/**
* 현재 시간을 업데이트하는 함수
*/
function updateTime() {
const timeElement = document.getElementById('current-time');
if (timeElement) {
const now = new Date();
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
}
}
// 메인 로직: DOMContentLoaded 시 실행
document.addEventListener('DOMContentLoaded', async () => {
const navbarContainer = document.getElementById('navbar-container');
if (!navbarContainer) return;
const currentUser = getUser();
if (!currentUser) return; // 사용자가 없으면 아무 작업도 하지 않음
try {
// navbar.html 파일 로드
const res = await fetch('/components/navbar.html');
const html = await res.text();
// navbar 컨테이너 찾기
const container = document.getElementById('navbar-container') || document.getElementById('navbar-placeholder');
if (!container) {
console.error('네비게이션 컨테이너를 찾을 수 없습니다');
return;
}
// HTML 삽입
container.innerHTML = html;
const response = await fetch('/components/navbar.html');
const htmlText = await response.text();
// 토큰 확인
const token = localStorage.getItem('token');
if (!token) return;
// 1. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 역할 매핑 테이블
const roleMap = {
worker: '작업자',
group_leader: '그룹장',
groupleader: '그룹장',
leader: '리더',
supervisor: '감독자',
team_leader: '팀장',
support_team: '지원팀',
support: '지원팀',
admin_ceo: '업무관리자',
admin_plant: '시스템관리자',
admin: '관리자',
administrator: '관리자',
system: '시스템관리자'
};
// 2. DOM에 삽입하기 *전*에 내용 수정
filterMenuByRole(doc, currentUser.role);
populateUserInfo(doc, currentUser);
// JWT 토큰 파싱
let payload;
try {
payload = JSON.parse(atob(token.split('.')[1]));
} catch (err) {
console.warn('JWT 파싱 실패:', err);
return;
}
// 3. 수정 완료된 HTML을 실제 DOM에 삽입 (깜빡임 방지)
navbarContainer.innerHTML = doc.body.innerHTML;
// 저장된 사용자 정보 확인
const storedUser = JSON.parse(localStorage.getItem('user') || '{}');
const currentUser = storedUser.access_level ? storedUser : payload;
// 4. DOM에 삽입된 후에 이벤트 리스너 설정
setupNavbarEvents();
// ✅ 사용자 정보 표시
const nameEl = document.getElementById('user-name');
if (nameEl) {
nameEl.textContent = currentUser.name || currentUser.username || '사용자';
}
const roleEl = document.getElementById('user-role');
if (roleEl) {
const accessLevel = (currentUser.access_level || '').toLowerCase();
const roleName = roleMap[accessLevel] || '사용자';
roleEl.textContent = roleName;
}
// ✅ 드롭다운 헤더 사용자 정보
const dropdownFullname = document.getElementById('dropdown-user-fullname');
if (dropdownFullname) {
dropdownFullname.textContent = currentUser.name || currentUser.username || '사용자';
}
const dropdownUserId = document.getElementById('dropdown-user-id');
if (dropdownUserId) {
dropdownUserId.textContent = `@${currentUser.username || 'user'}`;
}
// ✅ 현재 시간 업데이트 시작
// 5. 실시간 시간 업데이트 시작
updateTime();
setInterval(updateTime, 1000);
// ✅ 프로필 드롭다운 이벤트 설정
const userInfoDropdown = document.getElementById('user-info-dropdown');
const profileDropdownMenu = document.getElementById('profile-dropdown-menu');
if (userInfoDropdown && profileDropdownMenu) {
// 드롭다운 토글
userInfoDropdown.addEventListener('click', function(e) {
e.stopPropagation();
const isOpen = profileDropdownMenu.classList.contains('show');
if (isOpen) {
closeProfileDropdown();
} else {
openProfileDropdown();
}
});
console.log('✅ 네비게이션 바 로딩 완료');
// 드롭다운 외부 클릭 시 닫기
document.addEventListener('click', function(e) {
if (!userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) {
closeProfileDropdown();
}
});
// ESC 키로 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeProfileDropdown();
}
});
}
// ✅ 대시보드 버튼 이벤트
const dashboardBtn = document.querySelector('.dashboard-btn');
if (dashboardBtn) {
dashboardBtn.addEventListener('click', function() {
navigateToDashboard();
});
}
// ✅ 드롭다운 로그아웃 버튼
const dropdownLogout = document.getElementById('dropdown-logout');
if (dropdownLogout) {
dropdownLogout.addEventListener('click', function() {
logout();
});
}
console.log('✅ 네비게이션 바 로딩 및 이벤트 설정 완료:', {
name: currentUser.name || currentUser.username,
role: currentUser.access_level,
dashboardBtn: !!dashboardBtn,
profileDropdown: !!userInfoDropdown
});
} catch (err) {
console.error('🔴 네비게이션 바 로딩 실패:', err);
} catch (error) {
console.error('🔴 네비게이션 바 로딩 중 오류 발생:', error);
navbarContainer.innerHTML = '<p>네비게이션 바를 불러오는 데 실패했습니다.</p>';
}
});
// ✅ 프로필 드롭다운 열기
function openProfileDropdown() {
const userInfo = document.getElementById('user-info-dropdown');
const dropdown = document.getElementById('profile-dropdown-menu');
if (userInfo && dropdown) {
userInfo.classList.add('active');
dropdown.classList.add('show');
console.log('📂 프로필 드롭다운 열림');
}
}
// ✅ 프로필 드롭다운 닫기
function closeProfileDropdown() {
const userInfo = document.getElementById('user-info-dropdown');
const dropdown = document.getElementById('profile-dropdown-menu');
if (userInfo && dropdown) {
userInfo.classList.remove('active');
dropdown.classList.remove('show');
console.log('📁 프로필 드롭다운 닫힘');
}
}
// ✅ 시간 업데이트 함수
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.getElementById('current-time');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// ✅ 역할별 대시보드 네비게이션
function navigateToDashboard() {
console.log('🏠 대시보드 버튼 클릭됨');
const user = JSON.parse(localStorage.getItem('user') || '{}');
const accessLevel = (user.access_level || '').toLowerCase().trim();
console.log('👤 현재 사용자:', user);
console.log('🔑 access_level:', accessLevel);
// 그룹장/리더 관련 키워드들
const leaderKeywords = [
'group_leader', 'groupleader', 'group-leader',
'leader', 'supervisor', 'team_leader', 'teamleader',
'그룹장', '팀장', '현장책임자'
];
// 관리자 관련 키워드들
const adminKeywords = [
'admin', 'administrator', 'system',
'관리자', '시스템관리자'
];
// 지원팀 관련 키워드들
const supportKeywords = [
'support', 'support_team', 'supportteam',
'지원팀', '지원'
];
let targetUrl = '/pages/dashboard/user.html';
// 키워드 매칭
if (leaderKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
targetUrl = '/pages/dashboard/group-leader.html';
console.log('✅ 그룹장 페이지로 이동');
} else if (adminKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
targetUrl = '/pages/dashboard/admin.html';
console.log('✅ 관리자 페이지로 이동');
} else if (supportKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
targetUrl = '/pages/dashboard/support.html';
console.log('✅ 지원팀 페이지로 이동');
} else {
console.log('✅ 일반 사용자 페이지로 이동');
}
console.log('🎯 이동할 URL:', targetUrl);
window.location.href = targetUrl;
}
// ✅ 로그아웃 함수
function logout() {
console.log('🚪 로그아웃 버튼 클릭됨');
if (confirm('로그아웃 하시겠습니까?')) {
console.log('✅ 로그아웃 확인됨');
// 로컬 스토리지 정리
localStorage.removeItem('token');
localStorage.removeItem('user');
console.log('🗑️ 로컬 스토리지 정리 완료');
// 부드러운 전환 효과
document.body.style.opacity = '0';
setTimeout(() => {
console.log('🏠 로그인 페이지로 이동');
window.location.href = '/index.html';
}, 300);
} else {
console.log('❌ 로그아웃 취소됨');
}
}
});

View File

@@ -1,187 +1,104 @@
// /js/load-sections.js - 확장 가능한 구조 (개선됨)
import { API, getAuthHeaders } from '/js/api-config.js';
// /js/load-sections.js
import { getUser } from './auth.js';
import { apiGet } from './api-helper.js';
// 역할별 섹션 매핑 (쉽게 추가/수정 가능)
// 역할에 따라 불러올 섹션 HTML 파일을 매핑합니다.
const SECTION_MAP = {
'admin': '/components/sections/admin-sections.html',
'system': '/components/sections/admin-sections.html',
'leader': '/components/sections/leader-sections.html',
'group_leader': '/components/sections/leader-sections.html',
'support': '/components/sections/support-sections.html',
'support_team': '/components/sections/support-sections.html',
'user': '/components/sections/user-sections.html',
'worker': '/components/sections/user-sections.html'
admin: '/components/sections/admin-sections.html',
system: '/components/sections/admin-sections.html', // system도 admin과 동일한 섹션을 사용
leader: '/components/sections/leader-sections.html',
user: '/components/sections/user-sections.html',
default: '/components/sections/user-sections.html', // 역할이 없는 경우 기본값
};
// 공통 섹션 (모든 사용자에게 표시)
const COMMON_SECTIONS = '/components/sections/common-sections.html';
async function loadSections() {
/**
* API를 통해 대시보드 통계 데이터를 가져옵니다.
* @returns {Promise<object|null>} 통계 데이터 또는 에러 시 null
*/
async function fetchDashboardStats() {
try {
console.log('🔄 섹션 로딩 시작');
// 사용자 정보 확인
const token = localStorage.getItem('token');
if (!token) {
console.log('❌ 토큰 없음, 로그인 페이지로 이동');
window.location.href = '/index.html';
return;
}
let userInfo = { role: 'user', access_level: 'worker' };
try {
const payload = JSON.parse(atob(token.split('.')[1]));
userInfo = {
role: payload.role || 'user',
access_level: payload.access_level || 'worker'
};
console.log('👤 사용자 정보:', userInfo);
} catch (err) {
console.warn('⚠️ JWT 파싱 실패:', err);
}
// ✅ 컨테이너 찾기 - 더 안전한 방식
const possibleContainers = [
'#sections-container',
'#admin-sections',
'#user-sections',
'main[id$="-sections"]',
'#content-container main'
];
let container = null;
for (const selector of possibleContainers) {
container = document.querySelector(selector);
if (container) {
console.log(`✅ 컨테이너 발견: ${selector}`);
break;
}
}
if (!container) {
console.error('❌ 섹션 컨테이너를 찾을 수 없습니다');
return;
}
container.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
// 역할별 섹션 파일 결정 (수정된 버전)
console.log('🔍 사용자 정보 디버깅:');
console.log('- userInfo.role:', userInfo.role);
console.log('- userInfo.access_level:', userInfo.access_level);
// role이 없으므로 access_level을 우선 사용
const effectiveRole = userInfo.access_level || userInfo.role || 'user';
const sectionFile = SECTION_MAP[effectiveRole] || SECTION_MAP['user'];
console.log(`📄 실제 사용될 역할: ${effectiveRole}`);
console.log(`📄 로딩할 섹션 파일: ${sectionFile}`);
try {
// 1. 공통 섹션 로드 (있을 경우)
let commonHtml = '';
try {
console.log('📄 공통 섹션 로딩 시도');
const commonRes = await fetch(COMMON_SECTIONS);
if (commonRes.ok) {
commonHtml = await commonRes.text();
console.log('✅ 공통 섹션 로딩 성공');
}
} catch (e) {
console.log(' 공통 섹션 없음 (정상)');
}
// 2. 역할별 섹션 로드
console.log('📄 역할별 섹션 로딩 시도');
const res = await fetch(sectionFile);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: 섹션 파일을 찾을 수 없습니다 (${sectionFile})`);
}
const roleHtml = await res.text();
console.log('✅ 역할별 섹션 로딩 성공');
// 3. 조합하여 표시
container.innerHTML = commonHtml + roleHtml;
console.log('✅ 섹션 HTML 렌더링 완료');
// 4. 추가 데이터 로드 (필요시)
await loadDynamicData(userInfo);
console.log('✅ 섹션 로딩 완료');
} catch (err) {
console.error('❌ 섹션 로드 실패:', err);
container.innerHTML = `
<div class="error-state">
<h3>❌ 콘텐츠를 불러올 수 없습니다</h3>
<p>오류: ${err.message}</p>
<p>잠시 후 다시 시도해주세요.</p>
<button onclick="location.reload()" class="btn btn-primary">🔄 새로고침</button>
</div>
`;
}
} catch (err) {
console.error('🔴 섹션 로딩 실패:', err);
const today = new Date().toISOString().split('T')[0];
// 실제 백엔드 엔드포인트는 /api/dashboard/stats 와 같은 형태로 구현될 수 있습니다.
const stats = await apiGet(`/workreports?start=${today}&end=${today}`);
// 필요한 데이터 형태로 가공 (예시)
return {
today_reports_count: stats.length,
today_workers_count: new Set(stats.map(d => d.worker_id)).size,
};
} catch (error) {
console.error('대시보드 통계 데이터 로드 실패:', error);
return null;
}
}
// 동적 데이터 로드 (예: 대시보드 통계)
async function loadDynamicData(userInfo) {
console.log('📊 동적 데이터 로딩 시작');
/**
* 가상 DOM에 통계 데이터를 채워 넣습니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {object} stats - 통계 데이터
*/
function populateStatsData(doc, stats) {
if (!stats) return;
const todayStatsEl = doc.getElementById('today-stats');
if (todayStatsEl) {
todayStatsEl.innerHTML = `
<p>📝 오늘 등록된 작업: ${stats.today_reports_count}건</p>
<p>👥 참여 작업자: ${stats.today_workers_count}명</p>
`;
}
}
/**
* 메인 로직: 페이지에 역할별 섹션을 로드하고 내용을 채웁니다.
*/
async function initializeSections() {
const mainContainer = document.querySelector('main[id$="-sections"]');
if (!mainContainer) {
console.error('섹션을 담을 메인 컨테이너를 찾을 수 없습니다.');
return;
}
mainContainer.innerHTML = '<div class="loading">콘텐츠를 불러오는 중...</div>';
const currentUser = getUser();
if (!currentUser) {
mainContainer.innerHTML = '<div class="error-state">사용자 정보를 찾을 수 없습니다.</div>';
return;
}
// 오늘의 작업 현황
const todayStats = document.getElementById('today-stats');
if (todayStats) {
try {
const today = new Date().toISOString().split('T')[0];
const res = await fetch(`${API}/workreports?start=${today}&end=${today}`, {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
todayStats.innerHTML = `
<p>📝 오늘 등록된 작업: ${data.length}건</p>
<p>👥 참여 작업자: ${new Set(data.map(d => d.worker_id)).size}명</p>
`;
console.log('✅ 오늘 통계 로딩 완료');
}
} catch (e) {
console.error('❌ 통계 로드 실패:', e);
if (todayStats) {
todayStats.innerHTML = '<p>⚠️ 통계를 불러올 수 없습니다</p>';
}
const sectionFile = SECTION_MAP[currentUser.role] || SECTION_MAP.default;
try {
// 1. 역할에 맞는 HTML 템플릿과 동적 데이터를 동시에 로드 (Promise.all 활용)
const [htmlResponse, statsData] = await Promise.all([
fetch(sectionFile),
fetchDashboardStats()
]);
if (!htmlResponse.ok) {
throw new Error(`섹션 파일(${sectionFile})을 불러오는 데 실패했습니다.`);
}
}
const htmlText = await htmlResponse.text();
// 빠른 링크 활성화
initializeQuickLinks(userInfo);
// 2. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 3. (필요 시) 역할 기반으로 가상 DOM 필터링 - 현재는 파일 자체가 역할별로 나뉘어 불필요
// filterByRole(doc, currentUser.role);
// 4. 가상 DOM에 동적 데이터 채우기
populateStatsData(doc, statsData);
// 5. 모든 수정이 완료된 HTML을 실제 DOM에 한 번에 삽입
mainContainer.innerHTML = doc.body.innerHTML;
console.log(`${currentUser.role} 역할의 섹션 로딩 완료.`);
} catch (error) {
console.error('섹션 로딩 중 오류 발생:', error);
mainContainer.innerHTML = `<div class="error-state">콘텐츠 로딩에 실패했습니다: ${error.message}</div>`;
}
}
// 권한별 빠른 링크 표시/숨김
function initializeQuickLinks(userInfo) {
console.log('🔗 빠른 링크 초기화');
// 권한에 따라 특정 링크 숨기기
if (userInfo.role !== 'admin' && userInfo.access_level !== 'admin') {
document.querySelectorAll('.admin-only').forEach(el => {
el.style.display = 'none';
console.log('🔒 관리자 전용 링크 숨김');
});
}
if (userInfo.access_level !== 'group_leader') {
document.querySelectorAll('.leader-only').forEach(el => {
el.style.display = 'none';
console.log('🔒 그룹장 전용 링크 숨김');
});
}
console.log('✅ 빠른 링크 초기화 완료');
}
// 페이지 로드 시 실행
document.addEventListener('DOMContentLoaded', loadSections);
// 수동 새로고침 함수 (다른 곳에서 호출 가능)
window.refreshSections = loadSections;
// DOM이 로드되면 섹션 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializeSections);

View File

@@ -1,46 +1,67 @@
// /js/load-sidebar.js (access_level 기반 메뉴 필터링)
document.addEventListener('DOMContentLoaded', async () => {
try {
// 1) 사이드바 HTML 로딩
const res = await fetch('/components/sidebar.html');
const html = await res.text();
document.getElementById('sidebar-container').innerHTML = html;
// /js/load-sidebar.js
import { getUser } from './auth.js';
// 2) 토큰 존재 확인
const token = localStorage.getItem('token');
if (!token) return;
/**
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링합니다.
* @param {Document} doc - 파싱된 HTML 문서 객체
* @param {string} userRole - 현재 사용자의 역할
*/
function filterSidebarByRole(doc, userRole) {
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
if (userRole === 'system') {
return;
}
// 역할과 그에 해당하는 클래스 선택자 매핑
const roleClassMap = {
admin: '.admin-only',
leader: '.leader-only',
user: '.user-only', // 또는 'worker-only' 등, sidebar.html에 정의된 클래스에 맞춰야 함
support: '.support-only'
};
// 3) JWT 파싱해서 access_level 추출
let access;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
access = payload.access_level;
} catch (err) {
console.warn('JWT 파싱 실패:', err);
return;
// 모든 역할 기반 선택자를 가져옴
const allRoleSelectors = Object.values(roleClassMap).join(', ');
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
allRoleElements.forEach(el => {
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
const userRoleSelector = roleClassMap[userRole];
if (!userRoleSelector || !el.matches(userRoleSelector)) {
el.remove();
}
});
}
// 4) 시스템 계정은 전부 유지
if (access === 'system') return;
// 5) 클래스 이름 목록
const classMap = [
'worker-only',
'group-leader-only',
'support-only',
'admin-only',
'system-only'
];
document.addEventListener('DOMContentLoaded', async () => {
const sidebarContainer = document.getElementById('sidebar-container');
if (!sidebarContainer) return;
// 6) 본인 권한에 해당하지 않는 요소 제거
classMap.forEach(cls => {
const required = cls.replace('-only', '').replace('-', '_'); // 'group-leader-only' → 'group_leader'
if (access !== required) {
document.querySelectorAll(`.${cls}`).forEach(el => el.remove());
}
});
const currentUser = getUser();
if (!currentUser) return; // 비로그인 상태면 사이드바를 로드하지 않음
} catch (err) {
console.error('🔴 사이드바 로딩 실패:', err);
try {
const response = await fetch('/components/sidebar.html');
if (!response.ok) {
throw new Error(`사이드바 파일을 불러올 수 없습니다: ${response.statusText}`);
}
const htmlText = await response.text();
// 1. 텍스트를 가상 DOM으로 파싱
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// 2. DOM에 삽입하기 *전*에 역할에 따라 메뉴 필터링
filterSidebarByRole(doc, currentUser.role);
// 3. 수정 완료된 HTML을 실제 DOM에 삽입
sidebarContainer.innerHTML = doc.body.innerHTML;
console.log('✅ 사이드바 로딩 및 필터링 완료');
} catch (error) {
console.error('🔴 사이드바 로딩 실패:', error);
sidebarContainer.innerHTML = '<p>메뉴 로딩 실패</p>';
}
});

View File

@@ -1,52 +1,7 @@
// 깔끔한 로그인 로직 (login.js)
import { API } from './api-config.js';
// /js/login.js
function parseJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
// 역할별 대시보드 라우팅
function routeToDashboard(user) {
const accessLevel = (user.access_level || '').toLowerCase().trim();
// 그룹장/리더 관련 키워드들
const leaderKeywords = [
'group_leader', 'groupleader', 'group-leader',
'leader', 'supervisor', 'team_leader', 'teamleader',
'그룹장', '팀장', '현장책임자'
];
// 관리자 관련 키워드들
const adminKeywords = [
'admin', 'administrator', 'system',
'관리자', '시스템관리자'
];
// 지원팀 관련 키워드들
const supportKeywords = [
'support', 'support_team', 'supportteam',
'지원팀', '지원'
];
// 키워드 매칭
if (leaderKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
return '/pages/dashboard/group-leader.html';
}
if (adminKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
return '/pages/dashboard/admin.html';
}
if (supportKeywords.some(keyword => accessLevel.includes(keyword.toLowerCase()))) {
return '/pages/dashboard/support.html';
}
return '/pages/dashboard/user.html';
}
import { login } from './api-helper.js';
import { saveAuthData, clearAuthData } from './auth.js';
document.getElementById('loginForm').addEventListener('submit', async function (e) {
e.preventDefault();
@@ -55,48 +10,45 @@ document.getElementById('loginForm').addEventListener('submit', async function (
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('error');
// 로딩 상태 표시
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
// 로딩 상태 시작
submitBtn.disabled = true;
submitBtn.textContent = '로그인 중...';
errorDiv.style.display = 'none';
try {
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
// API 헬퍼를 통해 로그인 요청
const result = await login(username, password);
const result = await res.json();
if (result.success && result.token) {
// 인증 정보 저장
saveAuthData(result.token, result.user);
if (res.ok && result.success && result.token) {
localStorage.setItem('token', result.token);
localStorage.setItem('user', JSON.stringify(result.user));
// 역할별 대시보드로 리다이렉트
const redirectUrl = routeToDashboard(result.user);
// 백엔드가 지정한 URL로 리디렉션
const redirectUrl = result.redirectUrl || '/pages/dashboard/user.html'; // 혹시 모를 예외처리
// 부드러운 전환 효과
// 부드러운 화면 전환 효과
document.body.style.transition = 'opacity 0.3s ease-out';
document.body.style.opacity = '0';
setTimeout(() => {
window.location.href = redirectUrl;
}, 300);
} else {
localStorage.removeItem('token');
localStorage.removeItem('user');
// 이 케이스는 api-helper에서 throw new Error()로 처리되어 catch 블록으로 바로 이동합니다.
// 하지만, 만약의 경우를 대비해 방어 코드를 남겨둡니다.
clearAuthData();
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
errorDiv.style.display = 'block';
// 에러 메시지 자동 숨김
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
} catch (err) {
console.error('로그인 오류:', err);
errorDiv.textContent = '서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요.';
clearAuthData();
// api-helper에서 보낸 에러 메시지를 표시
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
errorDiv.style.display = 'block';
} finally {
// 로딩 상태 해제

View File

@@ -0,0 +1,91 @@
// /js/report-viewer-api.js
import { apiGet } from './api-helper.js';
import { getUser } from './auth.js';
/**
* 보고서 조회를 위한 마스터 데이터를 로드합니다. (작업 유형, 상태 등)
* 실패 시 기본값을 반환할 수 있도록 개별적으로 처리합니다.
* @returns {Promise<object>} - 각 마스터 데이터 배열을 포함하는 객체
*/
export async function loadMasterData() {
const masterData = {
workTypes: [],
workStatusTypes: [],
errorTypes: []
};
try {
// Promise.allSettled를 사용해 일부 API가 실패해도 전체가 중단되지 않도록 함
const results = await Promise.allSettled([
apiGet('/daily-work-reports/work-types'),
apiGet('/daily-work-reports/work-status-types'),
apiGet('/daily-work-reports/error-types')
]);
if (results[0].status === 'fulfilled') masterData.workTypes = results[0].value;
if (results[1].status === 'fulfilled') masterData.workStatusTypes = results[1].value;
if (results[2].status === 'fulfilled') masterData.errorTypes = results[2].value;
return masterData;
} catch (error) {
console.error('마스터 데이터 로딩 중 심각한 오류 발생:', error);
// 최소한의 기본값이라도 반환
return masterData;
}
}
/**
* 사용자의 권한을 확인하여 적절한 API 엔드포인트와 파라미터를 결정합니다.
* @param {string} selectedDate - 조회할 날짜
* @returns {string} - 호출할 API URL
*/
function getReportApiUrl(selectedDate) {
const user = getUser();
// 관리자(admin, system)는 모든 데이터를 조회
if (user && (user.role === 'admin' || user.role === 'system')) {
// 백엔드에서 GET /daily-work-reports?date=YYYY-MM-DD 요청 시
// 권한을 확인하고 모든 데이터를 내려준다고 가정
return `/daily-work-reports?date=${selectedDate}`;
}
// 그 외 사용자(leader, user)는 본인이 생성한 데이터만 조회
// 백엔드에서 동일한 엔드포인트로 요청 시, 권한을 확인하고
// 본인 데이터만 필터링해서 내려준다고 가정
// (만약 엔드포인트가 다르다면 이 부분을 수정해야 함)
return `/daily-work-reports?date=${selectedDate}`;
}
/**
* 특정 날짜의 작업 보고서 데이터를 서버에서 가져옵니다.
* @param {string} selectedDate - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} - 작업 보고서 데이터 배열
*/
export async function fetchReportData(selectedDate) {
if (!selectedDate) {
throw new Error('조회할 날짜가 선택되지 않았습니다.');
}
const apiUrl = getReportApiUrl(selectedDate);
try {
const rawData = await apiGet(apiUrl);
// 서버 응답이 { success: true, data: [...] } 형태일 경우와 [...] 형태일 경우 모두 처리
if (rawData && rawData.success && Array.isArray(rawData.data)) {
return rawData.data;
}
if (Array.isArray(rawData)) {
return rawData;
}
// 예상치 못한 형식의 응답
console.warn('예상치 못한 형식의 API 응답:', rawData);
return [];
} catch (error) {
console.error(`${selectedDate}의 작업 보고서 조회 실패:`, error);
throw new Error('서버에서 데이터를 가져오는 데 실패했습니다.');
}
}

View File

@@ -0,0 +1,72 @@
// /js/report-viewer-export.js
/**
* 주어진 데이터를 CSV 형식의 문자열로 변환합니다.
* @param {object} reportData - 요약 및 작업자별 데이터
* @returns {string} - CSV 형식의 문자열
*/
function convertToCsv(reportData) {
let csvContent = "\uFEFF"; // UTF-8 BOM
csvContent += "작업자명,프로젝트명,작업유형,작업상태,에러유형,작업시간,입력자\n";
reportData.workers.forEach(worker => {
worker.entries.forEach(entry => {
const row = [
worker.worker_name,
entry.project_name,
entry.work_type_name,
entry.work_status_name,
entry.error_type_name,
entry.work_hours,
entry.created_by_name
].map(field => `"${String(field || '').replace(/"/g, '""')}"`).join(',');
csvContent += row + "\n";
});
});
return csvContent;
}
/**
* 가공된 보고서 데이터를 CSV 파일로 다운로드합니다.
* @param {object|null} reportData - UI에 표시된 가공된 데이터
*/
export function exportToExcel(reportData) {
if (!reportData || !reportData.workers || reportData.workers.length === 0) {
alert('내보낼 데이터가 없습니다.');
return;
}
try {
const csv = convertToCsv(reportData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const fileName = `작업보고서_${reportData.summary.date}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Excel 내보내기 실패:', error);
alert('Excel 파일을 생성하는 중 오류가 발생했습니다.');
}
}
/**
* 현재 페이지의 인쇄 기능을 호출합니다.
*/
export function printReport() {
try {
window.print();
} catch (error) {
console.error('인쇄 실패:', error);
alert('인쇄 중 오류가 발생했습니다.');
}
}

View File

@@ -0,0 +1,144 @@
// /js/report-viewer-ui.js
/**
* 데이터를 가공하여 UI에 표시하기 좋은 요약 형태로 변환합니다.
* @param {Array} rawData - 서버에서 받은 원시 데이터 배열
* @param {string} selectedDate - 선택된 날짜
* @returns {object} - 요약 정보와 작업자별로 그룹화된 데이터를 포함하는 객체
*/
export function processReportData(rawData, selectedDate) {
if (!Array.isArray(rawData) || rawData.length === 0) {
return null;
}
const workerGroups = {};
let totalHours = 0;
let errorCount = 0;
rawData.forEach(item => {
const workerName = item.worker_name || '미지정';
const workHours = parseFloat(item.work_hours || 0);
totalHours += workHours;
if (item.work_status_id === 2) errorCount++; // '에러' 상태 ID가 2라고 가정
if (!workerGroups[workerName]) {
workerGroups[workerName] = {
worker_name: workerName,
total_hours: 0,
entries: []
};
}
workerGroups[workerName].total_hours += workHours;
workerGroups[workerName].entries.push(item);
});
return {
summary: {
date: selectedDate,
total_workers: Object.keys(workerGroups).length,
total_hours: totalHours,
total_entries: rawData.length,
error_count: errorCount
},
workers: Object.values(workerGroups)
};
}
function displaySummary(summary) {
const elements = {
totalWorkers: summary.total_workers,
totalHours: `${summary.total_hours}시간`,
totalEntries: `${summary.total_entries}`,
errorCount: `${summary.error_count}`
};
Object.entries(elements).forEach(([id, value]) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
});
document.getElementById('reportSummary').style.display = 'block';
}
function createWorkEntryElement(entry) {
const entryDiv = document.createElement('div');
entryDiv.className = `work-entry ${entry.work_status_id === 2 ? 'error-entry' : ''}`;
entryDiv.innerHTML = `
<div class="entry-header">
<div class="project-name">${entry.project_name || '프로젝트 미지정'}</div>
<div class="work-hours">${entry.work_hours || 0}시간</div>
</div>
<div class="entry-details">
<div class="entry-detail">
<span class="detail-label">작업 유형:</span>
<span class="detail-value">${entry.work_type_name || '-'}</span>
</div>
${entry.work_status_id === 2 ? `
<div class="entry-detail">
<span class="detail-label">에러 유형:</span>
<span class="detail-value error-type">${entry.error_type_name || '에러'}</span>
</div>` : ''}
</div>
`;
return entryDiv;
}
function displayWorkersDetails(workers) {
const workersListEl = document.getElementById('workersList');
workersListEl.innerHTML = '';
workers.forEach(worker => {
const workerCard = document.createElement('div');
workerCard.className = 'worker-card';
workerCard.innerHTML = `
<div class="worker-header">
<div class="worker-name">👤 ${worker.worker_name}</div>
<div class="worker-total-hours">총 ${worker.total_hours}시간</div>
</div>
`;
const entriesContainer = document.createElement('div');
entriesContainer.className = 'work-entries';
worker.entries.forEach(entry => entriesContainer.appendChild(createWorkEntryElement(entry)));
workerCard.appendChild(entriesContainer);
workersListEl.appendChild(workerCard);
});
document.getElementById('workersReport').style.display = 'block';
}
const hideElement = (id) => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
};
/**
* 가공된 데이터를 받아 화면 전체를 렌더링합니다.
* @param {object|null} processedData - 가공된 데이터 또는 데이터가 없을 경우 null
*/
export function renderReport(processedData) {
hideElement('loadingSpinner');
hideElement('errorMessage');
hideElement('noDataMessage');
hideElement('reportSummary');
hideElement('workersReport');
hideElement('exportSection');
if (!processedData) {
document.getElementById('noDataMessage').style.display = 'block';
return;
}
displaySummary(processedData.summary);
displayWorkersDetails(processedData.workers);
document.getElementById('exportSection').style.display = 'block';
}
export function showLoading(isLoading) {
document.getElementById('loadingSpinner').style.display = isLoading ? 'flex' : 'none';
if(isLoading) {
hideElement('errorMessage');
hideElement('noDataMessage');
}
}
export function showError(message) {
const errorEl = document.getElementById('errorMessage');
errorEl.querySelector('.error-text').textContent = message;
errorEl.style.display = 'block';
hideElement('loadingSpinner');
}

View File

@@ -1,31 +1,93 @@
import { API, getAuthHeaders } from '/js/api-config.js';
// /js/user-dashboard.js
import { getUser } from './auth.js';
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
// 오늘 일정 로드
/**
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
*/
async function loadTodaySchedule() {
// 구현 필요
document.getElementById('today-schedule').innerHTML =
'<p>오늘의 작업 일정이 여기에 표시됩니다.</p>';
}
const scheduleContainer = document.getElementById('today-schedule');
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
// 작업 통계 로드
async function loadWorkStats() {
// 구현 필요
document.getElementById('work-stats').innerHTML =
'<p>이번 달 작업 시간: 160시간</p>';
}
// 환영 메시지 개인화
function personalizeWelcome() {
const user = window.currentUser;
if (user) {
document.getElementById('welcome-message').textContent =
`${user.name}님, 환영합니다!`;
try {
// 예시: /api/dashboard/today-schedule 엔드포인트에서 데이터를 가져옵니다.
// 실제 엔드포인트는 백엔드 구현에 따라 달라질 수 있습니다.
const scheduleData = await apiGet('/dashboard/today-schedule');
if (scheduleData && scheduleData.length > 0) {
const scheduleHtml = scheduleData.map(item => `
<div class="schedule-item">
<span class="time">${item.time}</span>
<span class="task">${item.task_name}</span>
<span class="status ${item.status}">${item.status_kor}</span>
</div>
`).join('');
scheduleContainer.innerHTML = scheduleHtml;
} else {
scheduleContainer.innerHTML = '<p>오늘 예정된 작업이 없습니다.</p>';
}
} catch (error) {
console.error('오늘의 작업 일정 로드 실패:', error);
scheduleContainer.innerHTML = '<p class="error">일정 정보를 불러오는 데 실패했습니다.</p>';
}
}
// 초기화
document.addEventListener('DOMContentLoaded', () => {
/**
* API를 호출하여 현재 사용자의 작업 통계를 불러와 화면에 표시합니다.
*/
async function loadWorkStats() {
const statsContainer = document.getElementById('work-stats');
statsContainer.innerHTML = '<p>📈 내 작업 현황을 불러오는 중...</p>';
try {
// 예시: /api/dashboard/my-stats 엔드포인트에서 데이터를 가져옵니다.
const statsData = await apiGet('/dashboard/my-stats');
if (statsData) {
const statsHtml = `
<div class="stat-item">
<span>이번 주 작업 시간:</span>
<strong>${statsData.weekly_hours || 0} 시간</strong>
</div>
<div class="stat-item">
<span>이번 달 작업 시간:</span>
<strong>${statsData.monthly_hours || 0} 시간</strong>
</div>
<div class="stat-item">
<span>완료한 작업 수:</span>
<strong>${statsData.completed_tasks || 0} 건</strong>
</div>
`;
statsContainer.innerHTML = statsHtml;
} else {
statsContainer.innerHTML = '<p>표시할 통계 정보가 없습니다.</p>';
}
} catch (error) {
console.error('작업 통계 로드 실패:', error);
statsContainer.innerHTML = '<p class="error">통계 정보를 불러오는 데 실패했습니다.</p>';
}
}
/**
* 환영 메시지를 사용자 이름으로 개인화합니다.
*/
function personalizeWelcome() {
// 전역 변수 대신 auth.js 모듈을 통해 사용자 정보를 가져옵니다.
const user = getUser();
if (user) {
const welcomeEl = document.getElementById('welcome-message');
if (welcomeEl) {
welcomeEl.textContent = `${user.name || user.username}님, 환영합니다! 오늘 하루도 안전하게 작업하세요.`;
}
}
}
// 페이지 초기화 함수
function initializeDashboard() {
personalizeWelcome();
loadTodaySchedule();
loadWorkStats();
});
}
// DOM이 로드되면 대시보드 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializeDashboard);

View File

@@ -0,0 +1,46 @@
// /js/work-report-api.js
import { apiGet, apiPost } from './api-helper.js';
/**
* 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
* Promise.all을 사용하여 병렬로 API를 호출합니다.
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
*/
export async function getInitialData() {
try {
const [workers, projects, tasks] = await Promise.all([
apiGet('/workers'),
apiGet('/projects'),
apiGet('/tasks')
]);
// 데이터 형식 검증
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
}
// 작업자 목록은 ID 기준으로 정렬
workers.sort((a, b) => a.worker_id - b.worker_id);
return { workers, projects, tasks };
} catch (error) {
console.error('초기 데이터 로딩 중 오류 발생:', error);
// 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
throw error;
}
}
/**
* 작성된 작업 보고서 데이터를 서버에 전송합니다.
* @param {Array<object>} reportData - 전송할 작업 보고서 데이터 배열
* @returns {Promise<object>} - 서버의 응답 결과
*/
export async function createWorkReport(reportData) {
try {
const result = await apiPost('/workreports', reportData);
return result;
} catch (error) {
console.error('작업 보고서 생성 요청 실패:', error);
throw error;
}
}

View File

@@ -1,185 +1,79 @@
import { renderCalendar } from '/js/calendar.js'; // 날짜 캘린더 모듈
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// /js/work-report-create.js
import { renderCalendar } from './calendar.js';
import { getInitialData, createWorkReport } from './work-report-api.js';
import { initializeReportTable, getReportData } from './work-report-ui.js';
// 인증 확인
ensureAuthenticated();
// 전역 상태 변수
let selectedDate = '';
// ✅ DOM 요소
const reportBody = document.getElementById('reportBody');
const submitBtn = document.getElementById('submitBtn');
const defaultProjectId = '13';
const defaultTaskId = '15';
let selectedDateStr = '';
// ✅ 페이지 로드시 초기 렌더링
document.addEventListener('DOMContentLoaded', () => {
fetch('/components/navbar.html')
.then(r => r.text())
.then(html => {
document.getElementById('navbar-container').innerHTML = html;
})
.catch(err => console.error('🔴 네비게이션 바 로딩 실패:', err));
renderCalendar('calendar', date => {
selectedDateStr = date;
loadWorkers();
});
});
// ✅ 작업자, 프로젝트, 작업 불러오기
async function loadWorkers() {
if (!selectedDateStr) return;
/**
* 날짜가 선택되었을 때 실행되는 콜백 함수.
* 초기 데이터를 로드하고 테이블을 렌더링합니다.
* @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식)
*/
async function onDateSelect(date) {
selectedDate = date;
const tableBody = document.getElementById('reportBody');
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">데이터를 불러오는 중...</td></tr>';
try {
const [wrRes, prRes, tkRes] = await Promise.all([
fetch(`${API}/workers`, { headers: getAuthHeaders() }),
fetch(`${API}/projects`, { headers: getAuthHeaders() }),
fetch(`${API}/tasks`, { headers: getAuthHeaders() })
]);
if (!wrRes.ok || !prRes.ok || !tkRes.ok) {
throw new Error('데이터 불러오기 실패');
}
const workers = await wrRes.json();
const projects = await prRes.json();
const tasks = await tkRes.json();
// 배열 체크
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
throw new Error('잘못된 데이터 형식');
}
workers.sort((a, b) => a.worker_id - b.worker_id);
reportBody.innerHTML = '';
workers.forEach((w, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td>
<input type="hidden" name="worker_id" value="${w.worker_id}">
${w.worker_name}
</td>
<td>
<select name="project_id">
${projects.map(p =>
`<option value="${p.project_id}">${p.project_name}</option>`
).join('')}
</select>
</td>
<td>
<select name="task_id">
${tasks.map(t =>
`<option value="${t.task_id}">${t.category}:${t.subcategory}</option>`
).join('')}
</select>
</td>
<td>
<select name="overtime">
<option value="">없음</option>
<option>1</option><option>2</option>
<option>3</option><option>4</option>
</select>
</td>
<td>
<select name="work_type">
<option>근무</option><option>연차</option><option>유급</option>
<option>반차</option><option>반반차</option><option>조퇴</option>
<option>휴무</option>
</select>
</td>
<td>
<input type="text" name="memo" placeholder="메모">
</td>
<td>
<button class="remove-btn">x</button>
</td>
`;
reportBody.appendChild(tr);
// 근무형태 변경시 프로젝트/작업 필드 비활성화
const workSel = tr.querySelector('[name="work_type"]');
const projSel = tr.querySelector('[name="project_id"]');
const taskSel = tr.querySelector('[name="task_id"]');
workSel.addEventListener('change', () => {
const disabled = ['연차','휴무','유급'].includes(workSel.value);
projSel.value = disabled ? defaultProjectId : projSel.value;
taskSel.value = disabled ? defaultTaskId : taskSel.value;
projSel.disabled = taskSel.disabled = disabled;
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
updateRowNumbers();
});
});
} catch (err) {
console.error(err);
alert(err.message || '작업자 불러오기 중 오류 발생');
const initialData = await getInitialData();
initializeReportTable(initialData);
} catch (error) {
alert('데이터를 불러오는 데 실패했습니다: ' + error.message);
tableBody.innerHTML = '<tr><td colspan="8" class="text-center error">오류 발생! 데이터를 불러올 수 없습니다.</td></tr>';
}
}
// ✅ 행 번호 다시 매기기
function updateRowNumbers() {
reportBody.querySelectorAll('tr').forEach((tr, i) => {
tr.children[0].textContent = i + 1;
});
}
// ✅ 전체 등록 처리
submitBtn.addEventListener('click', async () => {
if (!selectedDateStr) {
alert('날짜를 먼저 선택하세요.');
/**
* '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러.
* 폼 데이터를 서버에 전송합니다.
*/
async function handleSubmit() {
if (!selectedDate) {
alert('먼저 달력에서 날짜를 선택해주세요.');
return;
}
const rows = Array.from(reportBody.querySelectorAll('tr'));
if (rows.length === 0) {
alert('등록할 작업자가 없습니다.');
const reportData = getReportData();
if (!reportData) {
// getReportData 내부에서 이미 alert으로 사용자에게 알림
return;
}
const seen = new Set();
const payload = [];
// 각 항목에 선택된 날짜 추가
const payload = reportData.map(item => ({ ...item, date: selectedDate }));
for (let tr of rows) {
const wid = tr.querySelector('[name="worker_id"]').value;
if (seen.has(wid)) {
alert('중복된 작업자가 있습니다.');
return;
}
seen.add(wid);
payload.push({
date: selectedDateStr,
worker_id: wid,
project_id: tr.querySelector('[name="project_id"]').value,
task_id: tr.querySelector('[name="task_id"]').value,
overtime_hours: tr.querySelector('[name="overtime"]').value,
work_details: tr.querySelector('[name="work_type"]').value,
memo: tr.querySelector('[name="memo"]').value
});
}
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '등록 중...';
try {
const res = await fetch(`${API}/workreports`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
const result = await res.json();
const result = await createWorkReport(payload);
if (result.success) {
alert('✅ 등록 완료!');
// 선택적: 페이지 새로고침 또는 다른 날짜로 이동
// loadWorkers();
alert('✅ 작업 보고서가 성공적으로 등록되었습니다!');
// 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능
onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드
} else {
alert('❌ 등록 실패: ' + (result.error || '알 수 없는 오류'));
throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.');
}
} catch (err) {
console.error(err);
alert('서버 오류가 발생했습니다: ' + err.message);
} catch (error) {
alert('❌ 등록 실패: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '전체 등록';
}
});
}
/**
* 페이지 초기화 함수
*/
function initializePage() {
renderCalendar('calendar', onDateSelect);
const submitBtn = document.getElementById('submitBtn');
submitBtn.addEventListener('click', handleSubmit);
}
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

141
web-ui/js/work-report-ui.js Normal file
View File

@@ -0,0 +1,141 @@
// /js/work-report-ui.js
const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음
const DEFAULT_TASK_ID = '15';
/**
* 주어진 데이터를 바탕으로 <select> 요소의 <option>들을 생성합니다.
* @param {Array<object>} items - 옵션으로 만들 데이터 배열
* @param {string} valueField - <option>의 value 속성에 사용할 필드 이름
* @param {string} textField - <option>의 텍스트에 사용할 필드 이름
* @returns {string} - 생성된 HTML 옵션 문자열
*/
function createOptions(items, valueField, textField) {
return items.map(item => `<option value="${item[valueField]}">${textField(item)}</option>`).join('');
}
/**
* 테이블의 모든 행 번호를 다시 매깁니다.
* @param {HTMLTableSectionElement} tableBody - tbody 요소
*/
function updateRowNumbers(tableBody) {
tableBody.querySelectorAll('tr').forEach((tr, index) => {
tr.cells[0].textContent = index + 1;
});
}
/**
* 하나의 작업 보고서 행(tr)을 생성합니다.
* @param {object} worker - 작업자 정보
* @param {Array} projects - 전체 프로젝트 목록
* @param {Array} tasks - 전체 태스크 목록
* @param {number} index - 행 번호
* @returns {HTMLTableRowElement} - 생성된 tr 요소
*/
function createReportRow(worker, projects, tasks, index) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${index + 1}</td>
<td>
<input type="hidden" name="worker_id" value="${worker.worker_id}">
${worker.worker_name}
</td>
<td><select name="project_id">${createOptions(projects, 'project_id', p => p.project_name)}</select></td>
<td><select name="task_id">${createOptions(tasks, 'task_id', t => `${t.category}:${t.subcategory}`)}</select></td>
<td>
<select name="overtime">
<option value="">없음</option>
${[1, 2, 3, 4].map(n => `<option>${n}</option>`).join('')}
</select>
</td>
<td>
<select name="work_type">
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(t => `<option>${t}</option>`).join('')}
</select>
</td>
<td><input type="text" name="memo" placeholder="메모"></td>
<td><button type="button" class="remove-btn">x</button></td>
`;
// 이벤트 리스너 설정
const workTypeSelect = tr.querySelector('[name="work_type"]');
const projectSelect = tr.querySelector('[name="project_id"]');
const taskSelect = tr.querySelector('[name="task_id"]');
workTypeSelect.addEventListener('change', () => {
const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value);
projectSelect.disabled = isDisabled;
taskSelect.disabled = isDisabled;
if (isDisabled) {
projectSelect.value = DEFAULT_PROJECT_ID;
taskSelect.value = DEFAULT_TASK_ID;
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
updateRowNumbers(tr.parentElement);
});
return tr;
}
/**
* 작업 보고서 테이블을 초기화하고 데이터를 채웁니다.
* @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터
*/
export function initializeReportTable(initialData) {
const tableBody = document.getElementById('reportBody');
if (!tableBody) return;
tableBody.innerHTML = ''; // 기존 내용 초기화
const { workers, projects, tasks } = initialData;
if (!workers || workers.length === 0) {
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">등록할 작업자 정보가 없습니다.</td></tr>';
return;
}
workers.forEach((worker, index) => {
const row = createReportRow(worker, projects, tasks, index);
tableBody.appendChild(row);
});
}
/**
* 테이블에서 폼 데이터를 추출하여 배열로 반환합니다.
* @returns {Array<object>|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null
*/
export function getReportData() {
const tableBody = document.getElementById('reportBody');
const rows = tableBody.querySelectorAll('tr');
if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) {
alert('등록할 내용이 없습니다.');
return null;
}
const reportData = [];
const workerIds = new Set();
for (const tr of rows) {
const workerId = tr.querySelector('[name="worker_id"]').value;
if (workerIds.has(workerId)) {
alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`);
return null;
}
workerIds.add(workerId);
reportData.push({
worker_id: workerId,
project_id: tr.querySelector('[name="project_id"]').value,
task_id: tr.querySelector('[name="task_id"]').value,
overtime_hours: tr.querySelector('[name="overtime"]').value || 0,
work_details: tr.querySelector('[name="work_type"]').value,
memo: tr.querySelector('[name="memo"]').value
});
}
return reportData;
}

View File

@@ -29,7 +29,6 @@
<script type="module" src="/js/load-sidebar.js"></script>
<script type="module" src="/js/load-sections.js"></script>
<!-- ✅ admin.js는 다른 모듈들이 로딩된 후 실행되도록 순서 조정 -->
<script type="module" src="/components/sections/admin-sections.html"></script>
<script type="module" src="/js/admin.js"></script>
</body>
</html>