feat(monthly-comparison): 검토완료 상단 토글 + 월 유지 + 확인요청 조건
- 검토완료 버튼: 하단 제거 → 헤더 토글 ("검토하기" ↔ "✓ 검토완료")
- 상세→목록 복귀: year/month URL 유지 (4월로 리셋 방지)
- 확인요청: 전원 admin_checked 시만 활성화 (정책 변경)
- Sprint 004 PLAN.md: 정책 변경 이력 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
154
.cowork/sprints/sprint-004/PLAN.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Sprint 004: 월간 근무 비교·확인·정산
|
||||||
|
|
||||||
|
> 작성: Cowork | 초안: 2026-03-30 | 갱신: 2026-03-31 (출근부 양식 반영)
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
1. **작업보고서 vs 근태관리 비교 페이지**: 그룹장이 입력한 작업보고서와 근태관리 데이터를 일별로 비교하여 불일치를 시각화
|
||||||
|
2. **월말 확인 프로세스**: 작업자가 자신의 월간 근무 내역을 확인(승인/반려)하는 워크플로우
|
||||||
|
3. **출근부 엑셀 다운로드**: 전원 확인 완료 시, 업로드된 양식에 맞는 출근부 엑셀 내보내기
|
||||||
|
4. **연차 잔액 연동**: sp_vacation_balances 기반 신규/사용/잔여 데이터 엑셀 포함
|
||||||
|
|
||||||
|
## 현재 구현 상태 (2026-03-31)
|
||||||
|
|
||||||
|
| 항목 | 파일 | 상태 | 비고 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| DB 마이그레이션 | `api/db/migrations/20260330_create_monthly_work_confirmations.sql` | ✅ 완료 | |
|
||||||
|
| 모델 | `api/models/monthlyComparisonModel.js` (232줄) | ✅ 완료 | getExportData 추가됨 |
|
||||||
|
| 컨트롤러 | `api/controllers/monthlyComparisonController.js` (560줄) | ✅ 완료 | exportExcel 출근부 양식 재작성 |
|
||||||
|
| 라우트 | `api/routes/monthlyComparisonRoutes.js` (33줄) | ✅ 완료 | |
|
||||||
|
| 라우트 등록 | `api/routes.js` 157줄 | ✅ 완료 | |
|
||||||
|
| 프론트엔드 HTML | `web/pages/attendance/monthly-comparison.html` | ✅ 완료 | |
|
||||||
|
| 프론트엔드 JS | `web/js/monthly-comparison.js` (558줄) | ✅ 완료 | |
|
||||||
|
| 프론트엔드 CSS | `web/css/monthly-comparison.css` | ✅ 완료 | |
|
||||||
|
| 대시보드 연동 | `web/js/production-dashboard.js` | ✅ 완료 | PAGE_ICONS 추가 |
|
||||||
|
|
||||||
|
> 모든 파일이 존재하고 라우트가 등록되어 있으나, **실 서버 배포 및 통합 테스트 미완료**.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- monthly.html에서 그룹장이 등록한 근태정보를 각 작업자가 개별 확인·승인
|
||||||
|
- 전원 confirmed 완료 시 지원팀이 출근부 엑셀 다운로드 가능
|
||||||
|
- 출근부 양식: 업로드된 `출근부_2026.02_TK 생산팀.xlsx` 기준
|
||||||
|
- attendanceService.js에서 연차 자동 차감/복원이 이미 구현되어 있으므로 sp_vacation_balances가 소스 오브 트루스
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly.html (그룹장 → 근태 입력) │
|
||||||
|
│ ↓ │
|
||||||
|
│ daily_attendance_records 저장 │
|
||||||
|
│ attendanceService.js → sp_vacation_balances 자동 반영 │
|
||||||
|
└─────────────────┬────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ monthly-comparison.html │
|
||||||
|
│ │
|
||||||
|
│ [작업자 뷰] [관리자 뷰] │
|
||||||
|
│ ├ 작업보고서 vs 근태 일별 비교 ├ 전체 확인 현황 │
|
||||||
|
│ ├ 요약 카드 (근무일/시간/연장/휴가) ├ 개별 작업자 상세 │
|
||||||
|
│ └ 확인(승인) / 반려(사유 입력) └ 엑셀 다운로드 │
|
||||||
|
│ ↓ (반려 시) │
|
||||||
|
│ notifications → 지원팀에 알림 │
|
||||||
|
│ ↓ (전원 확인 완료 시) │
|
||||||
|
│ 출근부 엑셀 다운로드 │
|
||||||
|
│ (출근부_YYYY.MM_부서명.xlsx) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출근부 엑셀 양식 (신규)
|
||||||
|
|
||||||
|
기존 2-시트 구조(월간 근무 현황 + 일별 상세)를 **출근부 단일 시트**로 변경.
|
||||||
|
|
||||||
|
```
|
||||||
|
시트명: {YY}.{M}월 출근부
|
||||||
|
|
||||||
|
Row 2: 부서명 | [부서명 (병합)]
|
||||||
|
Row 3: 근로기간 | [YYYY년 M월 (병합)]
|
||||||
|
Row 4: [이름] [담당] 1 2 3 ... 31 [총시간] [M월(병합)] [비고(병합)]
|
||||||
|
Row 5: 일 월 화 ... [ ] 신규 사용 잔여
|
||||||
|
Row 6+: 김두수 1 0 0 0 ... 휴무 =SUM() 15 2 =AF-AG
|
||||||
|
|
||||||
|
셀 값 규칙:
|
||||||
|
정상출근 → 근무시간(숫자, 0.00)
|
||||||
|
주말(근무없음) → '휴무'
|
||||||
|
연차(ANNUAL) → '연차'
|
||||||
|
반차(HALF_ANNUAL) → '반차'
|
||||||
|
반반차(ANNUAL_QUARTER) → '반반차'
|
||||||
|
조퇴(EARLY_LEAVE) → '조퇴'
|
||||||
|
병가(SICK) → '병가'
|
||||||
|
경조사(SPECIAL) → '경조사'
|
||||||
|
|
||||||
|
스타일:
|
||||||
|
폰트: 맑은 고딕 12pt, 가운데 정렬
|
||||||
|
주말 헤더: 빨간 폰트 (FFFF0000)
|
||||||
|
테두리: thin 전체
|
||||||
|
헤더 행 높이: 40, 데이터 행 높이: 60
|
||||||
|
수식: 총시간=SUM(날짜열), 잔여=신규-사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
| Method | Path | 접근권한 | 설명 |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| GET | /api/monthly-comparison/my-records | 인증된 사용자 | 내 일별 비교 데이터 |
|
||||||
|
| GET | /api/monthly-comparison/records | support_team+ 또는 본인 | 특정 작업자 비교 조회 |
|
||||||
|
| POST | /api/monthly-comparison/confirm | 인증된 사용자 (본인) | 승인/반려 |
|
||||||
|
| GET | /api/monthly-comparison/all-status | support_team+ | 전체 확인 현황 |
|
||||||
|
| GET | /api/monthly-comparison/export | support_team+ | 출근부 엑셀 다운로드 |
|
||||||
|
|
||||||
|
## 데이터 소스
|
||||||
|
|
||||||
|
| 데이터 | 테이블 | 비고 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 작업보고서 | daily_work_reports | report_date, work_hours, project_id |
|
||||||
|
| 근태 기록 | daily_attendance_records | record_date, total_work_hours, vacation_type_id |
|
||||||
|
| 확인 상태 | monthly_work_confirmations | 신규 테이블 (Sprint 004) |
|
||||||
|
| 연차 잔액 | sp_vacation_balances | year별 SUM(total_days, used_days) |
|
||||||
|
| 휴가 유형 | vacation_types | type_code → 출근부 텍스트 매핑 |
|
||||||
|
| 작업자 정보 | workers + departments | worker_id 순 정렬 |
|
||||||
|
|
||||||
|
## 작업 분할
|
||||||
|
|
||||||
|
| 섹션 | 핵심 작업 | 규모 |
|
||||||
|
|------|----------|------|
|
||||||
|
| **A (Backend)** | 모델·컨트롤러 검증 + 출근부 엑셀 엣지케이스 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
| **B (Frontend)** | UI 최종 검증 + 모바일 반응형 + 캐시 버스팅 + 배포 | 파일 4개, 검증 위주 |
|
||||||
|
|
||||||
|
> 기존 코드가 모두 구현되어 있으므로 **검증·배포 중심**으로 진행.
|
||||||
|
|
||||||
|
## 섹션 간 의존성
|
||||||
|
|
||||||
|
```
|
||||||
|
A ──(API 제공)──→ B
|
||||||
|
|
||||||
|
GET /api/monthly-comparison/my-records
|
||||||
|
POST /api/monthly-comparison/confirm
|
||||||
|
GET /api/monthly-comparison/all-status
|
||||||
|
GET /api/monthly-comparison/records
|
||||||
|
GET /api/monthly-comparison/export
|
||||||
|
```
|
||||||
|
|
||||||
|
Section A 배포 완료 후 Section B 프론트엔드가 정상 동작 가능.
|
||||||
|
|
||||||
|
## 완료 조건
|
||||||
|
- [x] 마이그레이션 파일 생성 (20260330_create_monthly_work_confirmations.sql)
|
||||||
|
- [x] 모든 Backend 파일 생성 (model, controller, routes)
|
||||||
|
- [x] routes.js에 라우트 등록
|
||||||
|
- [x] 프론트엔드 페이지 생성 (HTML, JS, CSS)
|
||||||
|
- [x] 대시보드 아이콘 매핑 추가
|
||||||
|
- [x] 출근부 양식 엑셀 재작성 (getExportData + exportExcel)
|
||||||
|
- [ ] 실 서버 마이그레이션 실행
|
||||||
|
- [ ] Docker 빌드·배포
|
||||||
|
- [ ] 통합 테스트 (작업자 확인 → 지원팀 엑셀 다운로드 전체 플로우)
|
||||||
|
- [ ] 모바일 반응형 확인
|
||||||
|
- [ ] 엣지케이스 확인 (데이터 없는 월, 중도 입사자 등)
|
||||||
|
|
||||||
|
## 주요 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 변경 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-03-30 | 초안 작성 + Section A/B 코드 구현 |
|
||||||
|
| 2026-03-31 | 출근부 양식 반영: getExcelData→getExportData, exportExcel 전면 재작성 |
|
||||||
|
| 2026-03-31 | 버그 수정: export 조건에 rejected 추가, admin detail 모드 버튼 숨김 |
|
||||||
|
| 2026-04-01 | 워크플로우 확장: pending→review_sent→confirmed/change_request/rejected 상태 추가 |
|
||||||
|
| 2026-04-01 | 정책 변경: 확인요청 발송 전 전원 admin_checked 필수 (선택적→필수) |
|
||||||
@@ -165,22 +165,21 @@ async function loadMyRecords() {
|
|||||||
|
|
||||||
comparisonData = res.data;
|
comparisonData = res.data;
|
||||||
|
|
||||||
// detail 모드: 작업자 이름 표시
|
// detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더)
|
||||||
if (currentMode === 'detail' && comparisonData.user) {
|
if (currentMode === 'detail' && comparisonData.user) {
|
||||||
document.getElementById('pageTitle').textContent =
|
var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked;
|
||||||
(comparisonData.user.worker_name || '') + ' 근무 비교';
|
var checkBtnHtml = '<button type="button" id="headerCheckBtn" onclick="toggleAdminCheck()" style="' +
|
||||||
|
'padding:6px 12px;border-radius:8px;font-size:0.75rem;font-weight:600;border:none;cursor:pointer;margin-left:auto;' +
|
||||||
|
(isChecked ? 'background:#dcfce7;color:#166534;' : 'background:#f3f4f6;color:#6b7280;') +
|
||||||
|
'">' + (isChecked ? '✓ 검토완료' : '검토하기') + '</button>';
|
||||||
|
document.getElementById('pageTitle').innerHTML =
|
||||||
|
(comparisonData.user.worker_name || '') + ' 근무 비교' + checkBtnHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSummaryCards(comparisonData.summary);
|
renderSummaryCards(comparisonData.summary);
|
||||||
renderMismatchAlert(comparisonData.summary);
|
renderMismatchAlert(comparisonData.summary);
|
||||||
renderDailyList(comparisonData.daily_records || []);
|
renderDailyList(comparisonData.daily_records || []);
|
||||||
renderConfirmationStatus(comparisonData.confirmation);
|
renderConfirmationStatus(comparisonData.confirmation);
|
||||||
|
|
||||||
// detail 모드: 검토완료 버튼 표시
|
|
||||||
var adminCheckBtn = document.getElementById('adminCheckBtn');
|
|
||||||
if (adminCheckBtn && currentMode === 'detail') {
|
|
||||||
adminCheckBtn.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
|
||||||
}
|
}
|
||||||
@@ -348,13 +347,21 @@ function renderAdminSummary(s) {
|
|||||||
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
|
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
|
||||||
`<span>❌ ${s.rejected || 0} 반려</span>`;
|
`<span>❌ ${s.rejected || 0} 반려</span>`;
|
||||||
|
|
||||||
// 확인요청 일괄 발송 버튼
|
// 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화
|
||||||
var reviewBtn = document.getElementById('reviewSendBtn');
|
var reviewBtn = document.getElementById('reviewSendBtn');
|
||||||
if (reviewBtn) {
|
if (reviewBtn) {
|
||||||
var pendingCount = (s.pending || 0);
|
var pendingCount = (s.pending || 0);
|
||||||
if (pendingCount > 0) {
|
var uncheckedCount = (adminData?.workers || []).filter(function(w) { return !w.admin_checked && w.status === 'pending'; }).length;
|
||||||
|
if (pendingCount > 0 && uncheckedCount === 0) {
|
||||||
reviewBtn.classList.remove('hidden');
|
reviewBtn.classList.remove('hidden');
|
||||||
reviewBtn.textContent = `미검토 ${pendingCount}명 확인요청 발송`;
|
reviewBtn.disabled = false;
|
||||||
|
reviewBtn.textContent = `${pendingCount}명 확인요청 발송`;
|
||||||
|
reviewBtn.style.background = '#2563eb';
|
||||||
|
} else if (pendingCount > 0 && uncheckedCount > 0) {
|
||||||
|
reviewBtn.classList.remove('hidden');
|
||||||
|
reviewBtn.disabled = true;
|
||||||
|
reviewBtn.textContent = `${uncheckedCount}명 미검토 — 전원 검토 후 발송 가능`;
|
||||||
|
reviewBtn.style.background = '#9ca3af';
|
||||||
} else {
|
} else {
|
||||||
reviewBtn.classList.add('hidden');
|
reviewBtn.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -524,17 +531,28 @@ async function downloadExcel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Admin Check (검토완료 태깅) =====
|
// ===== Admin Check (검토완료 토글) =====
|
||||||
async function markAdminChecked() {
|
async function toggleAdminCheck() {
|
||||||
if (!currentUserId || isProcessing) return;
|
if (!currentUserId || isProcessing) return;
|
||||||
|
var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked;
|
||||||
|
var newChecked = !isCurrentlyChecked;
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
try {
|
try {
|
||||||
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
|
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
|
||||||
user_id: currentUserId, year: currentYear, month: currentMonth, checked: true
|
user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked
|
||||||
});
|
});
|
||||||
if (res && res.success) {
|
if (res && res.success) {
|
||||||
showToast((comparisonData?.user?.worker_name || '') + ' 검토완료', 'success');
|
// 상태 업데이트
|
||||||
history.back();
|
if (comparisonData.confirmation) {
|
||||||
|
comparisonData.confirmation.admin_checked = newChecked ? 1 : 0;
|
||||||
|
}
|
||||||
|
var btn = document.getElementById('headerCheckBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = newChecked ? '✓ 검토완료' : '검토하기';
|
||||||
|
btn.style.background = newChecked ? '#dcfce7' : '#f3f4f6';
|
||||||
|
btn.style.color = newChecked ? '#166534' : '#6b7280';
|
||||||
|
}
|
||||||
|
showToast(newChecked ? '검토완료' : '검토 해제', 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(res?.message || '처리 실패', 'error');
|
showToast(res?.message || '처리 실패', 'error');
|
||||||
}
|
}
|
||||||
@@ -542,6 +560,11 @@ async function markAdminChecked() {
|
|||||||
finally { isProcessing = false; }
|
finally { isProcessing = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 목록으로 복귀 (월 유지)
|
||||||
|
function goBackToList() {
|
||||||
|
location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Review Send (확인요청 일괄 발송) =====
|
// ===== Review Send (확인요청 일괄 발송) =====
|
||||||
async function sendReviewAll() {
|
async function sendReviewAll() {
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
<!-- 페이지 헤더 -->
|
<!-- 페이지 헤더 -->
|
||||||
<div class="mc-header">
|
<div class="mc-header">
|
||||||
<div class="mc-header-row">
|
<div class="mc-header-row">
|
||||||
<button type="button" onclick="history.back()" class="mc-back-btn"><i class="fas fa-arrow-left"></i></button>
|
<button type="button" onclick="typeof goBackToList==='function'?goBackToList():history.back()" class="mc-back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||||
<h1 id="pageTitle">월간 근무 비교</h1>
|
<h1 id="pageTitle" style="display:flex;align-items:center;gap:8px;flex:1">월간 근무 비교</h1>
|
||||||
<button id="viewToggleBtn" class="mc-view-toggle hidden" onclick="toggleViewMode()">
|
<button id="viewToggleBtn" class="mc-view-toggle hidden" onclick="toggleViewMode()">
|
||||||
<i class="fas fa-users-cog"></i>
|
<i class="fas fa-users-cog"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -87,12 +87,7 @@
|
|||||||
<span id="confirmedText"></span>
|
<span id="confirmedText"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 관리자 검토완료 버튼 (detail 모드) -->
|
<!-- 하단 검토완료 버튼 제거됨 — 상단 헤더로 이동 -->
|
||||||
<div class="mc-bottom-actions hidden" id="adminCheckBtn">
|
|
||||||
<button type="button" style="flex:1;padding:14px;background:#10b981;color:white;font-size:0.9rem;font-weight:700;border:none;border-radius:12px;cursor:pointer;" onclick="markAdminChecked()">
|
|
||||||
<i class="fas fa-check mr-2"></i>검토완료
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ 관리자 뷰 ═══ -->
|
<!-- ═══ 관리자 뷰 ═══ -->
|
||||||
@@ -169,7 +164,7 @@
|
|||||||
|
|
||||||
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
<script src="/static/js/tkfb-core.js?v=2026033108"></script>
|
||||||
<script src="/js/api-base.js?v=2026031701"></script>
|
<script src="/js/api-base.js?v=2026031701"></script>
|
||||||
<script src="/js/monthly-comparison.js?v=2026040103"></script>
|
<script src="/js/monthly-comparison.js?v=2026040104"></script>
|
||||||
<script>initAuth();</script>
|
<script>initAuth();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user