diff --git a/api.hyungi.net/models/monthlyStatusModel.js b/api.hyungi.net/models/monthlyStatusModel.js index 31303e1..7f97e07 100644 --- a/api.hyungi.net/models/monthlyStatusModel.js +++ b/api.hyungi.net/models/monthlyStatusModel.js @@ -41,36 +41,47 @@ class MonthlyStatusModel { } // 특정 날짜의 작업자별 상태 조회 (모달용) + // ✅ 리팩토링: 집계 테이블 대신 daily_work_reports에서 직접 조회 (중복 문제 완전 해결) static async getDailyWorkerStatus(date) { const db = await getDb(); try { - // 중복 방지: worker_id와 date로 그룹화하고 최신 데이터만 조회 - const [rows] = await db.execute(` + // daily_work_reports에서 직접 집계하여 조회 (중복 없음 보장) + const [rows] = await db.query(` SELECT - mws.worker_id, + dwr.worker_id, w.worker_name, w.job_type, - MAX(mws.year) as year, - MAX(mws.month) as month, - mws.date, - SUM(mws.total_work_hours) as total_work_hours, - SUM(mws.actual_work_hours) as actual_work_hours, - SUM(mws.vacation_hours) as vacation_hours, - SUM(mws.total_work_count) as total_work_count, - SUM(mws.regular_work_count) as regular_work_count, - SUM(mws.error_work_count) as error_work_count, - MAX(mws.work_status) as work_status, - MAX(mws.has_vacation) as has_vacation, - MAX(mws.has_error) as has_error, - MAX(mws.has_issues) as has_issues, - MAX(mws.last_updated) as last_updated - FROM monthly_worker_status mws - JOIN workers w ON mws.worker_id = w.worker_id - WHERE mws.date = ? - GROUP BY mws.worker_id, mws.date, w.worker_name, w.job_type + YEAR(?) as year, + MONTH(?) as month, + ? as date, + COALESCE(SUM(dwr.work_hours), 0) as total_work_hours, + COALESCE(SUM(CASE WHEN dwr.project_id != 13 THEN dwr.work_hours ELSE 0 END), 0) as actual_work_hours, + COALESCE(SUM(CASE WHEN dwr.project_id = 13 THEN dwr.work_hours ELSE 0 END), 0) as vacation_hours, + COUNT(*) as total_work_count, + COUNT(CASE WHEN dwr.project_id != 13 AND dwr.work_status_id != 2 THEN 1 END) as regular_work_count, + COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_work_count, + CASE + WHEN MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) = 1 THEN 'error' + WHEN SUM(dwr.work_hours) > 12 THEN 'overtime-warning' + WHEN SUM(dwr.work_hours) > 8 THEN 'overtime' + WHEN SUM(dwr.work_hours) = 8 THEN 'complete' + WHEN SUM(dwr.work_hours) > 0 THEN 'partial' + ELSE 'incomplete' + END as work_status, + MAX(CASE WHEN dwr.project_id = 13 THEN 1 ELSE 0 END) as has_vacation, + MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as has_error, + CASE + WHEN SUM(dwr.work_hours) < 8 AND SUM(dwr.work_hours) > 0 THEN 1 + ELSE 0 + END as has_issues, + MAX(dwr.created_at) as last_updated + FROM daily_work_reports dwr + JOIN workers w ON dwr.worker_id = w.worker_id + WHERE dwr.report_date = ? + GROUP BY dwr.worker_id, w.worker_name, w.job_type ORDER BY w.worker_name ASC - `, [date]); + `, [date, date, date, date]); return rows; } catch (error) { diff --git a/api.hyungi.net/package.json b/api.hyungi.net/package.json index 2926d99..3a0fd55 100644 --- a/api.hyungi.net/package.json +++ b/api.hyungi.net/package.json @@ -1,6 +1,6 @@ { "name": "hyungi-api", - "version": "1.0.0", + "version": "2.2.0", "main": "index.js", "scripts": { "start": "pm2-runtime start ecosystem.config.js --env production", diff --git a/synology_deployment.tar.gz b/synology_deployment.tar.gz new file mode 100644 index 0000000..64e5396 Binary files /dev/null and b/synology_deployment.tar.gz differ diff --git a/synology_deployment/api/controllers/dailyWorkReportController.js b/synology_deployment/api/controllers/dailyWorkReportController.js index 23a23af..ee8ee3d 100644 --- a/synology_deployment/api/controllers/dailyWorkReportController.js +++ b/synology_deployment/api/controllers/dailyWorkReportController.js @@ -368,17 +368,28 @@ const updateWorkReport = async (req, res) => { /** * 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용) + * 권한: 그룹장(group_leader), 시스템(system), 관리자(admin)만 가능 */ const removeDailyWorkReport = async (req, res) => { try { const { id: reportId } = req.params; const userInfo = { user_id: req.user?.user_id || req.user?.id, + access_level: req.user?.access_level || req.user?.role, }; if (!userInfo.user_id) { return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' }); } + + // 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능 + const allowedRoles = ['admin', 'system', 'group_leader']; + if (!allowedRoles.includes(userInfo.access_level)) { + return res.status(403).json({ + error: '작업보고서 삭제 권한이 없습니다.', + details: '그룹장 이상의 권한이 필요합니다.' + }); + } const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo); @@ -405,6 +416,7 @@ const removeDailyWorkReport = async (req, res) => { const removeDailyWorkReportByDateAndWorker = (req, res) => { const { date, worker_id } = req.params; const deleted_by = req.user?.user_id || req.user?.id; + const access_level = req.user?.access_level || req.user?.role; if (!deleted_by) { return res.status(401).json({ @@ -412,6 +424,15 @@ const removeDailyWorkReportByDateAndWorker = (req, res) => { }); } + // 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능 + const allowedRoles = ['admin', 'system', 'group_leader']; + if (!allowedRoles.includes(access_level)) { + return res.status(403).json({ + error: '작업보고서 삭제 권한이 없습니다.', + details: '그룹장 이상의 권한이 필요합니다.' + }); + } + console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`); dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => { diff --git a/synology_deployment/api/migrations/009_fix_duplicate_monthly_status.sql b/synology_deployment/api/migrations/009_fix_duplicate_monthly_status.sql new file mode 100644 index 0000000..46e840d --- /dev/null +++ b/synology_deployment/api/migrations/009_fix_duplicate_monthly_status.sql @@ -0,0 +1,72 @@ +-- 009_fix_duplicate_monthly_status.sql +-- monthly_worker_status 테이블의 중복 데이터 정리 + +-- 1. 중복 데이터 확인 (디버깅용) +-- SELECT worker_id, date, COUNT(*) as cnt +-- FROM monthly_worker_status +-- GROUP BY worker_id, date +-- HAVING cnt > 1; + +-- 2. 중복 데이터 정리: 같은 worker_id, date에 대해 최신 데이터만 남기고 나머지 삭제 +DELETE mws1 FROM monthly_worker_status mws1 +INNER JOIN ( + SELECT + worker_id, + date, + MAX(id) as keep_id + FROM monthly_worker_status + GROUP BY worker_id, date +) mws2 ON mws1.worker_id = mws2.worker_id + AND mws1.date = mws2.date + AND mws1.id < mws2.keep_id; + +-- 3. 중복 제거 후 데이터 재집계 (선택사항) +-- 만약 합산이 필요하다면 다음 프로시저를 실행 +DELIMITER $$ + +CREATE OR REPLACE PROCEDURE ConsolidateDuplicateMonthlyStatus() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE v_worker_id INT; + DECLARE v_date DATE; + + -- 중복이 있는 worker_id, date 조합 찾기 + DECLARE cur CURSOR FOR + SELECT worker_id, date + FROM monthly_worker_status + GROUP BY worker_id, date + HAVING COUNT(*) > 1; + + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + OPEN cur; + + read_loop: LOOP + FETCH cur INTO v_worker_id, v_date; + IF done THEN + LEAVE read_loop; + END IF; + + -- 해당 작업자의 해당 날짜 데이터를 재계산하여 업데이트 + CALL UpdateMonthlyWorkerStatus(v_date, v_worker_id); + END LOOP; + + CLOSE cur; +END$$ + +DELIMITER ; + +-- 4. 프로시저 실행하여 중복 데이터 통합 +-- CALL ConsolidateDuplicateMonthlyStatus(); + +-- 5. 확인: 중복이 남아있는지 체크 +SELECT + '중복 체크 완료' as message, + COUNT(*) as remaining_duplicates +FROM ( + SELECT worker_id, date, COUNT(*) as cnt + FROM monthly_worker_status + GROUP BY worker_id, date + HAVING cnt > 1 +) duplicates; + diff --git a/synology_deployment/api/models/monthlyStatusModel.js b/synology_deployment/api/models/monthlyStatusModel.js index 31303e1..7f97e07 100644 --- a/synology_deployment/api/models/monthlyStatusModel.js +++ b/synology_deployment/api/models/monthlyStatusModel.js @@ -41,36 +41,47 @@ class MonthlyStatusModel { } // 특정 날짜의 작업자별 상태 조회 (모달용) + // ✅ 리팩토링: 집계 테이블 대신 daily_work_reports에서 직접 조회 (중복 문제 완전 해결) static async getDailyWorkerStatus(date) { const db = await getDb(); try { - // 중복 방지: worker_id와 date로 그룹화하고 최신 데이터만 조회 - const [rows] = await db.execute(` + // daily_work_reports에서 직접 집계하여 조회 (중복 없음 보장) + const [rows] = await db.query(` SELECT - mws.worker_id, + dwr.worker_id, w.worker_name, w.job_type, - MAX(mws.year) as year, - MAX(mws.month) as month, - mws.date, - SUM(mws.total_work_hours) as total_work_hours, - SUM(mws.actual_work_hours) as actual_work_hours, - SUM(mws.vacation_hours) as vacation_hours, - SUM(mws.total_work_count) as total_work_count, - SUM(mws.regular_work_count) as regular_work_count, - SUM(mws.error_work_count) as error_work_count, - MAX(mws.work_status) as work_status, - MAX(mws.has_vacation) as has_vacation, - MAX(mws.has_error) as has_error, - MAX(mws.has_issues) as has_issues, - MAX(mws.last_updated) as last_updated - FROM monthly_worker_status mws - JOIN workers w ON mws.worker_id = w.worker_id - WHERE mws.date = ? - GROUP BY mws.worker_id, mws.date, w.worker_name, w.job_type + YEAR(?) as year, + MONTH(?) as month, + ? as date, + COALESCE(SUM(dwr.work_hours), 0) as total_work_hours, + COALESCE(SUM(CASE WHEN dwr.project_id != 13 THEN dwr.work_hours ELSE 0 END), 0) as actual_work_hours, + COALESCE(SUM(CASE WHEN dwr.project_id = 13 THEN dwr.work_hours ELSE 0 END), 0) as vacation_hours, + COUNT(*) as total_work_count, + COUNT(CASE WHEN dwr.project_id != 13 AND dwr.work_status_id != 2 THEN 1 END) as regular_work_count, + COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_work_count, + CASE + WHEN MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) = 1 THEN 'error' + WHEN SUM(dwr.work_hours) > 12 THEN 'overtime-warning' + WHEN SUM(dwr.work_hours) > 8 THEN 'overtime' + WHEN SUM(dwr.work_hours) = 8 THEN 'complete' + WHEN SUM(dwr.work_hours) > 0 THEN 'partial' + ELSE 'incomplete' + END as work_status, + MAX(CASE WHEN dwr.project_id = 13 THEN 1 ELSE 0 END) as has_vacation, + MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as has_error, + CASE + WHEN SUM(dwr.work_hours) < 8 AND SUM(dwr.work_hours) > 0 THEN 1 + ELSE 0 + END as has_issues, + MAX(dwr.created_at) as last_updated + FROM daily_work_reports dwr + JOIN workers w ON dwr.worker_id = w.worker_id + WHERE dwr.report_date = ? + GROUP BY dwr.worker_id, w.worker_name, w.job_type ORDER BY w.worker_name ASC - `, [date]); + `, [date, date, date, date]); return rows; } catch (error) { diff --git a/synology_deployment/api/package.json b/synology_deployment/api/package.json index 2926d99..3a0fd55 100644 --- a/synology_deployment/api/package.json +++ b/synology_deployment/api/package.json @@ -1,6 +1,6 @@ { "name": "hyungi-api", - "version": "1.0.0", + "version": "2.2.0", "main": "index.js", "scripts": { "start": "pm2-runtime start ecosystem.config.js --env production", diff --git a/synology_deployment/update.sh b/synology_deployment/update.sh new file mode 100644 index 0000000..80aaf20 --- /dev/null +++ b/synology_deployment/update.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# ============================================================ +# 시놀로지 NAS 안전 업데이트 스크립트 +# Technical Korea Work Management System v2.2.0 +# ============================================================ +# +# ⚠️ 주의: 이 스크립트는 DB 데이터를 보존하면서 코드만 업데이트합니다. +# +# 사용법: +# cd /volume2/docker/synology_deployment +# chmod +x update.sh +# sudo ./update.sh +# +# ============================================================ + +set -e + +echo "" +echo "============================================================" +echo "🚀 Technical Korea Work Management System 업데이트" +echo " 버전: v2.2.0" +echo "============================================================" +echo "" + +# 현재 디렉토리 확인 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "📁 작업 디렉토리: $SCRIPT_DIR" +echo "" + +# 1. 현재 실행 중인 컨테이너 확인 +echo "🔍 현재 컨테이너 상태 확인..." +docker-compose ps + +echo "" +echo "⏸️ API 컨테이너만 재시작합니다. (DB 데이터 보존)" +echo "" + +# 2. API 컨테이너만 재빌드 및 재시작 (DB는 그대로 유지) +echo "🔄 API 컨테이너 재빌드 중..." +docker-compose build api + +echo "" +echo "🔄 API 컨테이너 재시작 중..." +docker-compose up -d api + +echo "" +echo "⏳ 서버 시작 대기 중... (10초)" +sleep 10 + +# 3. 로그 확인 +echo "" +echo "📋 API 서버 로그 (최근 30줄):" +echo "------------------------------------------------------------" +docker-compose logs --tail=30 api + +# 4. 헬스체크 +echo "" +echo "🏥 헬스체크 중..." +if curl -s http://localhost:20005/api/health > /dev/null 2>&1; then + echo "✅ API 서버 정상 작동 중!" +else + echo "⚠️ API 서버 응답 없음. 로그를 확인하세요:" + echo " docker-compose logs api" +fi + +echo "" +echo "============================================================" +echo "✅ 업데이트 완료!" +echo "" +echo "📌 확인사항:" +echo " - 브라우저에서 Ctrl+Shift+R (하드 리프레시)" +echo " - http://192.168.0.3:20000 접속 테스트" +echo "" +echo "📌 문제 발생 시:" +echo " - 로그 확인: docker-compose logs api" +echo " - 재시작: docker-compose restart api" +echo "============================================================" +echo "" + diff --git a/synology_deployment/web-ui/css/daily-work-report.css b/synology_deployment/web-ui/css/daily-work-report.css index 86d7597..cba6e55 100644 --- a/synology_deployment/web-ui/css/daily-work-report.css +++ b/synology_deployment/web-ui/css/daily-work-report.css @@ -351,25 +351,33 @@ } .remove-work-btn { - width: 40px; - height: 40px; - border-radius: var(--radius-full); - background: linear-gradient(135deg, var(--error-500), var(--error-600)); + width: 36px; + height: 36px; + border-radius: 50%; + background: linear-gradient(135deg, #ef4444, #dc2626); color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; + font-size: 18px; + font-weight: bold; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4); + transition: all 0.2s ease; transition: var(--transition-normal); font-size: var(--text-lg); box-shadow: var(--shadow-sm); } .remove-work-btn:hover { - background: linear-gradient(135deg, var(--error-600), var(--error-700)); - transform: scale(1.1) rotate(90deg); - box-shadow: var(--shadow-md); + background: linear-gradient(135deg, #dc2626, #b91c1c); + transform: scale(1.15); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.5); +} + +.remove-work-btn:active { + transform: scale(0.95); } .work-entry-grid { diff --git a/synology_deployment/web-ui/css/work-report-calendar.css b/synology_deployment/web-ui/css/work-report-calendar.css index 14e34f0..8a69d83 100644 --- a/synology_deployment/web-ui/css/work-report-calendar.css +++ b/synology_deployment/web-ui/css/work-report-calendar.css @@ -664,6 +664,32 @@ transform: scale(1.05); } +/* 작업 삭제 버튼 (관리자/그룹장용) */ +.btn-delete-worker-work { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: white; + border: none; + padding: 0.4rem 0.6rem; + border-radius: 0.5rem; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + margin-right: 0.5rem; +} + +.btn-delete-worker-work:hover { + background: linear-gradient(135deg, #dc2626, #b91c1c); + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); +} + +/* 작업자 액션 버튼 컨테이너 */ +.worker-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + /* 캘린더 페이지 컨테이너 */ .calendar-page-container { max-width: 1000px; diff --git a/synology_deployment/web-ui/js/daily-work-report.js b/synology_deployment/web-ui/js/daily-work-report.js index a551527..f9f13e2 100644 --- a/synology_deployment/web-ui/js/daily-work-report.js +++ b/synology_deployment/web-ui/js/daily-work-report.js @@ -334,8 +334,8 @@ function addWorkEntry() { entryDiv.innerHTML = `