refactor: 작업보고서 조회 페이지 삭제 및 출근체크 버그 수정
- report-view.html 및 관련 파일 삭제 (리소스 최적화) - work-report-calendar.js/css - modules/calendar/* (CalendarState, CalendarAPI, CalendarView) - report-viewer-*.js (미사용) - daily-report-viewer.js/css (미사용) - 사이드바에서 작업보고서 조회 링크 제거 - 출근체크 페이지: 날짜 변경 시 자동 새로고침 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,6 @@
|
|||||||
<a href="/pages/work/report-create.html" class="nav-item" data-page-key="work.report_create">
|
<a href="/pages/work/report-create.html" class="nav-item" data-page-key="work.report_create">
|
||||||
<span class="nav-text">작업보고서 작성</span>
|
<span class="nav-text">작업보고서 작성</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/pages/work/report-view.html" class="nav-item" data-page-key="work.report_view">
|
|
||||||
<span class="nav-text">작업보고서 조회</span>
|
|
||||||
</a>
|
|
||||||
<a href="/pages/work/analysis.html" class="nav-item admin-only" data-page-key="work.analysis">
|
<a href="/pages/work/analysis.html" class="nav-item admin-only" data-page-key="work.analysis">
|
||||||
<span class="nav-text">작업 분석</span>
|
<span class="nav-text">작업 분석</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,548 +0,0 @@
|
|||||||
/* daily-report-viewer.css */
|
|
||||||
|
|
||||||
/* 전체 레이아웃 */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 헤더 */
|
|
||||||
.page-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: #666;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 날짜 선택기 */
|
|
||||||
.date-selector {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input-group label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 2px solid #e1e5e9;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background: white;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn, .today-btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn {
|
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-btn {
|
|
||||||
background: #f8f9fa;
|
|
||||||
color: #667eea;
|
|
||||||
border: 2px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-btn:hover {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로딩 스피너 */
|
|
||||||
.loading-spinner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 50px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border: 4px solid #f3f3f3;
|
|
||||||
border-top: 4px solid #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner p {
|
|
||||||
color: #666;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 에러 메시지 */
|
|
||||||
.error-message {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
margin: 30px 0;
|
|
||||||
border-left: 5px solid #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #e74c3c;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 데이터 없음 메시지 */
|
|
||||||
.no-data-message {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 50px;
|
|
||||||
text-align: center;
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data-content {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data-icon {
|
|
||||||
font-size: 3rem;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data-content h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 요약 카드 */
|
|
||||||
.report-summary {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.error-card {
|
|
||||||
border-left: 5px solid #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card .card-value {
|
|
||||||
color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 작업자 리포트 */
|
|
||||||
.workers-report {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 30px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
color: #333;
|
|
||||||
border-bottom: 3px solid #667eea;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workers-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-card {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border-left: 5px solid #667eea;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-card:hover {
|
|
||||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateX(5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-name {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-total-hours {
|
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
||||||
color: white;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entries {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entry {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
border: 1px solid #e1e5e9;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entry:hover {
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entry.error-entry {
|
|
||||||
border-left: 4px solid #e74c3c;
|
|
||||||
background: #fff5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-hours {
|
|
||||||
background: #e8f4f8;
|
|
||||||
color: #2c5aa0;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entry.error-entry .work-hours {
|
|
||||||
background: #ffebee;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-details {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-detail {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-value {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-type {
|
|
||||||
color: #e74c3c;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 내보내기 섹션 */
|
|
||||||
.export-section {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-section h3 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 15px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.excel-btn {
|
|
||||||
background: #217346;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.excel-btn:hover {
|
|
||||||
background: #1a5a37;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-btn {
|
|
||||||
background: #495057;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-btn:hover {
|
|
||||||
background: #343a40;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 디자인 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input-group {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-input-group > * {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-cards {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.work-entries {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.worker-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-buttons {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 인쇄 스타일 */
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-selector,
|
|
||||||
.export-section {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card,
|
|
||||||
.workers-report,
|
|
||||||
.worker-card,
|
|
||||||
.work-entry {
|
|
||||||
background: white !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border: 1px solid #ddd !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
background: white !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border-bottom: 2px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
color: #333 !important;
|
|
||||||
-webkit-text-fill-color: #333 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
|||||||
// /js/daily-report-viewer.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 currentProcessedData = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜를 기준으로 보고서를 검색하고 화면에 렌더링합니다.
|
|
||||||
*/
|
|
||||||
async function searchReports() {
|
|
||||||
const dateInput = document.getElementById('reportDate');
|
|
||||||
const selectedDate = dateInput.value;
|
|
||||||
|
|
||||||
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 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 initializePage() {
|
|
||||||
// auth.js를 사용하여 인증 상태 확인
|
|
||||||
const user = getUser();
|
|
||||||
if (!user) {
|
|
||||||
showError('로그인이 필요합니다. 2초 후 로그인 페이지로 이동합니다.');
|
|
||||||
setTimeout(() => window.location.href = '/index.html', 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners();
|
|
||||||
|
|
||||||
// 페이지 로드 시 오늘 날짜로 자동 검색
|
|
||||||
const dateInput = document.getElementById('reportDate');
|
|
||||||
dateInput.value = new Date().toISOString().split('T')[0];
|
|
||||||
searchReports();
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOM이 로드되면 페이지 초기화를 시작합니다.
|
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
// web-ui/js/modules/calendar/CalendarAPI.js
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캘린더와 관련된 모든 API 호출을 관리하는 전역 객체입니다.
|
|
||||||
*/
|
|
||||||
(function(window) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const CalendarAPI = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 활성화된 모든 작업자 목록을 가져옵니다.
|
|
||||||
* @returns {Promise<Array>} 작업자 객체 배열
|
|
||||||
*/
|
|
||||||
CalendarAPI.getWorkers = async function() {
|
|
||||||
try {
|
|
||||||
// api-helper.js 에 정의된 전역 apiGet 함수를 사용합니다.
|
|
||||||
const response = await window.apiGet('/workers');
|
|
||||||
if (response.success && Array.isArray(response.data)) {
|
|
||||||
// 활성화된 작업자만 필터링
|
|
||||||
const activeWorkers = response.data.filter(worker =>
|
|
||||||
worker.status === 'active' || worker.is_active === 1 || worker.is_active === true
|
|
||||||
);
|
|
||||||
return activeWorkers;
|
|
||||||
}
|
|
||||||
console.warn('API 응답 형식이 올바르지 않거나 데이터가 없습니다:', response);
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('작업자 데이터 로딩 중 API 오류 발생:', error);
|
|
||||||
// 에러를 다시 던져서 호출부에서 처리할 수 있도록 함
|
|
||||||
throw new Error('작업자 데이터를 불러오는 데 실패했습니다.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 월별 작업 데이터 로드 (집계 테이블 사용으로 최적화)
|
|
||||||
* @param {number} year
|
|
||||||
* @param {number} month (0-indexed)
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
CalendarAPI.getMonthlyCalendarData = async function(year, month) {
|
|
||||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
|
||||||
try {
|
|
||||||
const response = await window.apiGet(`/monthly-status/calendar?year=${year}&month=${month + 1}`);
|
|
||||||
if (response.success) {
|
|
||||||
return response.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || '집계 데이터 조회 실패');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${monthKey} 집계 데이터 로딩 오류:`, error);
|
|
||||||
console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`);
|
|
||||||
return await _getMonthlyWorkDataFallback(year, month);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일일 상세 데이터 조회 (모달용)
|
|
||||||
* @param {string} dateStr (YYYY-MM-DD)
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
CalendarAPI.getDailyDetails = async function(dateStr) {
|
|
||||||
try {
|
|
||||||
const response = await window.apiGet(`/monthly-status/daily-details?date=${dateStr}`);
|
|
||||||
if (response.success) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
// Fallback to old API if new one fails
|
|
||||||
const fallbackResponse = await window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`);
|
|
||||||
return {
|
|
||||||
workers: fallbackResponse.data, // Assuming structure is different
|
|
||||||
summary: {} // No summary in fallback
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('일일 작업 데이터 로딩 오류:', error);
|
|
||||||
throw new Error('해당 날짜의 작업 데이터를 불러오는 데 실패했습니다.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 작업자의 하루치 작업을 모두 삭제합니다.
|
|
||||||
* @param {number} workerId
|
|
||||||
* @param {string} date (YYYY-MM-DD)
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
CalendarAPI.deleteWorkerDayWork = async function(workerId, date) {
|
|
||||||
return await window.apiDelete(`/daily-work-reports/date/${date}/worker/${workerId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 폴백: 순차적 로딩 (지연 시간 포함) - Private helper
|
|
||||||
* @param {number} year
|
|
||||||
* @param {number} month (0-indexed)
|
|
||||||
* @returns {Promise<object>}
|
|
||||||
*/
|
|
||||||
async function _getMonthlyWorkDataFallback(year, month) {
|
|
||||||
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
|
||||||
const monthData = {};
|
|
||||||
try {
|
|
||||||
const firstDay = new Date(year, month, 1);
|
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
|
||||||
const currentDay = new Date(firstDay);
|
|
||||||
|
|
||||||
console.log(`📅 폴백: ${monthKey} 순차 로딩 시작 (rate limit 방지)`);
|
|
||||||
|
|
||||||
// 순차적으로 요청하되 작은 배치로 나눔 (5개씩)
|
|
||||||
const BATCH_SIZE = 5;
|
|
||||||
const DELAY_BETWEEN_BATCHES = 100; // 100ms
|
|
||||||
|
|
||||||
let day = 1;
|
|
||||||
while (currentDay <= lastDay) {
|
|
||||||
const batch = [];
|
|
||||||
|
|
||||||
// 배치 생성
|
|
||||||
for (let i = 0; i < BATCH_SIZE && currentDay <= lastDay; i++) {
|
|
||||||
const dateStr = currentDay.toISOString().split('T')[0];
|
|
||||||
batch.push({
|
|
||||||
date: dateStr,
|
|
||||||
promise: window.apiGet(`/daily-work-reports?date=${dateStr}&view_all=true`)
|
|
||||||
});
|
|
||||||
currentDay.setDate(currentDay.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배치 실행
|
|
||||||
const results = await Promise.all(batch.map(b => b.promise));
|
|
||||||
|
|
||||||
// 결과 저장
|
|
||||||
batch.forEach((item, index) => {
|
|
||||||
const result = results[index];
|
|
||||||
monthData[item.date] = result.success && Array.isArray(result.data) ? result.data : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 다음 배치 전 잠시 대기 (rate limit 방지)
|
|
||||||
if (currentDay <= lastDay) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_BATCHES));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 폴백: ${monthKey} 순차 로딩 완료`);
|
|
||||||
return monthData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${monthKey} 순차 로딩 오류:`, error);
|
|
||||||
throw new Error('작업 데이터를 불러오는 데 실패했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 전역 스코프에 CalendarAPI 객체 할당
|
|
||||||
window.CalendarAPI = CalendarAPI;
|
|
||||||
|
|
||||||
})(window);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// web-ui/js/modules/calendar/CalendarState.js
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캘린더 페이지의 모든 상태를 관리하는 전역 객체입니다.
|
|
||||||
*/
|
|
||||||
(function(window) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const CalendarState = {
|
|
||||||
// 캘린더 상태
|
|
||||||
currentDate: new Date(),
|
|
||||||
monthlyData: {}, // 월별 데이터 캐시
|
|
||||||
allWorkers: [], // 전체 작업자 목록 캐시
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
currentModalDate: null,
|
|
||||||
currentEditingWork: null,
|
|
||||||
existingWorks: [],
|
|
||||||
|
|
||||||
// 상태 초기화
|
|
||||||
reset: function() {
|
|
||||||
this.currentDate = new Date();
|
|
||||||
this.monthlyData = {};
|
|
||||||
// allWorkers는 유지
|
|
||||||
this.currentModalDate = null;
|
|
||||||
this.currentEditingWork = null;
|
|
||||||
this.existingWorks = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 전역 스코프에 CalendarState 객체 할당
|
|
||||||
window.CalendarState = CalendarState;
|
|
||||||
|
|
||||||
})(window);
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// web-ui/js/modules/calendar/CalendarView.js
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캘린더 UI 렌더링 및 DOM 조작을 담당하는 전역 객체입니다.
|
|
||||||
*/
|
|
||||||
(function(window) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const CalendarView = {
|
|
||||||
elements: {},
|
|
||||||
|
|
||||||
initializeElements: function() {
|
|
||||||
this.elements.monthYearTitle = document.getElementById('monthYearTitle');
|
|
||||||
this.elements.calendarDays = document.getElementById('calendarDays');
|
|
||||||
this.elements.prevMonthBtn = document.getElementById('prevMonthBtn');
|
|
||||||
this.elements.nextMonthBtn = document.getElementById('nextMonthBtn');
|
|
||||||
this.elements.todayBtn = document.getElementById('todayBtn');
|
|
||||||
this.elements.dailyWorkModal = document.getElementById('dailyWorkModal');
|
|
||||||
this.elements.modalTitle = document.getElementById('modalTitle');
|
|
||||||
this.elements.modalSummary = document.querySelector('.daily-summary');
|
|
||||||
this.elements.modalTotalWorkers = document.getElementById('modalTotalWorkers');
|
|
||||||
this.elements.modalTotalHours = document.getElementById('modalTotalHours');
|
|
||||||
this.elements.modalTotalTasks = document.getElementById('modalTotalTasks');
|
|
||||||
this.elements.modalErrorCount = document.getElementById('modalErrorCount');
|
|
||||||
this.elements.modalWorkersList = document.getElementById('modalWorkersList');
|
|
||||||
this.elements.modalNoData = document.getElementById('modalNoData');
|
|
||||||
this.elements.statusFilter = document.getElementById('statusFilter');
|
|
||||||
this.elements.loadingSpinner = document.getElementById('loadingSpinner');
|
|
||||||
},
|
|
||||||
|
|
||||||
showLoading: function(show) {
|
|
||||||
if (this.elements.loadingSpinner) {
|
|
||||||
this.elements.loadingSpinner.style.display = show ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showToast: function(message, type = 'info') {
|
|
||||||
const existingToast = document.querySelector('.toast-message');
|
|
||||||
if (existingToast) existingToast.remove();
|
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast-message toast-${type}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => toast.remove(), 3000);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCalendar: async function() {
|
|
||||||
const year = CalendarState.currentDate.getFullYear();
|
|
||||||
const month = CalendarState.currentDate.getMonth();
|
|
||||||
|
|
||||||
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
|
||||||
this.elements.monthYearTitle.textContent = `${year}년 ${monthNames[month]}`;
|
|
||||||
|
|
||||||
this.showLoading(true);
|
|
||||||
try {
|
|
||||||
const monthData = await CalendarAPI.getMonthlyCalendarData(year, month);
|
|
||||||
const firstDay = new Date(year, month, 1);
|
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
|
||||||
const startDate = new Date(firstDay);
|
|
||||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
||||||
|
|
||||||
let calendarHTML = '';
|
|
||||||
let currentDay = new Date(startDate);
|
|
||||||
|
|
||||||
for (let i = 0; i < 42; i++) {
|
|
||||||
const dateStr = `${currentDay.getFullYear()}-${String(currentDay.getMonth() + 1).padStart(2, '0')}-${String(currentDay.getDate()).padStart(2, '0')}`;
|
|
||||||
const dayWorkData = monthData[dateStr] || { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
|
|
||||||
const dayStatus = this.analyzeDayStatus(dayWorkData);
|
|
||||||
|
|
||||||
let dayClasses = ['calendar-day'];
|
|
||||||
if (currentDay.getMonth() !== month) dayClasses.push('other-month');
|
|
||||||
if (dateStr === todayStr) dayClasses.push('today');
|
|
||||||
if (currentDay.getDay() === 0) dayClasses.push('sunday');
|
|
||||||
if (currentDay.getDay() === 6) dayClasses.push('saturday');
|
|
||||||
|
|
||||||
const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues;
|
|
||||||
if (dayStatus.hasData && !hasAnyProblem) dayClasses.push('has-normal');
|
|
||||||
|
|
||||||
let statusIcons = '';
|
|
||||||
if (hasAnyProblem) {
|
|
||||||
if (dayStatus.hasOvertimeWarning) statusIcons += '<div class="legend-icon purple">●</div>';
|
|
||||||
if (dayStatus.hasIncomplete) statusIcons += '<div class="legend-icon red">●</div>';
|
|
||||||
if (dayStatus.hasIssues) statusIcons += '<div class="legend-icon orange">●</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
calendarHTML += `<div class="${dayClasses.join(' ')}" onclick="openDailyWorkModal('${dateStr}')"><div class="day-number">${currentDay.getDate()}</div>${statusIcons}</div>`;
|
|
||||||
currentDay.setDate(currentDay.getDate() + 1);
|
|
||||||
}
|
|
||||||
this.elements.calendarDays.innerHTML = calendarHTML;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('캘린더 렌더링 오류:', error);
|
|
||||||
this.showToast('캘린더를 불러오는데 실패했습니다.', 'error');
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
analyzeDayStatus: function(dayData) {
|
|
||||||
if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) {
|
|
||||||
const totalRegisteredWorkers = CalendarState.allWorkers ? CalendarState.allWorkers.length : 0;
|
|
||||||
const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers);
|
|
||||||
return {
|
|
||||||
hasData: dayData.totalWorkers > 0,
|
|
||||||
hasIssues: dayData.partialWorkers > 0,
|
|
||||||
hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0,
|
|
||||||
hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0,
|
|
||||||
workerCount: dayData.totalWorkers || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { hasData: false, hasIssues: false, hasErrors: false, workerCount: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.CalendarView = CalendarView;
|
|
||||||
|
|
||||||
})(window);
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// /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('서버에서 데이터를 가져오는 데 실패했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// /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('인쇄 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
// /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');
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -231,6 +231,9 @@
|
|||||||
await waitForAxiosConfig();
|
await waitForAxiosConfig();
|
||||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||||
loadCheckinData();
|
loadCheckinData();
|
||||||
|
|
||||||
|
// 날짜 변경 시 자동 로드
|
||||||
|
document.getElementById('selectedDate').addEventListener('change', loadCheckinData);
|
||||||
});
|
});
|
||||||
|
|
||||||
function waitForAxiosConfig() {
|
function waitForAxiosConfig() {
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>작업 현황 확인 - TK 건설</title>
|
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
|
||||||
<link rel="stylesheet" href="/css/common.css?v=13">
|
|
||||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=14">
|
|
||||||
<link rel="stylesheet" href="/css/work-report-calendar.css?v=29">
|
|
||||||
<!-- 최적화된 로딩 -->
|
|
||||||
<script src="/js/api-base.js"></script>
|
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- 네비게이션 헤더 -->
|
|
||||||
<div id="navbar-container"></div>
|
|
||||||
|
|
||||||
<!-- 메인 콘텐츠 -->
|
|
||||||
<main class="dashboard-main">
|
|
||||||
<div class="calendar-page-container">
|
|
||||||
<!-- 페이지 제목 -->
|
|
||||||
<div class="page-title-section">
|
|
||||||
<h2 class="page-title">작업 현황 확인</h2>
|
|
||||||
<p class="page-subtitle">월별 작업자 현황을 한눈에 확인하세요</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 캘린더 카드 -->
|
|
||||||
<div class="calendar-card">
|
|
||||||
<!-- 월 네비게이션 -->
|
|
||||||
<div class="calendar-nav">
|
|
||||||
<button id="prevMonthBtn" class="nav-btn prev-btn">
|
|
||||||
<span class="nav-icon">‹</span>
|
|
||||||
<span class="nav-text">이전</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="calendar-title">
|
|
||||||
<h3 id="monthYearTitle">2025년 11월</h3>
|
|
||||||
<button id="todayBtn" class="today-btn">오늘</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="nextMonthBtn" class="nav-btn next-btn">
|
|
||||||
<span class="nav-text">다음</span>
|
|
||||||
<span class="nav-icon">›</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 범례 -->
|
|
||||||
<div class="calendar-legend">
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-dot has-overtime-warning"></div>
|
|
||||||
<span>확인필요</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-dot has-errors"></div>
|
|
||||||
<span>미입력</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-dot has-issues"></div>
|
|
||||||
<span>부분입력</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-dot has-normal"></div>
|
|
||||||
<span>이상 없음</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 캘린더 -->
|
|
||||||
<div class="calendar-grid">
|
|
||||||
<div class="calendar-header">
|
|
||||||
<div class="day-header sunday">일</div>
|
|
||||||
<div class="day-header">월</div>
|
|
||||||
<div class="day-header">화</div>
|
|
||||||
<div class="day-header">수</div>
|
|
||||||
<div class="day-header">목</div>
|
|
||||||
<div class="day-header">금</div>
|
|
||||||
<div class="day-header saturday">토</div>
|
|
||||||
</div>
|
|
||||||
<div class="calendar-days" id="calendarDays">
|
|
||||||
<!-- 캘린더 날짜들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- 로딩 스피너 -->
|
|
||||||
<div id="loadingSpinner" class="loading-overlay" style="display: none;">
|
|
||||||
<div class="loading-content">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>데이터를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 일일 작업 현황 모달 -->
|
|
||||||
<div id="dailyWorkModal" class="modal-overlay" style="display: none;">
|
|
||||||
<div class="modal-container large-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="modalTitle">2025년 11월 3일 작업 현황</h2>
|
|
||||||
<button class="modal-close-btn" onclick="closeDailyWorkModal()">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- 요약 정보 -->
|
|
||||||
<div class="daily-summary">
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="summary-icon success"></div>
|
|
||||||
<div class="summary-content">
|
|
||||||
<div class="summary-label">총 작업자</div>
|
|
||||||
<div class="summary-value" id="modalTotalWorkers">0명</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="summary-icon primary"></div>
|
|
||||||
<div class="summary-content">
|
|
||||||
<div class="summary-label">총 작업시간</div>
|
|
||||||
<div class="summary-value" id="modalTotalHours">0.0h</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="summary-icon warning"></div>
|
|
||||||
<div class="summary-content">
|
|
||||||
<div class="summary-label">작업 건수</div>
|
|
||||||
<div class="summary-value" id="modalTotalTasks">0건</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-card">
|
|
||||||
<div class="summary-icon error"></div>
|
|
||||||
<div class="summary-content">
|
|
||||||
<div class="summary-label">오류 건수</div>
|
|
||||||
<div class="summary-value" id="modalErrorCount">0건</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업자 현황 리스트 -->
|
|
||||||
<div class="modal-work-status">
|
|
||||||
<div class="work-status-header">
|
|
||||||
<h3>작업자별 현황</h3>
|
|
||||||
<div class="status-filter">
|
|
||||||
<select id="statusFilter">
|
|
||||||
<option value="all">전체</option>
|
|
||||||
<option value="incomplete">미입력</option>
|
|
||||||
<option value="partial">부분입력</option>
|
|
||||||
<option value="complete">완료</option>
|
|
||||||
<option value="overtime">연장근로</option>
|
|
||||||
<option value="error">오류</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="modalWorkersList" class="worker-status-list">
|
|
||||||
<!-- 작업자 리스트가 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="modalNoData" class="empty-state" style="display: none;">
|
|
||||||
<div class="empty-icon"></div>
|
|
||||||
<h3>해당 날짜의 작업 보고서가 없습니다</h3>
|
|
||||||
<p>다른 날짜를 선택해 주세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 입력/수정 모달 -->
|
|
||||||
<div id="workEntryModal" class="modal-overlay" style="display: none;">
|
|
||||||
<div class="modal-container large-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2 id="workEntryModalTitle">작업 관리</h2>
|
|
||||||
<button class="modal-close-btn" onclick="closeWorkEntryModal()">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- 탭 네비게이션 -->
|
|
||||||
<div class="modal-tabs">
|
|
||||||
<button class="tab-btn active" data-tab="existing" onclick="switchTab('existing')">
|
|
||||||
기존 작업 (0건)
|
|
||||||
</button>
|
|
||||||
<button class="tab-btn" data-tab="new" onclick="switchTab('new')">
|
|
||||||
새 작업 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 기존 작업 목록 탭 -->
|
|
||||||
<div id="existingWorkTab" class="tab-content active">
|
|
||||||
<div class="existing-work-header">
|
|
||||||
<h3>등록된 작업 목록</h3>
|
|
||||||
<div class="work-summary" id="workSummary">
|
|
||||||
총 <span id="totalWorkCount">0</span>건 | 총 <span id="totalWorkHours">0</span>시간
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="existingWorkList" class="existing-work-list">
|
|
||||||
<!-- 기존 작업들이 여기에 동적으로 생성됩니다 -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="noExistingWork" class="empty-state" style="display: none;">
|
|
||||||
<div class="empty-icon"></div>
|
|
||||||
<h3>등록된 작업이 없습니다</h3>
|
|
||||||
<p>"새 작업 추가" 탭에서 작업을 등록해보세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 새 작업 추가 탭 -->
|
|
||||||
<div id="newWorkTab" class="tab-content">
|
|
||||||
<form id="workEntryForm">
|
|
||||||
<!-- 작업자 정보 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>작업자 정보</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업자</label>
|
|
||||||
<input type="text" id="workerNameDisplay" class="form-control" readonly>
|
|
||||||
<input type="hidden" id="workerId">
|
|
||||||
<input type="hidden" id="editingWorkId">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업 날짜</label>
|
|
||||||
<input type="date" id="workDate" class="form-control" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 작업 내용 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3 id="workContentTitle">작업 내용</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">프로젝트 *</label>
|
|
||||||
<select id="projectSelect" class="form-control" required>
|
|
||||||
<option value="">프로젝트를 선택하세요</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업 유형 *</label>
|
|
||||||
<select id="workTypeSelect" class="form-control" required>
|
|
||||||
<option value="">작업 유형을 선택하세요</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업 시간 (시간) *</label>
|
|
||||||
<input type="number" id="workHours" class="form-control" min="0" max="24" step="0.5" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업 상태 *</label>
|
|
||||||
<select id="workStatusSelect" class="form-control" required>
|
|
||||||
<option value="">상태를 선택하세요</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">오류 유형</label>
|
|
||||||
<select id="errorTypeSelect" class="form-control">
|
|
||||||
<option value="">오류 유형 (선택사항)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">작업 설명</label>
|
|
||||||
<textarea id="workDescription" class="form-control" rows="3" placeholder="작업 내용을 상세히 입력하세요"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 휴가 처리 -->
|
|
||||||
<div class="form-section" id="vacationSection">
|
|
||||||
<h3>휴가 처리</h3>
|
|
||||||
<div class="vacation-buttons">
|
|
||||||
<button type="button" class="btn-vacation" onclick="handleVacation('full')">연차 (8시간)</button>
|
|
||||||
<button type="button" class="btn-vacation" onclick="handleVacation('half')">반차 (4시간)</button>
|
|
||||||
<button type="button" class="btn-vacation" onclick="handleVacation('quarter')">반반차 (2시간)</button>
|
|
||||||
<button type="button" class="btn-vacation" onclick="handleVacation('early')">조퇴 (6시간)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeWorkEntryModal()">취소</button>
|
|
||||||
<div class="footer-actions">
|
|
||||||
<button type="button" class="btn btn-danger" id="deleteWorkBtn" onclick="deleteWork()" style="display: none;">삭제</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="saveWorkBtn" onclick="saveWorkEntry()">저장</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- JavaScript -->
|
|
||||||
<script src="/js/api-base.js"></script>
|
|
||||||
<script src="/js/app-init.js?v=2" defer></script>
|
|
||||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
|
||||||
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
|
|
||||||
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
|
|
||||||
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
|
|
||||||
<script src="/js/work-report-calendar.js?v=41"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user