security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1,49 @@
<!-- ✅ /components/sections/admin-sections.html -->
<meta charset="UTF-8">
<section>
<h2>📄 작업 보고서</h2>
<ul>
<li><a href="/pages/work-reports/work-report-create.html">작업보고서 입력</a></li>
<li><a href="/pages/work-reports/work-report-manage.html">작업보고서 관리</a></li>
</ul>
</section>
<section>
<h2>📊 출근/공수 관리</h2>
<ul>
<li><a href="/pages/common/attendance.html">출근부</a></li>
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
</ul>
</section>
<section>
<h2>🔧 시스템 관리</h2>
<ul>
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
</ul>
</section>
<section>
<h2>🏭 공장 정보</h2>
<ul>
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
<li><a href="/pages/common/factory-view.html">공장 목록 보기</a></li>
</ul>
</section>
<section>
<h2>📊 이슈 리포트</h2>
<ul>
<li><a href="/pages/issue-reports/daily-issue-report.html">일일 이슈 보고</a></li>
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
<li><a href="/pages/analysis/daily_work_analysis.html">작업 정보 페이지</a></li>
<li><a href="/pages/analysis/work-report-analytics.html" class="admin-only-link">📊 작업보고서 종합분석 <span class="admin-badge">ADMIN</span></a></li>
</ul>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
/* daily-status.css — 일별 입력 현황 대시보드 */
/* Header */
.ds-header {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
padding: 16px 16px 12px;
border-radius: 0 0 16px 16px;
margin: -16px -16px 0;
position: sticky;
top: 56px;
z-index: 20;
}
.ds-header h1 { font-size: 1.125rem; font-weight: 700; }
/* Date Navigation */
.ds-date-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0;
background: white;
border-radius: 12px;
margin: 12px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.ds-date-btn {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #6b7280;
background: #f3f4f6;
border: none; cursor: pointer;
transition: all 0.15s;
}
.ds-date-btn:hover { background: #e5e7eb; color: #374151; }
.ds-date-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.ds-date-display {
display: flex; flex-direction: column; align-items: center;
cursor: pointer; user-select: none;
}
.ds-date-display #dateText { font-size: 1rem; font-weight: 700; color: #1f2937; }
.ds-day-label { font-size: 0.75rem; color: #6b7280; }
/* Summary Cards */
.ds-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.ds-card {
background: white;
border-radius: 12px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
border-top: 3px solid transparent;
}
.ds-card:active { transform: scale(0.97); }
.ds-card-total { border-top-color: #3b82f6; }
.ds-card-done { border-top-color: #16a34a; }
.ds-card-missing { border-top-color: #dc2626; }
.ds-card-num { font-size: 1.5rem; font-weight: 800; color: #1f2937; line-height: 1; }
.ds-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 4px; }
.ds-card-pct { font-size: 0.7rem; font-weight: 600; color: #9ca3af; margin-top: 2px; }
.ds-card-done .ds-card-pct { color: #16a34a; }
.ds-card-missing .ds-card-pct { color: #dc2626; }
/* Filter Tabs */
.ds-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: #f3f4f6;
border-radius: 10px;
margin-bottom: 12px;
}
.ds-tab {
flex: 1;
padding: 8px 4px;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.ds-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.ds-tab-badge {
display: inline-flex;
align-items: center; justify-content: center;
min-width: 18px; height: 18px;
font-size: 0.65rem; font-weight: 700;
background: #e5e7eb; color: #6b7280;
border-radius: 9px;
padding: 0 5px;
margin-left: 2px;
}
.ds-tab.active .ds-tab-badge { background: #dbeafe; color: #2563eb; }
/* Worker List */
.ds-list { padding-bottom: 140px; }
.ds-worker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: white;
border-radius: 10px;
margin-bottom: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
cursor: pointer;
transition: background 0.15s;
}
.ds-worker-row:active { background: #f9fafb; }
.ds-status-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.ds-status-dot.complete { background: #16a34a; }
.ds-status-dot.tbm_only, .ds-status-dot.report_only { background: #f59e0b; }
.ds-status-dot.both_missing { background: #dc2626; }
.ds-worker-info { flex: 1; min-width: 0; }
.ds-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
.ds-worker-dept { font-size: 0.7rem; color: #9ca3af; }
.ds-worker-status { text-align: right; flex-shrink: 0; }
.ds-worker-status span {
display: inline-block;
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 4px;
margin-left: 2px;
}
.ds-badge-ok { background: #dcfce7; color: #16a34a; }
.ds-badge-no { background: #fef2f2; color: #dc2626; }
.ds-badge-proxy { background: #ede9fe; color: #7c3aed; font-size: 0.6rem; }
/* Skeleton */
.ds-skeleton {
height: 56px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: ds-shimmer 1.5s infinite;
border-radius: 10px;
margin-bottom: 6px;
}
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Empty / No Permission */
.ds-empty, .ds-no-perm {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 16px;
color: #9ca3af;
font-size: 0.875rem;
}
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
/* Bottom Action */
.ds-bottom-action {
position: fixed;
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
left: 0; right: 0;
padding: 10px 16px;
background: white;
border-top: 1px solid #e5e7eb;
z-index: 30;
max-width: 480px;
margin: 0 auto;
}
.ds-proxy-btn {
width: 100%;
padding: 12px;
background: #2563eb;
color: white;
font-size: 0.875rem;
font-weight: 700;
border: none;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.ds-proxy-btn:hover { background: #1d4ed8; }
.ds-proxy-btn:disabled { background: #d1d5db; cursor: not-allowed; }
/* Bottom Sheet */
.ds-sheet-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 40;
}
.ds-sheet {
position: fixed;
bottom: 0; left: 0; right: 0;
background: white;
border-radius: 16px 16px 0 0;
max-height: 70vh;
overflow-y: auto;
z-index: 41;
padding: 0 16px 24px;
transform: translateY(100%);
transition: transform 0.3s ease;
max-width: 480px;
margin: 0 auto;
}
.ds-sheet.open { transform: translateY(0); }
.ds-sheet-handle {
width: 40px; height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 10px auto 12px;
cursor: pointer;
}
.ds-sheet-header {
display: flex; align-items: baseline; gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 12px;
}
.ds-sheet-header span:first-child { font-size: 1rem; font-weight: 700; color: #1f2937; }
.ds-sheet-sub { font-size: 0.75rem; color: #9ca3af; }
.ds-sheet-body { min-height: 80px; }
.ds-sheet-loading { text-align: center; padding: 24px; color: #9ca3af; font-size: 0.875rem; }
.ds-sheet-section { margin-bottom: 12px; }
.ds-sheet-section-title {
font-size: 0.75rem; font-weight: 700; color: #6b7280;
margin-bottom: 6px;
display: flex; align-items: center; gap: 6px;
}
.ds-sheet-card {
background: #f9fafb;
border-radius: 8px;
padding: 10px;
font-size: 0.8rem;
color: #374151;
}
.ds-sheet-card.empty { color: #9ca3af; text-align: center; }
.ds-sheet-actions {
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.ds-sheet-btn {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
font-size: 0.8rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
}
/* Bottom Nav (reuse tbm-mobile pattern) */
.m-bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; justify-content: space-around;
background: white;
border-top: 1px solid #e5e7eb;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
z-index: 35;
max-width: 480px;
margin: 0 auto;
}
.m-nav-item {
display: flex; flex-direction: column; align-items: center;
gap: 2px; color: #9ca3af;
text-decoration: none;
font-size: 0.65rem;
padding: 4px 8px;
}
.m-nav-item svg { width: 22px; height: 22px; }
.m-nav-item.active { color: #2563eb; }
.m-nav-label { font-weight: 500; }
@media (max-width: 480px) {
body { max-width: 480px; margin: 0 auto; }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,509 @@
/* equipment-detail.css - 설비 상세 페이지 스타일 */
/* 헤더 */
.eq-detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.eq-detail-header .page-title-section {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.btn-back {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: #374151;
transition: all 0.15s ease;
}
.btn-back:hover {
background: #e5e7eb;
}
.back-arrow {
font-size: 1.1rem;
}
.eq-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.eq-header-meta {
font-size: 0.875rem;
color: #6b7280;
}
.eq-status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.eq-status-badge.active { background: #d1fae5; color: #065f46; }
.eq-status-badge.maintenance { background: #fef3c7; color: #92400e; }
.eq-status-badge.repair_needed { background: #fee2e2; color: #991b1b; }
.eq-status-badge.inactive { background: #e5e7eb; color: #374151; }
.eq-status-badge.external { background: #dbeafe; color: #1e40af; }
.eq-status-badge.repair_external { background: #ede9fe; color: #5b21b6; }
/* 기본 정보 카드 */
.eq-info-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.eq-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.eq-info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.eq-info-label {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
.eq-info-value {
font-size: 0.9375rem;
color: #111827;
}
/* 섹션 */
.eq-section {
background: white;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.eq-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.eq-section-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin: 0;
}
/* 사진 그리드 */
.eq-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.eq-photo-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.eq-photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.eq-photo-item:hover img {
transform: scale(1.05);
}
.eq-photo-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(239, 68, 68, 0.9);
border: none;
border-radius: 50%;
color: white;
font-size: 14px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.eq-photo-item:hover .eq-photo-delete {
opacity: 1;
}
.eq-photo-empty {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: #9ca3af;
font-size: 0.875rem;
}
/* 위치 정보 */
.eq-location-card {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.eq-location-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.eq-location-row {
display: flex;
gap: 0.5rem;
}
.eq-location-label {
font-size: 0.875rem;
color: #6b7280;
min-width: 80px;
}
.eq-location-value {
font-size: 0.875rem;
color: #111827;
font-weight: 500;
}
.eq-location-value.eq-moved {
color: #dc2626;
}
.eq-map-preview {
width: 200px;
height: 150px;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.eq-map-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-map-marker {
position: absolute;
width: 12px;
height: 12px;
background: #dc2626;
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
/* 액션 버튼 */
.eq-action-buttons {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.btn-action {
flex: 1;
min-width: 140px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
border-radius: 10px;
font-size: 0.9375rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-action .btn-icon {
font-size: 1.25rem;
}
.btn-move {
background: #dbeafe;
color: #1e40af;
}
.btn-move:hover { background: #bfdbfe; }
.btn-repair {
background: #fef3c7;
color: #92400e;
}
.btn-repair:hover { background: #fde68a; }
.btn-export {
background: #ede9fe;
color: #5b21b6;
}
.btn-export:hover { background: #ddd6fe; }
/* 이력 리스트 */
.eq-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.eq-history-item {
display: flex;
gap: 1rem;
padding: 0.875rem;
background: #f9fafb;
border-radius: 8px;
align-items: flex-start;
}
.eq-history-date {
font-size: 0.8125rem;
color: #6b7280;
white-space: nowrap;
min-width: 80px;
}
.eq-history-content {
flex: 1;
}
.eq-history-title {
font-size: 0.875rem;
font-weight: 500;
color: #111827;
margin-bottom: 0.25rem;
}
.eq-history-detail {
font-size: 0.8125rem;
color: #6b7280;
}
.eq-history-status {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-weight: 500;
}
.eq-history-status.pending { background: #fef3c7; color: #92400e; }
.eq-history-status.in_progress { background: #dbeafe; color: #1e40af; }
.eq-history-status.completed { background: #d1fae5; color: #065f46; }
.eq-history-status.exported { background: #ede9fe; color: #5b21b6; }
.eq-history-status.returned { background: #d1fae5; color: #065f46; }
.eq-history-empty {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.875rem;
}
.eq-history-action {
padding: 0.375rem 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
}
.eq-history-action:hover {
background: #059669;
}
/* 모달 스타일 추가 */
.photo-preview-container {
margin-top: 1rem;
text-align: center;
}
.photo-preview {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
}
.move-step {
min-height: 200px;
}
.move-instruction {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 1rem;
text-align: center;
}
.move-map-container {
width: 100%;
height: 300px;
background: #f3f4f6;
border-radius: 8px;
margin-bottom: 1rem;
position: relative;
overflow: hidden;
}
.move-map-container img {
width: 100%;
height: 100%;
object-fit: contain;
cursor: crosshair;
}
.move-marker {
position: absolute;
width: 20px;
height: 20px;
background: #dc2626;
border: 3px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
pointer-events: none;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.repair-photo-previews {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.repair-photo-preview {
width: 60px;
height: 60px;
border-radius: 6px;
object-fit: cover;
}
/* 사진 확대 보기 */
.photo-view-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.photo-view-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
}
.photo-view-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
/* 버튼 스타일 */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-outline {
background: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-outline:hover {
background: #f3f4f6;
}
/* 반응형 */
@media (max-width: 768px) {
.eq-detail-header {
flex-direction: column;
}
.eq-location-card {
flex-direction: column;
}
.eq-map-preview {
width: 100%;
height: 200px;
}
.eq-action-buttons {
flex-direction: column;
}
.btn-action {
min-width: auto;
}
.eq-info-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,361 @@
/* equipment-management.css */
/* 설비 관리 페이지 전용 스타일 */
/* 통계 요약 섹션 */
.eq-stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.eq-stat-card {
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 12px;
padding: 1.25rem;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.eq-stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.eq-stat-card.highlight {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
border: none;
}
.eq-stat-card.highlight .eq-stat-label {
color: rgba(255, 255, 255, 0.85);
}
.eq-stat-label {
font-size: 0.8rem;
color: #64748b;
margin-bottom: 0.5rem;
font-weight: 500;
}
.eq-stat-value {
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
}
.eq-stat-sub {
font-size: 0.75rem;
color: #94a3b8;
margin-top: 0.25rem;
}
.eq-stat-card.highlight .eq-stat-sub {
color: rgba(255, 255, 255, 0.7);
}
/* 필터 섹션 개선 */
.eq-filter-section {
background: #f8fafc;
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.eq-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 140px;
}
.eq-filter-group label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.eq-filter-group .form-control {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
}
.eq-filter-group .form-control:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.eq-search-group {
flex: 1;
min-width: 200px;
}
.eq-search-group .form-control {
width: 100%;
}
/* 테이블 개선 */
.eq-table-container {
background: white;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.eq-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.eq-table thead {
background: #f1f5f9;
position: sticky;
top: 0;
z-index: 10;
}
.eq-table th {
padding: 0.875rem 1rem;
text-align: left;
font-weight: 600;
color: #475569;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
}
.eq-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.eq-table tbody tr {
transition: background 0.15s ease;
}
.eq-table tbody tr:hover {
background: #f8fafc;
}
.eq-table tbody tr:last-child td {
border-bottom: none;
}
/* 테이블 컬럼별 스타일 */
.eq-col-code {
font-weight: 600;
color: #1e40af;
white-space: nowrap;
}
.eq-col-name {
font-weight: 500;
color: #1e293b;
max-width: 200px;
}
.eq-col-model,
.eq-col-spec {
color: #64748b;
font-size: 0.8125rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-col-price {
font-weight: 600;
color: #059669;
text-align: right;
white-space: nowrap;
}
.eq-col-date {
color: #64748b;
font-size: 0.8125rem;
white-space: nowrap;
}
/* 상태 배지 */
.eq-status {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.eq-status-active {
background: #dcfce7;
color: #166534;
}
.eq-status-maintenance {
background: #fef3c7;
color: #92400e;
}
.eq-status-inactive {
background: #fee2e2;
color: #991b1b;
}
/* 액션 버튼 */
.eq-actions {
display: flex;
gap: 0.5rem;
}
.eq-btn-action {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.875rem;
}
.eq-btn-edit {
background: #eff6ff;
color: #3b82f6;
}
.eq-btn-edit:hover {
background: #3b82f6;
color: white;
}
.eq-btn-delete {
background: #fef2f2;
color: #ef4444;
}
.eq-btn-delete:hover {
background: #ef4444;
color: white;
}
/* 빈 상태 */
.eq-empty-state {
text-align: center;
padding: 4rem 2rem;
color: #64748b;
}
.eq-empty-state p {
margin-bottom: 1.5rem;
font-size: 1rem;
}
/* 테이블 스크롤 래퍼 */
.eq-table-wrapper {
overflow-x: auto;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
/* 결과 카운트 */
.eq-result-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 0.8125rem;
color: #64748b;
}
.eq-result-count strong {
color: #1e293b;
}
/* 반응형 */
@media (max-width: 1200px) {
.eq-stats-section {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.eq-stats-section {
grid-template-columns: repeat(2, 1fr);
}
.eq-filter-section {
flex-direction: column;
}
.eq-filter-group {
width: 100%;
}
.eq-table th,
.eq-table td {
padding: 0.625rem 0.75rem;
}
.eq-col-spec,
.eq-col-model {
display: none;
}
}
/* 모달 개선 */
.eq-modal-body {
padding: 1.5rem;
}
.eq-form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
.eq-form-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.eq-form-section-title {
font-size: 0.8rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 1rem;
}
.eq-form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 600px) {
.eq-form-row {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
/* monthly-comparison.css — 월간 비교·확인·정산 */
/* Header */
.mc-header {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
padding: 14px 16px;
border-radius: 0 0 16px 16px;
margin: -16px -16px 0;
position: sticky;
top: 56px;
z-index: 20;
}
.mc-header-row { display: flex; align-items: center; gap: 12px; }
.mc-back-btn {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.15);
border-radius: 8px;
border: none; color: white; cursor: pointer;
}
.mc-header h1 { font-size: 1.05rem; font-weight: 700; flex: 1; }
.mc-view-toggle {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.15);
border-radius: 8px;
border: none; color: white; cursor: pointer;
}
/* Month Navigation */
.mc-month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0;
position: relative;
}
.mc-month-nav button {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #6b7280; background: #f3f4f6;
border: none; cursor: pointer;
}
.mc-month-nav button:hover { background: #e5e7eb; }
.mc-month-nav span { font-size: 1rem; font-weight: 700; color: #1f2937; }
.mc-status-badge {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
font-size: 0.7rem; font-weight: 600;
padding: 3px 8px; border-radius: 12px;
}
.mc-status-badge.pending { background: #fef3c7; color: #92400e; }
.mc-status-badge.confirmed { background: #dcfce7; color: #166534; }
.mc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
.mc-status-badge.change_request { background: #fff7ed; color: #c2410c; }
.mc-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
.mc-status-badge.admin_checked { background: #dcfce7; color: #166534; }
/* Summary Cards */
.mc-summary-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
@media (min-width: 640px) {
.mc-summary-cards { grid-template-columns: repeat(4, 1fr); }
}
.mc-card {
background: white;
border-radius: 10px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.mc-card-value { font-size: 1.25rem; font-weight: 800; color: #1f2937; }
.mc-card-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
/* Mismatch Alert */
.mc-mismatch-alert {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px;
background: #fffbeb; border: 1px solid #fde68a;
border-radius: 8px;
margin-bottom: 12px;
font-size: 0.8rem; color: #92400e;
}
/* Daily List */
.mc-daily-list { padding-bottom: 100px; }
.mc-daily-card {
background: white;
border-radius: 10px;
padding: 12px;
margin-bottom: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
border-left: 3px solid transparent;
}
.mc-daily-card.match { border-left-color: #10b981; }
.mc-daily-card.mismatch { background: #fffbeb; border-left-color: #f59e0b; }
.mc-daily-card.report_only { background: #eff6ff; border-left-color: #3b82f6; }
.mc-daily-card.attend_only { background: #f5f3ff; border-left-color: #8b5cf6; }
.mc-daily-card.vacation { background: #f0fdf4; border-left-color: #34d399; }
.mc-daily-card.holiday { background: #f9fafb; border-left-color: #9ca3af; }
.mc-daily-card.none { background: #fef2f2; border-left-color: #ef4444; }
.mc-daily-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 6px;
}
.mc-daily-date { font-size: 0.85rem; font-weight: 600; color: #1f2937; }
.mc-daily-status { font-size: 0.7rem; font-weight: 600; display: flex; align-items: center; gap: 4px; }
.mc-daily-row { font-size: 0.8rem; color: #374151; margin: 2px 0; }
.mc-daily-row span { color: #6b7280; }
.mc-daily-diff {
font-size: 0.75rem; font-weight: 600; color: #f59e0b;
margin-top: 4px;
display: flex; align-items: center; gap: 4px;
}
/* Bottom Actions */
.mc-bottom-actions {
position: fixed;
bottom: 0; left: 0; right: 0;
display: flex; gap: 8px;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
background: white;
border-top: 1px solid #e5e7eb;
z-index: 30;
max-width: 480px;
margin: 0 auto;
}
.mc-confirm-btn {
flex: 1; padding: 12px;
background: #10b981; color: white;
font-size: 0.85rem; font-weight: 700;
border: none; border-radius: 10px; cursor: pointer;
}
.mc-confirm-btn:hover { background: #059669; }
.mc-reject-btn {
flex: 1; padding: 12px;
background: white; color: #ef4444;
font-size: 0.85rem; font-weight: 700;
border: 2px solid #fecaca; border-radius: 10px; cursor: pointer;
}
.mc-reject-btn:hover { background: #fef2f2; }
.mc-confirmed-status {
display: flex; align-items: center; gap: 8px;
padding: 16px;
text-align: center;
justify-content: center;
font-size: 0.85rem; color: #059669; font-weight: 600;
margin-bottom: 80px;
}
/* Admin View */
.mc-admin-summary {
background: white; border-radius: 10px;
padding: 16px; margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.mc-progress-bar {
height: 8px; background: #e5e7eb; border-radius: 4px;
overflow: hidden; margin-bottom: 8px;
}
.mc-progress-fill {
height: 100%;
background: linear-gradient(90deg, #f59e0b, #10b981);
border-radius: 4px;
transition: width 0.3s;
}
.mc-progress-text { font-size: 0.8rem; font-weight: 600; color: #1f2937; margin-bottom: 4px; }
.mc-status-counts { font-size: 0.75rem; color: #6b7280; display: flex; gap: 12px; }
/* Filter Tabs */
.mc-filter-tabs {
display: flex; gap: 4px;
padding: 4px; background: #f3f4f6;
border-radius: 10px; margin-bottom: 12px;
}
.mc-tab {
flex: 1; padding: 8px 4px;
font-size: 0.75rem; font-weight: 600;
color: #6b7280; background: transparent;
border: none; border-radius: 8px; cursor: pointer;
text-align: center;
}
.mc-tab.active { background: white; color: #2563eb; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
/* Worker List (admin) */
.mc-worker-list { padding-bottom: 100px; }
.mc-worker-card {
background: white;
border-radius: 10px;
padding: 12px;
margin-bottom: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
cursor: pointer;
transition: background 0.15s;
}
.mc-worker-card:active { background: #f9fafb; }
.mc-worker-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
.mc-worker-dept { font-size: 0.7rem; color: #9ca3af; }
.mc-worker-stats { font-size: 0.75rem; color: #6b7280; margin: 4px 0; }
.mc-worker-status {
display: flex; align-items: center; justify-content: space-between;
}
.mc-worker-status-badge {
font-size: 0.65rem; font-weight: 600;
padding: 2px 8px; border-radius: 10px;
}
.mc-worker-status-badge.confirmed { background: #166534; color: white; }
.mc-worker-status-badge.admin_checked { background: #dcfce7; color: #166534; }
.mc-worker-status-badge.pending { background: #fef3c7; color: #92400e; }
.mc-worker-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
.mc-worker-status-badge.change_request { background: #fff7ed; color: #c2410c; }
.mc-worker-status-badge.rejected { background: #fef2f2; color: #991b1b; }
.mc-worker-reject-reason {
font-size: 0.7rem; color: #991b1b;
margin-top: 4px; padding-left: 8px;
border-left: 2px solid #fecaca;
}
.mc-worker-mismatch {
font-size: 0.65rem; font-weight: 600;
color: #f59e0b; background: #fffbeb;
padding: 1px 6px; border-radius: 4px;
}
/* Bottom Export */
.mc-bottom-export {
position: fixed;
bottom: 0; left: 0; right: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
background: white;
border-top: 1px solid #e5e7eb;
z-index: 30;
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.mc-export-btn {
width: 100%; padding: 12px;
background: #059669; color: white;
font-size: 0.85rem; font-weight: 700;
border: none; border-radius: 10px; cursor: pointer;
}
.mc-export-btn:disabled { background: #d1d5db; cursor: not-allowed; }
.mc-export-note { font-size: 0.7rem; color: #9ca3af; margin-top: 4px; }
/* Modal */
.mc-modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 50;
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.mc-modal {
background: white; border-radius: 12px;
width: 100%; max-width: 400px; overflow: hidden;
}
.mc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
font-weight: 700; font-size: 0.9rem;
}
.mc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; }
.mc-modal-body { padding: 16px; }
.mc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; }
.mc-textarea {
width: 100%; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 10px; font-size: 0.85rem; resize: none;
}
.mc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; }
.mc-modal-footer {
display: flex; gap: 8px; padding: 12px 16px;
border-top: 1px solid #f3f4f6;
}
.mc-modal-cancel {
flex: 1; padding: 10px; border: 1px solid #e5e7eb;
border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem;
}
.mc-modal-submit {
flex: 1; padding: 10px; background: #ef4444; color: white;
border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
}
/* Empty / No Permission */
.mc-empty {
display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem;
}
/* Skeleton (reuse) */
.ds-skeleton {
height: 56px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: ds-shimmer 1.5s infinite;
border-radius: 10px;
margin-bottom: 6px;
}
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.ds-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; }
.ds-link { color: #2563eb; font-size: 0.8rem; text-decoration: underline; }
/* Change Request Panel (detail mode) */
.mc-change-panel {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 10px;
padding: 14px;
margin-bottom: 12px;
}
.mc-change-header {
font-size: 0.85rem; font-weight: 700; color: #c2410c;
margin-bottom: 8px;
display: flex; align-items: center; gap: 6px;
}
.mc-change-list { margin-bottom: 10px; }
.mc-change-item {
font-size: 0.8rem; color: #374151;
padding: 4px 0;
border-bottom: 1px solid #fde6d0;
}
.mc-change-item:last-child { border-bottom: none; }
.mc-change-from { color: #9ca3af; text-decoration: line-through; }
.mc-change-to { color: #c2410c; font-weight: 600; }
.mc-change-desc {
font-size: 0.8rem; color: #374151;
margin-bottom: 10px;
white-space: pre-wrap;
}
.mc-change-actions {
display: flex; gap: 8px;
}
.mc-change-approve {
flex: 1; padding: 10px;
background: #2563eb; color: white;
font-size: 0.8rem; font-weight: 600;
border: none; border-radius: 8px; cursor: pointer;
}
.mc-change-approve:hover { background: #1d4ed8; }
.mc-change-reject {
flex: 1; padding: 10px;
background: white; color: #ef4444;
font-size: 0.8rem; font-weight: 600;
border: 2px solid #fecaca; border-radius: 8px; cursor: pointer;
}
.mc-change-reject:hover { background: #fef2f2; }
/* Worker card change summary */
.mc-worker-change-summary {
font-size: 0.7rem; color: #c2410c;
margin-top: 4px; padding-left: 8px;
border-left: 2px solid #fed7aa;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) { body { max-width: 480px; margin: 0 auto; } }
/* Inline Edit */
.mc-edit-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 12px; padding: 2px 6px; margin-left: auto; }
.mc-edit-btn:hover { color: #2563eb; }
.mc-attend-row { display: flex; align-items: center; }
.mc-edit-form { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
.mc-edit-row { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.mc-edit-row label { width: 36px; font-weight: 600; color: #6b7280; font-size: 12px; }
.mc-edit-input { width: 60px; padding: 4px 6px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; text-align: center; }
.mc-edit-select { padding: 4px 6px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; flex: 1; }
.mc-edit-actions { display: flex; gap: 6px; margin-top: 2px; }
.mc-edit-save { padding: 4px 12px; background: #10b981; color: white; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; }
.mc-edit-save:hover { background: #059669; }
.mc-edit-cancel { padding: 4px 12px; background: #e5e7eb; color: #374151; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; }
.mc-edit-cancel:hover { background: #d1d5db; }

View File

@@ -0,0 +1,170 @@
/* my-monthly-confirm.css — 작업자 월간 확인 (모바일 캘린더) */
body { max-width: 480px; margin: 0 auto; }
/* 월 네비게이션 */
.mmc-month-nav {
display: flex; align-items: center; justify-content: center;
gap: 12px; padding: 12px 0; position: relative;
}
.mmc-month-nav button {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #6b7280; background: #f3f4f6; border: none; cursor: pointer;
}
.mmc-month-nav button:hover { background: #e5e7eb; }
.mmc-month-nav > span { font-size: 1rem; font-weight: 700; color: #1f2937; }
.mmc-status-badge {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
font-size: 0.7rem; font-weight: 600; padding: 3px 8px; border-radius: 12px;
}
.mmc-status-badge.pending { background: #f3f4f6; color: #6b7280; }
.mmc-status-badge.review_sent { background: #dbeafe; color: #1e40af; }
.mmc-status-badge.confirmed { background: #dcfce7; color: #166534; }
.mmc-status-badge.change_request { background: #fef3c7; color: #92400e; }
.mmc-status-badge.rejected { background: #fef2f2; color: #991b1b; }
/* 사용자 정보 */
.mmc-user-info {
display: flex; align-items: baseline; gap: 8px;
padding: 0 4px 8px; font-size: 0.95rem; font-weight: 700; color: #1f2937;
}
.mmc-user-dept { font-size: 0.8rem; font-weight: 400; color: #6b7280; }
/* ===== 캘린더 그리드 ===== */
.cal-grid {
background: white; border-radius: 12px; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.06); margin-bottom: 10px;
}
.cal-header {
display: grid; grid-template-columns: repeat(7, 1fr);
background: #f9fafb; border-bottom: 1px solid #e5e7eb;
}
.cal-dow {
text-align: center; padding: 8px 0; font-size: 0.7rem; font-weight: 600; color: #6b7280;
}
.cal-dow.sun { color: #ef4444; }
.cal-dow.sat { color: #3b82f6; }
.cal-body { display: grid; grid-template-columns: repeat(7, 1fr); }
.cal-cell {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 6px 2px; min-height: 54px; border-bottom: 1px solid #f3f4f6;
border-right: 1px solid #f3f4f6; cursor: pointer;
transition: background 0.15s;
}
.cal-cell:nth-child(7n) { border-right: none; }
.cal-cell:active { background: #eff6ff; }
.cal-cell.selected { background: #dbeafe; }
.cal-cell.empty { background: #fafafa; cursor: default; }
.cal-day { font-size: 0.7rem; font-weight: 600; color: #374151; margin-bottom: 2px; }
.cal-cell.sun .cal-day { color: #ef4444; }
.cal-cell.sat .cal-day { color: #3b82f6; }
.cal-val { font-size: 0.65rem; font-weight: 700; line-height: 1.2; text-align: center; }
/* 셀 상태별 색상 — 정시=흰색, 연차=노랑, 연장=연보라, 휴무=회색 */
.cal-cell.normal { background: white; }
.cal-cell.normal .cal-val { color: #1f2937; }
.cal-cell.vac { background: #fefce8; }
.cal-cell.vac .cal-val { color: #92400e; font-weight: 700; }
.cal-cell.off { background: #f3f4f6; }
.cal-cell.off .cal-val { color: #9ca3af; font-weight: 500; }
.cal-cell.overtime { background: #f5f3ff; }
.cal-cell.overtime .cal-val { color: #7c3aed; }
.cal-cell.special { background: #fff7ed; }
.cal-cell.special .cal-val { color: #b45309; }
.cal-cell.partial .cal-val { color: #6b7280; }
.cal-cell.none .cal-val { color: #d1d5db; }
.cal-cell.changed { outline: 2px solid #f59e0b; outline-offset: -2px; }
.cal-cell.changed::after { content: '수정'; position: absolute; top: 1px; right: 2px; font-size: 0.5rem; color: #f59e0b; font-weight: 700; }
.cal-cell { position: relative; }
/* 상세 표시 + 수정 */
.cal-edit-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; }
.cal-edit-select { padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.8rem; flex: 1; }
.cal-changed-badge { font-size: 0.65rem; font-weight: 700; color: #f59e0b; background: #fefce8; padding: 1px 6px; border-radius: 4px; }
.cal-detail { display: none; margin-bottom: 10px; }
.cal-detail-inner {
background: white; border-radius: 10px; padding: 10px 14px;
font-size: 0.8rem; color: #374151;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
/* ===== 요약 카드 ===== */
.mmc-sum-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.mmc-sum-card {
background: white; border-radius: 10px; padding: 10px 6px;
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.mmc-sum-num { font-size: 1.1rem; font-weight: 800; color: #1f2937; }
.mmc-sum-num.ot { color: #f59e0b; }
.mmc-sum-num.vac { color: #059669; }
.mmc-sum-label { font-size: 0.65rem; color: #6b7280; margin-top: 2px; }
/* 연차 현황 */
.mmc-vac-title { font-size: 0.8rem; font-weight: 600; color: #6b7280; margin-bottom: 6px; padding: 0 4px; }
.mmc-vac-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 160px; }
.mmc-vac-card {
background: white; border-radius: 10px; padding: 12px 8px;
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.mmc-vac-num { font-size: 1.25rem; font-weight: 800; color: #1f2937; }
.mmc-vac-num.used { color: #f59e0b; }
.mmc-vac-num.remain { color: #059669; }
.mmc-vac-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
/* 확인 상태 */
.mmc-confirmed-status {
display: flex; align-items: center; gap: 8px;
justify-content: center; padding: 16px;
font-size: 0.85rem; color: #059669; font-weight: 600;
margin-bottom: 80px;
}
/* 하단 버튼 */
.mmc-bottom-actions {
position: fixed; bottom: 68px; left: 0; right: 0;
display: flex; gap: 8px;
padding: 10px 16px;
background: white; border-top: 1px solid #e5e7eb; z-index: 30;
max-width: 480px; margin: 0 auto;
}
.mmc-confirm-btn {
flex: 1; padding: 14px; background: #10b981; color: white;
font-size: 0.9rem; font-weight: 700;
border: none; border-radius: 12px; cursor: pointer;
}
.mmc-confirm-btn:hover { background: #059669; }
.mmc-reject-btn {
flex: 1; padding: 14px; background: white; color: #ef4444;
font-size: 0.9rem; font-weight: 700;
border: 2px solid #fecaca; border-radius: 12px; cursor: pointer;
}
.mmc-reject-btn:hover { background: #fef2f2; }
/* 모달 */
.mmc-modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4); z-index: 50;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.mmc-modal { background: white; border-radius: 12px; width: 100%; max-width: 400px; overflow: hidden; }
.mmc-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid #f3f4f6;
font-weight: 700; font-size: 0.9rem;
}
.mmc-modal-header button { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1.1rem; }
.mmc-modal-body { padding: 16px; }
.mmc-modal-desc { font-size: 0.85rem; color: #374151; margin-bottom: 8px; }
.mmc-textarea { width: 100%; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; font-size: 0.85rem; resize: none; }
.mmc-modal-note { font-size: 0.75rem; color: #6b7280; margin-top: 8px; }
.mmc-modal-footer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #f3f4f6; }
.mmc-modal-cancel { flex: 1; padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; font-size: 0.8rem; }
.mmc-modal-submit { flex: 1; padding: 10px; background: #ef4444; color: white; border: none; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; }
/* 빈 상태 / 스켈레톤 */
.mmc-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 0.875rem; }
.mmc-skeleton { height: 40px; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: mmc-shimmer 1.5s infinite; border-radius: 8px; margin-bottom: 4px; }
@keyframes mmc-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

View File

@@ -0,0 +1,113 @@
/* 생산팀 대시보드 — Sprint 003 */
.pd-main { max-width: 640px; margin: 0 auto; padding: 16px 16px 80px; }
/* 프로필 카드 */
.pd-profile-card {
background: linear-gradient(135deg, #9a3412, #ea580c);
color: white; border-radius: 16px; padding: 20px; margin-bottom: 16px;
position: relative;
}
.pd-logout-btn {
position: absolute; top: 16px; right: 16px;
background: rgba(255,255,255,0.2); border: none; border-radius: 50%;
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.8); font-size: 14px; cursor: pointer; transition: background 0.15s;
}
.pd-logout-btn:hover { background: rgba(255,255,255,0.3); color: white; }
.pd-profile-header { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.pd-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700; flex-shrink: 0;
}
.pd-profile-name { font-size: 18px; font-weight: 700; }
.pd-profile-sub { font-size: 13px; opacity: 0.8; margin-top: 2px; }
/* 통합 정보 리스트 */
.pd-info-list { display: flex; flex-direction: column; gap: 2px; }
.pd-info-row {
display: flex; justify-content: space-between; align-items: center;
background: rgba(255,255,255,0.12); border-radius: 10px; padding: 10px 12px;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.pd-info-row:active { background: rgba(255,255,255,0.18); }
.pd-info-left { display: flex; align-items: center; gap: 8px; }
.pd-info-icon { font-size: 14px; opacity: 0.8; width: 18px; text-align: center; }
.pd-info-label { font-size: 12px; font-weight: 600; opacity: 0.9; }
.pd-info-right { display: flex; align-items: center; gap: 6px; }
.pd-info-value { font-size: 14px; font-weight: 700; }
.pd-info-sub { font-size: 11px; opacity: 0.6; }
.pd-info-arrow { font-size: 10px; opacity: 0.5; margin-left: 2px; }
.pd-progress-bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.2); overflow: hidden; }
.pd-progress-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
.pd-progress-green { background: #4ade80; }
.pd-progress-yellow { background: #fbbf24; }
.pd-progress-red { background: #f87171; }
/* 연차 상세 모달 */
.pd-detail-modal {
position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center;
background: rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
.pd-detail-modal.active { opacity: 1; pointer-events: auto; }
.pd-detail-sheet {
background: linear-gradient(135deg, #9a3412, #ea580c); color: white;
border-radius: 16px 16px 0 0; width: 100%; max-width: 640px;
padding: 20px 20px calc(20px + 70px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%); transition: transform 0.3s ease;
}
.pd-detail-modal.active .pd-detail-sheet { transform: translateY(0); }
.pd-detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.pd-detail-title { font-size: 16px; font-weight: 700; }
.pd-detail-close { background: none; border: none; color: white; opacity: 0.7; font-size: 18px; cursor: pointer; }
.pd-detail-row {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.15); font-size: 13px;
}
.pd-detail-label { font-weight: 600; opacity: 0.9; }
.pd-detail-value { text-align: right; opacity: 0.85; }
.pd-detail-total {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 0 0; font-size: 14px; font-weight: 700; margin-top: 4px;
}
/* 섹션 */
.pd-section { margin-bottom: 20px; }
.pd-section-title {
font-size: 12px; font-weight: 700; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px;
padding-left: 2px;
}
/* 아이콘 그리드 */
.pd-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.pd-grid-item {
display: flex; flex-direction: column; align-items: center; gap: 6px;
cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent;
}
.pd-grid-item:active .pd-grid-icon { transform: scale(0.93); }
.pd-grid-icon {
width: 52px; height: 52px; border-radius: 14px;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 20px; transition: transform 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.pd-grid-label {
font-size: 11px; text-align: center; color: #374151; line-height: 1.3;
max-width: 64px; overflow: hidden; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* 스켈레톤 */
.pd-skeleton { background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pd-shimmer 1.5s infinite; border-radius: 8px; }
@keyframes pd-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* 에러 */
.pd-error { text-align: center; padding: 40px 20px; color: #6b7280; }
.pd-error i { font-size: 40px; margin-bottom: 12px; color: #d1d5db; }
.pd-error-btn { margin-top: 12px; padding: 8px 20px; background: #2563eb; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
/* 반응형 */
@media (min-width: 640px) { .pd-grid { grid-template-columns: repeat(6, 1fr); } }
@media (min-width: 1024px) { .pd-main { max-width: 800px; } .pd-grid { grid-template-columns: repeat(8, 1fr); } }

View File

@@ -0,0 +1,85 @@
/* proxy-input.css — 대리입력 리뉴얼 */
/* Title Row */
.pi-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.pi-title { font-size: 18px; font-weight: 700; color: #1f2937; flex: 1; }
.pi-back-btn { background: none; border: none; font-size: 18px; color: #6b7280; cursor: pointer; padding: 4px 8px; }
.pi-date-group { display: flex; align-items: center; gap: 6px; }
.pi-date-input { border: 1px solid #d1d5db; border-radius: 8px; padding: 6px 10px; font-size: 14px; }
.pi-refresh-btn { background: none; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 6px; }
/* Status Bar */
.pi-status-bar { display: flex; gap: 16px; background: white; border-radius: 10px; padding: 10px 14px; margin-bottom: 10px; font-size: 13px; color: #6b7280; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
/* Select All */
.pi-select-all { padding: 6px 2px; font-size: 13px; color: #6b7280; }
.pi-select-all input { margin-right: 6px; }
/* Worker List */
.pi-worker-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 80px; }
.pi-worker { display: flex; align-items: center; gap: 10px; background: white; border-radius: 10px; padding: 10px 12px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
.pi-worker:hover { border-color: #93c5fd; }
.pi-worker.disabled { opacity: 0.45; cursor: not-allowed; }
.pi-check { width: 18px; height: 18px; flex-shrink: 0; accent-color: #2563eb; }
.pi-worker-info { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.pi-worker-name { font-size: 14px; font-weight: 600; color: #1f2937; }
.pi-worker-job { font-size: 11px; color: #9ca3af; }
.pi-worker-badges { display: flex; gap: 4px; flex-shrink: 0; }
/* Badges */
.pi-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
.pi-badge.done { background: #dcfce7; color: #166534; }
.pi-badge.missing { background: #fee2e2; color: #991b1b; }
.pi-badge.vac { background: #dbeafe; color: #1e40af; }
.pi-badge.vac-half { background: #fef3c7; color: #92400e; }
/* Bottom Bar */
.pi-bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: white; padding: 12px 16px; border-top: 1px solid #e5e7eb; box-shadow: 0 -2px 8px rgba(0,0,0,0.06); }
.pi-edit-btn, .pi-save-btn { width: 100%; padding: 12px; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; color: white; cursor: pointer; }
.pi-edit-btn { background: #2563eb; }
.pi-edit-btn:hover { background: #1d4ed8; }
.pi-edit-btn:disabled { background: #9ca3af; cursor: not-allowed; }
.pi-save-btn { background: #10b981; }
.pi-save-btn:hover { background: #059669; }
.pi-save-btn:disabled { background: #9ca3af; }
/* Edit Cards */
.pi-edit-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 80px; }
.pi-edit-card { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.pi-edit-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 14px; }
.pi-edit-job { font-size: 11px; color: #9ca3af; }
.pi-edit-fields { display: flex; flex-direction: column; gap: 6px; }
.pi-edit-row { display: flex; gap: 6px; }
.pi-select { flex: 1; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; background: white; }
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
.pi-input { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; text-align: center; }
.pi-note-input { width: 100%; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
/* Skeleton */
.pi-skeleton { height: 52px; border-radius: 10px; background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); background-size: 200% 100%; animation: pi-shimmer 1.5s infinite; }
@keyframes pi-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Department Label */
.pi-dept-label { font-size: 11px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 2px 4px; }
/* Bulk Form */
.pi-bulk-form { background: white; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 8px; }
.pi-edit-row { display: flex; gap: 8px; }
.pi-select { flex: 1; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: white; }
.pi-field { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.pi-field span { font-size: 11px; color: #6b7280; font-weight: 600; }
.pi-input { padding: 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; text-align: center; font-weight: 600; }
.pi-note-input { width: 100%; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 13px; }
/* Target Section */
.pi-target-section { background: white; border-radius: 12px; padding: 12px; margin-bottom: 80px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.pi-target-label { font-size: 12px; font-weight: 700; color: #6b7280; margin-bottom: 8px; }
.pi-target-list { display: flex; flex-wrap: wrap; gap: 6px; }
.pi-target-chip { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #1e40af; }
/* Empty */
.pi-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: #9ca3af; font-size: 14px; }
/* Responsive */
@media (min-width: 640px) { .pi-bottom-bar { max-width: 640px; margin: 0 auto; } }

View File

@@ -0,0 +1,425 @@
/* purchase-mobile.css — 소모품 신청 모바일 전용 */
/* 메인 컨텐츠 (하단 네비 여유) */
.pm-content {
padding-bottom: calc(140px + env(safe-area-inset-bottom));
min-height: 100vh;
}
/* 상태 탭 */
.pm-tabs {
display: flex;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 8px 16px;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.pm-tabs::-webkit-scrollbar { display: none; }
.pm-tab {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
background: #f3f4f6;
color: #6b7280;
border: none;
cursor: pointer;
white-space: nowrap;
}
.pm-tab.active {
background: #ea580c;
color: white;
}
.pm-tab .tab-count {
margin-left: 4px;
font-size: 11px;
opacity: 0.8;
}
/* 카드 리스트 */
.pm-cards {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.pm-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 14px;
cursor: pointer;
transition: box-shadow 0.15s;
}
.pm-card:active { box-shadow: 0 0 0 2px rgba(234,88,12,0.2); }
.pm-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.pm-card-name {
font-size: 15px;
font-weight: 600;
color: #1f2937;
line-height: 1.3;
}
.pm-card-custom { font-size: 11px; color: #ea580c; margin-left: 4px; }
.pm-card-meta {
display: flex;
gap: 10px;
margin-top: 8px;
font-size: 12px;
color: #9ca3af;
}
.pm-card-qty { color: #374151; font-weight: 600; }
/* FAB */
.pm-fab {
position: fixed;
bottom: calc(84px + env(safe-area-inset-bottom));
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #ea580c;
color: white;
border: none;
box-shadow: 0 4px 12px rgba(234,88,12,0.35);
font-size: 24px;
cursor: pointer;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s;
}
.pm-fab:active { transform: scale(0.92); }
/* 바텀시트 */
.pm-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 1005;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
}
.pm-overlay.open { opacity: 1; pointer-events: auto; }
.pm-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 16px 16px 0 0;
z-index: 1010;
max-height: 92vh;
overflow-y: auto;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
.pm-sheet.open { transform: translateY(0); }
.pm-sheet-handle {
width: 36px;
height: 4px;
background: #d1d5db;
border-radius: 2px;
margin: 8px auto;
}
.pm-sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px 8px;
}
.pm-sheet-title { font-size: 17px; font-weight: 700; color: #1f2937; }
.pm-sheet-close {
width: 32px;
height: 32px;
border: none;
background: #f3f4f6;
border-radius: 50%;
font-size: 16px;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.pm-sheet-body { padding: 0 20px 20px; }
/* 검색 */
.pm-search-wrap { position: relative; margin-bottom: 12px; }
.pm-search-input {
width: 100%;
padding: 12px 40px 12px 14px;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
font-size: 16px;
outline: none;
box-sizing: border-box;
}
.pm-search-input:focus { border-color: #ea580c; }
.pm-search-spinner {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: none;
}
.pm-search-spinner.show { display: block; }
.pm-search-results {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 8px;
display: none;
}
.pm-search-results.open { display: block; }
.pm-search-thumb {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.pm-search-thumb-empty {
width: 36px;
height: 36px;
border-radius: 6px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #d1d5db;
font-size: 14px;
flex-shrink: 0;
}
.pm-search-item {
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid #f3f4f6;
}
.pm-search-item:last-child { border-bottom: none; }
.pm-search-item:active { background: #fff7ed; }
.pm-search-item .match-type {
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
background: #f3f4f6;
color: #9ca3af;
flex-shrink: 0;
}
.pm-search-register {
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
color: #ea580c;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.pm-search-register:active { background: #fff7ed; }
/* 장바구니 */
.pm-cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.pm-cart-title { font-size: 14px; font-weight: 600; color: #374151; }
.pm-cart-count { font-size: 12px; color: #ea580c; font-weight: 600; }
.pm-cart-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 8px;
margin-bottom: 6px;
}
.pm-cart-item-info { flex: 1; min-width: 0; }
.pm-cart-item-name { font-size: 13px; font-weight: 600; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pm-cart-item-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.pm-cart-item-new { font-size: 10px; color: #ea580c; }
.pm-cart-qty {
width: 48px;
padding: 4px 6px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
text-align: center;
flex-shrink: 0;
}
.pm-cart-memo {
width: 80px;
padding: 4px 6px;
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 12px;
flex-shrink: 0;
}
.pm-cart-thumb {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.pm-cart-photo-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px dashed #d1d5db;
border-radius: 4px;
font-size: 11px;
color: #6b7280;
cursor: pointer;
}
.pm-cart-remove {
width: 24px;
height: 24px;
border: none;
background: #fecaca;
color: #dc2626;
border-radius: 50%;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* 신규 품목 인라인 필드 */
.pm-cart-new-fields {
display: flex;
gap: 4px;
margin-top: 4px;
}
.pm-cart-new-fields input, .pm-cart-new-fields select {
padding: 3px 6px;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 11px;
width: 100%;
}
/* 폼 필드 */
.pm-field { margin-bottom: 12px; }
.pm-label { display: block; font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; }
.pm-input {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
font-size: 16px;
outline: none;
box-sizing: border-box;
}
.pm-input:focus { border-color: #ea580c; }
.pm-select {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
font-size: 16px;
background: white;
outline: none;
box-sizing: border-box;
}
/* 사진 */
.pm-photo-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1.5px dashed #d1d5db;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
font-size: 14px;
color: #6b7280;
min-height: 44px;
}
.pm-photo-preview {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
margin-top: 8px;
}
/* 제출 */
.pm-submit {
width: 100%;
padding: 14px;
border: none;
border-radius: 10px;
background: #ea580c;
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 16px;
min-height: 48px;
}
.pm-submit:active { background: #c2410c; }
.pm-submit:disabled { background: #d1d5db; cursor: not-allowed; }
/* 상세 시트 */
.pm-detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
border-bottom: 1px solid #f3f4f6;
}
.pm-detail-label { color: #9ca3af; }
.pm-detail-value { color: #1f2937; font-weight: 500; }
.pm-received-photo {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 10px;
margin-top: 12px;
}
/* 빈 상태 */
.pm-empty {
text-align: center;
padding: 48px 16px;
color: #9ca3af;
}
.pm-empty i { font-size: 32px; margin-bottom: 8px; display: block; }
/* 로딩 */
.pm-loading {
text-align: center;
padding: 24px;
color: #9ca3af;
font-size: 14px;
}
/* 스피너 애니메이션 */
@keyframes pm-spin { to { transform: translateY(-50%) rotate(360deg); } }
.pm-search-spinner.show i { animation: pm-spin 0.8s linear infinite; }

View File

@@ -0,0 +1,805 @@
/* TBM Mobile Styles */
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: #f3f4f6;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
touch-action: manipulation;
}
button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
.picker-item, .split-radio-item, .split-session-item, .pull-btn,
.de-save-btn, .de-group-btn, .de-split-btn, .pill-btn, .worker-card,
[onclick] {
touch-action: manipulation;
}
@media (min-width: 480px) {
body { max-width: 768px; margin: 0 auto; min-height: 100vh; }
}
/* Header */
.m-header {
position: sticky;
top: 0;
z-index: 30;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
padding: 0.875rem 1rem;
padding-top: calc(0.875rem + env(safe-area-inset-top));
}
.m-header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.m-header h1 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.m-header .m-date {
font-size: 0.75rem;
opacity: 0.8;
}
.m-new-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.2);
color: white;
border: 1.5px solid rgba(255,255,255,0.4);
border-radius: 2rem;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-new-btn:active { background: rgba(255,255,255,0.3); }
/* Tabs */
.m-tabs {
display: flex;
background: white;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 20;
}
.m-tab {
flex: 1;
padding: 0.75rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: #9ca3af;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.m-tab.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.m-tab .tab-count {
display: inline-block;
min-width: 18px;
height: 18px;
line-height: 18px;
border-radius: 9px;
background: #e5e7eb;
color: #6b7280;
font-size: 0.6875rem;
font-weight: 700;
text-align: center;
margin-left: 0.25rem;
padding: 0 0.25rem;
}
.m-tab.active .tab-count {
background: #dbeafe;
color: #1d4ed8;
}
/* Content area */
.m-content {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
min-height: 60vh;
}
/* Date group */
.m-date-group {
padding: 0.5rem 1rem 0.25rem;
}
.m-date-label {
font-size: 0.6875rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* TBM list row */
.m-tbm-row {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: white;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-tbm-row:active { background: #f9fafb; }
.m-tbm-row:first-child { border-top: 1px solid #e5e7eb; }
.m-row-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-right: 0.75rem;
}
.m-row-status.draft { background: #f59e0b; }
.m-row-status.completed { background: #10b981; }
.m-row-status.cancelled { background: #ef4444; }
.m-row-body {
flex: 1;
min-width: 0;
}
.m-row-main {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-row-sub {
font-size: 0.6875rem;
color: #9ca3af;
margin-top: 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-row-right {
flex-shrink: 0;
text-align: right;
margin-left: 0.75rem;
}
.m-row-count {
font-size: 0.8125rem;
font-weight: 700;
color: #1f2937;
}
.m-row-count-label {
font-size: 0.625rem;
color: #9ca3af;
}
.m-row-time {
font-size: 0.625rem;
color: #9ca3af;
margin-top: 0.125rem;
}
/* TBM detail expanded */
.m-tbm-detail {
display: none;
background: #f9fafb;
padding: 0.75rem 1rem 0.75rem 2.75rem;
border-bottom: 1px solid #e5e7eb;
}
.m-tbm-row.expanded + .m-tbm-detail { display: block; }
.m-detail-row {
display: flex;
padding: 0.25rem 0;
font-size: 0.75rem;
}
.m-detail-label {
color: #6b7280;
width: 50px;
flex-shrink: 0;
}
.m-detail-value {
color: #1f2937;
font-weight: 500;
flex: 1;
}
.m-detail-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #e5e7eb;
}
.m-detail-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.m-detail-btn:active { background: #f3f4f6; }
.m-detail-btn.primary {
background: #2563eb;
color: white;
border-color: #2563eb;
}
.m-detail-btn.primary:active { background: #1d4ed8; }
.m-detail-btn.danger {
color: #ef4444;
border-color: #fca5a5;
}
/* Empty state */
.m-empty {
text-align: center;
padding: 3rem 1rem;
color: #9ca3af;
}
.m-empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.m-empty-text {
font-size: 0.875rem;
}
.m-empty-sub {
font-size: 0.75rem;
margin-top: 0.25rem;
}
/* Load more */
.m-load-more {
display: block;
width: calc(100% - 2rem);
margin: 0.75rem 1rem;
padding: 0.75rem;
border: 1px dashed #d1d5db;
border-radius: 0.75rem;
background: white;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.m-load-more:active { background: #f3f4f6; }
/* Loading skeleton */
.m-skeleton {
height: 56px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-bottom: 1px solid #f3f4f6;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Bottom nav → tkfb.css로 이동됨 (shared-bottom-nav.js 공통) */
/* Detail badge */
.m-detail-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.375rem;
vertical-align: middle;
}
.m-detail-badge.incomplete {
background: #fef3c7;
color: #92400e;
}
.m-detail-badge.complete {
background: #d1fae5;
color: #065f46;
}
/* Worker card status indicator */
.de-worker-card.filled {
background: #f0fdf4;
border-left: 3px solid #10b981;
padding-left: calc(0.625rem - 3px);
}
.de-worker-card.unfilled {
border-left: 3px solid #f59e0b;
padding-left: calc(0.625rem - 3px);
}
.de-worker-status {
font-size: 0.625rem;
font-weight: 600;
margin-left: 0.375rem;
}
.de-worker-status.ok { color: #059669; }
.de-worker-status.missing { color: #d97706; }
/* Group select */
.de-worker-check {
width: 20px;
height: 20px;
accent-color: #2563eb;
margin-right: 0.5rem;
flex-shrink: 0;
}
.de-group-bar {
display: none;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
margin: 0 1rem 0.5rem;
font-size: 0.75rem;
color: #1e40af;
}
.de-group-bar.visible { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }
.de-group-btn {
padding: 0.25rem 0.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.de-select-all-row {
display: flex;
align-items: center;
padding: 0.375rem 1rem;
font-size: 0.75rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
/* Detail edit bottom sheet */
.detail-edit-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9000;
}
.detail-edit-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9001;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 90vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.de-header {
position: sticky;
top: 0;
background: white;
padding: 1rem 1rem 0;
border-radius: 1rem 1rem 0 0;
z-index: 1;
}
.de-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.de-header h3 { margin: 0; font-size: 1rem; font-weight: 700; }
.de-close {
background: none;
border: none;
font-size: 1.25rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
}
/* Picker popup */
.picker-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 9100;
}
.picker-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9101;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 70vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.picker-header {
position: sticky;
top: 0;
background: white;
padding: 0.875rem 1rem 0.5rem;
border-radius: 1rem 1rem 0 0;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
.picker-close {
background: none;
border: none;
font-size: 1.125rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
}
.picker-list { padding: 0.25rem 0; }
.picker-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #1f2937;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
-webkit-tap-highlight-color: transparent;
}
.picker-item:active { background: #f3f4f6; }
.picker-item.selected { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
.picker-item-sub { font-size: 0.6875rem; color: #9ca3af; margin-left: 0.375rem; }
.picker-divider {
padding: 0.375rem 1rem;
font-size: 0.6875rem;
font-weight: 700;
color: #6b7280;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.picker-add-row {
display: flex;
gap: 0.375rem;
padding: 0.625rem 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
position: sticky;
bottom: 0;
}
.picker-add-input {
flex: 1;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.picker-add-btn {
padding: 0.5rem 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.de-worker-list { padding: 0 1rem; }
.de-worker-card {
padding: 0.625rem 0;
border-bottom: 1px solid #f3f4f6;
}
.de-worker-card:last-child { border-bottom: none; }
.de-worker-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.de-worker-job {
font-size: 0.75rem;
color: #6b7280;
}
.de-worker-fields {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.de-field-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.de-field-label {
font-size: 0.6875rem;
color: #6b7280;
width: 32px;
flex-shrink: 0;
}
.de-field-row select {
flex: 1;
padding: 0.4375rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
background: white;
}
.de-save-area {
padding: 0.75rem 1rem 1rem;
position: sticky;
bottom: 0;
background: white;
border-top: 1px solid #e5e7eb;
}
.de-save-btn {
width: 100%;
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
}
.de-save-btn:disabled { opacity: 0.5; }
/* My TBM highlight */
.m-tbm-row.my-tbm {
border-left: 3px solid #2563eb;
}
.m-leader-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #eff6ff;
color: #1d4ed8;
}
.m-transfer-badge {
display: inline-block;
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.3125rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #fef3c7;
color: #92400e;
}
.m-work-hours-tag {
display: inline-block;
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #dbeafe;
color: #1d4ed8;
}
/* Split sheet */
.split-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9201;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 85vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.split-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9200;
}
.split-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.split-header h4 { margin: 0 0 0.25rem; font-size: 0.9375rem; font-weight: 700; }
.split-header p { margin: 0; font-size: 0.75rem; color: #6b7280; }
.split-body { padding: 0.75rem 1rem; }
.split-field { margin-bottom: 0.75rem; }
.split-field label { display: block; font-size: 0.75rem; font-weight: 600; color: #374151; margin-bottom: 0.25rem; }
.split-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.split-radio-group { display: flex; gap: 0.5rem; margin-top: 0.25rem; }
.split-radio-item {
flex: 1;
padding: 0.5rem;
border: 1.5px solid #d1d5db;
border-radius: 0.5rem;
text-align: center;
font-size: 0.8125rem;
cursor: pointer;
background: white;
}
.split-radio-item.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.split-session-list { margin-top: 0.5rem; }
.split-session-item {
padding: 0.625rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin-bottom: 0.375rem;
cursor: pointer;
font-size: 0.8125rem;
}
.split-session-item:active, .split-session-item.active {
border-color: #2563eb;
background: #eff6ff;
}
.split-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #e5e7eb;
}
.split-btn {
width: 100%;
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
}
.split-btn:disabled { opacity: 0.5; }
/* Pull sheet */
.pull-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9101;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 85vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.pull-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9100;
}
.pull-header {
position: sticky;
top: 0;
background: white;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
border-radius: 1rem 1rem 0 0;
z-index: 1;
}
.pull-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
.pull-header p { margin: 0.25rem 0 0; font-size: 0.75rem; color: #6b7280; }
.pull-member-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
}
.pull-member-info { flex: 1; }
.pull-member-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
.pull-member-sub { font-size: 0.6875rem; color: #9ca3af; margin-top: 0.125rem; }
.pull-btn {
padding: 0.375rem 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.pull-btn:disabled {
background: #d1d5db;
color: #9ca3af;
cursor: default;
}
/* de-split-btn in detail edit */
.de-split-btn {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: #374151;
cursor: pointer;
margin-left: auto;
}
.de-split-btn:active { background: #e5e7eb; }
/* Toast */
.toast-container {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
/* Loading overlay */
.m-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(255,255,255,0.75);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.m-loading-overlay.active { display: flex; }
.m-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.m-loading-text {
font-size: 0.875rem;
color: #6b7280;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
/**
* vacation-allocation.css
* 휴가 발생 입력 페이지 스타일
*/
.page-container {
min-height: 100vh;
background: var(--color-bg-primary);
}
.main-content {
padding: 2rem 0;
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
}
/* 페이지 헤더 */
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.page-description {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
}
/* 탭 네비게이션 */
.tab-navigation {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 2px solid var(--color-border);
}
.tab-button {
padding: 1rem 2rem;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.tab-button:hover {
color: var(--color-primary);
background: var(--color-bg-secondary);
}
.tab-button.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
/* 탭 콘텐츠 */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 폼 섹션 */
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--color-border);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
}
.required {
color: #ef4444;
}
.form-select,
.form-input {
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: white;
font-size: 0.875rem;
color: var(--color-text-primary);
transition: all 0.2s;
}
.form-select:focus,
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-input[type="number"] {
max-width: 200px;
}
.form-group small {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* 자동 계산 섹션 */
.auto-calculate-section {
background: var(--color-bg-secondary);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.alert-warning {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
/* 기존 데이터 섹션 */
.existing-data-section {
margin-top: 2rem;
}
.existing-data-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
/* 미리보기 섹션 */
.preview-section {
margin-top: 2rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.preview-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 1rem;
}
/* 테이블 */
.table-responsive {
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--color-border);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table thead {
background: var(--color-bg-secondary);
}
.data-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table tbody tr {
border-bottom: 1px solid var(--color-border);
transition: background 0.2s;
}
.data-table tbody tr:hover {
background: var(--color-bg-secondary);
}
.data-table td {
padding: 1rem;
color: var(--color-text-primary);
}
.loading-state {
padding: 3rem 1rem !important;
text-align: center;
}
.loading-state .spinner {
margin: 0 auto 1rem;
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
margin: 0;
color: var(--color-text-secondary);
}
/* 액션 버튼 */
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-icon {
padding: 0.5rem;
min-width: auto;
font-size: 1rem;
}
/* 배지 */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-info {
background: #dbeafe;
color: #1e40af;
}
.badge-success {
background: #d1fae5;
color: #065f46;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-error {
background: #fee2e2;
color: #991b1b;
}
/* 모달 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.modal-close:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.modal-body {
padding: 1.5rem;
}
/* 반응형 */
@media (max-width: 768px) {
.content-wrapper {
padding: 0 1rem;
}
.page-title {
font-size: 1.5rem;
}
.tab-navigation {
overflow-x: auto;
}
.tab-button {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
white-space: nowrap;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.form-actions button {
width: 100%;
}
.section-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.modal-content {
width: 95%;
max-height: 95vh;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
/* ✅ /css/workreport.css */
body {
font-family: 'Malgun Gothic', sans-serif;
background-color: #f8f9fa;
margin: 0;
padding: 30px;
color: #333;
}
h2 {
text-align: center;
color: #1976d2;
margin-bottom: 20px;
}
#calendar {
max-width: 500px;
margin: 0 auto 30px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
text-align: center;
}
#calendar .nav {
grid-column: span 7;
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
#calendar button {
padding: 8px;
background-color: #ffffff;
border: 1px solid #ccc;
cursor: pointer;
border-radius: 4px;
}
#calendar button:hover {
background-color: #e3f2fd;
}
.selected-date {
background-color: #4caf50 !important;
color: white;
font-weight: bold;
}
table {
width: 100%;
max-width: 1200px;
margin: auto;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
th, td {
border: 1px solid #ddd;
padding: 10px;
text-align: center;
}
th {
background-color: #f1f3f4;
color: #333;
}
select,
input[type="text"] {
width: 100%;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
.remove-btn {
background-color: #d9534f;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.remove-btn:hover {
background-color: #c9302c;
}
.submit-btn {
display: block;
margin: 30px auto;
padding: 12px 30px;
font-size: 1rem;
font-weight: bold;
background-color: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.submit-btn:hover {
background-color: #1565c0;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>(주)테크니컬코리아 생산팀 포털</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- SW 캐시 강제 해제 (Chrome 대응) -->
<script>
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
</script>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script>
// SSO 토큰 확인
var token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
if (token && token !== 'undefined' && token !== 'null') {
// 이미 로그인된 경우 대시보드로 이동
window.location.replace('/pages/dashboard-new.html');
} else {
// SSO 로그인 페이지로 리다이렉트 (gateway의 /login)
window.location.replace('/login?redirect=' + encodeURIComponent(window.location.origin + '/pages/dashboard-new.html'));
}
</script>
</head>
<body>
<!-- SSO 로그인 페이지로 자동 리다이렉트됩니다 -->
</body>
</html>

View File

@@ -0,0 +1,248 @@
// /js/api-base.js
// API 기본 설정 및 보안 유틸리티 (비모듈 - 빠른 로딩용)
// ==================== SW 캐시 강제 해제 (PWA 홈화면 추가 대응) ====================
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(reg) { reg.unregister(); });
});
}
if ('caches' in window) {
caches.keys().then(function(keys) {
keys.forEach(function(key) { caches.delete(key); });
});
}
(function() {
'use strict';
// ==================== SSO 쿠키 유틸리티 ====================
function cookieGet(name) {
var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
}
function cookieRemove(name) {
var cookie = name + '=; path=/; max-age=0';
if (window.location.hostname.includes('technicalkorea.net')) {
cookie += '; domain=.technicalkorea.net';
}
document.cookie = cookie;
}
/**
* SSO 토큰 가져오기 (쿠키 우선, localStorage 폴백)
* sso_token이 없으면 기존 token도 확인 (하위 호환)
*/
window.getSSOToken = function() {
return cookieGet('sso_token') || localStorage.getItem('sso_token');
};
/**
* SSO 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
*/
window.getSSOUser = function() {
var raw = cookieGet('sso_user') || localStorage.getItem('sso_user');
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
};
/**
* 중앙 로그인 URL 반환
*/
window.getLoginUrl = function() {
var hostname = window.location.hostname;
if (hostname.includes('technicalkorea.net')) {
return window.location.protocol + '//tkfb.technicalkorea.net/dashboard?redirect=' + encodeURIComponent(window.location.href);
}
// 개발 환경: tkds 포트 (30780)
return window.location.protocol + '//' + hostname + ':30780/dashboard?redirect=' + encodeURIComponent(window.location.href);
};
/**
* SSO 토큰 및 사용자 정보 삭제
*/
window.clearSSOAuth = function() {
cookieRemove('sso_token');
cookieRemove('sso_user');
cookieRemove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) {
localStorage.removeItem(k);
});
};
// ==================== 보안 유틸리티 (XSS 방지) ====================
/**
* HTML 특수문자 이스케이프 (XSS 방지)
*/
window.escapeHtml = function(str) {
if (str === null || str === undefined) return '';
if (typeof str !== 'string') str = String(str);
var htmlEntities = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
return str.replace(/[&<>"'`=\/]/g, function(char) {
return htmlEntities[char];
});
};
/**
* URL 파라미터 이스케이프
*/
window.escapeUrl = function(str) {
if (str === null || str === undefined) return '';
return encodeURIComponent(String(str));
};
// ==================== API 설정 ====================
var API_PORT = 30005;
var API_PATH = '/api';
function getApiBaseUrl() {
var hostname = window.location.hostname;
var protocol = window.location.protocol;
// 프로덕션 환경 (technicalkorea.net 도메인) - 같은 도메인의 /api 경로
if (hostname.includes('technicalkorea.net')) {
return protocol + '//' + hostname + API_PATH;
}
// 개발 환경 (localhost 또는 IP)
return protocol + '//' + hostname + ':' + API_PORT + API_PATH;
}
// 전역 API 설정
var apiUrl = getApiBaseUrl();
window.API_BASE_URL = apiUrl;
window.API = apiUrl; // 이전 호환성
// 인증 헤더 생성 (쿠키/localStorage에서 토큰 읽기)
window.getAuthHeaders = function() {
var token = window.getSSOToken();
return {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
};
};
// API 호출 헬퍼
window.apiCall = async function(endpoint, method, data) {
method = method || 'GET';
var url = window.API_BASE_URL + endpoint;
var config = {
method: method,
headers: window.getAuthHeaders(),
cache: 'no-store'
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
config.body = JSON.stringify(data);
}
var response = await fetch(url, config);
// 401 Unauthorized 처리
if (response.status === 401) {
window.clearSSOAuth();
window.location.href = window.getLoginUrl() + '&logout=1';
throw new Error('인증이 만료되었습니다.');
}
return response.json();
};
// ==================== 공통 유틸리티 ====================
/**
* Toast 알림 표시
*/
window.showToast = function(message, type, duration) {
type = type || 'info';
duration = duration || 3000;
var container = document.getElementById('toastContainer');
if (!container) {
container = document.createElement('div');
container.id = 'toastContainer';
container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;display:flex;flex-direction:column;gap:10px;';
document.body.appendChild(container);
}
if (!document.getElementById('toastStyles')) {
var style = document.createElement('style');
style.id = 'toastStyles';
style.textContent =
'.toast{display:flex;align-items:center;gap:12px;padding:12px 20px;background:#fff;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);opacity:0;transform:translateX(100px);transition:all .3s ease;min-width:250px;max-width:400px}' +
'.toast.show{opacity:1;transform:translateX(0)}' +
'.toast-success{border-left:4px solid #10b981}.toast-error{border-left:4px solid #ef4444}' +
'.toast-warning{border-left:4px solid #f59e0b}.toast-info{border-left:4px solid #3b82f6}' +
'.toast-icon{font-size:20px}.toast-message{font-size:14px;color:#374151}';
document.head.appendChild(style);
}
var iconMap = { success: '\u2705', error: '\u274C', warning: '\u26A0\uFE0F', info: '\u2139\uFE0F' };
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.innerHTML = '<span class="toast-icon">' + (iconMap[type] || '\u2139\uFE0F') + '</span><span class="toast-message">' + escapeHtml(message) + '</span>';
container.appendChild(toast);
setTimeout(function() { toast.classList.add('show'); }, 10);
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, duration);
};
/**
* 날짜를 YYYY-MM-DD 형식으로 변환
*/
window.formatDate = function(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) return dateString;
var d = new Date(dateString);
if (isNaN(d.getTime())) return '';
var y = d.getFullYear();
var m = String(d.getMonth() + 1).padStart(2, '0');
var day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
};
/**
* apiCall이 로드될 때까지 대기
*/
window.waitForApi = function(timeout) {
timeout = timeout || 5000;
return new Promise(function(resolve, reject) {
if (window.apiCall) return resolve();
var elapsed = 0;
var iv = setInterval(function() {
elapsed += 50;
if (window.apiCall) { clearInterval(iv); resolve(); }
else if (elapsed >= timeout) { clearInterval(iv); reject(new Error('apiCall timeout')); }
}, 50);
});
};
/**
* UUID v4 생성
*/
window.generateUUID = function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
console.log('API 설정 완료:', window.API_BASE_URL);
})();

View File

@@ -0,0 +1,205 @@
// api-config.js - nginx 프록시 대응 API 설정
import { config } from './config.js';
import { redirectToLogin } from './navigation.js';
function getApiBaseUrl() {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
// 🔗 외부 도메인 (Cloudflare Tunnel) - Gateway nginx가 /api/를 프록시
if (hostname.includes('technicalkorea.net')) {
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
return baseUrl;
}
// 🔗 로컬/내부 네트워크 - API 포트 직접 접근
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.') ||
hostname === 'localhost' || hostname === '127.0.0.1' ||
hostname.includes('.local') || hostname.includes('hyungi')) {
const baseUrl = `${protocol}//${hostname}:${config.api.port}${config.api.path}`;
return baseUrl;
}
// 🚨 기타: 포트 없이 상대 경로
const baseUrl = `${protocol}//${hostname}${config.api.path}`;
return baseUrl;
}
// API 설정
const API_URL = getApiBaseUrl();
// 전역 변수로 설정 (api-base.js가 이미 설정한 경우 유지)
if (!window.API) window.API = API_URL;
if (!window.API_BASE_URL) window.API_BASE_URL = API_URL;
function ensureAuthenticated() {
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined' || token === 'null') {
clearAuthData(); // 만약을 위해 한번 더 정리
redirectToLogin();
return false; // 이후 코드 실행 방지
}
// 토큰 만료 확인
if (isTokenExpired(token)) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
return false;
}
return token;
}
// 토큰 만료 확인 함수
function isTokenExpired(token) {
try {
const b = atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'));
const payload = JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0))));
const currentTime = Math.floor(Date.now() / 1000);
return payload.exp < currentTime;
} catch (error) {
console.error('토큰 파싱 오류:', error);
return true; // 파싱 실패 시 만료된 것으로 간주
}
}
// 인증 데이터 정리 함수
function clearAuthData() {
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
// SSO 쿠키도 삭제 (로그인 페이지 자동 리다이렉트 방지)
var cookieDomain = window.location.hostname.includes('technicalkorea.net')
? '; domain=.technicalkorea.net' : '';
document.cookie = 'sso_token=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_user=; path=/; max-age=0' + cookieDomain;
document.cookie = 'sso_refresh_token=; path=/; max-age=0' + cookieDomain;
}
function getAuthHeaders() {
const token = localStorage.getItem('sso_token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
}
// 🔧 개선된 API 호출 함수 (에러 처리 강화)
async function apiCall(url, method = 'GET', data = null) {
// 상대 경로를 절대 경로로 변환
const fullUrl = url.startsWith('http') ? url : `${API}${url}`;
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
}
};
// POST/PUT 요청시 데이터 추가
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(fullUrl, options);
// 인증 만료 처리
if (response.status === 401) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
throw new Error('인증에 실패했습니다.');
}
// 응답 실패 처리
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
// 에러 메시지 추출 (여러 형식 지원)
if (typeof errorData === 'string') {
errorMessage = errorData;
} else if (errorData.error) {
errorMessage = typeof errorData.error === 'string'
? errorData.error
: JSON.stringify(errorData.error);
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.details) {
errorMessage = errorData.details;
} else {
errorMessage = `HTTP ${response.status}: ${JSON.stringify(errorData)}`;
}
} else {
const errorText = await response.text();
errorMessage = errorText || errorMessage;
}
} catch (e) {
// 파싱 실패해도 HTTP 상태 코드는 전달
}
throw new Error(errorMessage);
}
const result = await response.json();
return result;
} catch (error) {
// 네트워크 오류 vs 서버 오류 구분
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('네트워크 연결 오류입니다. 인터넷 연결을 확인해주세요.');
}
throw error;
}
}
// API 헬퍼 함수들
async function apiGet(url) {
return apiCall(url, 'GET');
}
async function apiPost(url, data) {
return apiCall(url, 'POST', data);
}
async function apiPut(url, data) {
return apiCall(url, 'PUT', data);
}
async function apiDelete(url) {
return apiCall(url, 'DELETE');
}
// 전역 함수로 설정 (api-base.js가 이미 등록한 것은 덮어쓰지 않음)
window.ensureAuthenticated = ensureAuthenticated;
if (!window.getAuthHeaders) window.getAuthHeaders = getAuthHeaders;
if (!window.apiCall) window.apiCall = apiCall;
window.apiGet = apiGet;
window.apiPost = apiPost;
window.apiPut = apiPut;
window.apiDelete = apiDelete;
window.isTokenExpired = isTokenExpired;
if (!window.clearAuthData) window.clearAuthData = clearAuthData;
// 주기적으로 토큰 만료 확인 (5분마다)
setInterval(() => {
const token = localStorage.getItem('sso_token');
if (token && isTokenExpired(token)) {
clearAuthData();
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
redirectToLogin();
}
}, config.app.tokenRefreshInterval);
// ES6 모듈 export
export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
import { API, getAuthHeaders } from '/js/api-config.js';
const yearSel = document.getElementById('year');
const monthSel = document.getElementById('month');
const container = document.getElementById('attendanceTableContainer');
const holidays = [
'2025-01-01','2025-01-27','2025-01-28','2025-01-29','2025-01-30','2025-01-31',
'2025-03-01','2025-03-03','2025-05-01','2025-05-05','2025-05-06',
'2025-06-03','2025-06-06','2025-08-15','2025-10-03','2025-10-09','2025-12-25'
];
const leaveDefaults = {
'김두수':16,'임영규':16,'반치원':16,'황인용':16,'표영진':15,
'김윤섭':16,'이창호':16,'최광욱':16,'박현수':14,'조윤호':0
};
let workers = [];
// ✅ 셀렉트 박스 옵션 + 기본 선택 추가
function fillSelectOptions() {
const currentY = new Date().getFullYear();
const currentM = String(new Date().getMonth() + 1).padStart(2, '0');
for (let y = currentY; y <= currentY + 5; y++) {
const selected = y === currentY ? 'selected' : '';
yearSel.insertAdjacentHTML('beforeend', `<option value="${y}" ${selected}>${y}</option>`);
}
for (let m = 1; m <= 12; m++) {
const mm = String(m).padStart(2, '0');
const selected = mm === currentM ? 'selected' : '';
monthSel.insertAdjacentHTML('beforeend', `<option value="${mm}" ${selected}>${m}월</option>`);
}
}
// ✅ 작업자 목록 불러오기
async function fetchWorkers() {
try {
const res = await fetch(`${API}/workers`, { headers: getAuthHeaders() });
const allWorkers = await res.json();
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
workers.sort((a, b) => a.user_id - b.user_id);
} catch (err) {
alert('작업자 불러오기 실패');
}
}
// ✅ 출근부 불러오기 (해당 연도 전체)
async function loadAttendance() {
const year = yearSel.value;
const month = monthSel.value;
if (!year || !month) return alert('연도와 월을 선택하세요');
const lastDay = new Date(+year, +month, 0).getDate();
const start = `${year}-01-01`;
const end = `${year}-12-31`;
try {
const res = await fetch(`${API}/workreports?start=${start}&end=${end}`, {
headers: getAuthHeaders()
});
const data = await res.json();
renderTable(data, year, month, lastDay);
} catch (err) {
alert('출근부 로딩 실패');
}
}
// ✅ 테이블 렌더링
function renderTable(data, year, month, lastDay) {
container.innerHTML = '';
const weekdays = ['일','월','화','수','목','금','토'];
const tbl = document.createElement('table');
// ⬆️ 헤더 구성
let thead = `<thead><tr><th rowspan="2">작업자</th>`;
for (let d = 1; d <= lastDay; d++) thead += `<th>${d}</th>`;
thead += `<th class="divider" rowspan="2">잔업합계</th><th rowspan="2">사용연차</th><th rowspan="2">잔여연차</th></tr><tr>`;
for (let d = 1; d <= lastDay; d++) {
const dow = new Date(+year, +month - 1, d).getDay();
thead += `<th>${weekdays[dow]}</th>`;
}
thead += '</tr></thead>';
tbl.innerHTML = thead;
// ⬇️ 본문
workers.forEach(w => {
// ✅ 월간 데이터 (표에 표시용)
const recsThisMonth = data.filter(r =>
r.user_id === w.user_id &&
new Date(r.date).getFullYear() === +year &&
new Date(r.date).getMonth() + 1 === +month
);
// ✅ 연간 데이터 (연차 계산용)
const recsThisYear = data.filter(r =>
r.user_id === w.user_id &&
new Date(r.date).getFullYear() === +year
);
let otSum = 0;
let row = `<tr><td>${w.worker_name}</td>`;
for (let d = 1; d <= lastDay; d++) {
const dd = String(d).padStart(2, '0');
const date = `${year}-${month}-${dd}`;
const rec = recsThisMonth.find(r => {
const rDate = new Date(r.date);
const yyyy = rDate.getFullYear();
const mm = String(rDate.getMonth() + 1).padStart(2, '0');
const dd = String(rDate.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}` === date;
});
const dow = new Date(+year, +month - 1, d).getDay();
const isWe = dow === 0 || dow === 6;
const isHo = holidays.includes(date);
let txt = '', cls = '';
if (rec) {
const ot = +rec.overtime_hours || 0;
if (ot > 0) {
txt = ot; cls = 'overtime-cell'; otSum += ot;
} else if (rec.work_details) {
const d = rec.work_details;
if (['연차','반차','반반차','조퇴'].includes(d)) {
txt = d; cls = 'leave';
} else if (d === '유급') {
txt = d; cls = 'paid-leave';
} else if (d === '휴무') {
txt = d; cls = 'holiday';
} else {
txt = d;
}
}
} else {
txt = (isWe || isHo) ? '휴무' : '';
cls = (isWe || isHo) ? 'holiday' : 'no-data';
}
row += `<td class="${cls}">${txt}</td>`;
}
const usedTot = recsThisYear
.filter(r => ['연차','반차','반반차','조퇴'].includes(r.work_details))
.reduce((s, r) => s + (
r.work_details === '연차' ? 1 :
r.work_details === '반차' ? 0.5 :
r.work_details === '반반차' ? 0.25 : 0.75
), 0);
const remain = (leaveDefaults[w.worker_name] || 0) - usedTot;
row += `<td class="divider overtime-sum">${otSum.toFixed(1)}</td>`;
row += `<td>${usedTot.toFixed(2)}</td><td>${remain.toFixed(2)}</td></tr>`;
row += `<tr class="separator"><td colspan="${lastDay + 4}"></td></tr>`;
tbl.insertAdjacentHTML('beforeend', row);
});
container.appendChild(tbl);
}
// ✅ 초기 로딩
fillSelectOptions();
fetchWorkers().then(() => {
loadAttendance(); // 자동 조회
});
document.getElementById('loadAttendance').addEventListener('click', loadAttendance);

View File

@@ -0,0 +1,55 @@
// js/auth.js
/**
* JWT 토큰을 디코딩하여 페이로드(내용)를 반환합니다.
* @param {string} token - JWT 토큰
* @returns {object|null} - 디코딩된 페이로드 객체 또는 파싱 실패 시 null
*/
export function parseJwt(token) {
try {
const b = atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(b, c => c.charCodeAt(0))));
} catch (e) {
console.error("잘못된 토큰입니다.", e);
return null;
}
}
/**
* 인증 토큰을 가져옵니다 (쿠키 → localStorage 폴백).
*/
export function getToken() {
if (window.getSSOToken) return window.getSSOToken();
return null;
}
/**
* 사용자 정보를 가져옵니다 (쿠키 → localStorage 폴백).
*/
export function getUser() {
if (window.getSSOUser) return window.getSSOUser();
return null;
}
/**
* 로그인 성공 후 토큰과 사용자 정보를 저장합니다.
* sso_token/sso_user 키로 저장합니다.
*/
export function saveAuthData(token, user) {
// 쿠키 기반 인증 — localStorage 저장 불필요 (gateway에서 쿠키 설정)
}
/**
* 로그아웃 시 인증 정보를 제거합니다.
*/
export function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
}
/**
* 현재 사용자가 로그인 상태인지 확인합니다.
*/
export function isLoggedIn() {
const token = getToken();
return !!token && token !== 'undefined' && token !== 'null';
}

View File

@@ -0,0 +1,59 @@
// ✅ /js/calendar.js
export function renderCalendar(containerId, onDateSelect) {
const container = document.getElementById(containerId);
if (!container) return;
let currentDate = new Date();
let selectedDateStr = '';
function drawCalendar(date) {
container.innerHTML = '';
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1).getDay();
const lastDate = new Date(year, month + 1, 0).getDate();
const nav = document.createElement('div');
nav.className = 'nav';
const prev = document.createElement('button');
prev.textContent = '◀';
prev.addEventListener('click', () => {
currentDate = new Date(year, month - 1, 1);
drawCalendar(currentDate);
});
const title = document.createElement('div');
title.innerHTML = `<strong>${year}${month + 1}월</strong>`;
const next = document.createElement('button');
next.textContent = '▶';
next.addEventListener('click', () => {
currentDate = new Date(year, month + 1, 1);
drawCalendar(currentDate);
});
nav.append(prev, title, next);
container.appendChild(nav);
['일','월','화','수','목','금','토'].forEach(day => {
const el = document.createElement('div');
el.innerHTML = `<strong>${day}</strong>`;
container.appendChild(el);
});
for (let i = 0; i < firstDay; i++) container.appendChild(document.createElement('div'));
for (let i = 1; i <= lastDate; i++) {
const btn = document.createElement('button');
const ymd = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
btn.textContent = i;
btn.className = (ymd === selectedDateStr) ? 'selected-date' : '';
btn.addEventListener('click', () => {
selectedDateStr = ymd;
drawCalendar(currentDate);
onDateSelect(ymd);
});
container.appendChild(btn);
}
}
drawCalendar(currentDate);
}

View File

@@ -0,0 +1,130 @@
// js/change-password.js — 비밀번호 변경 (일반 스크립트, tkfb-core.js 전역 함수 사용)
(function() {
var form = document.getElementById('changePasswordForm');
var messageArea = document.getElementById('message-area');
var submitBtn = document.getElementById('submitBtn');
var resetBtn = document.getElementById('resetBtn');
if (!form) return;
// 비밀번호 토글
document.querySelectorAll('.password-toggle').forEach(function(button) {
button.addEventListener('click', function() {
var input = document.getElementById(this.getAttribute('data-target'));
if (input) {
var isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
this.textContent = isPassword ? '숨기기' : '보기';
}
});
});
// 초기화
if (resetBtn) resetBtn.addEventListener('click', function() {
form.reset();
messageArea.innerHTML = '';
var s = document.getElementById('passwordStrength');
if (s) s.innerHTML = '';
});
function showMessage(type, msg) {
messageArea.innerHTML = '<div class="message-box ' + type + '">' +
(type === 'error' ? '&#10060; ' : '&#9989; ') + msg + '</div>';
if (type === 'error') setTimeout(function() { messageArea.innerHTML = ''; }, 5000);
}
// 비밀번호 강도 체크
var strengthTimer;
var newPwInput = document.getElementById('newPassword');
if (newPwInput) newPwInput.addEventListener('input', function() {
clearTimeout(strengthTimer);
var pw = this.value;
strengthTimer = setTimeout(function() {
if (!pw) { document.getElementById('passwordStrength').innerHTML = ''; return; }
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
fetch('/api/auth/check-password-strength', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ password: pw })
}).then(function(r) { return r.json(); }).then(function(result) {
if (!result.success) return;
var d = result.data;
var colors = { weak: '#f44336', medium: '#ffc107', strong: '#4caf50' };
var labels = { weak: '약함', medium: '보통', strong: '강함' };
var pct = (d.score / 5) * 100;
document.getElementById('passwordStrength').innerHTML =
'<div style="margin-top:10px"><div style="display:flex;justify-content:space-between;margin-bottom:4px">' +
'<span style="font-size:0.85rem;color:' + (colors[d.level]||'#ccc') + ';font-weight:500">' + (labels[d.level]||'') + '</span>' +
'<span style="font-size:0.8rem;color:#666">' + d.score + '/5</span></div>' +
'<div style="height:6px;background:#e0e0e0;border-radius:3px;overflow:hidden">' +
'<div style="width:' + pct + '%;height:100%;background:' + (colors[d.level]||'#ccc') + ';transition:all 0.3s"></div></div></div>';
}).catch(function() {});
}, 300);
});
// 폼 제출
form.addEventListener('submit', function(e) {
e.preventDefault();
messageArea.innerHTML = '';
var currentPassword = document.getElementById('currentPassword').value;
var newPassword = document.getElementById('newPassword').value;
var confirmPassword = document.getElementById('confirmPassword').value;
if (!currentPassword || !newPassword || !confirmPassword) {
showMessage('error', '모든 필드를 입력해주세요.');
return;
}
if (newPassword !== confirmPassword) {
showMessage('error', '새 비밀번호가 일치하지 않습니다.');
return;
}
if (newPassword.length < 6) {
showMessage('error', '비밀번호는 최소 6자 이상이어야 합니다.');
return;
}
if (currentPassword === newPassword) {
showMessage('error', '새 비밀번호는 현재 비밀번호와 달라야 합니다.');
return;
}
var originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '처리 중...';
var token = (window.getSSOToken && window.getSSOToken()) || localStorage.getItem('sso_token') || '';
fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ currentPassword: currentPassword, newPassword: newPassword })
}).then(function(res) {
return res.json().then(function(data) { return { ok: res.ok, data: data }; });
}).then(function(result) {
if (result.ok && result.data.success) {
showMessage('success', '비밀번호가 변경되었습니다.');
form.reset();
var s = document.getElementById('passwordStrength');
if (s) s.innerHTML = '';
var countdown = 3;
var interval = setInterval(function() {
showMessage('success', '비밀번호가 변경되었습니다. ' + countdown + '초 후 로그인 페이지로 이동합니다.');
countdown--;
if (countdown < 0) {
clearInterval(interval);
// 쿠키 + localStorage 전부 삭제 (doLogout 로직 재사용)
_cookieRemove('sso_token'); _cookieRemove('sso_user'); _cookieRemove('sso_refresh_token');
['sso_token','sso_user','sso_refresh_token','token','user','access_token','currentUser','current_user','userInfo','userPageAccess'].forEach(function(k) { localStorage.removeItem(k); });
window.location.href = (window.getLoginUrl ? window.getLoginUrl() : '/login') + '&logout=1';
}
}, 1000);
} else {
showMessage('error', result.data.message || result.data.error || '비밀번호 변경에 실패했습니다.');
}
}).catch(function() {
showMessage('error', '서버와의 연결에 실패했습니다. 잠시 후 다시 시도해주세요.');
}).finally(function() {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
})();

View File

@@ -0,0 +1,56 @@
/**
* BaseState - 상태 관리 베이스 클래스
* TbmState / DailyWorkReportState 공통 패턴
*/
class BaseState {
constructor() {
this.listeners = new Map();
}
/**
* 상태 업데이트 + 리스너 알림
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[${this.constructor.name}] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 (localStorage)
*/
getUser() {
const userInfo = localStorage.getItem('sso_user');
return userInfo ? JSON.parse(userInfo) : null;
}
}
// 전역 노출
window.BaseState = BaseState;
console.log('[Module] common/base-state.js 로드 완료');

View File

@@ -0,0 +1,144 @@
/**
* Common Utilities
* TBM/작업보고 공통 유틸리티 함수
*/
class CommonUtils {
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
getTodayKST() {
const now = new Date();
const kstOffset = 9 * 60;
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kstTime = new Date(utc + (kstOffset * 60000));
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜를 YYYY-MM-DD 형식으로 변환 (문자열 또는 Date 객체)
*/
formatDate(date) {
if (!date) return '';
// 이미 YYYY-MM-DD 형식이면 그대로 반환
if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return date;
}
const dateObj = date instanceof Date ? date : new Date(date);
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 요일 반환 (일/월/화/수/목/금/토)
*/
getDayOfWeek(date) {
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date instanceof String || typeof date === 'string' ? date + 'T00:00:00' : date);
return dayNames[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
return this.formatDate(date) === this.getTodayKST();
}
/**
* UUID v4 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 디바운스
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀
*/
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* 객체 깊은 복사
*/
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 빈 값 확인
*/
isEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/**
* 배열 그룹화
*/
groupBy(array, key) {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {});
}
}
// 전역 인스턴스 생성
window.CommonUtils = new CommonUtils();
console.log('[Module] common/utils.js 로드 완료');

View File

@@ -0,0 +1,42 @@
// /js/config.js
// ES6 모듈을 사용하여 설정을 내보냅니다.
// 이 파일을 통해 프로젝트의 모든 하드코딩된 값을 관리합니다.
export const config = {
// API 관련 설정
api: {
// 로컬 개발 및 Docker 환경에서 사용하는 API 서버 포트
port: 30005,
// API의 기본 경로
path: '/api',
},
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
loginPage: '/login',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들
defaultDashboard: '/pages/dashboard.html',
systemDashboard: '/pages/dashboard.html',
groupLeaderDashboard: '/pages/dashboard.html',
},
// 공용 컴포넌트 경로 설정
components: {
// 사이드바 HTML 파일 경로 (구버전)
sidebar: '/components/sidebar.html',
// 새 사이드바 네비게이션 (카테고리별)
'sidebar-nav': '/components/sidebar-nav.html',
// 네비게이션 바 HTML 파일 경로
navbar: '/components/navbar.html',
},
// 애플리케이션 관련 기타 설정
app: {
// 토큰 만료 확인 주기 (밀리초 단위, 예: 5분)
tokenRefreshInterval: 5 * 60 * 1000,
}
};

View File

@@ -0,0 +1,71 @@
// /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.user_id)) {
workerMap.set(r.user_id, { user_id: r.user_id, worker_name: r.worker_name });
}
});
workers = Array.from(workerMap.values());
} else {
// 보고서가 없으면 전체 작업자 목록을 가져옵니다.
const allWorkers = await apiGet('/workers');
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
}
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;
}
}

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.user_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,
user_ids: selectedWorkers, // user_id -> user_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

@@ -0,0 +1,89 @@
// /js/daily-issue.js
import { getInitialData, getWorkersByDate, createIssueReport } from './daily-issue-api.js';
import {
populateProjects,
populateIssueTypes,
populateTimeOptions,
renderWorkerList,
getFormData,
setSubmitButtonState
} from './daily-issue-ui.js';
const dateSelect = document.getElementById('dateSelect');
const form = document.getElementById('issueForm');
/**
* 날짜가 변경될 때마다 해당 날짜의 작업자 목록을 다시 불러옵니다.
*/
async function handleDateChange() {
const selectedDate = dateSelect.value;
if (!selectedDate) {
document.getElementById('workerList').textContent = '날짜를 먼저 선택하세요.';
return;
}
document.getElementById('workerList').textContent = '작업자 목록을 불러오는 중...';
try {
const workers = await getWorkersByDate(selectedDate);
renderWorkerList(workers);
} catch (error) {
document.getElementById('workerList').textContent = '작업자 목록 로딩에 실패했습니다.';
}
}
/**
* 폼 제출 이벤트를 처리합니다.
*/
async function handleSubmit(event) {
event.preventDefault();
const issueData = getFormData();
if (!issueData) return; // 유효성 검사 실패
setSubmitButtonState(true);
try {
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 (error) {
alert(`🚨 등록 실패: ${error.message}`);
} finally {
setSubmitButtonState(false);
}
}
/**
* 페이지 초기화 함수
*/
async function initializePage() {
// 오늘 날짜 기본 설정
dateSelect.value = new Date().toISOString().split('T')[0];
populateTimeOptions();
// 프로젝트, 이슈유형, 작업자 목록을 병렬로 로드
try {
const [initialData] = await Promise.all([
getInitialData(),
handleDateChange() // 초기 작업자 목록 로드
]);
populateProjects(initialData.projects);
populateIssueTypes(initialData.issueTypes);
} catch (error) {
alert('페이지 초기화 중 오류가 발생했습니다. 새로고침 해주세요.');
}
// 이벤트 리스너 설정
dateSelect.addEventListener('change', handleDateChange);
form.addEventListener('submit', handleSubmit);
}
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
/**
* daily-status.js — 일별 TBM/작업보고서 입력 현황 대시보드
* Sprint 002 Section B
*/
// ===== Mock 설정 =====
const MOCK_ENABLED = false;
const MOCK_DATA = {
success: true,
data: {
date: '2026-03-30',
summary: {
total_active_workers: 45, tbm_completed: 38, tbm_missing: 7,
report_completed: 35, report_missing: 10, both_completed: 33, both_missing: 5
},
workers: [
{ user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
{ user_id: 22, worker_name: '이영희', job_type: '배관', department_name: '생산2팀', has_tbm: true, has_report: false, tbm_session_id: 140, total_report_hours: 0, status: 'tbm_only', proxy_history: null },
{ user_id: 30, worker_name: '박민수', job_type: '전기', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 141, total_report_hours: 8, status: 'complete', proxy_history: { proxy_by: '관리자', proxy_at: '2026-03-30T14:30:00' } },
{ user_id: 35, worker_name: '정대호', job_type: '도장', department_name: '생산2팀', has_tbm: false, has_report: true, tbm_session_id: null, total_report_hours: 8, status: 'report_only', proxy_history: null },
{ user_id: 40, worker_name: '최윤서', job_type: '용접', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 142, total_report_hours: 9, status: 'complete', proxy_history: null },
{ user_id: 41, worker_name: '한지민', job_type: '사상', department_name: '생산2팀', has_tbm: false, has_report: false, tbm_session_id: null, total_report_hours: 0, status: 'both_missing', proxy_history: null },
{ user_id: 42, worker_name: '송민호', job_type: '절단', department_name: '생산1팀', has_tbm: true, has_report: true, tbm_session_id: 143, total_report_hours: 8, status: 'complete', proxy_history: null },
]
}
};
const MOCK_DETAIL = {
success: true,
data: {
worker: { user_id: 15, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
tbm_sessions: [],
work_reports: [],
proxy_history: []
}
};
// ===== State =====
let currentDate = new Date();
let workers = [];
let currentFilter = 'all';
let selectedWorkerId = null;
const DAYS_KR = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
const ALLOWED_ROLES = ['support_team', 'admin', 'system'];
// ===== Init =====
document.addEventListener('DOMContentLoaded', async () => {
// URL 파라미터에서 날짜 가져오기
const urlDate = new URLSearchParams(location.search).get('date');
if (urlDate) currentDate = new Date(urlDate + 'T00:00:00');
// 권한 체크 (initAuth 완료 후)
setTimeout(() => {
const user = window.currentUser;
if (user && !ALLOWED_ROLES.includes(user.role)) {
document.getElementById('workerList').classList.add('hidden');
document.getElementById('bottomAction').classList.add('hidden');
document.getElementById('noPermission').classList.remove('hidden');
return;
}
loadStatus();
}, 500);
});
// ===== Date Navigation =====
function formatDateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function updateDateDisplay() {
const str = formatDateStr(currentDate);
document.getElementById('dateText').textContent = str;
document.getElementById('dayText').textContent = DAYS_KR[currentDate.getDay()];
// 미래 날짜 비활성
const today = new Date();
today.setHours(0, 0, 0, 0);
const nextBtn = document.getElementById('nextDate');
nextBtn.disabled = currentDate >= today;
}
function changeDate(delta) {
currentDate.setDate(currentDate.getDate() + delta);
updateDateDisplay();
loadStatus();
}
function openDatePicker() {
const picker = document.getElementById('datePicker');
picker.value = formatDateStr(currentDate);
picker.max = formatDateStr(new Date());
picker.showPicker ? picker.showPicker() : picker.click();
}
function onDatePicked(val) {
if (!val) return;
currentDate = new Date(val + 'T00:00:00');
updateDateDisplay();
loadStatus();
}
// ===== Data Loading =====
async function loadStatus() {
const listEl = document.getElementById('workerList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
document.getElementById('emptyState').classList.add('hidden');
updateDateDisplay();
try {
let res;
if (MOCK_ENABLED) {
res = MOCK_DATA;
} else {
res = await window.apiCall('/proxy-input/daily-status?date=' + formatDateStr(currentDate));
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="ds-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
workers = res.data.workers || [];
updateSummary(res.data.summary || {});
updateFilterCounts();
renderWorkerList();
} catch (e) {
listEl.innerHTML = '<div class="ds-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류. 다시 시도해주세요.</p></div>';
}
}
function updateSummary(s) {
document.getElementById('totalCount').textContent = s.total_active_workers || 0;
document.getElementById('doneCount').textContent = s.both_completed || 0;
document.getElementById('missingCount').textContent = s.both_missing || 0;
const total = s.total_active_workers || 1;
document.getElementById('donePct').textContent = Math.round((s.both_completed || 0) / total * 100) + '%';
document.getElementById('missingPct').textContent = Math.round((s.both_missing || 0) / total * 100) + '%';
// 하단 버튼 카운트
const missingWorkers = workers.filter(w => w.status !== 'complete').length;
document.getElementById('proxyCount').textContent = missingWorkers;
document.getElementById('proxyBtn').disabled = missingWorkers === 0;
}
function updateFilterCounts() {
document.getElementById('filterAll').textContent = workers.length;
document.getElementById('filterComplete').textContent = workers.filter(w => w.status === 'complete').length;
document.getElementById('filterMissing').textContent = workers.filter(w => w.status === 'both_missing').length;
document.getElementById('filterPartial').textContent = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only').length;
}
// ===== Filter =====
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.ds-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.filter === f);
});
renderWorkerList();
}
// ===== Render =====
function renderWorkerList() {
const listEl = document.getElementById('workerList');
const emptyEl = document.getElementById('emptyState');
let filtered = workers;
if (currentFilter === 'complete') filtered = workers.filter(w => w.status === 'complete');
else if (currentFilter === 'both_missing') filtered = workers.filter(w => w.status === 'both_missing');
else if (currentFilter === 'partial') filtered = workers.filter(w => w.status === 'tbm_only' || w.status === 'report_only');
if (filtered.length === 0) {
listEl.innerHTML = '';
emptyEl.classList.remove('hidden');
return;
}
emptyEl.classList.add('hidden');
listEl.innerHTML = filtered.map(w => {
const tbmBadge = w.has_tbm
? '<span class="ds-badge-ok">TBM ✓</span>'
: '<span class="ds-badge-no">TBM ✗</span>';
const reportBadge = w.has_report
? `<span class="ds-badge-ok">보고서 ✓${w.total_report_hours ? ' ' + w.total_report_hours + 'h' : ''}</span>`
: '<span class="ds-badge-no">보고서 ✗</span>';
const isProxy = w.tbm_sessions?.some(t => t.is_proxy_input) || false;
const proxyBadge = isProxy
? '<span class="ds-badge-proxy">대리입력</span>'
: '';
return `
<div class="ds-worker-row" onclick="openSheet(${w.user_id})">
<div class="ds-status-dot ${w.status}"></div>
<div class="ds-worker-info">
<div class="ds-worker-name">${escHtml(w.worker_name)}</div>
<div class="ds-worker-dept">${escHtml(w.job_type)} · ${escHtml(w.department_name)}</div>
</div>
<div class="ds-worker-status">${tbmBadge}${reportBadge}${proxyBadge}</div>
</div>`;
}).join('');
}
function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// ===== Bottom Sheet =====
function openSheet(userId) {
selectedWorkerId = userId;
const w = workers.find(x => x.user_id === userId);
if (!w) return;
document.getElementById('sheetWorkerName').textContent = w.worker_name;
document.getElementById('sheetWorkerInfo').textContent = `${w.job_type} · ${w.department_name}`;
document.getElementById('sheetBody').innerHTML = '<div class="ds-sheet-loading"><i class="fas fa-spinner fa-spin"></i> 로딩 중...</div>';
document.getElementById('sheetOverlay').classList.remove('hidden');
document.getElementById('detailSheet').classList.remove('hidden');
setTimeout(() => document.getElementById('detailSheet').classList.add('open'), 10);
// 상세 데이터 로드
loadDetail(userId, w);
}
async function loadDetail(userId, workerBasic) {
const bodyEl = document.getElementById('sheetBody');
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_DETAIL));
res.data.worker = workerBasic;
// mock: complete 상태면 TBM/보고서 데이터 채우기
if (workerBasic.has_tbm) {
res.data.tbm_sessions = [{ session_id: workerBasic.tbm_session_id, session_date: formatDateStr(currentDate), status: 'completed', leader_name: '반장' }];
}
if (workerBasic.has_report) {
res.data.work_reports = [{ report_date: formatDateStr(currentDate), project_name: '프로젝트A', work_type_name: workerBasic.job_type, work_hours: workerBasic.total_report_hours }];
}
if (workerBasic.proxy_history) {
res.data.proxy_history = [workerBasic.proxy_history];
}
} else {
res = await window.apiCall('/proxy-input/daily-status/detail?date=' + formatDateStr(currentDate) + '&user_id=' + userId);
}
if (!res || !res.success) { bodyEl.innerHTML = '<div class="ds-sheet-card empty">상세 정보를 불러올 수 없습니다</div>'; return; }
const d = res.data;
let html = '';
// TBM 섹션
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-clipboard-check"></i> TBM</div>';
if (d.tbm_sessions && d.tbm_sessions.length > 0) {
html += d.tbm_sessions.map(s => {
const proxyTag = s.is_proxy_input ? ` · <span class="ds-badge-proxy">대리입력(${escHtml(s.proxy_input_by_name || '-')})</span>` : '';
return `<div class="ds-sheet-card">세션 #${s.session_id} · ${s.status === 'completed' ? '완료' : '진행중'} · 리더: ${escHtml(s.leader_name || '-')}${proxyTag}</div>`;
}).join('');
} else {
html += '<div class="ds-sheet-card empty">세션 없음</div>';
}
html += '</div>';
// 작업보고서 섹션
html += '<div class="ds-sheet-section"><div class="ds-sheet-section-title"><i class="fas fa-file-alt"></i> 작업보고서</div>';
if (d.work_reports && d.work_reports.length > 0) {
html += d.work_reports.map(r => `<div class="ds-sheet-card">${escHtml(r.project_name || '-')} · ${escHtml(r.work_type_name || '-')} · ${r.work_hours || 0}시간</div>`).join('');
} else {
html += '<div class="ds-sheet-card empty">보고서 없음</div>';
}
html += '</div>';
bodyEl.innerHTML = html;
// 완료 상태면 대리입력 버튼 숨김
const btn = document.getElementById('sheetProxyBtn');
btn.style.display = workerBasic.status === 'complete' ? 'none' : 'block';
} catch (e) {
bodyEl.innerHTML = '<div class="ds-sheet-card empty">네트워크 오류</div>';
}
}
function closeSheet() {
document.getElementById('detailSheet').classList.remove('open');
setTimeout(() => {
document.getElementById('sheetOverlay').classList.add('hidden');
document.getElementById('detailSheet').classList.add('hidden');
}, 300);
}
// ===== Navigation =====
function goProxyInput() {
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate);
}
function goProxyInputSingle() {
if (selectedWorkerId) {
location.href = '/pages/work/proxy-input.html?date=' + formatDateStr(currentDate) + '&user_id=' + selectedWorkerId;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
/**
* Daily Work Report - API Client
* 작업보고서 관련 모든 API 호출을 관리
*/
class DailyWorkReportAPI {
constructor() {
this.state = window.DailyWorkReportState;
console.log('[API] DailyWorkReportAPI 초기화');
}
/**
* 작업자 로드 (생산팀 소속)
*/
async loadWorkers() {
try {
console.log('[API] Workers 로딩 중...');
const data = await window.apiCall('/workers?limit=1000&department_id=1');
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 퇴사자만 제외
const filtered = allWorkers.filter(worker => worker.employment_status !== 'resigned');
this.state.workers = filtered;
console.log(`[API] Workers 로드 완료: ${filtered.length}`);
return filtered;
} catch (error) {
console.error('[API] 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
console.log('[API] Projects 로딩 중...');
const data = await window.apiCall('/projects/active/list');
const projects = Array.isArray(data) ? data : (data.data || data.projects || []);
this.state.projects = projects;
console.log(`[API] Projects 로드 완료: ${projects.length}`);
return projects;
} catch (error) {
console.error('[API] 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 작업 유형 로드
*/
async loadWorkTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workTypes = data;
console.log('[API] 작업 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 작업 유형 API 사용 불가, 기본값 사용');
this.state.workTypes = [
{ id: 1, name: 'Base' },
{ id: 2, name: 'Vessel' },
{ id: 3, name: 'Piping' }
];
return this.state.workTypes;
}
}
/**
* 업무 상태 유형 로드
*/
async loadWorkStatusTypes() {
try {
const data = await window.apiCall('/daily-work-reports/work-status-types');
if (Array.isArray(data) && data.length > 0) {
this.state.workStatusTypes = data;
console.log('[API] 업무 상태 유형 로드 완료:', data.length);
return data;
}
throw new Error('API 실패');
} catch (error) {
console.log('[API] 업무 상태 유형 API 사용 불가, 기본값 사용');
this.state.workStatusTypes = [
{ id: 1, name: '정상', is_error: false },
{ id: 2, name: '부적합', is_error: true }
];
return this.state.workStatusTypes;
}
}
/**
* 오류 유형 로드 (신고 카테고리/아이템)
*/
async loadErrorTypes() {
try {
// 1. 신고 카테고리 (nonconformity만)
const categoriesResponse = await window.apiCall('/work-issues/categories');
if (categoriesResponse.success && categoriesResponse.data) {
this.state.issueCategories = categoriesResponse.data.filter(
c => c.category_type === 'nonconformity'
);
console.log('[API] 신고 카테고리 로드:', this.state.issueCategories.length);
}
// 2. 신고 아이템 전체
const itemsResponse = await window.apiCall('/work-issues/items');
if (itemsResponse.success && itemsResponse.data) {
// nonconformity 카테고리의 아이템만 필터링
const nonconfCatIds = this.state.issueCategories.map(c => c.category_id);
this.state.issueItems = itemsResponse.data.filter(
item => nonconfCatIds.includes(item.category_id)
);
console.log('[API] 신고 아이템 로드:', this.state.issueItems.length);
}
// 레거시 호환: errorTypes에 카테고리 매핑
this.state.errorTypes = this.state.issueCategories.map(cat => ({
id: cat.category_id,
name: cat.category_name
}));
} catch (error) {
console.error('[API] 오류 유형 로딩 오류:', error);
// 기본값 설정
this.state.errorTypes = [
{ id: 1, name: '자재 부적합' },
{ id: 2, name: '도면 오류' },
{ id: 3, name: '장비 고장' }
];
}
}
/**
* 미완료 TBM 세션 로드
*/
async loadIncompleteTbms() {
try {
const response = await window.apiCall('/tbm/sessions/incomplete-reports');
if (!response.success) {
throw new Error(response.message || '미완료 TBM 조회 실패');
}
let data = response.data || [];
// 사용자 권한 확인 및 필터링
const user = this.state.getUser();
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
const userId = user.user_id;
data = data.filter(tbm => tbm.created_by === userId);
}
this.state.incompleteTbms = data;
console.log('[API] 미완료 TBM 로드 완료:', data.length);
return data;
} catch (error) {
console.error('[API] 미완료 TBM 로드 오류:', error);
throw error;
}
}
/**
* TBM 세션별 당일 신고 로드
*/
async loadDailyIssuesForTbms() {
const tbms = this.state.incompleteTbms;
if (!tbms || tbms.length === 0) {
console.log('[API] 미완료 TBM 없음, 신고 조회 건너뜀');
return;
}
// 고유한 날짜 수집
const uniqueDates = [...new Set(tbms.map(tbm => {
return window.DailyWorkReportUtils?.formatDateForApi(tbm.session_date) ||
this.formatDateForApi(tbm.session_date);
}).filter(Boolean))];
console.log('[API] 조회할 날짜들:', uniqueDates);
for (const dateStr of uniqueDates) {
if (this.state.dailyIssuesCache[dateStr]) {
console.log(`[API] 캐시 사용 (${dateStr})`);
continue;
}
try {
const response = await window.apiCall(`/work-issues?start_date=${dateStr}&end_date=${dateStr}`);
if (response.success) {
this.state.setDailyIssuesCache(dateStr, response.data || []);
console.log(`[API] 신고 로드 완료 (${dateStr}):`, this.state.dailyIssuesCache[dateStr].length);
} else {
this.state.setDailyIssuesCache(dateStr, []);
}
} catch (error) {
console.error(`[API] 신고 조회 오류 (${dateStr}):`, error);
this.state.setDailyIssuesCache(dateStr, []);
}
}
}
/**
* 완료된 작업보고서 조회
*/
async loadCompletedReports(date) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports?date=${date}`);
if (response.success) {
console.log(`[API] 완료 보고서 로드 (${date}):`, response.data?.length || 0);
return response.data || [];
}
throw new Error(response.message || '조회 실패');
} catch (error) {
console.error('[API] 완료 보고서 로드 오류:', error);
throw error;
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] TBM 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] TBM 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 수동 작업보고서 제출
*/
async submitManualWorkReport(reportData) {
try {
const response = await window.apiCall('/daily-work-reports/v2/reports', 'POST', reportData);
if (!response.success) {
throw new Error(response.message || '제출 실패');
}
console.log('[API] 수동 작업보고서 제출 완료:', response);
return response;
} catch (error) {
console.error('[API] 수동 작업보고서 제출 오류:', error);
throw error;
}
}
/**
* 작업보고서 삭제
*/
async deleteWorkReport(reportId) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'DELETE');
if (!response.success) {
throw new Error(response.message || '삭제 실패');
}
console.log('[API] 작업보고서 삭제 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 삭제 오류:', error);
throw error;
}
}
/**
* 작업보고서 수정
*/
async updateWorkReport(reportId, updateData) {
try {
const response = await window.apiCall(`/daily-work-reports/v2/reports/${reportId}`, 'PUT', updateData);
if (!response.success) {
throw new Error(response.message || '수정 실패');
}
console.log('[API] 작업보고서 수정 완료:', reportId);
return response;
} catch (error) {
console.error('[API] 작업보고서 수정 오류:', error);
throw error;
}
}
/**
* 신고 카테고리 추가
*/
async addIssueCategory(categoryData) {
try {
const response = await window.apiCall('/work-issues/categories', 'POST', categoryData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 카테고리 추가 오류:', error);
throw error;
}
}
/**
* 신고 아이템 추가
*/
async addIssueItem(itemData) {
try {
const response = await window.apiCall('/work-issues/items', 'POST', itemData);
if (response.success) {
await this.loadErrorTypes(); // 목록 새로고침
}
return response;
} catch (error) {
console.error('[API] 아이템 추가 오류:', error);
throw error;
}
}
/**
* 모든 기본 데이터 로드
*/
async loadAllData() {
console.log('[API] 모든 기본 데이터 로딩 시작...');
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadWorkTypes(),
this.loadWorkStatusTypes(),
this.loadErrorTypes()
]);
console.log('[API] 모든 기본 데이터 로딩 완료');
}
// 유틸리티: 날짜 형식 변환 (API 형식)
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
// 전역 인스턴스 생성
window.DailyWorkReportAPI = new DailyWorkReportAPI();
// 하위 호환성: 기존 함수들
window.loadWorkers = () => window.DailyWorkReportAPI.loadWorkers();
window.loadProjects = () => window.DailyWorkReportAPI.loadProjects();
window.loadWorkTypes = () => window.DailyWorkReportAPI.loadWorkTypes();
window.loadWorkStatusTypes = () => window.DailyWorkReportAPI.loadWorkStatusTypes();
window.loadErrorTypes = () => window.DailyWorkReportAPI.loadErrorTypes();
window.loadIncompleteTbms = () => window.DailyWorkReportAPI.loadIncompleteTbms();
window.loadDailyIssuesForTbms = () => window.DailyWorkReportAPI.loadDailyIssuesForTbms();
window.loadCompletedReports = () => window.DailyWorkReportAPI.loadCompletedReports(
document.getElementById('completedReportDate')?.value
);
// 통합 데이터 로드 함수
window.loadData = async () => {
try {
window.showMessage?.('데이터를 불러오는 중...', 'loading');
await window.DailyWorkReportAPI.loadAllData();
window.hideMessage?.();
} catch (error) {
console.error('[API] 데이터 로드 실패:', error);
window.showMessage?.('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error');
}
};

View File

@@ -0,0 +1,300 @@
/**
* Daily Work Report - State Manager
* 작업보고서 페이지의 전역 상태 관리 (BaseState 상속)
*/
class DailyWorkReportState extends BaseState {
constructor() {
super();
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
this.errorTypes = []; // 레거시 호환용
this.issueCategories = []; // 신고 카테고리 (nonconformity)
this.issueItems = []; // 신고 아이템
this.workers = [];
this.projects = [];
// UI 상태
this.selectedWorkers = new Set();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.currentTab = 'tbm';
// TBM 관련
this.incompleteTbms = [];
// 부적합 원인 관리
this.currentDefectIndex = null;
this.tempDefects = {}; // { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
this.selectedWorkplace = null;
this.selectedWorkplaceName = null;
this.selectedWorkplaceCategory = null;
this.selectedWorkplaceCategoryName = null;
// 시간 선택 관련
this.currentEditingField = null; // { index, type: 'total' | 'error' }
this.currentTimeValue = 0;
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
console.log('[State] DailyWorkReportState 초기화 완료');
}
/**
* 토큰에서 사용자 정보 추출
*/
getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
console.log('[State] 토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('sso_user');
if (userInfo) {
return JSON.parse(userInfo);
}
} catch (error) {
console.log('[State] localStorage에서 사용자 정보 가져오기 실패:', error);
}
return null;
}
/**
* 선택된 작업자 토글
*/
toggleWorkerSelection(workerId) {
if (this.selectedWorkers.has(workerId)) {
this.selectedWorkers.delete(workerId);
} else {
this.selectedWorkers.add(workerId);
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업자 전체 선택/해제
*/
selectAllWorkers(select = true) {
if (select) {
this.workers.forEach(w => this.selectedWorkers.add(w.user_id));
} else {
this.selectedWorkers.clear();
}
this.notifyListeners('selectedWorkers', this.selectedWorkers, null);
}
/**
* 작업 항목 카운터 증가
*/
incrementWorkEntryCounter() {
this.workEntryCounter++;
return this.workEntryCounter;
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 부적합 임시 저장소 초기화
*/
initTempDefects(index) {
if (!this.tempDefects[index]) {
this.tempDefects[index] = [];
}
}
/**
* 부적합 추가
*/
addTempDefect(index, defect) {
this.initTempDefects(index);
this.tempDefects[index].push(defect);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
/**
* 부적합 업데이트
*/
updateTempDefect(index, defectIndex, field, value) {
if (this.tempDefects[index] && this.tempDefects[index][defectIndex]) {
this.tempDefects[index][defectIndex][field] = value;
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 부적합 삭제
*/
removeTempDefect(index, defectIndex) {
if (this.tempDefects[index]) {
this.tempDefects[index].splice(defectIndex, 1);
this.notifyListeners('tempDefects', this.tempDefects, null);
}
}
/**
* 일일 이슈 캐시 설정
*/
setDailyIssuesCache(dateStr, issues) {
this.dailyIssuesCache[dateStr] = issues;
}
/**
* 일일 이슈 캐시 조회
*/
getDailyIssuesCache(dateStr) {
return this.dailyIssuesCache[dateStr] || [];
}
/**
* 상태 초기화
*/
reset() {
this.selectedWorkers.clear();
this.workEntryCounter = 0;
this.currentStep = 1;
this.editingWorkId = null;
this.tempDefects = {};
this.currentDefectIndex = null;
this.dailyIssuesCache = {};
}
/**
* 디버그 출력
*/
debug() {
console.log('[State] 현재 상태:', {
workTypes: this.workTypes.length,
workers: this.workers.length,
projects: this.projects.length,
selectedWorkers: this.selectedWorkers.size,
currentTab: this.currentTab,
incompleteTbms: this.incompleteTbms.length,
tempDefects: Object.keys(this.tempDefects).length
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportState = new DailyWorkReportState();
// 하위 호환성을 위한 전역 변수 프록시
const stateProxy = window.DailyWorkReportState;
// 기존 전역 변수들과 호환
Object.defineProperties(window, {
workTypes: {
get: () => stateProxy.workTypes,
set: (v) => { stateProxy.workTypes = v; }
},
workStatusTypes: {
get: () => stateProxy.workStatusTypes,
set: (v) => { stateProxy.workStatusTypes = v; }
},
errorTypes: {
get: () => stateProxy.errorTypes,
set: (v) => { stateProxy.errorTypes = v; }
},
issueCategories: {
get: () => stateProxy.issueCategories,
set: (v) => { stateProxy.issueCategories = v; }
},
issueItems: {
get: () => stateProxy.issueItems,
set: (v) => { stateProxy.issueItems = v; }
},
workers: {
get: () => stateProxy.workers,
set: (v) => { stateProxy.workers = v; }
},
projects: {
get: () => stateProxy.projects,
set: (v) => { stateProxy.projects = v; }
},
selectedWorkers: {
get: () => stateProxy.selectedWorkers,
set: (v) => { stateProxy.selectedWorkers = v; }
},
incompleteTbms: {
get: () => stateProxy.incompleteTbms,
set: (v) => { stateProxy.incompleteTbms = v; }
},
tempDefects: {
get: () => stateProxy.tempDefects,
set: (v) => { stateProxy.tempDefects = v; }
},
dailyIssuesCache: {
get: () => stateProxy.dailyIssuesCache,
set: (v) => { stateProxy.dailyIssuesCache = v; }
},
currentTab: {
get: () => stateProxy.currentTab,
set: (v) => { stateProxy.currentTab = v; }
},
currentStep: {
get: () => stateProxy.currentStep,
set: (v) => { stateProxy.currentStep = v; }
},
editingWorkId: {
get: () => stateProxy.editingWorkId,
set: (v) => { stateProxy.editingWorkId = v; }
},
workEntryCounter: {
get: () => stateProxy.workEntryCounter,
set: (v) => { stateProxy.workEntryCounter = v; }
},
currentDefectIndex: {
get: () => stateProxy.currentDefectIndex,
set: (v) => { stateProxy.currentDefectIndex = v; }
},
currentEditingField: {
get: () => stateProxy.currentEditingField,
set: (v) => { stateProxy.currentEditingField = v; }
},
currentTimeValue: {
get: () => stateProxy.currentTimeValue,
set: (v) => { stateProxy.currentTimeValue = v; }
},
selectedWorkplace: {
get: () => stateProxy.selectedWorkplace,
set: (v) => { stateProxy.selectedWorkplace = v; }
},
selectedWorkplaceName: {
get: () => stateProxy.selectedWorkplaceName,
set: (v) => { stateProxy.selectedWorkplaceName = v; }
},
selectedWorkplaceCategory: {
get: () => stateProxy.selectedWorkplaceCategory,
set: (v) => { stateProxy.selectedWorkplaceCategory = v; }
},
selectedWorkplaceCategoryName: {
get: () => stateProxy.selectedWorkplaceCategoryName,
set: (v) => { stateProxy.selectedWorkplaceCategoryName = v; }
}
});

View File

@@ -0,0 +1,299 @@
/**
* Daily Work Report - Utilities
* 작업보고서 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class DailyWorkReportUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[Utils] DailyWorkReportUtils 초기화');
}
// --- CommonUtils 위임 ---
getKoreaToday() { return this._common.getTodayKST(); }
formatDateForApi(date) { return this._common.formatDate(date); }
formatDate(date) { return this._common.formatDate(date) || '-'; }
getDayOfWeek(date) { return this._common.getDayOfWeek(date); }
isToday(date) { return this._common.isToday(date); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
debounce(func, wait) { return this._common.debounce(func, wait); }
throttle(func, limit) { return this._common.throttle(func, limit); }
deepClone(obj) { return this._common.deepClone(obj); }
isEmpty(value) { return this._common.isEmpty(value); }
groupBy(array, key) { return this._common.groupBy(array, key); }
// --- 작업보고 전용 ---
/**
* 시간 포맷팅 (HH:mm)
*/
formatTime(time) {
if (!time) return '-';
if (typeof time === 'string' && time.includes(':')) {
return time.substring(0, 5);
}
return time;
}
/**
* 상태 라벨 반환
*/
getStatusLabel(status) {
const labels = {
'pending': '접수',
'in_progress': '처리중',
'resolved': '해결',
'completed': '완료',
'closed': '종료'
};
return labels[status] || status || '-';
}
/**
* 숫자 포맷팅 (천 단위 콤마)
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('ko-KR');
}
/**
* 소수점 자리수 포맷팅
*/
formatDecimal(num, decimals = 1) {
if (num === null || num === undefined) return '0';
return Number(num).toFixed(decimals);
}
/**
* 두 날짜 사이 일수 계산
*/
daysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* 숫자 유효성 검사
*/
isValidNumber(value) {
return !isNaN(value) && isFinite(value);
}
/**
* 시간 유효성 검사 (0-24)
*/
isValidHours(hours) {
const num = parseFloat(hours);
return this.isValidNumber(num) && num >= 0 && num <= 24;
}
/**
* 쿼리 스트링 파싱
*/
parseQueryString(queryString) {
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
}
/**
* 쿼리 스트링 생성
*/
buildQueryString(params) {
return new URLSearchParams(params).toString();
}
/**
* 로컬 스토리지 안전하게 가져오기
*/
getLocalStorage(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error('[Utils] localStorage 읽기 오류:', error);
return defaultValue;
}
}
/**
* 로컬 스토리지 안전하게 저장하기
*/
setLocalStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error('[Utils] localStorage 저장 오류:', error);
return false;
}
}
/**
* 배열 정렬 (다중 키)
*/
sortBy(array, ...keys) {
return [...array].sort((a, b) => {
for (const key of keys) {
const direction = key.startsWith('-') ? -1 : 1;
const actualKey = key.replace(/^-/, '');
const aVal = a[actualKey];
const bVal = b[actualKey];
if (aVal < bVal) return -1 * direction;
if (aVal > bVal) return 1 * direction;
}
return 0;
});
}
}
// 전역 인스턴스 생성
window.DailyWorkReportUtils = new DailyWorkReportUtils();
// 하위 호환성: 기존 함수들
window.getKoreaToday = () => window.DailyWorkReportUtils.getKoreaToday();
window.formatDateForApi = (date) => window.DailyWorkReportUtils.formatDateForApi(date);
window.formatDate = (date) => window.DailyWorkReportUtils.formatDate(date);
window.getStatusLabel = (status) => window.DailyWorkReportUtils.getStatusLabel(status);
// 메시지 표시 함수들
window.showMessage = function(message, type = 'info') {
const container = document.getElementById('message-container');
if (!container) {
console.log(`[Message] ${type}: ${message}`);
return;
}
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => window.hideMessage(), 5000);
}
};
window.hideMessage = function() {
const container = document.getElementById('message-container');
if (container) {
container.innerHTML = '';
}
};
// 저장 결과 모달
window.showSaveResultModal = function(type, title, message, details = null) {
const modal = document.getElementById('saveResultModal');
const titleElement = document.getElementById('resultModalTitle');
const contentElement = document.getElementById('resultModalContent');
if (!modal || !contentElement) {
alert(`${title}\n\n${message}`);
return;
}
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
let content = `
<div class="result-icon ${type}">${icons[type] || icons.info}</div>
<h3 class="result-title ${type}">${title}</h3>
<p class="result-message">${message}</p>
`;
if (details) {
if (Array.isArray(details) && details.length > 0) {
content += `
<div class="result-details">
<h4>상세 정보:</h4>
<ul>${details.map(d => `<li>${d}</li>`).join('')}</ul>
</div>
`;
} else if (typeof details === 'string') {
content += `<div class="result-details"><p>${details}</p></div>`;
}
}
if (titleElement) titleElement.textContent = '저장 결과';
contentElement.innerHTML = content;
modal.style.display = 'flex';
// ESC 키로 닫기
const escHandler = (e) => {
if (e.key === 'Escape') {
window.closeSaveResultModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
// 배경 클릭으로 닫기
modal.onclick = (e) => {
if (e.target === modal) {
window.closeSaveResultModal();
}
};
};
window.closeSaveResultModal = function() {
const modal = document.getElementById('saveResultModal');
if (modal) {
modal.style.display = 'none';
}
};
// 단계 이동 함수
window.goToStep = function(stepNumber) {
const state = window.DailyWorkReportState;
for (let i = 1; i <= 3; i++) {
const step = document.getElementById(`step${i}`);
if (step) {
step.classList.remove('active', 'completed');
if (i < stepNumber) {
step.classList.add('completed');
const stepNum = step.querySelector('.step-number');
if (stepNum) stepNum.classList.add('completed');
} else if (i === stepNumber) {
step.classList.add('active');
}
}
}
window.updateProgressSteps(stepNumber);
state.currentStep = stepNumber;
};
window.updateProgressSteps = function(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
};
// showToast → api-base.js 전역 사용
// 확인 다이얼로그
window.showConfirmDialog = function(message, onConfirm, onCancel) {
if (confirm(message)) {
onConfirm?.();
} else {
onCancel?.();
}
};

View File

@@ -0,0 +1,329 @@
// department-management.js
// 부서 관리 페이지 JavaScript
let departments = [];
let selectedDepartmentId = null;
let selectedWorkers = new Set();
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForApi();
await loadDepartments();
});
// waitForApi → api-base.js 전역 사용
// 부서 목록 로드
async function loadDepartments() {
try {
const result = await window.apiCall('/departments');
if (result.success) {
departments = result.data;
renderDepartmentList();
updateMoveToDepartmentSelect();
}
} catch (error) {
console.error('부서 목록 로드 실패:', error);
}
}
// 부서 목록 렌더링
function renderDepartmentList() {
const container = document.getElementById('departmentList');
if (departments.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
등록된 부서가 없습니다.<br>
<button class="btn btn-primary btn-sm" style="margin-top: 1rem;" onclick="openDepartmentModal()">
첫 부서 등록하기
</button>
</div>
`;
return;
}
container.innerHTML = departments.map(dept => `
<div class="department-item ${selectedDepartmentId === dept.department_id ? 'active' : ''}"
onclick="selectDepartment(${dept.department_id})">
<div class="department-info">
<span class="department-name">${dept.department_name}</span>
<span class="department-count">${dept.worker_count || 0}명</span>
</div>
<div class="department-actions" onclick="event.stopPropagation()">
<button class="btn-icon" onclick="editDepartment(${dept.department_id})" title="수정">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn-icon danger" onclick="deleteDepartment(${dept.department_id})" title="삭제">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`).join('');
}
// 부서 선택
async function selectDepartment(departmentId) {
selectedDepartmentId = departmentId;
selectedWorkers.clear();
updateBulkActions();
renderDepartmentList();
const dept = departments.find(d => d.department_id === departmentId);
document.getElementById('workerListTitle').textContent = `${dept.department_name} 작업자`;
document.getElementById('addWorkerBtn').style.display = 'inline-flex';
await loadWorkers(departmentId);
}
// 부서별 작업자 로드
async function loadWorkers(departmentId) {
try {
const result = await window.apiCall(`/departments/${departmentId}/workers`);
if (result.success) {
renderWorkerList(result.data);
}
} catch (error) {
console.error('작업자 목록 로드 실패:', error);
}
}
// 작업자 목록 렌더링
function renderWorkerList(workers) {
const container = document.getElementById('workerList');
if (workers.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
이 부서에 소속된 작업자가 없습니다.
</div>
`;
return;
}
container.innerHTML = workers.map(worker => `
<div class="worker-card ${selectedWorkers.has(worker.user_id) ? 'selected' : ''}"
onclick="toggleWorkerSelection(${worker.user_id})">
<div class="worker-info-row">
<input type="checkbox" ${selectedWorkers.has(worker.user_id) ? 'checked' : ''}
onclick="event.stopPropagation(); toggleWorkerSelection(${worker.user_id})">
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
<div class="worker-details">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${getJobTypeName(worker.job_type)}</span>
</div>
</div>
</div>
`).join('');
}
// 직책 한글 변환
function getJobTypeName(jobType) {
const names = {
leader: '그룹장',
worker: '작업자',
admin: '관리자'
};
return names[jobType] || jobType || '-';
}
// 작업자 선택 토글
function toggleWorkerSelection(workerId) {
if (selectedWorkers.has(workerId)) {
selectedWorkers.delete(workerId);
} else {
selectedWorkers.add(workerId);
}
updateBulkActions();
// 선택 상태 업데이트
const card = document.querySelector(`.worker-card[onclick*="${workerId}"]`);
if (card) {
card.classList.toggle('selected', selectedWorkers.has(workerId));
const checkbox = card.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = selectedWorkers.has(workerId);
}
}
// 일괄 작업 영역 업데이트
function updateBulkActions() {
const bulkActions = document.getElementById('bulkActions');
const selectedCount = document.getElementById('selectedCount');
if (selectedWorkers.size > 0) {
bulkActions.classList.add('visible');
selectedCount.textContent = selectedWorkers.size;
} else {
bulkActions.classList.remove('visible');
}
}
// 이동 대상 부서 선택 업데이트
function updateMoveToDepartmentSelect() {
const select = document.getElementById('moveToDepartment');
select.innerHTML = '<option value="">부서 이동...</option>' +
departments.map(d => `<option value="${d.department_id}">${d.department_name}</option>`).join('');
}
// 선택한 작업자 이동
async function moveSelectedWorkers() {
const targetDepartmentId = document.getElementById('moveToDepartment').value;
if (!targetDepartmentId) {
alert('이동할 부서를 선택하세요.');
return;
}
if (selectedWorkers.size === 0) {
alert('이동할 작업자를 선택하세요.');
return;
}
if (parseInt(targetDepartmentId) === selectedDepartmentId) {
alert('같은 부서로는 이동할 수 없습니다.');
return;
}
try {
const result = await window.apiCall('/departments/move-workers', 'POST', {
workerIds: Array.from(selectedWorkers),
departmentId: parseInt(targetDepartmentId)
});
if (result.success) {
alert(result.message);
selectedWorkers.clear();
updateBulkActions();
document.getElementById('moveToDepartment').value = '';
await loadDepartments();
await loadWorkers(selectedDepartmentId);
} else {
alert(result.error || '이동 실패');
}
} catch (error) {
console.error('작업자 이동 실패:', error);
alert('작업자 이동에 실패했습니다.');
}
}
// 부서 모달 열기
function openDepartmentModal(departmentId = null) {
const modal = document.getElementById('departmentModal');
const title = document.getElementById('departmentModalTitle');
const form = document.getElementById('departmentForm');
// 상위 부서 선택 옵션 업데이트
const parentSelect = document.getElementById('parentDepartment');
parentSelect.innerHTML = '<option value="">없음 (최상위 부서)</option>' +
departments
.filter(d => d.department_id !== departmentId)
.map(d => `<option value="${d.department_id}">${d.department_name}</option>`)
.join('');
if (departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
title.textContent = '부서 수정';
document.getElementById('departmentId').value = dept.department_id;
document.getElementById('departmentName').value = dept.department_name;
document.getElementById('parentDepartment').value = dept.parent_id || '';
document.getElementById('departmentDescription').value = dept.description || '';
document.getElementById('displayOrder').value = dept.display_order || 0;
document.getElementById('isActive').checked = dept.is_active;
} else {
title.textContent = '새 부서 등록';
form.reset();
document.getElementById('departmentId').value = '';
document.getElementById('isActive').checked = true;
}
modal.classList.add('show');
}
// 부서 모달 닫기
function closeDepartmentModal() {
document.getElementById('departmentModal').classList.remove('show');
}
// 부서 저장
async function saveDepartment(event) {
event.preventDefault();
const departmentId = document.getElementById('departmentId').value;
const data = {
department_name: document.getElementById('departmentName').value,
parent_id: document.getElementById('parentDepartment').value || null,
description: document.getElementById('departmentDescription').value,
display_order: parseInt(document.getElementById('displayOrder').value) || 0,
is_active: document.getElementById('isActive').checked
};
try {
const url = departmentId ? `/departments/${departmentId}` : '/departments';
const method = departmentId ? 'PUT' : 'POST';
const result = await window.apiCall(url, method, data);
if (result.success) {
alert(result.message);
closeDepartmentModal();
await loadDepartments();
} else {
alert(result.error || '저장 실패');
}
} catch (error) {
console.error('부서 저장 실패:', error);
alert('부서 저장에 실패했습니다.');
}
}
// 부서 수정
function editDepartment(departmentId) {
openDepartmentModal(departmentId);
}
// 부서 삭제
async function deleteDepartment(departmentId) {
const dept = departments.find(d => d.department_id === departmentId);
if (!confirm(`"${dept.department_name}" 부서를 삭제하시겠습니까?\n\n소속 작업자가 있거나 하위 부서가 있으면 삭제할 수 없습니다.`)) {
return;
}
try {
const result = await window.apiCall(`/departments/${departmentId}`, 'DELETE');
if (result.success) {
alert('부서가 삭제되었습니다.');
if (selectedDepartmentId === departmentId) {
selectedDepartmentId = null;
document.getElementById('workerListTitle').textContent = '부서를 선택하세요';
document.getElementById('addWorkerBtn').style.display = 'none';
document.getElementById('workerList').innerHTML = `
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.
</div>
`;
}
await loadDepartments();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
console.error('부서 삭제 실패:', error);
alert('부서 삭제에 실패했습니다.');
}
}
// 작업자 추가 모달 (작업자 관리 페이지로 이동)
function openAddWorkerModal() {
alert('작업자 관리 페이지에서 작업자를 등록한 후 이 페이지에서 부서를 배정하세요.');
// window.location.href = '/pages/admin/workers.html';
}

View File

@@ -0,0 +1,793 @@
/**
* equipment-detail.js - 설비 상세 페이지 스크립트
*/
// 전역 변수
let currentEquipment = null;
let equipmentId = null;
let workplaces = [];
let factories = [];
let selectedMovePosition = null;
let repairPhotoBases = [];
// 상태 라벨
const STATUS_LABELS = {
active: '정상 가동',
maintenance: '점검 중',
repair_needed: '수리 필요',
inactive: '비활성',
external: '외부 반출',
repair_external: '수리 외주'
};
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
// URL에서 equipment_id 추출
const urlParams = new URLSearchParams(window.location.search);
equipmentId = urlParams.get('id');
if (!equipmentId) {
alert('설비 ID가 필요합니다.');
goBack();
return;
}
// API 설정 후 데이터 로드
waitForApi().then(() => {
loadEquipmentData();
loadFactories();
loadRepairCategories();
});
});
// waitForApi → api-base.js 전역 사용
// 뒤로가기
function goBack() {
if (document.referrer && document.referrer.includes(window.location.host)) {
history.back();
} else {
window.location.href = '/pages/admin/equipments.html';
}
}
// ==========================================
// 설비 데이터 로드
// ==========================================
async function loadEquipmentData() {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
currentEquipment = response.data.data;
renderEquipmentInfo();
loadPhotos();
loadRepairHistory();
loadExternalLogs();
loadMoveLogs();
}
} catch (error) {
console.error('설비 정보 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
function renderEquipmentInfo() {
const eq = currentEquipment;
// 헤더
document.getElementById('equipmentTitle').textContent = `[${eq.equipment_code}] ${eq.equipment_name}`;
document.getElementById('equipmentMeta').textContent = `${eq.model_name || '-'} | ${eq.manufacturer || '-'}`;
// 상태 배지
const statusBadge = document.getElementById('equipmentStatus');
statusBadge.textContent = STATUS_LABELS[eq.status] || eq.status;
statusBadge.className = `eq-status-badge ${eq.status}`;
// 기본 정보 카드
document.getElementById('equipmentInfoCard').innerHTML = `
<div class="eq-info-grid">
<div class="eq-info-item">
<span class="eq-info-label">관리번호</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_code || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비명</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">모델명</span>
<span class="eq-info-value">${escapeHtml(eq.model_name || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">규격</span>
<span class="eq-info-value">${escapeHtml(eq.specifications || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">제조사</span>
<span class="eq-info-value">${escapeHtml(eq.manufacturer || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입처</span>
<span class="eq-info-value">${escapeHtml(eq.supplier || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입일</span>
<span class="eq-info-value">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">구입가격</span>
<span class="eq-info-value">${eq.purchase_price ? Number(eq.purchase_price).toLocaleString() + '원' : '-'}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">시리얼번호</span>
<span class="eq-info-value">${escapeHtml(eq.serial_number || '-')}</span>
</div>
<div class="eq-info-item">
<span class="eq-info-label">설비유형</span>
<span class="eq-info-value">${escapeHtml(eq.equipment_type || '-')}</span>
</div>
</div>
`;
// 위치 정보
const originalLocation = eq.workplace_name
? `${eq.category_name || ''} > ${eq.workplace_name}`
: '미배정';
document.getElementById('originalLocation').textContent = originalLocation;
if (eq.is_temporarily_moved && eq.current_workplace_id) {
document.getElementById('currentLocationRow').style.display = 'flex';
// 현재 위치 작업장 이름 로드 필요
loadCurrentWorkplaceName(eq.current_workplace_id);
}
// 지도 미리보기 (작업장 지도 표시)
renderMapPreview();
}
async function loadCurrentWorkplaceName(workplaceId) {
try {
const response = await axios.get(`/workplaces/${workplaceId}`);
if (response.data.success) {
const wp = response.data.data;
document.getElementById('currentLocation').textContent = `${wp.category_name || ''} > ${wp.workplace_name}`;
}
} catch (error) {
console.error('현재 위치 로드 실패:', error);
}
}
function renderMapPreview() {
const eq = currentEquipment;
const mapPreview = document.getElementById('mapPreview');
if (!eq.workplace_id) {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">위치 미배정</div>';
return;
}
// 작업장 지도 정보 로드
axios.get(`/workplaces/${eq.workplace_id}`).then(response => {
if (response.data.success && response.data.data.map_image_url) {
const wp = response.data.data;
const xPercent = eq.is_temporarily_moved ? eq.current_map_x_percent : eq.map_x_percent;
const yPercent = eq.is_temporarily_moved ? eq.current_map_y_percent : eq.map_y_percent;
mapPreview.innerHTML = `
<img src="${window.API_BASE_URL}${wp.map_image_url}" alt="작업장 지도">
<div class="eq-map-marker" style="left: ${xPercent}%; top: ${yPercent}%;"></div>
`;
} else {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 없음</div>';
}
}).catch(() => {
mapPreview.innerHTML = '<div style="padding: 1rem; text-align: center; color: #9ca3af;">지도 로드 실패</div>';
});
}
// ==========================================
// 사진 관리
// ==========================================
async function loadPhotos() {
try {
const response = await axios.get(`/equipments/${equipmentId}/photos`);
if (response.data.success) {
renderPhotos(response.data.data);
}
} catch (error) {
console.error('사진 로드 실패:', error);
}
}
function renderPhotos(photos) {
const grid = document.getElementById('photoGrid');
if (!photos || photos.length === 0) {
grid.innerHTML = '<div class="eq-photo-empty">등록된 사진이 없습니다</div>';
return;
}
grid.innerHTML = photos.map(photo => {
const safePhotoId = parseInt(photo.photo_id) || 0;
const safePhotoPath = encodeURI(photo.photo_path || '');
const safeDescription = escapeHtml(photo.description || '설비 사진');
return `
<div class="eq-photo-item" onclick="viewPhoto('${window.API_BASE_URL}${safePhotoPath}')">
<img src="${window.API_BASE_URL}${safePhotoPath}" alt="${safeDescription}">
<button class="eq-photo-delete" onclick="event.stopPropagation(); deletePhoto(${safePhotoId})">&times;</button>
</div>
`;
}).join('');
}
function openPhotoModal() {
document.getElementById('photoInput').value = '';
document.getElementById('photoDescription').value = '';
document.getElementById('photoPreviewContainer').style.display = 'none';
document.getElementById('photoModal').style.display = 'flex';
}
function closePhotoModal() {
document.getElementById('photoModal').style.display = 'none';
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = e => {
document.getElementById('photoPreview').src = e.target.result;
document.getElementById('photoPreviewContainer').style.display = 'block';
};
reader.readAsDataURL(file);
}
}
async function uploadPhoto() {
const fileInput = document.getElementById('photoInput');
const description = document.getElementById('photoDescription').value;
if (!fileInput.files[0]) {
alert('사진을 선택하세요.');
return;
}
const reader = new FileReader();
reader.onload = async e => {
try {
const response = await axios.post(`/equipments/${equipmentId}/photos`, {
photo_base64: e.target.result,
description: description
});
if (response.data.success) {
closePhotoModal();
loadPhotos();
alert('사진이 추가되었습니다.');
}
} catch (error) {
console.error('사진 업로드 실패:', error);
alert('사진 업로드에 실패했습니다.');
}
};
reader.readAsDataURL(fileInput.files[0]);
}
async function deletePhoto(photoId) {
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
try {
const response = await axios.delete(`/equipments/photos/${photoId}`);
if (response.data.success) {
loadPhotos();
}
} catch (error) {
console.error('사진 삭제 실패:', error);
alert('사진 삭제에 실패했습니다.');
}
}
function viewPhoto(url) {
document.getElementById('photoViewImage').src = url;
document.getElementById('photoViewModal').style.display = 'flex';
}
function closePhotoView() {
document.getElementById('photoViewModal').style.display = 'none';
}
// ==========================================
// 임시 이동
// ==========================================
async function loadFactories() {
try {
const response = await axios.get('/workplace-categories');
if (response.data.success) {
factories = response.data.data;
}
} catch (error) {
console.error('공장 목록 로드 실패:', error);
}
}
function openMoveModal() {
// 공장 선택 초기화
const factorySelect = document.getElementById('moveFactorySelect');
factorySelect.innerHTML = '<option value="">공장을 선택하세요</option>';
factories.forEach(f => {
const safeCategoryId = parseInt(f.category_id) || 0;
factorySelect.innerHTML += `<option value="${safeCategoryId}">${escapeHtml(f.category_name || '-')}</option>`;
});
document.getElementById('moveWorkplaceSelect').innerHTML = '<option value="">작업장을 선택하세요</option>';
document.getElementById('moveStep2').style.display = 'none';
document.getElementById('moveStep1').style.display = 'block';
document.getElementById('moveConfirmBtn').disabled = true;
document.getElementById('moveReason').value = '';
selectedMovePosition = null;
document.getElementById('moveModal').style.display = 'flex';
}
function closeMoveModal() {
document.getElementById('moveModal').style.display = 'none';
}
async function loadMoveWorkplaces() {
const categoryId = document.getElementById('moveFactorySelect').value;
const workplaceSelect = document.getElementById('moveWorkplaceSelect');
workplaceSelect.innerHTML = '<option value="">작업장을 선택하세요</option>';
if (!categoryId) return;
try {
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
if (response.data.success) {
workplaces = response.data.data;
workplaces.forEach(wp => {
if (wp.map_image_url) {
const safeWorkplaceId = parseInt(wp.workplace_id) || 0;
workplaceSelect.innerHTML += `<option value="${safeWorkplaceId}">${escapeHtml(wp.workplace_name || '-')}</option>`;
}
});
}
} catch (error) {
console.error('작업장 로드 실패:', error);
}
}
function loadMoveMap() {
const workplaceId = document.getElementById('moveWorkplaceSelect').value;
if (!workplaceId) {
document.getElementById('moveStep2').style.display = 'none';
return;
}
const workplace = workplaces.find(wp => wp.workplace_id == workplaceId);
if (!workplace || !workplace.map_image_url) {
alert('선택한 작업장에 지도가 없습니다.');
return;
}
const container = document.getElementById('moveMapContainer');
container.innerHTML = `<img src="${window.API_BASE_URL}${workplace.map_image_url}" id="moveMapImage" onclick="onMoveMapClick(event)">`;
document.getElementById('moveStep2').style.display = 'block';
}
function onMoveMapClick(event) {
const img = event.target;
const rect = img.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
selectedMovePosition = { x, y };
// 기존 마커 제거
const container = document.getElementById('moveMapContainer');
const existingMarker = container.querySelector('.move-marker');
if (existingMarker) existingMarker.remove();
// 새 마커 추가
const marker = document.createElement('div');
marker.className = 'move-marker';
marker.style.left = x + '%';
marker.style.top = y + '%';
container.appendChild(marker);
document.getElementById('moveConfirmBtn').disabled = false;
}
async function confirmMove() {
const targetWorkplaceId = document.getElementById('moveWorkplaceSelect').value;
const reason = document.getElementById('moveReason').value;
if (!targetWorkplaceId || !selectedMovePosition) {
alert('이동할 위치를 선택하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/move`, {
target_workplace_id: targetWorkplaceId,
target_x_percent: selectedMovePosition.x.toFixed(2),
target_y_percent: selectedMovePosition.y.toFixed(2),
from_workplace_id: currentEquipment.workplace_id,
from_x_percent: currentEquipment.map_x_percent,
from_y_percent: currentEquipment.map_y_percent,
reason: reason
});
if (response.data.success) {
closeMoveModal();
loadEquipmentData();
loadMoveLogs();
alert('설비가 임시 이동되었습니다.');
}
} catch (error) {
console.error('이동 실패:', error);
alert('설비 이동에 실패했습니다.');
}
}
async function returnToOriginal() {
if (!confirm('설비를 원래 위치로 복귀시키겠습니까?')) return;
try {
const response = await axios.post(`/equipments/${equipmentId}/return`);
if (response.data.success) {
loadEquipmentData();
loadMoveLogs();
alert('설비가 원위치로 복귀되었습니다.');
}
} catch (error) {
console.error('복귀 실패:', error);
alert('설비 복귀에 실패했습니다.');
}
}
// ==========================================
// 수리 신청
// ==========================================
let repairCategories = [];
async function loadRepairCategories() {
try {
const response = await axios.get('/equipments/repair-categories');
if (response.data.success) {
repairCategories = response.data.data;
}
} catch (error) {
console.error('수리 항목 로드 실패:', error);
}
}
function openRepairModal() {
const select = document.getElementById('repairItemSelect');
select.innerHTML = '<option value="">선택하세요</option>';
repairCategories.forEach(item => {
const safeItemId = parseInt(item.item_id) || 0;
select.innerHTML += `<option value="${safeItemId}">${escapeHtml(item.item_name || '-')}</option>`;
});
document.getElementById('repairDescription').value = '';
document.getElementById('repairPhotoInput').value = '';
document.getElementById('repairPhotoPreviews').innerHTML = '';
repairPhotoBases = [];
document.getElementById('repairModal').style.display = 'flex';
}
function closeRepairModal() {
document.getElementById('repairModal').style.display = 'none';
}
function previewRepairPhotos(event) {
const files = event.target.files;
const previewContainer = document.getElementById('repairPhotoPreviews');
previewContainer.innerHTML = '';
repairPhotoBases = [];
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
repairPhotoBases.push(e.target.result);
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'repair-photo-preview';
previewContainer.appendChild(img);
};
reader.readAsDataURL(file);
});
}
async function submitRepairRequest() {
const itemId = document.getElementById('repairItemSelect').value;
const description = document.getElementById('repairDescription').value;
if (!description) {
alert('수리 내용을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/repair-request`, {
item_id: itemId || null,
description: description,
photo_base64_list: repairPhotoBases,
workplace_id: currentEquipment.workplace_id
});
if (response.data.success) {
closeRepairModal();
loadEquipmentData();
loadRepairHistory();
alert('수리 신청이 접수되었습니다.');
}
} catch (error) {
console.error('수리 신청 실패:', error);
alert('수리 신청에 실패했습니다.');
}
}
async function loadRepairHistory() {
try {
const response = await axios.get(`/equipments/${equipmentId}/repair-history`);
if (response.data.success) {
renderRepairHistory(response.data.data);
}
} catch (error) {
console.error('수리 이력 로드 실패:', error);
}
}
function renderRepairHistory(history) {
const container = document.getElementById('repairHistory');
if (!history || history.length === 0) {
container.innerHTML = '<div class="eq-history-empty">수리 이력이 없습니다</div>';
return;
}
const validStatuses = ['pending', 'in_progress', 'completed', 'closed'];
container.innerHTML = history.map(h => {
const safeStatus = validStatuses.includes(h.status) ? h.status : 'pending';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDate(h.created_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(h.item_name || '수리 요청')}</div>
<div class="eq-history-detail">${escapeHtml(h.description || '-')}</div>
</div>
<span class="eq-history-status ${safeStatus}">${getRepairStatusLabel(h.status)}</span>
</div>
`;
}).join('');
}
function getRepairStatusLabel(status) {
const labels = {
pending: '대기중',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
return labels[status] || status;
}
// ==========================================
// 외부 반출
// ==========================================
function openExportModal() {
document.getElementById('exportDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('expectedReturnDate').value = '';
document.getElementById('exportDestination').value = '';
document.getElementById('exportReason').value = '';
document.getElementById('exportNotes').value = '';
document.getElementById('isRepairExport').checked = false;
document.getElementById('exportModal').style.display = 'flex';
}
function closeExportModal() {
document.getElementById('exportModal').style.display = 'none';
}
function toggleRepairFields() {
// 현재는 특별한 필드 차이 없음
}
async function submitExport() {
const exportDate = document.getElementById('exportDate').value;
const expectedReturnDate = document.getElementById('expectedReturnDate').value;
const destination = document.getElementById('exportDestination').value;
const reason = document.getElementById('exportReason').value;
const notes = document.getElementById('exportNotes').value;
const isRepair = document.getElementById('isRepairExport').checked;
if (!exportDate) {
alert('반출일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/${equipmentId}/export`, {
export_date: exportDate,
expected_return_date: expectedReturnDate || null,
destination: destination,
reason: reason,
notes: notes,
is_repair: isRepair
});
if (response.data.success) {
closeExportModal();
loadEquipmentData();
loadExternalLogs();
alert('외부 반출이 등록되었습니다.');
}
} catch (error) {
console.error('반출 등록 실패:', error);
alert('반출 등록에 실패했습니다.');
}
}
async function loadExternalLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/external-logs`);
if (response.data.success) {
renderExternalLogs(response.data.data);
}
} catch (error) {
console.error('외부반출 이력 로드 실패:', error);
}
}
function renderExternalLogs(logs) {
const container = document.getElementById('externalHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">외부반출 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const dateRange = log.actual_return_date
? `${formatDate(log.export_date)} ~ ${formatDate(log.actual_return_date)}`
: `${formatDate(log.export_date)} ~ (미반입)`;
const isReturned = !!log.actual_return_date;
const statusClass = isReturned ? 'returned' : 'exported';
const statusLabel = isReturned ? '반입완료' : '반출중';
const safeLogId = parseInt(log.log_id) || 0;
return `
<div class="eq-history-item">
<span class="eq-history-date">${dateRange}</span>
<div class="eq-history-content">
<div class="eq-history-title">${escapeHtml(log.destination || '외부')}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')}</div>
</div>
<span class="eq-history-status ${statusClass}">${statusLabel}</span>
${!isReturned ? `<button class="eq-history-action" onclick="openReturnModal(${safeLogId})">반입처리</button>` : ''}
</div>
`;
}).join('');
}
function openReturnModal(logId) {
document.getElementById('returnLogId').value = logId;
document.getElementById('returnDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('returnStatus').value = 'active';
document.getElementById('returnNotes').value = '';
document.getElementById('returnModal').style.display = 'flex';
}
function closeReturnModal() {
document.getElementById('returnModal').style.display = 'none';
}
async function submitReturn() {
const logId = document.getElementById('returnLogId').value;
const returnDate = document.getElementById('returnDate').value;
const newStatus = document.getElementById('returnStatus').value;
const notes = document.getElementById('returnNotes').value;
if (!returnDate) {
alert('반입일을 입력하세요.');
return;
}
try {
const response = await axios.post(`/equipments/external-logs/${logId}/return`, {
return_date: returnDate,
new_status: newStatus,
notes: notes
});
if (response.data.success) {
closeReturnModal();
loadEquipmentData();
loadExternalLogs();
alert('반입 처리가 완료되었습니다.');
}
} catch (error) {
console.error('반입 처리 실패:', error);
alert('반입 처리에 실패했습니다.');
}
}
// ==========================================
// 이동 이력
// ==========================================
async function loadMoveLogs() {
try {
const response = await axios.get(`/equipments/${equipmentId}/move-logs`);
if (response.data.success) {
renderMoveLogs(response.data.data);
}
} catch (error) {
console.error('이동 이력 로드 실패:', error);
}
}
function renderMoveLogs(logs) {
const container = document.getElementById('moveHistory');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="eq-history-empty">이동 이력이 없습니다</div>';
return;
}
container.innerHTML = logs.map(log => {
const typeLabel = log.move_type === 'temporary' ? '임시이동' : '복귀';
const location = log.move_type === 'temporary'
? escapeHtml(log.to_workplace_name || '-')
: '원위치 복귀';
return `
<div class="eq-history-item">
<span class="eq-history-date">${formatDateTime(log.moved_at)}</span>
<div class="eq-history-content">
<div class="eq-history-title">${typeLabel}: ${location}</div>
<div class="eq-history-detail">${escapeHtml(log.reason || '-')} (${escapeHtml(log.moved_by_name || '시스템')})</div>
</div>
</div>
`;
}).join('');
}
// ==========================================
// 유틸리티
// ==========================================
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\. /g, '-').replace('.', '');
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}

View File

@@ -0,0 +1,466 @@
// equipment-management.js
// 설비 관리 페이지 JavaScript
let equipments = [];
let allEquipments = []; // 필터링 전 전체 데이터
let workplaces = [];
let equipmentTypes = [];
let currentEquipment = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
await loadInitialData();
});
// axios 설정 대기 함수
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
if (!axios.defaults.baseURL) {
console.error('Axios 설정 시간 초과');
}
resolve();
}, 5000);
});
}
// 초기 데이터 로드
async function loadInitialData() {
try {
await Promise.all([
loadEquipments(),
loadWorkplaces(),
loadEquipmentTypes()
]);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
alert('데이터를 불러오는데 실패했습니다.');
}
}
// 설비 목록 로드
async function loadEquipments() {
try {
const response = await axios.get('/equipments');
if (response.data.success) {
allEquipments = response.data.data;
equipments = [...allEquipments];
renderStats();
renderEquipmentList();
}
} catch (error) {
console.error('설비 목록 로드 실패:', error);
throw error;
}
}
// 작업장 목록 로드
async function loadWorkplaces() {
try {
const response = await axios.get('/workplaces');
if (response.data.success) {
workplaces = response.data.data;
populateWorkplaceFilters();
}
} catch (error) {
console.error('작업장 목록 로드 실패:', error);
}
}
// 설비 유형 목록 로드
async function loadEquipmentTypes() {
try {
const response = await axios.get('/equipments/types');
if (response.data.success) {
equipmentTypes = response.data.data;
populateTypeFilter();
}
} catch (error) {
console.error('설비 유형 로드 실패:', error);
}
}
// 통계 렌더링
function renderStats() {
const container = document.getElementById('statsSection');
if (!container) return;
const totalCount = allEquipments.length;
const activeCount = allEquipments.filter(e => e.status === 'active').length;
const maintenanceCount = allEquipments.filter(e => e.status === 'maintenance').length;
const inactiveCount = allEquipments.filter(e => e.status === 'inactive').length;
const totalValue = allEquipments.reduce((sum, e) => sum + (Number(e.purchase_price) || 0), 0);
const avgValue = totalCount > 0 ? totalValue / totalCount : 0;
container.innerHTML = `
<div class="eq-stat-card highlight">
<div class="eq-stat-label">전체 설비</div>
<div class="eq-stat-value">${totalCount}대</div>
<div class="eq-stat-sub">총 자산가치 ${formatPriceShort(totalValue)}</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">활성</div>
<div class="eq-stat-value" style="color: #16a34a;">${activeCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(activeCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">정비중</div>
<div class="eq-stat-value" style="color: #d97706;">${maintenanceCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(maintenanceCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">비활성</div>
<div class="eq-stat-value" style="color: #dc2626;">${inactiveCount}대</div>
<div class="eq-stat-sub">${totalCount > 0 ? Math.round(inactiveCount / totalCount * 100) : 0}%</div>
</div>
<div class="eq-stat-card">
<div class="eq-stat-label">평균 구입가</div>
<div class="eq-stat-value">${formatPriceShort(avgValue)}</div>
<div class="eq-stat-sub">설비당 평균</div>
</div>
`;
}
// 작업장 필터 채우기
function populateWorkplaceFilters() {
const filterWorkplace = document.getElementById('filterWorkplace');
const modalWorkplace = document.getElementById('workplaceId');
const workplaceOptions = workplaces.map(w => {
const safeId = parseInt(w.workplace_id) || 0;
const categoryName = escapeHtml(w.category_name || '');
const workplaceName = escapeHtml(w.workplace_name || '');
const label = categoryName ? categoryName + ' - ' + workplaceName : workplaceName;
return `<option value="${safeId}">${label}</option>`;
}).join('');
if (filterWorkplace) filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
if (modalWorkplace) modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
}
// 설비 유형 필터 채우기
function populateTypeFilter() {
const filterType = document.getElementById('filterType');
if (!filterType) return;
const typeOptions = equipmentTypes.map(type => {
const safeType = escapeHtml(type || '');
return `<option value="${safeType}">${safeType}</option>`;
}).join('');
filterType.innerHTML = '<option value="">전체</option>' + typeOptions;
}
// 설비 목록 렌더링
function renderEquipmentList() {
const container = document.getElementById('equipmentList');
if (equipments.length === 0) {
container.innerHTML = `
<div class="eq-empty-state">
<p>등록된 설비가 없습니다.</p>
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
</div>
`;
return;
}
const tableHTML = `
<div class="eq-result-count">
<span>검색 결과 <strong>${equipments.length}건</strong></span>
</div>
<div class="eq-table-wrapper">
<table class="eq-table">
<thead>
<tr>
<th>관리번호</th>
<th>설비명</th>
<th>모델명</th>
<th>규격</th>
<th>제조사</th>
<th>구입처</th>
<th style="text-align:right">구입가격</th>
<th>구입일자</th>
<th>상태</th>
<th style="width:80px">관리</th>
</tr>
</thead>
<tbody>
${equipments.map(eq => {
const safeId = parseInt(eq.equipment_id) || 0;
const safeCode = escapeHtml(eq.equipment_code || '-');
const safeName = escapeHtml(eq.equipment_name || '-');
const safeModel = escapeHtml(eq.model_name || '-');
const safeSpec = escapeHtml(eq.specifications || '-');
const safeManufacturer = escapeHtml(eq.manufacturer || '-');
const safeSupplier = escapeHtml(eq.supplier || '-');
const validStatuses = ['active', 'maintenance', 'inactive'];
const safeStatus = validStatuses.includes(eq.status) ? eq.status : 'inactive';
return `
<tr>
<td class="eq-col-code">${safeCode}</td>
<td class="eq-col-name" title="${safeName}">${safeName}</td>
<td class="eq-col-model" title="${safeModel}">${safeModel}</td>
<td class="eq-col-spec" title="${safeSpec}">${safeSpec}</td>
<td>${safeManufacturer}</td>
<td>${safeSupplier}</td>
<td class="eq-col-price">${eq.purchase_price ? formatPrice(eq.purchase_price) : '-'}</td>
<td class="eq-col-date">${eq.installation_date ? formatDate(eq.installation_date) : '-'}</td>
<td>
<span class="eq-status eq-status-${safeStatus}">
${getStatusText(eq.status)}
</span>
</td>
<td>
<div class="eq-actions">
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${safeId})" title="수정">
✏️
</button>
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${safeId})" title="삭제">
🗑️
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
container.innerHTML = tableHTML;
}
// 상태 텍스트 변환
function getStatusText(status) {
const statusMap = {
'active': '활성',
'maintenance': '정비중',
'inactive': '비활성'
};
return statusMap[status] || status || '-';
}
// 가격 포맷팅 (전체)
function formatPrice(price) {
if (!price) return '-';
return Number(price).toLocaleString('ko-KR') + '원';
}
// 가격 포맷팅 (축약)
function formatPriceShort(price) {
if (!price) return '0원';
const num = Number(price);
if (num >= 100000000) {
return (num / 100000000).toFixed(1).replace(/\.0$/, '') + '억원';
} else if (num >= 10000) {
return (num / 10000).toFixed(0) + '만원';
}
return num.toLocaleString('ko-KR') + '원';
}
// 날짜 포맷팅
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// 필터링
function filterEquipments() {
const workplaceFilter = document.getElementById('filterWorkplace').value;
const typeFilter = document.getElementById('filterType').value;
const statusFilter = document.getElementById('filterStatus').value;
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
equipments = allEquipments.filter(e => {
if (workplaceFilter && e.workplace_id != workplaceFilter) return false;
if (typeFilter && e.equipment_type !== typeFilter) return false;
if (statusFilter && e.status !== statusFilter) return false;
if (searchTerm) {
const searchFields = [
e.equipment_name,
e.equipment_code,
e.manufacturer,
e.supplier,
e.model_name
].map(f => (f || '').toLowerCase());
if (!searchFields.some(f => f.includes(searchTerm))) return false;
}
return true;
});
renderEquipmentList();
}
// 설비 추가 모달 열기
async function openEquipmentModal(equipmentId = null) {
currentEquipment = equipmentId;
const modal = document.getElementById('equipmentModal');
const modalTitle = document.getElementById('modalTitle');
const form = document.getElementById('equipmentForm');
form.reset();
document.getElementById('equipmentId').value = '';
if (equipmentId) {
modalTitle.textContent = '설비 수정';
loadEquipmentData(equipmentId);
} else {
modalTitle.textContent = '설비 추가';
// 새 설비일 경우 다음 관리번호 자동 생성
await loadNextEquipmentCode();
}
modal.style.display = 'flex';
}
// 다음 관리번호 로드
async function loadNextEquipmentCode() {
try {
const response = await axios.get('/equipments/next-code');
if (response.data.success) {
document.getElementById('equipmentCode').value = response.data.data.next_code;
}
} catch (error) {
console.error(' 다음 관리번호 조회 실패:', error);
console.error(' 에러 상세:', error.response?.data || error.message);
// 오류 시 기본값으로 빈 값 유지 (사용자가 직접 입력)
}
}
// 설비 데이터 로드 (수정용)
async function loadEquipmentData(equipmentId) {
try {
const response = await axios.get(`/equipments/${equipmentId}`);
if (response.data.success) {
const eq = response.data.data;
document.getElementById('equipmentId').value = eq.equipment_id;
document.getElementById('equipmentCode').value = eq.equipment_code || '';
document.getElementById('equipmentName').value = eq.equipment_name || '';
document.getElementById('equipmentType').value = eq.equipment_type || '';
document.getElementById('workplaceId').value = eq.workplace_id || '';
document.getElementById('manufacturer').value = eq.manufacturer || '';
document.getElementById('supplier').value = eq.supplier || '';
document.getElementById('purchasePrice').value = eq.purchase_price || '';
document.getElementById('modelName').value = eq.model_name || '';
document.getElementById('serialNumber').value = eq.serial_number || '';
document.getElementById('installationDate').value = eq.installation_date ? eq.installation_date.split('T')[0] : '';
document.getElementById('equipmentStatus').value = eq.status || 'active';
document.getElementById('specifications').value = eq.specifications || '';
document.getElementById('notes').value = eq.notes || '';
}
} catch (error) {
console.error('설비 데이터 로드 실패:', error);
alert('설비 정보를 불러오는데 실패했습니다.');
}
}
// 설비 모달 닫기
function closeEquipmentModal() {
document.getElementById('equipmentModal').style.display = 'none';
currentEquipment = null;
}
// 설비 저장
async function saveEquipment() {
const equipmentId = document.getElementById('equipmentId').value;
const equipmentData = {
equipment_code: document.getElementById('equipmentCode').value.trim(),
equipment_name: document.getElementById('equipmentName').value.trim(),
equipment_type: document.getElementById('equipmentType').value.trim() || null,
workplace_id: document.getElementById('workplaceId').value || null,
manufacturer: document.getElementById('manufacturer').value.trim() || null,
supplier: document.getElementById('supplier').value.trim() || null,
purchase_price: document.getElementById('purchasePrice').value || null,
model_name: document.getElementById('modelName').value.trim() || null,
serial_number: document.getElementById('serialNumber').value.trim() || null,
installation_date: document.getElementById('installationDate').value || null,
status: document.getElementById('equipmentStatus').value,
specifications: document.getElementById('specifications').value.trim() || null,
notes: document.getElementById('notes').value.trim() || null
};
if (!equipmentData.equipment_code) {
alert('관리번호를 입력해주세요.');
return;
}
if (!equipmentData.equipment_name) {
alert('설비명을 입력해주세요.');
return;
}
try {
let response;
if (equipmentId) {
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
} else {
response = await axios.post('/equipments', equipmentData);
}
if (response.data.success) {
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
closeEquipmentModal();
await loadEquipments();
await loadEquipmentTypes();
}
} catch (error) {
console.error('설비 저장 실패:', error);
if (error.response?.data?.message) {
alert(error.response.data.message);
} else {
alert('설비 저장 중 오류가 발생했습니다.');
}
}
}
// 설비 수정
function editEquipment(equipmentId) {
openEquipmentModal(equipmentId);
}
// 설비 삭제
async function deleteEquipment(equipmentId) {
const equipment = allEquipments.find(e => e.equipment_id === equipmentId);
if (!equipment) return;
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
return;
}
try {
const response = await axios.delete(`/equipments/${equipmentId}`);
if (response.data.success) {
alert('설비가 삭제되었습니다.');
await loadEquipments();
}
} catch (error) {
console.error('설비 삭제 실패:', error);
alert('설비 삭제 중 오류가 발생했습니다.');
}
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeEquipmentModal();
}
});
// 모달 외부 클릭 시 닫기
document.getElementById('equipmentModal')?.addEventListener('click', (e) => {
if (e.target.id === 'equipmentModal') {
closeEquipmentModal();
}
});

View File

@@ -0,0 +1,49 @@
import { API, getAuthHeaders } from '/js/api-config.js';
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
// FormData를 사용할 때는 Content-Type을 설정하지 않음 (자동 설정됨)
const token = localStorage.getItem('sso_token');
const res = await fetch(`${API}/factoryinfo`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || '등록 실패');
}
alert('등록 완료!');
location.reload();
} catch (err) {
console.error(err);
alert('등록 실패: ' + err.message);
}
});
// 파일 선택 시 미리보기 (선택사항)
const fileInput = document.querySelector('input[name="map_image"]');
if (fileInput) {
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
// 미리보기 요소가 있을 경우에만 동작
const preview = document.getElementById('file-preview');
if (preview) {
const reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = `<img src="${e.target.result}" alt="미리보기" style="max-width: 200px; max-height: 200px; border-radius: 8px;">`;
};
reader.readAsDataURL(file);
}
}
});
}

View File

@@ -0,0 +1,38 @@
import { API, getAuthHeaders } from '/js/api-config.js';
(async () => {
const pathParts = location.pathname.split('/');
const id = pathParts[pathParts.length - 1];
try {
const res = await fetch(`${API}/factoryinfo/${id}`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error('조회 실패');
}
const data = await res.json();
// DOM 요소가 존재하는지 확인 후 설정
const nameEl = document.getElementById('factoryName');
if (nameEl) nameEl.textContent = data.factory_name;
const addressEl = document.getElementById('factoryAddress');
if (addressEl) addressEl.textContent = '📍 ' + data.address;
const imageEl = document.getElementById('factoryImage');
if (imageEl) imageEl.src = data.map_image_url;
const descEl = document.getElementById('factoryDescription');
if (descEl) descEl.textContent = data.description;
} catch (err) {
console.error(err);
const container = document.querySelector('.container');
if (container) {
container.innerHTML = '<p>공장 정보를 불러올 수 없습니다.</p>';
}
}
})();

View File

@@ -0,0 +1,149 @@
// /js/group-leader-dashboard.js
// 그룹장 전용 대시보드 - 실시간 근태 및 작업 현황 (Real Data Version)
import { apiCall } from './api-config.js';
// 상태별 스타일/텍스트 매핑
const STATUS_MAP = {
'incomplete': { text: '미제출', class: 'status-incomplete', icon: '❌', color: '#ff5252' },
'partial': { text: '작성중', class: 'status-warning', icon: '📝', color: '#ff9800' },
'complete': { text: '제출완료', class: 'status-success', icon: '✅', color: '#4caf50' },
'overtime': { text: '초과근무', class: 'status-info', icon: '🌙', color: '#673ab7' },
'vacation': { text: '휴가', class: 'status-vacation', icon: '🏖️', color: '#2196f3' }
};
// 현재 선택된 날짜
let currentSelectedDate = new Date().toISOString().split('T')[0];
/**
* 📅 날짜 초기화 및 이벤트 리스너 등록
*/
function initDateSelector() {
const dateInput = document.getElementById('selectedDate');
const refreshBtn = document.getElementById('refreshBtn');
if (dateInput) {
dateInput.value = currentSelectedDate;
dateInput.addEventListener('change', (e) => {
currentSelectedDate = e.target.value;
loadDailyWorkStatus();
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadDailyWorkStatus();
showToast('데이터를 새로고침했습니다.', 'success');
});
}
}
/**
* 🔄 일일 근태 현황 로드 (API 호출)
*/
async function loadDailyWorkStatus() {
const container = document.getElementById('workStatusContainer');
if (!container) return;
// 로딩 표시
container.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>작업 현황을 불러오는 중...</p>
</div>
`;
try {
const result = await apiCall(`/attendance/daily-status?date=${currentSelectedDate}`);
const workers = result.data || [];
renderWorkStatus(workers);
updateSummaryStats(workers);
} catch (error) {
console.error('현황 로드 오류:', error);
container.innerHTML = `
<div class="error-state">
<p>⚠️ 데이터를 불러오는데 실패했습니다.</p>
<button onclick="loadDailyWorkStatus()" class="btn btn-sm btn-outline">재시도</button>
</div>
`;
}
}
/**
* 📊 통계 요약 업데이트
*/
function updateSummaryStats(workers) {
// 요약 카드가 있다면 업데이트 (현재 HTML에는 없으므로 생략 가능하거나 동적으로 추가)
// 여기서는 콘솔에만 로그
const stats = workers.reduce((acc, w) => {
acc[w.status] = (acc[w.status] || 0) + 1;
return acc;
}, {});
console.log('Daily Stats:', stats);
}
/**
* 🎨 현황 리스트 렌더링
*/
function renderWorkStatus(workers) {
const container = document.getElementById('workStatusContainer');
if (!container) return;
if (workers.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 작업자가 없습니다.</div>';
return;
}
// 상태 우선순위 정렬 (미제출 -> 작성중 -> 완료)
const sortOrder = ['incomplete', 'partial', 'vacation', 'complete', 'overtime'];
workers.sort((a, b) => {
return sortOrder.indexOf(a.status) - sortOrder.indexOf(b.status) || a.worker_name.localeCompare(b.worker_name);
});
const html = `
<div class="status-grid">
${workers.map(worker => {
const statusInfo = STATUS_MAP[worker.status] || { text: worker.status, class: '', icon: '❓', color: '#999' };
return `
<div class="worker-card ${worker.status === 'incomplete' ? 'status-alert' : ''}" style="border-left: 4px solid ${statusInfo.color}">
<div class="worker-header">
<span class="worker-name">${worker.worker_name}</span>
<span class="worker-job">${worker.job_type || '-'}</span>
</div>
<div class="worker-body">
<div class="status-badge" style="background-color: ${statusInfo.color}20; color: ${statusInfo.color}">
${statusInfo.icon} ${statusInfo.text}
</div>
<div class="work-hours">
${worker.total_work_hours > 0 ? worker.total_work_hours + '시간' : '-'}
</div>
</div>
${worker.status === 'incomplete' ? `
<div class="worker-footer">
<span class="alert-text">⚠️ 보고서 미제출</span>
</div>
` : ''}
</div>
`;
}).join('')}
</div>
`;
container.innerHTML = html;
}
// showToast → api-base.js 전역 사용
// 초기화
document.addEventListener('DOMContentLoaded', () => {
initDateSelector();
loadDailyWorkStatus();
});
// 전역 노출 대신 모듈로 내보내기
export { loadDailyWorkStatus as refreshTeamStatus };

View File

@@ -0,0 +1,421 @@
/**
* 신고 카테고리 관리 JavaScript
*/
import { API, getAuthHeaders } from '/js/api-config.js';
let currentType = 'nonconformity';
let categories = [];
let items = [];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadCategories();
});
/**
* 유형 탭 전환
*/
window.switchType = async function(type) {
currentType = type;
// 탭 상태 업데이트
document.querySelectorAll('.type-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.type === type);
});
await loadCategories();
};
/**
* 카테고리 로드
*/
async function loadCategories() {
const container = document.getElementById('categoryList');
container.innerHTML = '<div class="empty-state">카테고리를 불러오는 중...</div>';
try {
const response = await fetch(`${API}/work-issues/categories/type/${currentType}`, {
headers: getAuthHeaders()
});
if (!response.ok) throw new Error('카테고리 조회 실패');
const data = await response.json();
if (data.success && data.data) {
categories = data.data;
// 항목도 로드
const itemsResponse = await fetch(`${API}/work-issues/items`, {
headers: getAuthHeaders()
});
if (itemsResponse.ok) {
const itemsData = await itemsResponse.json();
if (itemsData.success) {
items = itemsData.data || [];
}
}
renderCategories();
} else {
container.innerHTML = '<div class="empty-state">카테고리가 없습니다.</div>';
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
container.innerHTML = '<div class="empty-state">카테고리를 불러오지 못했습니다.</div>';
}
}
/**
* 카테고리 렌더링
*/
function renderCategories() {
const container = document.getElementById('categoryList');
if (categories.length === 0) {
container.innerHTML = '<div class="empty-state">등록된 카테고리가 없습니다.</div>';
return;
}
const severityLabel = {
low: '낮음',
medium: '보통',
high: '높음',
critical: '심각'
};
container.innerHTML = categories.map(cat => {
const catItems = items.filter(item => item.category_id === cat.category_id);
return `
<div class="category-section" data-category-id="${cat.category_id}">
<div class="category-header" onclick="toggleCategory(${cat.category_id})">
<div class="category-name">${cat.category_name}</div>
<div class="category-badge">
<span class="severity-badge ${cat.severity || 'medium'}">${severityLabel[cat.severity] || '보통'}</span>
<span class="item-count">${catItems.length}개 항목</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); openCategoryModal(${cat.category_id})">수정</button>
</div>
</div>
<div class="category-items">
<div class="item-list">
${catItems.length > 0 ? catItems.map(item => `
<div class="item-card">
<div class="item-info">
<div class="item-name">${item.item_name}</div>
${item.description ? `<div class="item-desc">${item.description}</div>` : ''}
</div>
<div class="item-actions">
<span class="severity-badge ${item.severity || 'medium'}">${severityLabel[item.severity] || '보통'}</span>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id}, ${item.item_id})">수정</button>
</div>
</div>
`).join('') : '<div class="empty-state" style="padding: 24px;">등록된 항목이 없습니다.</div>'}
</div>
<div class="add-item-form">
<input type="text" id="newItemName_${cat.category_id}" placeholder="새 항목 이름">
<button class="btn btn-primary btn-sm" onclick="quickAddItem(${cat.category_id})">추가</button>
<button class="btn btn-secondary btn-sm" onclick="openItemModal(${cat.category_id})">상세 추가</button>
</div>
</div>
</div>
`;
}).join('');
}
/**
* 카테고리 토글
*/
window.toggleCategory = function(categoryId) {
const section = document.querySelector(`.category-section[data-category-id="${categoryId}"]`);
if (section) {
section.classList.toggle('expanded');
}
};
/**
* 카테고리 모달 열기
*/
window.openCategoryModal = function(categoryId = null) {
const modal = document.getElementById('categoryModal');
const title = document.getElementById('categoryModalTitle');
const deleteBtn = document.getElementById('deleteCategoryBtn');
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryDescription').value = '';
document.getElementById('categorySeverity').value = 'medium';
if (categoryId) {
const category = categories.find(c => c.category_id === categoryId);
if (category) {
title.textContent = '카테고리 수정';
document.getElementById('categoryId').value = category.category_id;
document.getElementById('categoryName').value = category.category_name;
document.getElementById('categoryDescription').value = category.description || '';
document.getElementById('categorySeverity').value = category.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
title.textContent = '새 카테고리';
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 카테고리 모달 닫기
*/
window.closeCategoryModal = function() {
document.getElementById('categoryModal').style.display = 'none';
};
/**
* 카테고리 저장
*/
window.saveCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value.trim();
const description = document.getElementById('categoryDescription').value.trim();
const severity = document.getElementById('categorySeverity').value;
if (!name) {
alert('카테고리 이름을 입력하세요.');
return;
}
try {
const url = categoryId
? `${API}/work-issues/categories/${categoryId}`
: `${API}/work-issues/categories`;
const response = await fetch(url, {
method: categoryId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_name: name,
category_type: currentType,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(categoryId ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('카테고리 저장 실패:', error);
alert('카테고리 저장에 실패했습니다: ' + error.message);
}
};
/**
* 카테고리 삭제
*/
window.deleteCategory = async function() {
const categoryId = document.getElementById('categoryId').value;
if (!categoryId) return;
const catItems = items.filter(item => item.category_id == categoryId);
if (catItems.length > 0) {
alert(`이 카테고리에 ${catItems.length}개의 항목이 있습니다. 먼저 항목을 삭제하세요.`);
return;
}
if (!confirm('이 카테고리를 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/categories/${categoryId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('카테고리가 삭제되었습니다.');
closeCategoryModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
alert('카테고리 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 항목 모달 열기
*/
window.openItemModal = function(categoryId, itemId = null) {
const modal = document.getElementById('itemModal');
const title = document.getElementById('itemModalTitle');
const deleteBtn = document.getElementById('deleteItemBtn');
document.getElementById('itemId').value = '';
document.getElementById('itemCategoryId').value = categoryId;
document.getElementById('itemName').value = '';
document.getElementById('itemDescription').value = '';
document.getElementById('itemSeverity').value = 'medium';
if (itemId) {
const item = items.find(i => i.item_id === itemId);
if (item) {
title.textContent = '항목 수정';
document.getElementById('itemId').value = item.item_id;
document.getElementById('itemName').value = item.item_name;
document.getElementById('itemDescription').value = item.description || '';
document.getElementById('itemSeverity').value = item.severity || 'medium';
deleteBtn.style.display = 'block';
}
} else {
const category = categories.find(c => c.category_id === categoryId);
title.textContent = `새 항목 (${category?.category_name || ''})`;
deleteBtn.style.display = 'none';
}
modal.style.display = 'flex';
};
/**
* 항목 모달 닫기
*/
window.closeItemModal = function() {
document.getElementById('itemModal').style.display = 'none';
};
/**
* 항목 저장
*/
window.saveItem = async function() {
const itemId = document.getElementById('itemId').value;
const categoryId = document.getElementById('itemCategoryId').value;
const name = document.getElementById('itemName').value.trim();
const description = document.getElementById('itemDescription').value.trim();
const severity = document.getElementById('itemSeverity').value;
if (!name) {
alert('항목 이름을 입력하세요.');
return;
}
try {
const url = itemId
? `${API}/work-issues/items/${itemId}`
: `${API}/work-issues/items`;
const response = await fetch(url, {
method: itemId ? 'PUT' : 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
description,
severity
})
});
const data = await response.json();
if (response.ok && data.success) {
alert(itemId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '저장 실패');
}
} catch (error) {
console.error('항목 저장 실패:', error);
alert('항목 저장에 실패했습니다: ' + error.message);
}
};
/**
* 항목 삭제
*/
window.deleteItem = async function() {
const itemId = document.getElementById('itemId').value;
if (!itemId) return;
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
try {
const response = await fetch(`${API}/work-issues/items/${itemId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const data = await response.json();
if (response.ok && data.success) {
alert('항목이 삭제되었습니다.');
closeItemModal();
await loadCategories();
} else {
throw new Error(data.error || '삭제 실패');
}
} catch (error) {
console.error('항목 삭제 실패:', error);
alert('항목 삭제에 실패했습니다: ' + error.message);
}
};
/**
* 빠른 항목 추가
*/
window.quickAddItem = async function(categoryId) {
const input = document.getElementById(`newItemName_${categoryId}`);
const name = input.value.trim();
if (!name) {
alert('항목 이름을 입력하세요.');
input.focus();
return;
}
try {
const response = await fetch(`${API}/work-issues/items`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
category_id: categoryId,
item_name: name,
severity: 'medium'
})
});
const data = await response.json();
if (response.ok && data.success) {
input.value = '';
await loadCategories();
// 카테고리 펼침 유지
toggleCategory(categoryId);
} else {
throw new Error(data.error || '추가 실패');
}
} catch (error) {
console.error('항목 추가 실패:', error);
alert('항목 추가에 실패했습니다: ' + error.message);
}
};

View File

@@ -0,0 +1,49 @@
// /js/login.js
import { saveAuthData, clearAuthData } from './auth.js';
import { redirectToDefaultDashboard } from './navigation.js';
// api-helper.js가 ES6 모듈로 변환되면 import를 사용해야 합니다.
// import { login } from './api-helper.js';
document.getElementById('loginForm').addEventListener('submit', async function (e) {
e.preventDefault();
const username = document.getElementById('username').value;
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 {
// 현재는 window 객체를 통해 호출하지만, 향후 모듈화 필요
const result = await window.login(username, password);
if (result.success && result.data && result.data.token) {
// auth.js에서 가져온 함수로 인증 정보 저장
saveAuthData(result.data.token, result.data.user);
// navigation.js를 통해 리디렉션
redirectToDefaultDashboard(result.data.redirectUrl);
} else {
// api-helper가 에러를 throw하므로 이 블록은 실행될 가능성이 낮음
clearAuthData();
errorDiv.textContent = result.error || '로그인에 실패했습니다.';
errorDiv.style.display = 'block';
}
} catch (err) {
console.error('로그인 오류:', err);
clearAuthData();
errorDiv.textContent = err.message || '서버 연결에 실패했습니다.';
errorDiv.style.display = 'block';
} finally {
// 로딩 상태 해제
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});

View File

@@ -0,0 +1,86 @@
import { API, getAuthHeaders } from '/js/api-config.js';
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key];
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
const form = document.getElementById('issueTypeForm');
form?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
category: document.getElementById('category').value,
subcategory: document.getElementById('subcategory').value
};
try {
const res = await fetch(`${API}/issue-types`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
form.reset();
loadIssueTypes();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
async function loadIssueTypes() {
const tbody = document.getElementById('issueTypeTableBody');
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/issue-types`, {
headers: getAuthHeaders()
});
const list = await res.json();
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, ['issue_type_id', 'category', 'subcategory'], async t => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/issue-types/${t.issue_type_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
loadIssueTypes();
} else {
alert('삭제 실패');
}
} catch (err) {
alert('삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
}
}
document.addEventListener('DOMContentLoaded', () => {
loadIssueTypes();
});

View File

@@ -0,0 +1,93 @@
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
ensureAuthenticated();
// 행 생성
function createRow(item, delHandler) {
const tr = document.createElement('tr');
const label = `${item.material} / ${item.diameter_in} / ${item.schedule}`;
tr.innerHTML = `
<td>${item.spec_id}</td>
<td>${label}</td>
<td><button class="btn-delete">삭제</button></td>
`;
tr.querySelector('.btn-delete').onclick = () => delHandler(item);
return tr;
}
// 등록
document.getElementById('specForm')?.addEventListener('submit', async e => {
e.preventDefault();
const material = document.getElementById('material').value.trim();
const diameter = document.getElementById('diameter_in').value.trim();
const schedule = document.getElementById('schedule').value.trim();
if (!material || !diameter || !schedule) {
return alert('모든 항목을 입력하세요.');
}
try {
const res = await fetch(`${API}/pipespecs`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ material, diameter_in: diameter, schedule })
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
e.target.reset();
loadSpecs();
} else {
alert('❌ 실패: ' + (result.error || '등록 실패'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
// 불러오기
async function loadSpecs() {
const tbody = document.getElementById('specTableBody');
tbody.innerHTML = '<tr><td colspan="3">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/pipespecs`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const list = await res.json();
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, async (spec) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/pipespecs/${spec.spec_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
loadSpecs();
} else {
alert('삭제 실패');
}
} catch (err) {
alert('삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="3">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="3">로드 실패: ' + err.message + '</td></tr>';
}
}
window.addEventListener('DOMContentLoaded', loadSpecs);

View File

@@ -0,0 +1,90 @@
// /js/manage-project.js
// The ensureAuthenticated, API, and getAuthHeaders functions are now handled by the global api-helper.js
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key];
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
const projectForm = document.getElementById('projectForm');
projectForm?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
job_no: document.getElementById('job_no').value.trim(),
project_name: document.getElementById('project_name').value.trim(),
contract_date: document.getElementById('contract_date').value,
due_date: document.getElementById('due_date').value,
delivery_method: document.getElementById('delivery_method').value.trim(),
site: document.getElementById('site').value.trim(),
pm: document.getElementById('pm').value.trim()
};
if (!body.project_name || !body.job_no) {
return alert('필수 항목을 입력하세요.');
}
try {
const result = await apiPost('/projects', body);
if (result.success) {
alert('✅ 등록 완료');
projectForm.reset();
loadProjects();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
async function loadProjects() {
const tbody = document.getElementById('projectTableBody');
tbody.innerHTML = '<tr><td colspan="9">불러오는 중...</td></tr>';
try {
const result = await apiGet('/projects');
tbody.innerHTML = '';
if (result.success && Array.isArray(result.data)) {
result.data.forEach(item => {
const row = createRow(item, [
'project_id', 'job_no', 'project_name', 'contract_date',
'due_date', 'delivery_method', 'site', 'pm'
], async p => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await apiDelete(`/projects/${p.project_id}`);
if (delRes.success) {
alert('✅ 삭제 완료');
loadProjects();
} else {
alert('❌ 삭제 실패: ' + (delRes.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="9">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="9">로드 실패: ' + err.message + '</td></tr>';
}
}
window.addEventListener('DOMContentLoaded', loadProjects);

View File

@@ -0,0 +1,111 @@
// /js/manage-worker.js
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
ensureAuthenticated();
// ✅ 테이블 행 생성
function createRow(item, cols, delHandler) {
const tr = document.createElement('tr');
cols.forEach(key => {
const td = document.createElement('td');
td.textContent = item[key] || '-';
tr.appendChild(td);
});
const delBtn = document.createElement('button');
delBtn.textContent = '삭제';
delBtn.className = 'btn-delete';
delBtn.onclick = () => delHandler(item);
const td = document.createElement('td');
td.appendChild(delBtn);
tr.appendChild(td);
return tr;
}
// ✅ 작업자 등록
const workerForm = document.getElementById('workerForm');
workerForm?.addEventListener('submit', async e => {
e.preventDefault();
const body = {
worker_name: document.getElementById('workerName').value.trim(),
position: document.getElementById('position').value.trim()
};
if (!body.worker_name || !body.position) {
return alert('모든 필드를 입력해주세요.');
}
try {
const res = await fetch(`${API}/workers`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
});
const result = await res.json();
if (res.ok && result.success) {
alert('✅ 등록 완료');
workerForm.reset();
loadWorkers();
} else {
alert('❌ 실패: ' + (result.error || '알 수 없는 오류'));
}
} catch (err) {
alert('🚨 서버 오류: ' + err.message);
}
});
// ✅ 작업자 목록 불러오기
async function loadWorkers() {
const tbody = document.getElementById('workerTableBody');
tbody.innerHTML = '<tr><td colspan="4">불러오는 중...</td></tr>';
try {
const res = await fetch(`${API}/workers`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const response = await res.json();
const list = response.data || response; // 새로운 API 응답 구조 지원
tbody.innerHTML = '';
if (Array.isArray(list)) {
list.forEach(item => {
const row = createRow(item, ['user_id', 'worker_name', 'position'], async w => {
if (!confirm('삭제하시겠습니까?')) return;
try {
const delRes = await fetch(`${API}/workers/${w.user_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (delRes.ok) {
alert('✅ 삭제 완료');
loadWorkers();
} else {
alert('❌ 삭제 실패');
}
} catch (err) {
alert('🚨 삭제 중 오류: ' + err.message);
}
});
tbody.appendChild(row);
});
} else {
tbody.innerHTML = '<tr><td colspan="4">데이터 형식 오류</td></tr>';
}
} catch (err) {
tbody.innerHTML = '<tr><td colspan="4">로드 실패: ' + err.message + '</td></tr>';
}
}
// ✅ 초기 로딩
window.addEventListener('DOMContentLoaded', loadWorkers);

View File

@@ -0,0 +1,940 @@
// management-dashboard.js - 관리자 대시보드 전용 스크립트
// =================================================================
// 🌐 통합 API 설정 import
// =================================================================
import { API, getAuthHeaders, apiCall } from '/js/api-config.js';
// 전역 변수
let workers = [];
let workData = [];
let filteredWorkData = [];
let currentDate = '';
let currentUser = null;
// 권한 레벨 매핑
const ACCESS_LEVELS = {
worker: 1,
group_leader: 2,
support_team: 3,
admin: 4,
system: 5
};
// 한국 시간 기준 오늘 날짜 가져오기
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 getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
return payload;
}
} catch (error) {
}
try {
const userInfo = localStorage.getItem('sso_user');
if (userInfo) {
const parsed = JSON.parse(userInfo);
return parsed;
}
} catch (error) {
}
return null;
}
// 권한 체크 함수
function checkPermission() {
currentUser = getCurrentUser();
if (!currentUser) {
showMessage('로그인이 필요합니다.', 'error');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return false;
}
const userAccessLevel = currentUser.access_level;
const accessLevelValue = ACCESS_LEVELS[userAccessLevel] || 0;
console.log('사용자 권한 체크:', {
username: currentUser.username || currentUser.name,
access_level: userAccessLevel,
level_value: accessLevelValue,
required_level: ACCESS_LEVELS.group_leader
});
if (accessLevelValue < ACCESS_LEVELS.group_leader) {
showMessage('그룹장 이상의 권한이 필요합니다. 현재 권한: ' + userAccessLevel, 'error');
setTimeout(() => {
window.location.href = '/';
}, 3000);
return false;
}
return true;
}
// 메시지 표시
function showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.innerHTML = `<div class="message ${type}">${message}</div>`;
if (type === 'success') {
setTimeout(() => {
hideMessage();
}, 5000);
}
}
function hideMessage() {
document.getElementById('message-container').innerHTML = '';
}
// 로딩 표시
function showLoading() {
document.getElementById('loadingSpinner').style.display = 'flex';
document.getElementById('summarySection').style.display = 'none';
document.getElementById('actionBar').style.display = 'none';
document.getElementById('workersSection').style.display = 'none';
document.getElementById('noDataMessage').style.display = 'none';
}
function hideLoading() {
document.getElementById('loadingSpinner').style.display = 'none';
}
// 작업자 데이터 로드
async function loadWorkers() {
try {
console.log('작업자 데이터 로딩 중... (통합 API)');
const data = await apiCall(`${API}/workers`);
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
// 활성화된 작업자만 필터링
workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
} catch (error) {
console.error('작업자 로딩 오류:', error);
throw error;
}
}
// 특정 날짜의 작업 데이터 로드 (개선된 버전)
async function loadWorkData(date) {
try {
console.log(`${date} 날짜의 작업 데이터 로딩 중... (통합 API)`);
// 1차: view_all=true로 전체 데이터 시도
let queryParams = `date=${date}&view_all=true`;
let data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
workData = Array.isArray(data) ? data : (data.data || []);
// 데이터가 없으면 다른 방법들 시도
if (workData.length === 0) {
// 2차: admin=true로 시도
queryParams = `date=${date}&admin=true`;
data = await apiCall(`${API}/daily-work-reports?${queryParams}`);
workData = Array.isArray(data) ? data : (data.data || []);
if (workData.length === 0) {
// 3차: 날짜 경로 파라미터로 시도
data = await apiCall(`${API}/daily-work-reports/date/${date}`);
workData = Array.isArray(data) ? data : (data.data || []);
if (workData.length === 0) {
// 4차: 기본 파라미터만으로 시도
data = await apiCall(`${API}/daily-work-reports?date=${date}`);
workData = Array.isArray(data) ? data : (data.data || []);
}
}
}
// 디버깅을 위한 상세 로그
if (workData.length > 0) {
const uniqueWorkers = [...new Set(workData.map(w => w.worker_name))];
} else {
}
return workData;
} catch (error) {
console.error('작업 데이터 로딩 오류:', error);
// 에러 시에도 빈 배열 반환하여 앱이 중단되지 않도록
workData = [];
// 구체적인 에러 정보 표시
if (error.message.includes('403')) {
throw new Error('해당 날짜의 데이터에 접근할 권한이 없습니다.');
} else if (error.message.includes('404')) {
throw new Error('해당 날짜에 입력된 작업 데이터가 없습니다.');
} else {
throw error;
}
}
}
// 대시보드 데이터 로드
async function loadDashboardData() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
showMessage('날짜를 선택해주세요.', 'error');
return;
}
currentDate = selectedDate;
showLoading();
hideMessage();
try {
// 병렬로 데이터 로드
await Promise.all([
loadWorkers(),
loadWorkData(selectedDate)
]);
// 데이터 분석 및 표시
const dashboardData = analyzeDashboardData();
displayDashboard(dashboardData);
hideLoading();
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
hideLoading();
showMessage('데이터를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
// 에러 시 데이터 없음 메시지 표시
document.getElementById('noDataMessage').style.display = 'block';
}
}
// 대시보드 데이터 분석 (개선된 버전)
function analyzeDashboardData() {
console.log('대시보드 데이터 분석 시작');
// 작업자별 데이터 그룹화
const workerWorkData = {};
workData.forEach(work => {
const userId = work.user_id;
if (!workerWorkData[userId]) {
workerWorkData[userId] = [];
}
workerWorkData[userId].push(work);
});
// 전체 통계 계산
const totalWorkers = workers.length;
const workersWithData = Object.keys(workerWorkData).length;
const workersWithoutData = totalWorkers - workersWithData;
const totalHours = workData.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
const totalEntries = workData.length;
const errorCount = workData.filter(work => work.work_status_id === 2).length;
// 작업자별 상세 분석 (개선된 버전)
const workerAnalysis = workers.map(worker => {
const workerWorks = workerWorkData[worker.user_id] || [];
const workerHours = workerWorks.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0);
// 작업 유형 분석 (실제 이름으로)
const workTypes = [...new Set(workerWorks.map(work => work.work_type_name).filter(Boolean))];
// 프로젝트 분석
const workerProjects = [...new Set(workerWorks.map(work => work.project_name).filter(Boolean))];
// 기여자 분석
const workerContributors = [...new Set(workerWorks.map(work => work.created_by_name).filter(Boolean))];
// 상태 결정 (더 세밀한 기준)
let status = 'missing';
if (workerWorks.length > 0) {
if (workerHours >= 6) {
status = 'completed'; // 6시간 이상을 완료로 간주
} else {
status = 'partial'; // 1시간 이상이지만 6시간 미만은 부분입력
}
}
// 최근 업데이트 시간
const lastUpdate = workerWorks.length > 0
? new Date(Math.max(...workerWorks.map(work => new Date(work.created_at))))
: null;
return {
...worker,
status,
totalHours: Math.round(workerHours * 10) / 10, // 소수점 1자리로 반올림
entryCount: workerWorks.length,
workTypes, // 작업 유형 배열 (실제 이름)
projects: workerProjects,
contributors: workerContributors,
lastUpdate,
works: workerWorks
};
});
const summary = {
totalWorkers,
completedWorkers: workerAnalysis.filter(w => w.status === 'completed').length,
missingWorkers: workerAnalysis.filter(w => w.status === 'missing').length,
partialWorkers: workerAnalysis.filter(w => w.status === 'partial').length,
totalHours: Math.round(totalHours * 10) / 10,
totalEntries,
errorCount
};
console.log('대시보드 분석 결과:', { summary, workerAnalysis });
return {
summary,
workers: workerAnalysis,
date: currentDate
};
}
// 대시보드 표시
function displayDashboard(data) {
displaySummary(data.summary);
displayWorkers(data.workers);
// 섹션 표시
document.getElementById('summarySection').style.display = 'block';
document.getElementById('actionBar').style.display = 'flex';
document.getElementById('workersSection').style.display = 'block';
// 필터링 설정
filteredWorkData = data.workers;
setupFiltering();
}
// 요약 섹션 표시
function displaySummary(summary) {
document.getElementById('totalWorkers').textContent = summary.totalWorkers;
document.getElementById('completedWorkers').textContent = summary.completedWorkers;
document.getElementById('missingWorkers').textContent = summary.missingWorkers;
document.getElementById('totalHours').textContent = summary.totalHours + 'h';
document.getElementById('totalEntries').textContent = summary.totalEntries;
document.getElementById('errorCount').textContent = summary.errorCount;
}
// 작업자 목록 표시 (테이블 형태로 개선)
function displayWorkers(workersData) {
const tableBody = document.getElementById('workersTableBody');
tableBody.innerHTML = '';
if (workersData.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="9" class="no-data-row">표시할 작업자가 없습니다.</td>
</tr>
`;
return;
}
workersData.forEach(worker => {
const row = createWorkerRow(worker);
tableBody.appendChild(row);
});
}
// 작업자 테이블 행 생성 (개선된 버전)
function createWorkerRow(worker) {
const row = document.createElement('tr');
const statusText = {
completed: '✅ 완료',
missing: '❌ 미입력',
partial: '⚠️ 부분입력'
};
const statusClass = {
completed: 'completed',
missing: 'missing',
partial: 'partial'
};
// 작업 유형 태그 생성 (실제 이름으로)
const workTypeTags = worker.workTypes && worker.workTypes.length > 0
? worker.workTypes.map(type => `<span class="work-type-tag">${type}</span>`).join('')
: '<span class="work-type-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 프로젝트 태그 생성
const projectTags = worker.projects && worker.projects.length > 0
? worker.projects.map(project => `<span class="project-tag">${project}</span>`).join('')
: '<span class="project-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 기여자 태그 생성
const contributorTags = worker.contributors && worker.contributors.length > 0
? worker.contributors.map(contributor => `<span class="contributor-tag">${contributor}</span>`).join('')
: '<span class="contributor-tag" style="background: #f8f9fa; color: #6c757d;">없음</span>';
// 시간에 따른 스타일 클래스
let hoursClass = 'zero';
if (worker.totalHours > 0) {
hoursClass = worker.totalHours >= 6 ? 'full' : 'partial';
}
// 업데이트 시간 포맷팅 및 스타일
let updateTimeText = '없음';
let updateClass = '';
if (worker.lastUpdate) {
const now = new Date();
const diff = now - worker.lastUpdate;
const hours = diff / (1000 * 60 * 60);
updateTimeText = formatDateTime(worker.lastUpdate);
updateClass = hours < 1 ? 'recent' : hours > 24 ? 'old' : '';
}
row.innerHTML = `
<td>
<div class="worker-name-cell">
👤 ${worker.worker_name}
</div>
</td>
<td>
<span class="status-badge ${statusClass[worker.status]}">${statusText[worker.status]}</span>
</td>
<td>
<div class="hours-cell ${hoursClass}">${worker.totalHours}h</div>
</td>
<td>
<strong>${worker.entryCount}</strong>개
</td>
<td>
<div class="work-types-container">${workTypeTags}</div>
</td>
<td>
<div class="projects-container">${projectTags}</div>
</td>
<td>
<div class="contributors-container">${contributorTags}</div>
</td>
<td>
<div class="update-time ${updateClass}">${updateTimeText}</div>
</td>
<td>
<button class="detail-btn" onclick="showWorkerDetailSafe('${worker.user_id}')">
📋 상세
</button>
</td>
`;
return row;
}
// 날짜/시간 포맷팅
function formatDateTime(date) {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 작업자 상세 모달 표시 (안전한 버전)
function showWorkerDetailSafe(workerId) {
// 현재 분석된 데이터에서 해당 작업자 찾기
const worker = filteredWorkData.find(w => w.user_id == workerId);
if (!worker) {
showMessage('작업자 정보를 찾을 수 없습니다.', 'error');
return;
}
showWorkerDetail(worker);
}
// 작업자 상세 모달 표시 (개선된 버전)
function showWorkerDetail(worker) {
const modal = document.getElementById('workerDetailModal');
const modalTitle = document.getElementById('modalWorkerName');
const modalBody = document.getElementById('modalWorkerDetails');
modalTitle.textContent = `👤 ${worker.worker_name} 상세 현황`;
let detailHtml = `
<div style="margin-bottom: 20px;">
<h4>📊 기본 정보</h4>
<p><strong>작업자명:</strong> ${worker.worker_name}</p>
<p><strong>총 작업시간:</strong> ${worker.totalHours}시간</p>
<p><strong>작업 항목 수:</strong> ${worker.entryCount}개</p>
<p><strong>상태:</strong> ${worker.status === 'completed' ? '✅ 완료' : worker.status === 'missing' ? '❌ 미입력' : '⚠️ 부분입력'}</p>
<p><strong>작업 유형:</strong> ${worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join(', ') : '없음'}</p>
</div>
`;
if (worker.works && worker.works.length > 0) {
detailHtml += `
<div style="margin-bottom: 20px;">
<h4>🔧 작업 내역</h4>
<div style="max-height: 400px; overflow-y: auto;">
`;
worker.works.forEach((work, index) => {
detailHtml += `
<div style="border: 1px solid #e9ecef; padding: 15px; margin-bottom: 10px; border-radius: 8px; background: #f8f9fa; position: relative;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
<p><strong>작업 ${index + 1}</strong></p>
<div style="display: flex; gap: 8px;">
<button onclick="editWorkItem('${work.id}')" style="background: #007bff; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
✏️ 수정
</button>
<button onclick="deleteWorkItem('${work.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
🗑️ 삭제
</button>
</div>
</div>
<p><strong>프로젝트:</strong> ${work.project_name || '미지정'}</p>
<p><strong>작업 유형:</strong> ${work.work_type_name || '미지정'}</p>
<p><strong>작업 시간:</strong> ${work.work_hours}시간</p>
<p><strong>상태:</strong> ${work.work_status_name || '미지정'}</p>
${work.error_type_name ? `<p><strong>에러 유형:</strong> ${work.error_type_name}</p>` : ''}
<p><strong>입력자:</strong> ${work.created_by_name || '미지정'}</p>
<p><strong>입력 시간:</strong> ${formatDateTime(work.created_at)}</p>
</div>
`;
});
detailHtml += `
</div>
</div>
`;
} else {
detailHtml += `
<div style="margin-bottom: 20px;">
<h4>📭 작업 내역</h4>
<p style="text-align: center; color: #666; padding: 20px;">입력된 작업이 없습니다.</p>
</div>
`;
}
if (worker.contributors && worker.contributors.length > 0) {
detailHtml += `
<div>
<h4>👥 기여자</h4>
<p>${worker.contributors.join(', ')}</p>
</div>
`;
}
modalBody.innerHTML = detailHtml;
modal.style.display = 'flex';
}
// 작업 항목 수정 함수 (통합 API 사용)
async function editWorkItem(workId) {
try {
console.log('수정할 작업 ID:', workId);
// 현재 작업 데이터에서 해당 작업 찾기
let workData = null;
for (const worker of filteredWorkData) {
if (worker.works) {
workData = worker.works.find(work => work.id == workId);
if (workData) break;
}
}
if (!workData) {
showMessage('수정할 작업을 찾을 수 없습니다.', 'error');
return;
}
// 필요한 마스터 데이터 로드
await loadMasterDataForEdit();
// 수정 모달 표시
showEditModal(workData);
} catch (error) {
console.error('작업 정보 조회 오류:', error);
showMessage('작업 정보를 불러올 수 없습니다: ' + error.message, 'error');
}
}
// 수정용 마스터 데이터 로드
async function loadMasterDataForEdit() {
try {
if (!window.projects || window.projects.length === 0) {
const projectData = await apiCall(`${API}/projects`);
window.projects = Array.isArray(projectData) ? projectData : (projectData.projects || []);
}
if (!window.workTypes || window.workTypes.length === 0) {
const workTypeData = await apiCall(`${API}/daily-work-reports/work-types`);
window.workTypes = Array.isArray(workTypeData) ? workTypeData : [];
}
if (!window.workStatusTypes || window.workStatusTypes.length === 0) {
const statusData = await apiCall(`${API}/daily-work-reports/work-status-types`);
window.workStatusTypes = Array.isArray(statusData) ? statusData : [];
}
if (!window.errorTypes || window.errorTypes.length === 0) {
const errorData = await apiCall(`${API}/daily-work-reports/error-types`);
window.errorTypes = Array.isArray(errorData) ? errorData : [];
}
} catch (error) {
console.error('마스터 데이터 로드 오류:', error);
// 기본값 설정
window.projects = window.projects || [];
window.workTypes = window.workTypes || [
{id: 1, name: 'Base'},
{id: 2, name: 'Vessel'},
{id: 3, name: 'Piping'}
];
window.workStatusTypes = window.workStatusTypes || [
{id: 1, name: '정규'},
{id: 2, name: '에러'}
];
window.errorTypes = window.errorTypes || [
{id: 1, name: '설계미스'},
{id: 2, name: '외주작업 불량'},
{id: 3, name: '입고지연'},
{id: 4, name: '작업 불량'}
];
}
}
// 수정 모달 표시
function showEditModal(workData) {
// 기존 상세 모달 닫기
closeWorkerDetailModal();
const modalHtml = `
<div class="edit-modal" id="editModal">
<div class="edit-modal-content">
<div class="edit-modal-header">
<h3>✏️ 작업 수정</h3>
<button class="close-modal-btn" onclick="closeEditModal()">×</button>
</div>
<div class="edit-modal-body">
<div class="edit-form-group">
<label>🏗️ 프로젝트</label>
<select class="edit-select" id="editProject">
<option value="">프로젝트 선택</option>
${(window.projects || []).map(p => `
<option value="${p.project_id}" ${p.project_id == workData.project_id ? 'selected' : ''}>
${p.project_name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⚙️ 작업 유형</label>
<select class="edit-select" id="editWorkType">
<option value="">작업 유형 선택</option>
${(window.workTypes || []).map(wt => `
<option value="${wt.id}" ${wt.id == workData.work_type_id ? 'selected' : ''}>
${wt.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>📊 업무 상태</label>
<select class="edit-select" id="editWorkStatus">
<option value="">업무 상태 선택</option>
${(window.workStatusTypes || []).map(ws => `
<option value="${ws.id}" ${ws.id == workData.work_status_id ? 'selected' : ''}>
${ws.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group" id="editErrorTypeGroup" style="${workData.work_status_id == 2 ? '' : 'display: none;'}">
<label>❌ 에러 유형</label>
<select class="edit-select" id="editErrorType">
<option value="">에러 유형 선택</option>
${(window.errorTypes || []).map(et => `
<option value="${et.id}" ${et.id == workData.error_type_id ? 'selected' : ''}>
${et.name}
</option>
`).join('')}
</select>
</div>
<div class="edit-form-group">
<label>⏰ 작업 시간</label>
<input type="number" class="edit-input" id="editWorkHours"
value="${workData.work_hours}"
min="0" max="24" step="0.5">
</div>
</div>
<div class="edit-modal-footer">
<button class="btn btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn btn-success" onclick="saveEditedWork('${workData.id}')">💾 저장</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 업무 상태 변경 이벤트
document.getElementById('editWorkStatus').addEventListener('change', (e) => {
const errorTypeGroup = document.getElementById('editErrorTypeGroup');
if (e.target.value === '2') {
errorTypeGroup.style.display = 'block';
} else {
errorTypeGroup.style.display = 'none';
}
});
}
// 수정 모달 닫기
function closeEditModal() {
const modal = document.getElementById('editModal');
if (modal) {
modal.remove();
}
}
// 수정된 작업 저장 (통합 API 사용)
async function saveEditedWork(workId) {
try {
const projectId = document.getElementById('editProject').value;
const workTypeId = document.getElementById('editWorkType').value;
const workStatusId = document.getElementById('editWorkStatus').value;
const errorTypeId = document.getElementById('editErrorType').value;
const workHours = document.getElementById('editWorkHours').value;
if (!projectId || !workTypeId || !workStatusId || !workHours) {
showMessage('모든 필수 항목을 입력해주세요.', 'error');
return;
}
if (workStatusId === '2' && !errorTypeId) {
showMessage('에러 상태인 경우 에러 유형을 선택해주세요.', 'error');
return;
}
const updateData = {
project_id: parseInt(projectId),
work_type_id: parseInt(workTypeId),
work_status_id: parseInt(workStatusId),
error_type_id: errorTypeId ? parseInt(errorTypeId) : null,
work_hours: parseFloat(workHours)
};
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
method: 'PUT',
body: JSON.stringify(updateData)
});
showMessage('✅ 작업이 성공적으로 수정되었습니다!', 'success');
closeEditModal();
closeWorkerDetailModal();
// 데이터 새로고침
await loadDashboardData();
} catch (error) {
console.error(' 수정 실패:', error);
showMessage('수정 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 작업 항목 삭제 함수 (통합 API 사용)
async function deleteWorkItem(workId) {
if (!confirm('정말로 이 작업을 삭제하시겠습니까?\n삭제된 작업은 복구할 수 없습니다.')) {
return;
}
try {
console.log('삭제할 작업 ID:', workId);
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
// 개별 항목 삭제 API 호출 - 통합 API 사용
const result = await apiCall(`${API}/daily-work-reports/${workId}`, {
method: 'DELETE'
});
showMessage('✅ 작업이 성공적으로 삭제되었습니다!', 'success');
closeWorkerDetailModal();
// 데이터 새로고침
await loadDashboardData();
} catch (error) {
console.error(' 삭제 실패:', error);
showMessage('삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
// 작업자 상세 모달 닫기
function closeWorkerDetailModal() {
document.getElementById('workerDetailModal').style.display = 'none';
}
// 필터링 설정
function setupFiltering() {
const showOnlyMissingCheckbox = document.getElementById('showOnlyMissing');
showOnlyMissingCheckbox.addEventListener('change', (e) => {
if (e.target.checked) {
// 미입력자만 필터링
const missingWorkers = filteredWorkData.filter(worker => worker.status === 'missing');
displayWorkers(missingWorkers);
} else {
// 전체 표시
displayWorkers(filteredWorkData);
}
});
}
// 엑셀 다운로드 (개선된 버전)
function exportToExcel() {
try {
// CSV 형태로 데이터 구성 (개선된 버전)
let csvContent = "작업자명,상태,총시간,작업항목수,작업유형,프로젝트,기여자,최근업데이트\n";
filteredWorkData.forEach(worker => {
const statusText = {
completed: '완료',
missing: '미입력',
partial: '부분입력'
};
const workTypes = worker.workTypes && worker.workTypes.length > 0 ? worker.workTypes.join('; ') : '없음';
const projects = worker.projects && worker.projects.length > 0 ? worker.projects.join('; ') : '없음';
const contributors = worker.contributors && worker.contributors.length > 0 ? worker.contributors.join('; ') : '없음';
const lastUpdate = worker.lastUpdate ? formatDateTime(worker.lastUpdate) : '없음';
csvContent += `"${worker.worker_name}","${statusText[worker.status]}","${worker.totalHours}","${worker.entryCount}","${workTypes}","${projects}","${contributors}","${lastUpdate}"\n`;
});
// UTF-8 BOM 추가 (한글 깨짐 방지)
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `작업현황_${currentDate}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showMessage('✅ 엑셀 파일이 다운로드되었습니다!', 'success');
} catch (error) {
console.error('엑셀 다운로드 오류:', error);
showMessage('엑셀 다운로드 중 오류가 발생했습니다.', 'error');
}
}
// 새로고침
function refreshData() {
loadDashboardData();
}
// 이벤트 리스너 설정
function setupEventListeners() {
document.getElementById('loadDataBtn').addEventListener('click', loadDashboardData);
document.getElementById('refreshBtn').addEventListener('click', refreshData);
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
// 엔터키로 조회
document.getElementById('selectedDate').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
loadDashboardData();
}
});
}
// 초기화
async function init() {
try {
// 권한 체크
if (!checkPermission()) {
return;
}
// 권한 체크 메시지 숨기기
document.getElementById('permission-check-message').style.display = 'none';
// 오늘 날짜 설정
document.getElementById('selectedDate').value = getKoreaToday();
// 이벤트 리스너 설정
setupEventListeners();
// 자동으로 오늘 데이터 로드
loadDashboardData();
} catch (error) {
console.error('초기화 오류:', error);
showMessage('초기화 중 오류가 발생했습니다.', 'error');
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
// 권한 체크 메시지 표시
document.getElementById('permission-check-message').style.display = 'block';
// 토큰 확인
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
}
// 초기화 실행
init();
});
// 전역 함수로 노출
window.closeWorkerDetailModal = closeWorkerDetailModal;
window.refreshData = refreshData;
window.showWorkerDetailSafe = showWorkerDetailSafe;
window.showWorkerDetail = showWorkerDetail;
window.editWorkItem = editWorkItem;
window.deleteWorkItem = deleteWorkItem;
window.closeEditModal = closeEditModal;
window.saveEditedWork = saveEditedWork;

View File

@@ -0,0 +1,358 @@
/* meeting-detail.js — 회의록 상세/작성 */
let meetingId = null;
let meetingData = null;
let selectedAttendees = []; // [{user_id, name, username}]
let projects = [];
let users = [];
let canEdit = false;
let isAdmin = false;
let isPublished = false;
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
isAdmin = ['admin', 'system', 'system admin'].includes(role);
// Parse URL
const params = new URLSearchParams(location.search);
meetingId = params.get('id');
// Load master data
try {
const [projRes, userRes] = await Promise.all([
api('/projects'),
api('/users')
]);
projects = projRes.data || [];
users = (userRes.data || []).filter(u => u.is_active !== 0);
} catch {}
// Populate project select in item modal
const projSel = document.getElementById('itemProject');
projects.forEach(p => {
projSel.innerHTML += `<option value="${p.project_id}">${escapeHtml(p.job_no)} ${escapeHtml(p.project_name)}</option>`;
});
// Populate responsible user select
const respSel = document.getElementById('itemResponsible');
users.forEach(u => {
respSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name)} (${escapeHtml(u.username)})</option>`;
});
// Attendee search
const searchInput = document.getElementById('attendeeSearch');
const resultsDiv = document.getElementById('attendeeResults');
searchInput.addEventListener('input', debounce(() => {
const q = searchInput.value.trim().toLowerCase();
if (q.length < 1) { resultsDiv.classList.add('hidden'); return; }
const matches = users.filter(u =>
!selectedAttendees.some(a => a.user_id === u.user_id) &&
(u.name?.toLowerCase().includes(q) || u.username?.toLowerCase().includes(q))
).slice(0, 10);
if (matches.length === 0) { resultsDiv.classList.add('hidden'); return; }
resultsDiv.innerHTML = matches.map(u =>
`<div class="user-search-item" onclick="addAttendee(${u.user_id}, '${escapeHtml(u.name)}', '${escapeHtml(u.username)}')">${escapeHtml(u.name)} <span class="text-gray-400">(${escapeHtml(u.username)})</span></div>`
).join('');
resultsDiv.classList.remove('hidden');
}, 200));
searchInput.addEventListener('blur', () => setTimeout(() => resultsDiv.classList.add('hidden'), 200));
if (meetingId) {
await loadMeeting();
} else {
// New meeting
document.getElementById('meetingDate').value = new Date().toISOString().split('T')[0];
updateUI();
}
});
async function loadMeeting() {
try {
const res = await api(`/meetings/${meetingId}`);
meetingData = res.data;
isPublished = meetingData.status === 'published';
document.getElementById('pageTitle').textContent = meetingData.title;
document.getElementById('meetingDate').value = formatDate(meetingData.meeting_date);
document.getElementById('meetingTime').value = meetingData.meeting_time || '';
document.getElementById('meetingTitle').value = meetingData.title;
document.getElementById('meetingLocation').value = meetingData.location || '';
document.getElementById('meetingSummary').value = meetingData.summary || '';
// Status badge
const badge = document.getElementById('statusBadge');
badge.classList.remove('hidden');
if (isPublished) {
badge.className = 'badge badge-green';
badge.textContent = '발행';
} else {
badge.className = 'badge badge-gray';
badge.textContent = '초안';
}
// Attendees
selectedAttendees = (meetingData.attendees || []).map(a => ({
user_id: a.user_id, name: a.name, username: a.username
}));
renderAttendees();
// Agenda items
renderAgendaItems(meetingData.items || []);
updateUI();
} catch (err) {
showToast('회의록 로드 실패: ' + err.message, 'error');
}
}
function updateUI() {
const editable = canEdit && (!isPublished || isAdmin);
// Fields
['meetingDate', 'meetingTime', 'meetingTitle', 'meetingLocation', 'meetingSummary', 'attendeeSearch'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.disabled = !editable; if (!editable) el.classList.add('bg-gray-100'); }
});
// Buttons
document.getElementById('btnSave').classList.toggle('hidden', !editable);
document.getElementById('btnAddItem').classList.toggle('hidden', !editable);
document.getElementById('btnPublish').classList.toggle('hidden', !canEdit || isPublished || !meetingId);
document.getElementById('btnUnpublish').classList.toggle('hidden', !isAdmin || !isPublished);
document.getElementById('btnDelete').classList.toggle('hidden', !isAdmin || !meetingId);
}
/* ===== Attendees ===== */
function addAttendee(userId, name, username) {
if (selectedAttendees.some(a => a.user_id === userId)) return;
selectedAttendees.push({ user_id: userId, name, username });
renderAttendees();
document.getElementById('attendeeSearch').value = '';
document.getElementById('attendeeResults').classList.add('hidden');
}
function removeAttendee(userId) {
selectedAttendees = selectedAttendees.filter(a => a.user_id !== userId);
renderAttendees();
}
function renderAttendees() {
const container = document.getElementById('attendeeTags');
const editable = canEdit && (!isPublished || isAdmin);
container.innerHTML = selectedAttendees.map(a =>
`<span class="attendee-tag">${escapeHtml(a.name)}${editable ? ` <span class="remove-btn" onclick="removeAttendee(${a.user_id})">×</span>` : ''}</span>`
).join('');
}
/* ===== Agenda Items ===== */
function renderAgendaItems(items) {
const list = document.getElementById('agendaList');
const empty = document.getElementById('agendaEmpty');
if (items.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const typeLabels = { schedule_update: '공정현황', issue: '이슈', decision: '결정사항', action_item: '조치사항', other: '기타' };
const typeColors = { schedule_update: 'badge-blue', issue: 'badge-red', decision: 'badge-green', action_item: 'badge-amber', other: 'badge-gray' };
const statusLabels = { open: '미처리', in_progress: '진행중', completed: '완료', cancelled: '취소' };
const statusColors = { open: 'badge-amber', in_progress: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray' };
const editable = canEdit && (!isPublished || isAdmin);
const canUpdateStatus = ['group_leader', 'support_team', 'admin', 'system', 'system admin'].includes(currentUser?.role || '');
list.innerHTML = items.map(item => `
<div class="border rounded-lg p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<span class="badge ${typeColors[item.item_type] || 'badge-gray'}">${typeLabels[item.item_type] || item.item_type}</span>
<span class="badge ${statusColors[item.status] || 'badge-gray'}">${statusLabels[item.status] || item.status}</span>
${item.project_code ? `<span class="text-xs text-gray-400">${escapeHtml(item.project_code)}</span>` : ''}
${item.milestone_name ? `<span class="text-xs text-purple-500">◆ ${escapeHtml(item.milestone_name)}</span>` : ''}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
${canUpdateStatus && item.status !== 'completed' ? `<select class="text-xs border rounded px-1 py-0.5" onchange="updateItemStatus(${item.item_id}, this.value)">
<option value="">상태변경</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
</select>` : ''}
${editable ? `<button onclick="openItemModal(${item.item_id})" class="text-gray-400 hover:text-orange-600 text-xs px-1"><i class="fas fa-edit"></i></button>
<button onclick="deleteItem(${item.item_id})" class="text-gray-400 hover:text-red-600 text-xs px-1"><i class="fas fa-trash"></i></button>` : ''}
</div>
</div>
<p class="text-sm text-gray-800 mb-1">${escapeHtml(item.content)}</p>
${item.decision ? `<p class="text-sm text-green-700 bg-green-50 rounded p-2 mb-1"><strong>결정:</strong> ${escapeHtml(item.decision)}</p>` : ''}
${item.action_required ? `<p class="text-sm text-amber-700 bg-amber-50 rounded p-2 mb-1"><strong>조치:</strong> ${escapeHtml(item.action_required)}</p>` : ''}
<div class="flex items-center gap-4 text-xs text-gray-400 mt-2">
${item.responsible_name ? `<span><i class="fas fa-user mr-1"></i>${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="${new Date(item.due_date) < new Date() && item.status !== 'completed' ? 'text-red-500 font-semibold' : ''}"><i class="fas fa-clock mr-1"></i>${formatDate(item.due_date)}</span>` : ''}
${item.milestone_name ? `<a href="/pages/work/schedule.html?highlight=${item.milestone_id}" class="text-purple-500 hover:text-purple-700"><i class="fas fa-calendar-alt mr-1"></i>공정표 보기</a>` : ''}
</div>
</div>
`).join('');
}
/* ===== Save Meeting ===== */
async function saveMeeting() {
const title = document.getElementById('meetingTitle').value.trim();
const meetingDate = document.getElementById('meetingDate').value;
if (!title || !meetingDate) { showToast('날짜와 제목은 필수입니다.', 'error'); return; }
const data = {
meeting_date: meetingDate,
meeting_time: document.getElementById('meetingTime').value || null,
title,
location: document.getElementById('meetingLocation').value || null,
summary: document.getElementById('meetingSummary').value || null,
attendees: selectedAttendees.map(a => a.user_id)
};
try {
if (meetingId) {
await api(`/meetings/${meetingId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('회의록이 저장되었습니다.');
} else {
const res = await api('/meetings', { method: 'POST', body: JSON.stringify(data) });
meetingId = res.data.meeting_id;
history.replaceState(null, '', `?id=${meetingId}`);
showToast('회의록이 생성되었습니다.');
}
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function publishMeeting() {
if (!confirm('회의록을 발행하시겠습니까? 발행 후 일반 사용자는 수정할 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}/publish`, { method: 'PUT' });
showToast('회의록이 발행되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function unpublishMeeting() {
if (!confirm('발행을 취소하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/unpublish`, { method: 'PUT' });
showToast('발행이 취소되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteMeeting() {
if (!confirm('회의록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
try {
await api(`/meetings/${meetingId}`, { method: 'DELETE' });
showToast('회의록이 삭제되었습니다.');
location.href = '/pages/work/meetings.html';
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Agenda Item Modal ===== */
function openItemModal(editItemId) {
const modal = document.getElementById('itemModal');
const isEdit = !!editItemId;
document.getElementById('itemModalTitle').textContent = isEdit ? '안건 수정' : '안건 추가';
if (isEdit && meetingData) {
const item = meetingData.items.find(i => i.item_id === editItemId);
if (!item) return;
document.getElementById('itemId').value = editItemId;
document.getElementById('itemType').value = item.item_type;
document.getElementById('itemProject').value = item.project_id || '';
loadItemMilestones(item.milestone_id);
document.getElementById('itemContent').value = item.content;
document.getElementById('itemDecision').value = item.decision || '';
document.getElementById('itemAction').value = item.action_required || '';
document.getElementById('itemResponsible').value = item.responsible_user_id || '';
document.getElementById('itemDueDate').value = item.due_date ? formatDate(item.due_date) : '';
document.getElementById('itemStatus').value = item.status;
} else {
document.getElementById('itemId').value = '';
document.getElementById('itemType').value = 'schedule_update';
document.getElementById('itemProject').value = '';
document.getElementById('itemMilestone').innerHTML = '<option value="">선택안함</option>';
document.getElementById('itemContent').value = '';
document.getElementById('itemDecision').value = '';
document.getElementById('itemAction').value = '';
document.getElementById('itemResponsible').value = '';
document.getElementById('itemDueDate').value = '';
document.getElementById('itemStatus').value = 'open';
}
modal.classList.remove('hidden');
}
function closeItemModal() { document.getElementById('itemModal').classList.add('hidden'); }
async function loadItemMilestones(selectedId) {
const projectId = document.getElementById('itemProject').value;
const sel = document.getElementById('itemMilestone');
sel.innerHTML = '<option value="">선택안함</option>';
if (!projectId) return;
try {
const res = await api(`/schedule/milestones?project_id=${projectId}`);
(res.data || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.milestone_id;
opt.textContent = `${m.milestone_name} (${formatDate(m.milestone_date)})`;
if (selectedId && m.milestone_id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
} catch {}
}
async function saveItem() {
const content = document.getElementById('itemContent').value.trim();
if (!content) { showToast('안건 내용을 입력해주세요.', 'error'); return; }
if (!meetingId) { showToast('회의록을 먼저 저장해주세요.', 'error'); return; }
const itemId = document.getElementById('itemId').value;
const data = {
item_type: document.getElementById('itemType').value,
project_id: document.getElementById('itemProject').value || null,
milestone_id: document.getElementById('itemMilestone').value || null,
content,
decision: document.getElementById('itemDecision').value || null,
action_required: document.getElementById('itemAction').value || null,
responsible_user_id: document.getElementById('itemResponsible').value || null,
due_date: document.getElementById('itemDueDate').value || null,
status: document.getElementById('itemStatus').value
};
try {
if (itemId) {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('안건이 수정되었습니다.');
} else {
await api(`/meetings/${meetingId}/items`, { method: 'POST', body: JSON.stringify(data) });
showToast('안건이 추가되었습니다.');
}
closeItemModal();
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function deleteItem(itemId) {
if (!confirm('안건을 삭제하시겠습니까?')) return;
try {
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'DELETE' });
showToast('안건이 삭제되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}
async function updateItemStatus(itemId, status) {
if (!status) return;
try {
await api(`/meetings/items/${itemId}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
showToast('상태가 업데이트되었습니다.');
await loadMeeting();
} catch (err) { showToast(err.message, 'error'); }
}

View File

@@ -0,0 +1,106 @@
/* meetings.js — 생산회의록 목록 */
let canEdit = false;
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
if (canEdit) document.getElementById('btnNewMeeting').classList.remove('hidden');
// Year filter
const yearSel = document.getElementById('yearFilter');
const now = new Date();
for (let y = now.getFullYear() - 2; y <= now.getFullYear() + 1; y++) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y + '년';
if (y === now.getFullYear()) opt.selected = true;
yearSel.appendChild(opt);
}
document.getElementById('monthFilter').value = String(now.getMonth() + 1);
yearSel.addEventListener('change', loadMeetings);
document.getElementById('monthFilter').addEventListener('change', loadMeetings);
document.getElementById('searchInput').addEventListener('input', debounce(loadMeetings, 300));
document.getElementById('btnNewMeeting').addEventListener('click', () => {
location.href = '/pages/work/meeting-detail.html';
});
await Promise.all([loadMeetings(), loadActionItems()]);
});
async function loadMeetings() {
try {
const year = document.getElementById('yearFilter').value;
const month = document.getElementById('monthFilter').value;
const search = document.getElementById('searchInput').value.trim();
let url = `/meetings?year=${year}`;
if (month) url += `&month=${month}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const res = await api(url);
renderMeetings(res.data || []);
} catch (err) {
showToast('회의록 목록 로드 실패: ' + err.message, 'error');
}
}
function renderMeetings(meetings) {
const list = document.getElementById('meetingList');
const empty = document.getElementById('emptyState');
if (meetings.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = meetings.map(m => {
const statusBadge = m.status === 'published'
? '<span class="badge badge-green">발행</span>'
: '<span class="badge badge-gray">초안</span>';
return `
<a href="/pages/work/meeting-detail.html?id=${m.meeting_id}" class="block bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-gray-500">${formatDate(m.meeting_date)}</span>
${statusBadge}
</div>
<h3 class="font-semibold text-gray-800 truncate">${escapeHtml(m.title)}</h3>
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span><i class="fas fa-user mr-1"></i>${escapeHtml(m.created_by_name || '-')}</span>
<span><i class="fas fa-users mr-1"></i>참석 ${m.attendee_count || 0}명</span>
<span><i class="fas fa-list mr-1"></i>안건 ${m.agenda_count || 0}건</span>
${m.open_action_count > 0 ? `<span class="text-amber-600 font-semibold"><i class="fas fa-exclamation-circle mr-1"></i>미완료 ${m.open_action_count}건</span>` : ''}
</div>
</div>
<i class="fas fa-chevron-right text-gray-300 mt-2"></i>
</div>
</a>
`;
}).join('');
}
async function loadActionItems() {
try {
const res = await api('/meetings/action-items?status=open');
const items = res.data || [];
if (items.length === 0) return;
document.getElementById('actionSummary').classList.remove('hidden');
document.getElementById('actionCount').textContent = items.length;
document.getElementById('actionList').innerHTML = items.slice(0, 5).map(item => `
<div class="flex items-center gap-2 p-1.5 bg-white rounded">
<span class="text-amber-600"><i class="fas fa-circle text-[6px]"></i></span>
<span class="flex-1 truncate">${escapeHtml(item.content)}</span>
${item.responsible_name ? `<span class="text-gray-400 text-xs">${escapeHtml(item.responsible_name)}</span>` : ''}
${item.due_date ? `<span class="text-xs ${new Date(item.due_date) < new Date() ? 'text-red-500 font-semibold' : 'text-gray-400'}">${formatDate(item.due_date)}</span>` : ''}
</div>
`).join('') + (items.length > 5 ? `<div class="text-xs text-gray-400 text-center mt-1">외 ${items.length - 5}건</div>` : '');
} catch {}
}

View File

@@ -0,0 +1,371 @@
// mobile-dashboard.js - 모바일 대시보드 v2
// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약
(function() {
'use strict';
if (window.innerWidth > 768) return;
var today = new Date().toISOString().slice(0, 10);
// ==================== 캐시 변수 ====================
var categories = [];
var allWorkplaces = [];
var tbmByWorkplace = {};
var movedByWorkplace = {};
var issuesByWorkplace = {};
var workplacesByCategory = {};
// ==================== 유틸리티 ====================
// escapeHtml, waitForApi → api-base.js 전역 사용
// ==================== 데이터 그룹핑 ====================
function groupTbmByWorkplace(sessions) {
tbmByWorkplace = {};
if (!Array.isArray(sessions)) return;
sessions.forEach(function(s) {
var wpId = s.workplace_id;
if (!wpId) return;
if (!tbmByWorkplace[wpId]) {
tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] };
}
tbmByWorkplace[wpId].taskCount++;
tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0);
tbmByWorkplace[wpId].sessions.push(s);
});
}
function groupMovedByWorkplace(items) {
movedByWorkplace = {};
if (!Array.isArray(items)) return;
items.forEach(function(eq) {
var wpId = eq.current_workplace_id;
if (!wpId) return;
if (!movedByWorkplace[wpId]) {
movedByWorkplace[wpId] = { movedCount: 0, items: [] };
}
movedByWorkplace[wpId].movedCount++;
movedByWorkplace[wpId].items.push(eq);
});
}
function groupIssuesByWorkplace(issues) {
issuesByWorkplace = {};
if (!Array.isArray(issues)) return;
var activeStatuses = ['reported', 'received', 'in_progress'];
issues.forEach(function(issue) {
var wpId = issue.workplace_id;
if (!wpId) return;
if (activeStatuses.indexOf(issue.status) === -1) return;
if (!issuesByWorkplace[wpId]) {
issuesByWorkplace[wpId] = { activeCount: 0, items: [] };
}
issuesByWorkplace[wpId].activeCount++;
issuesByWorkplace[wpId].items.push(issue);
});
}
function groupWorkplacesByCategory(workplaces) {
workplacesByCategory = {};
if (!Array.isArray(workplaces)) return;
workplaces.forEach(function(wp) {
var catId = wp.category_id;
if (!catId) return;
if (!workplacesByCategory[catId]) {
workplacesByCategory[catId] = [];
}
workplacesByCategory[catId].push(wp);
});
}
// ==================== 렌더링 ====================
function renderCategoryTabs() {
var container = document.getElementById('mCategoryTabs');
if (!container || !categories.length) return;
var html = '';
categories.forEach(function(cat, idx) {
html += '<button class="md-cat-tab' + (idx === 0 ? ' active' : '') +
'" data-id="' + cat.category_id + '">' +
escapeHtml(cat.category_name) + '</button>';
});
// 전체 탭
html += '<button class="md-cat-tab" data-id="all">전체</button>';
container.innerHTML = html;
// 이벤트 바인딩
var tabs = container.querySelectorAll('.md-cat-tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var catId = tab.getAttribute('data-id');
selectCategory(catId);
});
});
// 첫 번째 카테고리 자동 선택
if (categories.length > 0) {
selectCategory(String(categories[0].category_id));
}
}
function selectCategory(categoryId) {
var workplaces;
if (categoryId === 'all') {
workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; });
} else {
workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) {
return wp.is_active !== false;
});
}
renderWorkplaceList(workplaces);
}
function renderWorkplaceList(workplaces) {
var container = document.getElementById('mWorkplaceList');
if (!container) return;
if (!workplaces || workplaces.length === 0) {
container.innerHTML = '<div class="md-wp-empty-all">등록된 작업장이 없습니다.</div>';
return;
}
var html = '';
workplaces.forEach(function(wp) {
var wpId = wp.workplace_id;
var tbm = tbmByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var hasAny = tbm || moved || issues;
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
// 헤더 (클릭 영역)
html += '<div class="md-wp-header">';
html += '<h3 class="md-wp-name">' + escapeHtml(wp.workplace_name);
if (hasAny) {
html += '<span class="md-wp-toggle">&#9660;</span>';
}
html += '</h3>';
if (!hasAny) {
html += '<p class="md-wp-no-activity">오늘 활동이 없습니다</p>';
} else {
html += '<div class="md-wp-stats">';
// TBM 작업
if (tbm) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128736;</span>' +
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 &middot; ' + tbm.totalWorkers + '명</span>' +
'</div>';
}
// 신고 (미완료만)
if (issues && issues.activeCount > 0) {
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
'<span class="md-wp-stat-icon">&#9888;</span>' +
'<span class="md-wp-stat-text">신고 ' + issues.activeCount + '건</span>' +
'</div>';
}
// 이동설비
if (moved && moved.movedCount > 0) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#8596;</span>' +
'<span class="md-wp-stat-text">이동설비 ' + moved.movedCount + '건</span>' +
'</div>';
}
html += '</div>';
}
html += '</div>'; // .md-wp-header
// 상세 영역 (활동 있는 카드만)
if (hasAny) {
html += '<div class="md-wp-detail">' + renderCardDetail(wpId) + '</div>';
}
html += '</div>'; // .md-wp-card
});
container.innerHTML = html;
// 클릭 이벤트 바인딩
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
cards.forEach(function(card) {
var wpId = card.getAttribute('data-wp-id');
var hasActivity = tbmByWorkplace[wpId] ||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
if (!hasActivity) return;
card.querySelector('.md-wp-header').addEventListener('click', function() {
toggleCard(wpId);
});
});
}
// ==================== 카드 확장/접기 ====================
function toggleCard(wpId) {
var allCards = document.querySelectorAll('.md-wp-card.expanded');
var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]');
if (!targetCard) return;
var isExpanded = targetCard.classList.contains('expanded');
// 다른 카드 모두 접기 (아코디언)
allCards.forEach(function(card) {
card.classList.remove('expanded');
});
// 토글
if (!isExpanded) {
targetCard.classList.add('expanded');
}
}
function renderCardDetail(wpId) {
var html = '';
var tbm = tbmByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
// TBM 작업
if (tbm && tbm.sessions.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 작업</div>';
tbm.sessions.forEach(function(s) {
var taskName = s.task_name || '작업명 미지정';
var leaderName = s.leader_name || '미지정';
var memberCount = (parseInt(s.team_member_count) || 0);
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(taskName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(leaderName) + ' &middot; ' + memberCount + '명</div>';
html += '</div>';
});
html += '</div>';
}
// 신고
if (issues && issues.items.length > 0) {
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 신고</div>';
issues.items.forEach(function(issue) {
var category = issue.issue_category_name || '미분류';
var desc = issue.additional_description || '';
if (desc.length > 30) desc = desc.substring(0, 30) + '...';
var statusText = statusMap[issue.status] || issue.status;
var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported');
var reporter = issue.reporter_name || '';
var icon = issue.status === 'in_progress' ? '&#128308;' : '&#9888;';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
if (desc) html += ' &middot; ' + escapeHtml(desc);
html += '</div>';
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
if (reporter) html += ' &rarr; ' + escapeHtml(reporter);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 이동설비
if (moved && moved.items.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 이동설비</div>';
moved.items.forEach(function(eq) {
var eqName = eq.equipment_name || '설비명 미지정';
var fromWp = eq.original_workplace_name || '?';
var toWp = eq.current_workplace_name || '?';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(eqName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(fromWp) + ' &rarr; ' + escapeHtml(toWp) + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
await waitForApi();
} catch (e) {
console.error('mobile-dashboard: apiCall not available');
return;
}
var view = document.getElementById('mobileDashboardView');
if (!view) return;
view.style.display = 'block';
// 날짜 표시
var now = new Date();
var days = ['일', '월', '화', '수', '목', '금', '토'];
var dateEl = document.getElementById('mDateValue');
if (dateEl) {
dateEl.textContent = now.getFullYear() + '.' +
String(now.getMonth() + 1).padStart(2, '0') + '.' +
String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')';
}
// 로딩 표시
var listContainer = document.getElementById('mWorkplaceList');
if (listContainer) {
listContainer.innerHTML =
'<div class="md-skeleton"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>';
}
// 데이터 병렬 로딩
var results = await Promise.allSettled([
window.apiCall('/workplaces/categories'),
window.apiCall('/tbm/sessions/date/' + today),
window.apiCall('/equipments/moved/list'),
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
window.apiCall('/workplaces')
]);
// 카테고리
if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) {
categories = results[0].value.data || [];
}
// TBM
if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) {
groupTbmByWorkplace(results[1].value.data || []);
}
// 이동설비
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupMovedByWorkplace(results[2].value.data || []);
}
// 신고
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupIssuesByWorkplace(results[3].value.data || []);
}
// 작업장 전체 (카테고리별 그룹핑)
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
allWorkplaces = results[4].value.data || [];
groupWorkplacesByCategory(allWorkplaces);
}
// 렌더링
renderCategoryTabs();
});
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,833 @@
/**
* monthly-comparison.js — 월간 비교·확인·정산
* Sprint 004 Section B
*/
// ===== Mock =====
const MOCK_ENABLED = false;
const MOCK_MY_RECORDS = {
success: true,
data: {
user: { user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀' },
period: { year: 2026, month: 3 },
summary: {
total_work_days: 22, total_work_hours: 182.5,
total_overtime_hours: 6.5, vacation_days: 1,
mismatch_count: 3,
mismatch_details: { hours_diff: 2, missing_report: 1, missing_attendance: 0 }
},
confirmation: { status: 'pending', confirmed_at: null, reject_reason: null },
daily_records: [
{ date: '2026-03-01', day_of_week: '월', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'match', hours_diff: 0 },
{ date: '2026-03-02', day_of_week: '화', is_holiday: false,
work_report: { total_hours: 9.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 9.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'mismatch', hours_diff: 1.0 },
{ date: '2026-03-03', day_of_week: '수', is_holiday: false,
work_report: null,
attendance: { total_work_hours: 0, attendance_type: '휴가근로', vacation_type: '연차' },
status: 'vacation', hours_diff: 0 },
{ date: '2026-03-04', day_of_week: '목', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'A동 신축', work_type: '용접', hours: 8.0 }] },
attendance: null,
status: 'report_only', hours_diff: 0 },
{ date: '2026-03-05', day_of_week: '금', is_holiday: false,
work_report: { total_hours: 8.0, entries: [{ project_name: 'B동 보수', work_type: '배관', hours: 8.0 }] },
attendance: { total_work_hours: 8.0, attendance_type: '정시근로', vacation_type: null },
status: 'match', hours_diff: 0 },
{ date: '2026-03-06', day_of_week: '토', is_holiday: true,
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
{ date: '2026-03-07', day_of_week: '일', is_holiday: true,
work_report: null, attendance: null, status: 'holiday', hours_diff: 0 },
]
}
};
const MOCK_ADMIN_STATUS = {
success: true,
data: {
period: { year: 2026, month: 3 },
summary: { total_workers: 25, confirmed: 15, pending: 8, rejected: 2 },
workers: [
{ user_id: 10, worker_name: '김철수', job_type: '용접', department_name: '생산1팀',
total_work_days: 22, total_work_hours: 182.5, total_overtime_hours: 6.5,
status: 'confirmed', confirmed_at: '2026-03-30T10:00:00', mismatch_count: 0 },
{ user_id: 11, worker_name: '이영희', job_type: '도장', department_name: '생산1팀',
total_work_days: 20, total_work_hours: 168.0, total_overtime_hours: 2.0,
status: 'pending', confirmed_at: null, mismatch_count: 0 },
{ user_id: 12, worker_name: '박민수', job_type: '배관', department_name: '생산2팀',
total_work_days: 22, total_work_hours: 190.0, total_overtime_hours: 14.0,
status: 'rejected', confirmed_at: null, reject_reason: '3/15 근무시간 오류', mismatch_count: 2 },
]
}
};
// ===== State =====
let currentYear, currentMonth;
let currentMode = 'my'; // 'my' | 'admin' | 'detail'
let currentUserId = null;
let comparisonData = null;
let adminData = null;
let currentFilter = 'all';
const ADMIN_ROLES = ['support_team', 'admin', 'system'];
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
// URL 파라미터
const params = new URLSearchParams(location.search);
if (params.get('year')) currentYear = parseInt(params.get('year'));
if (params.get('month')) currentMonth = parseInt(params.get('month'));
if (params.get('user_id')) currentUserId = parseInt(params.get('user_id'));
const urlMode = params.get('mode');
setTimeout(() => {
const user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
if (!user) return;
// 비관리자 → 작업자 전용 확인 페이지로 리다이렉트
if (!ADMIN_ROLES.includes(user.role)) {
location.href = '/pages/attendance/my-monthly-confirm.html';
return;
}
// 관리자 mode 결정
if (currentUserId) {
currentMode = 'detail';
} else {
currentMode = 'admin';
}
// 관리자 뷰 전환 버튼 (관리자만)
if (ADMIN_ROLES.includes(user.role)) {
document.getElementById('viewToggleBtn').classList.remove('hidden');
}
updateMonthLabel();
loadData();
}, 500);
});
// ===== Month Nav =====
function updateMonthLabel() {
document.getElementById('monthLabel').textContent = `${currentYear}${currentMonth}`;
}
function changeMonth(delta) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
updateMonthLabel();
loadData();
}
// ===== Data Load =====
async function loadData() {
if (currentMode === 'admin') {
await loadAdminStatus();
} else {
await loadMyRecords();
}
}
async function loadMyRecords() {
document.getElementById('workerView').classList.remove('hidden');
document.getElementById('adminView').classList.add('hidden');
document.getElementById('pageTitle').textContent = currentMode === 'detail' ? '작업자 근무 비교' : '월간 근무 비교';
const listEl = document.getElementById('dailyList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_MY_RECORDS));
} else {
const endpoint = currentMode === 'detail' && currentUserId
? `/monthly-comparison/records?year=${currentYear}&month=${currentMonth}&user_id=${currentUserId}`
: `/monthly-comparison/my-records?year=${currentYear}&month=${currentMonth}`;
res = await window.apiCall(endpoint);
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
comparisonData = res.data;
// detail 모드: 작업자 이름 + 검토완료 버튼 (상단 헤더)
if (currentMode === 'detail' && comparisonData.user) {
var isChecked = comparisonData.confirmation && comparisonData.confirmation.admin_checked;
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);
renderMismatchAlert(comparisonData.summary);
renderDailyList(comparisonData.daily_records || []);
renderConfirmationStatus(comparisonData.confirmation);
} catch (e) {
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
async function loadAdminStatus() {
document.getElementById('workerView').classList.add('hidden');
document.getElementById('adminView').classList.remove('hidden');
document.getElementById('pageTitle').textContent = '월간 근무 확인 현황';
const listEl = document.getElementById('adminWorkerList');
listEl.innerHTML = '<div class="ds-skeleton"></div><div class="ds-skeleton"></div>';
try {
let res;
if (MOCK_ENABLED) {
res = JSON.parse(JSON.stringify(MOCK_ADMIN_STATUS));
} else {
res = await window.apiCall(`/monthly-comparison/all-status?year=${currentYear}&month=${currentMonth}`);
}
if (!res || !res.success) {
listEl.innerHTML = '<div class="mc-empty"><p>데이터를 불러올 수 없습니다</p></div>';
return;
}
adminData = res.data;
renderAdminSummary(adminData.summary);
renderWorkerList(adminData.workers || []);
updateExportButton(adminData.summary, adminData.workers || []);
} catch (e) {
listEl.innerHTML = '<div class="mc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
// ===== Render: Worker View =====
function renderSummaryCards(s) {
document.getElementById('totalDays').textContent = s.total_work_days || 0;
document.getElementById('totalHours').textContent = (s.total_work_hours || 0) + 'h';
document.getElementById('overtimeHours').textContent = (s.total_overtime_hours || 0) + 'h';
document.getElementById('vacationDays').textContent = (s.vacation_days || 0) + '일';
}
function renderMismatchAlert(s) {
const el = document.getElementById('mismatchAlert');
if (!s.mismatch_count || s.mismatch_count === 0) {
el.classList.add('hidden');
return;
}
el.classList.remove('hidden');
const details = s.mismatch_details || {};
const parts = [];
if (details.hours_diff) parts.push(`시간차이 ${details.hours_diff}`);
if (details.missing_report) parts.push(`보고서만 ${details.missing_report}`);
if (details.missing_attendance) parts.push(`근태만 ${details.missing_attendance}`);
document.getElementById('mismatchText').textContent =
`${s.mismatch_count}건의 불일치가 있습니다` + (parts.length ? ` (${parts.join(' | ')})` : '');
}
function renderDailyList(records) {
const el = document.getElementById('dailyList');
if (!records.length) {
el.innerHTML = '<div class="mc-empty"><p>데이터가 없습니다</p></div>';
return;
}
el.innerHTML = records.map(r => {
const dateStr = r.date.substring(5); // "03-01"
const dayStr = r.day_of_week || '';
const icon = getStatusIcon(r.status);
const label = getStatusLabel(r.status, r);
let reportLine = '';
let attendLine = '';
let diffLine = '';
if (r.work_report) {
const entries = (r.work_report.entries || []).map(e => `${e.project_name}-${e.work_type}`).join(', ');
reportLine = `<div class="mc-daily-row">작업보고: <strong>${r.work_report.total_hours}h</strong> <span>(${escHtml(entries)})</span></div>`;
} else if (r.status !== 'holiday') {
reportLine = '<div class="mc-daily-row" style="color:#9ca3af">작업보고: -</div>';
}
if (r.attendance) {
const vacInfo = r.attendance.vacation_type ? ` (${r.attendance.vacation_type})` : '';
// 주말+0h → 편집 불필요
const showEdit = currentMode === 'detail' && !(r.is_holiday && r.attendance.total_work_hours === 0);
const editBtn = showEdit ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', ${r.attendance.total_work_hours}, ${r.attendance.vacation_type_id || 'null'})" title="근태 수정"><i class="fas fa-pen"></i></button>` : '';
// 주말+0h → 근태 행 숨김 (주말로 표시)
if (r.is_holiday && r.attendance.total_work_hours === 0) {
// 주말 표시만, 근태 행 생략
} else {
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}">근태관리: <strong>${r.attendance.total_work_hours}h</strong> <span>(${escHtml(r.attendance.attendance_type)}${vacInfo})</span>${editBtn}</div>`;
}
} else if (r.status !== 'holiday') {
const addBtn = currentMode === 'detail' ? `<button class="mc-edit-btn" onclick="editAttendance('${r.date}', 0, null)" title="근태 입력"><i class="fas fa-plus"></i></button>` : '';
attendLine = `<div class="mc-daily-row mc-attend-row" id="attend-${r.date}" style="color:#9ca3af">근태관리: 미입력${addBtn}</div>`;
}
if (r.hours_diff && r.hours_diff !== 0) {
const sign = r.hours_diff > 0 ? '+' : '';
diffLine = `<div class="mc-daily-diff"><i class="fas fa-thumbtack"></i> 차이: ${sign}${r.hours_diff}h</div>`;
}
return `
<div class="mc-daily-card ${r.status}">
<div class="mc-daily-header">
<div class="mc-daily-date">${dateStr}(${dayStr})</div>
<div class="mc-daily-status">${icon} ${label}</div>
</div>
${reportLine}${attendLine}${diffLine}
</div>`;
}).join('');
}
function renderConfirmationStatus(conf) {
const actions = document.getElementById('bottomActions');
const statusEl = document.getElementById('confirmedStatus');
const badge = document.getElementById('statusBadge');
// 관리자 페이지: 확인/문제 버튼 항상 숨김 (작업자는 my-monthly-confirm에서 처리)
actions.classList.add('hidden');
if (!conf) {
statusEl.classList.add('hidden');
badge.textContent = '';
return;
}
var displayStatus = (conf.status === 'pending' && conf.admin_checked) ? 'admin_checked' : conf.status;
var labels = { pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', confirmed: '확인완료', change_request: '수정요청', rejected: '반려' };
badge.textContent = labels[displayStatus] || '';
badge.className = 'mc-status-badge ' + displayStatus;
if (conf.status === 'confirmed') {
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (conf.status === 'rejected') {
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '반려: ' + (conf.reject_reason || '-');
} else if (conf.status === 'change_request') {
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').innerHTML = '수정요청 접수됨';
// detail 모드: 수정 내역 + 승인/거부 버튼 표시
if (currentMode === 'detail') {
renderChangeRequestPanel(conf);
}
} else {
statusEl.classList.add('hidden');
}
}
// ===== Render: Admin View =====
function renderAdminSummary(s) {
const total = s.total_workers || 1;
const pct = Math.round((s.confirmed || 0) / total * 100);
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = `확인 현황: ${s.confirmed || 0}/${total}명 완료`;
document.getElementById('statusCounts').innerHTML =
`<span>✅ ${s.confirmed || 0} 확인</span>` +
`<span>📩 ${s.review_sent || 0} 확인요청</span>` +
`<span>⏳ ${s.pending || 0} 미검토</span>` +
`<span>📝 ${s.change_request || 0} 수정요청</span>` +
`<span>❌ ${s.rejected || 0} 반려</span>`;
// 확인요청 일괄 발송 버튼 — 전원 검토완료 시만 활성화
var reviewBtn = document.getElementById('reviewSendBtn');
if (reviewBtn) {
var pendingCount = (s.pending || 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.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 {
reviewBtn.classList.add('hidden');
}
}
}
function renderWorkerList(workers) {
const el = document.getElementById('adminWorkerList');
let filtered = workers;
if (currentFilter !== 'all') {
filtered = workers.filter(w => w.status === currentFilter);
}
if (!filtered.length) {
el.innerHTML = '<div class="mc-empty"><p>해당 조건의 작업자가 없습니다</p></div>';
return;
}
el.innerHTML = filtered.map(w => {
// admin_checked면 "미검토" → "검토완료"로 표시
var displayStatus = (w.status === 'pending' && w.admin_checked) ? 'admin_checked' : w.status;
const statusLabels = { confirmed: '확인완료', pending: '미검토', admin_checked: '검토완료', review_sent: '확인요청', change_request: '수정요청', rejected: '반려' };
const statusBadge = `<span class="mc-worker-status-badge ${displayStatus}">${statusLabels[displayStatus] || ''}</span>`;
const mismatchBadge = w.mismatch_count > 0
? `<span class="mc-worker-mismatch">⚠️ 불일치${w.mismatch_count}</span>` : '';
const rejectReason = w.status === 'rejected' && w.reject_reason
? `<div class="mc-worker-reject-reason">사유: ${escHtml(w.reject_reason)}</div>` : '';
const changeSummary = w.status === 'change_request' && w.change_details
? `<div class="mc-worker-change-summary"><i class="fas fa-edit" style="font-size:10px"></i> ${escHtml(formatChangeDetailsSummary(w.change_details))}</div>` : '';
const confirmedAt = w.confirmed_at ? `(${new Date(w.confirmed_at).toLocaleDateString('ko')})` : '';
return `
<div class="mc-worker-card" onclick="viewWorkerDetail(${w.user_id})">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div class="mc-worker-name">${escHtml(w.worker_name)} ${mismatchBadge}</div>
<div class="mc-worker-dept">${escHtml(w.department_name)} · ${escHtml(w.job_type)}</div>
</div>
<i class="fas fa-chevron-right text-gray-300"></i>
</div>
<div class="mc-worker-stats">${w.total_work_days}일 | ${w.total_work_hours}h | 연장 ${w.total_overtime_hours}h</div>
<div class="mc-worker-status">
${statusBadge} <span style="font-size:0.7rem;color:#9ca3af">${confirmedAt}</span>
</div>
${rejectReason}${changeSummary}
</div>`;
}).join('');
}
function filterWorkers(status) {
currentFilter = status;
document.querySelectorAll('.mc-tab').forEach(t => {
t.classList.toggle('active', t.dataset.filter === status);
});
if (adminData) renderWorkerList(adminData.workers || []);
}
function updateExportButton(summary, workers) {
const btn = document.getElementById('exportBtn');
const note = document.getElementById('exportNote');
const pendingCount = (workers || []).filter(w => !w.status || w.status === 'pending').length;
const rejectedCount = (workers || []).filter(w => w.status === 'rejected').length;
const allConfirmed = pendingCount === 0 && rejectedCount === 0;
if (allConfirmed) {
btn.disabled = false;
note.textContent = '모든 작업자가 확인을 완료했습니다';
} else {
btn.disabled = true;
const parts = [];
if (pendingCount > 0) parts.push(`${pendingCount}명 미확인`);
if (rejectedCount > 0) parts.push(`${rejectedCount}명 반려`);
note.textContent = `${parts.join(', ')} — 전원 확인 후 다운로드 가능합니다`;
}
}
// ===== Actions =====
let isProcessing = false;
async function confirmMonth() {
if (isProcessing) return;
if (!confirm(`${currentYear}${currentMonth}월 근무 내역을 확인하시겠습니까?`)) return;
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
await new Promise(r => setTimeout(r, 500));
res = { success: true, message: '확인이 완료되었습니다.' };
} else {
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'confirmed'
});
}
if (res && res.success) {
showToast(res.message || '확인 완료', 'success');
loadMyRecords();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
function openRejectModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
function closeRejectModal() {
document.getElementById('rejectModal').classList.add('hidden');
}
async function submitReject() {
if (isProcessing) return;
const reason = document.getElementById('rejectReason').value.trim();
if (!reason) {
showToast('반려 사유를 입력해주세요', 'error');
return;
}
isProcessing = true;
try {
let res;
if (MOCK_ENABLED) {
await new Promise(r => setTimeout(r, 500));
res = { success: true, message: '이의가 접수되었습니다. 지원팀에 알림이 전달됩니다.' };
} else {
res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'rejected', reject_reason: reason
});
}
if (res && res.success) {
showToast(res.message || '반려 제출 완료', 'success');
closeRejectModal();
loadMyRecords();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
async function downloadExcel() {
try {
if (MOCK_ENABLED) {
showToast('Mock 모드에서는 다운로드를 지원하지 않습니다', 'info');
return;
}
const token = (window.getSSOToken && window.getSSOToken()) || '';
const response = await fetch(`/api/monthly-comparison/export?year=${currentYear}&month=${currentMonth}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 401) {
if (typeof _safeRedirect === 'function') _safeRedirect();
else location.href = '/pages/login.html';
return;
}
if (!response.ok) throw new Error('다운로드 실패');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `월간근무_${currentYear}${currentMonth}월.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
showToast('엑셀 다운로드 실패', 'error');
}
}
// ===== Admin Check (검토완료 토글) =====
async function toggleAdminCheck() {
if (!currentUserId || isProcessing) return;
var isCurrentlyChecked = comparisonData?.confirmation?.admin_checked;
var newChecked = !isCurrentlyChecked;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/admin-check', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth, checked: newChecked
});
if (res && res.success) {
// 상태 업데이트
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 {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
// 목록으로 복귀 (월 유지)
function goBackToList() {
location.href = '/pages/attendance/monthly-comparison.html?mode=admin&year=' + currentYear + '&month=' + currentMonth;
}
// ===== Review Send (확인요청 일괄 발송) =====
async function sendReviewAll() {
if (isProcessing) return;
if (!confirm(currentYear + '년 ' + currentMonth + '월 미검토 작업자 전체에게 확인요청을 발송하시겠습니까?')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-send', 'POST', {
year: currentYear, month: currentMonth
});
if (res && res.success) {
showToast(res.message || '확인요청 발송 완료', 'success');
loadAdminStatus();
} else {
showToast(res && res.message || '발송 실패', 'error');
}
} catch (e) {
showToast('네트워크 오류', 'error');
} finally {
isProcessing = false;
}
}
// ===== View Toggle =====
function toggleViewMode() {
if (currentMode === 'admin') {
currentMode = 'my';
} else {
currentMode = 'admin';
}
currentFilter = 'all';
loadData();
}
function viewWorkerDetail(userId) {
location.href = `/pages/attendance/monthly-comparison.html?mode=detail&user_id=${userId}&year=${currentYear}&month=${currentMonth}`;
}
// ===== Helpers =====
function getStatusIcon(status) {
const icons = {
match: '<i class="fas fa-check-circle text-green-500"></i>',
mismatch: '<i class="fas fa-exclamation-triangle text-amber-500"></i>',
report_only: '<i class="fas fa-file-alt text-blue-500"></i>',
attend_only: '<i class="fas fa-clock text-purple-500"></i>',
vacation: '<i class="fas fa-umbrella-beach text-green-400"></i>',
holiday: '<i class="fas fa-calendar text-gray-400"></i>',
none: '<i class="fas fa-minus-circle text-red-400"></i>'
};
return icons[status] || '';
}
function getStatusLabel(status, record) {
const labels = {
match: '일치', mismatch: '불일치', report_only: '보고서만',
attend_only: '근태만', holiday: '주말', none: '미입력'
};
if (status === 'vacation') {
return record?.attendance?.vacation_type || '연차';
}
return labels[status] || '';
}
function escHtml(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
// ===== Inline Attendance Edit (detail mode) =====
function getAttendanceTypeId(hours, vacTypeId) {
if (vacTypeId) return 4; // VACATION
if (hours >= 8) return 1; // REGULAR
if (hours > 0) return 3; // PARTIAL
return 0;
}
function editAttendance(date, currentHours, currentVacTypeId) {
const el = document.getElementById('attend-' + date);
if (!el) return;
const vacTypeId = currentVacTypeId === 'null' || currentVacTypeId === null ? '' : currentVacTypeId;
el.innerHTML = `
<div class="mc-edit-form">
<div class="mc-edit-row">
<label>시간</label>
<input type="number" id="editHours-${date}" value="${currentHours}" step="0.5" min="0" max="24" class="mc-edit-input">
<span>h</span>
</div>
<div class="mc-edit-row">
<label>휴가</label>
<select id="editVacType-${date}" class="mc-edit-select" onchange="onVacTypeChange('${date}')">
<option value="">없음</option>
<option value="1" ${vacTypeId == 1 ? 'selected' : ''}>연차</option>
<option value="2" ${vacTypeId == 2 ? 'selected' : ''}>반차</option>
<option value="3" ${vacTypeId == 3 ? 'selected' : ''}>반반차</option>
<option value="10" ${vacTypeId == 10 ? 'selected' : ''}>조퇴</option>
</select>
</div>
<div class="mc-edit-actions">
<button class="mc-edit-save" onclick="saveAttendance('${date}')"><i class="fas fa-check"></i> 저장</button>
<button class="mc-edit-cancel" onclick="loadData()">취소</button>
</div>
</div>
`;
}
function onVacTypeChange(date) {
const vacType = document.getElementById('editVacType-' + date).value;
const hoursInput = document.getElementById('editHours-' + date);
if (vacType === '1') hoursInput.value = '0'; // 연차 → 0시간
else if (vacType === '2') hoursInput.value = '4'; // 반차 → 4시간
else if (vacType === '3') hoursInput.value = '6'; // 반반차 → 6시간
else if (vacType === '10') hoursInput.value = '2'; // 조퇴 → 2시간
}
async function saveAttendance(date) {
const hours = parseFloat(document.getElementById('editHours-' + date).value) || 0;
const vacTypeVal = document.getElementById('editVacType-' + date).value;
const vacTypeId = vacTypeVal ? parseInt(vacTypeVal) : null;
const attTypeId = getAttendanceTypeId(hours, vacTypeId);
try {
await window.apiCall('/attendance/records', 'POST', {
record_date: date,
user_id: currentUserId,
total_work_hours: hours,
vacation_type_id: vacTypeId,
attendance_type_id: attTypeId
});
showToast('근태 수정 완료', 'success');
await loadData(); // 전체 새로고침
} catch (e) {
showToast('저장 실패: ' + (e.message || e), 'error');
}
}
// ===== Change Request Panel (detail mode) =====
function renderChangeRequestPanel(conf) {
var panel = document.getElementById('changeRequestPanel');
if (!panel) {
// 동적 생성: mismatchAlert 뒤에 삽입
panel = document.createElement('div');
panel.id = 'changeRequestPanel';
var anchor = document.getElementById('mismatchAlert');
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
}
var details = null;
if (conf.change_details) {
try { details = typeof conf.change_details === 'string' ? JSON.parse(conf.change_details) : conf.change_details; }
catch (e) { details = null; }
}
var html = '<div class="mc-change-panel">';
html += '<div class="mc-change-header"><i class="fas fa-edit text-orange-500"></i> 수정요청 내역</div>';
if (details && details.changes && details.changes.length) {
html += '<div class="mc-change-list">';
details.changes.forEach(function(c) {
var dateLabel = c.date ? c.date.substring(5).replace('-', '/') : '';
html += '<div class="mc-change-item">' + escHtml(dateLabel) + ': ' +
'<span class="mc-change-from">' + escHtml(c.from) + '</span>' +
' <i class="fas fa-arrow-right" style="font-size:10px;color:#9ca3af"></i> ' +
'<span class="mc-change-to">' + escHtml(c.to) + '</span></div>';
});
html += '</div>';
} else if (details && details.description) {
html += '<div class="mc-change-desc">' + escHtml(details.description) + '</div>';
} else {
html += '<div class="mc-change-desc" style="color:#9ca3af">상세 내역 없음</div>';
}
html += '<div class="mc-change-actions">';
html += '<button type="button" class="mc-change-approve" onclick="approveChangeRequest()"><i class="fas fa-check"></i> 승인 (재확인 요청)</button>';
html += '<button type="button" class="mc-change-reject" onclick="openRejectChangeModal()"><i class="fas fa-times"></i> 거부</button>';
html += '</div>';
html += '</div>';
panel.innerHTML = html;
}
async function approveChangeRequest() {
if (!currentUserId || isProcessing) return;
if (!confirm('수정요청을 승인하시겠습니까? 작업자에게 재확인 요청이 발송됩니다.')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth, action: 'approve'
});
if (res && res.success) {
showToast(res.message || '승인 완료', 'success');
loadData();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function openRejectChangeModal() {
document.getElementById('rejectReason').value = '';
// 모달 텍스트를 수정요청 거부용으로 변경
var headerSpan = document.querySelector('#rejectModal .mc-modal-header span');
if (headerSpan) headerSpan.innerHTML = '<i class="fas fa-times-circle text-red-500 mr-2"></i>수정요청 거부';
var desc = document.querySelector('#rejectModal .mc-modal-desc');
if (desc) desc.textContent = '거부 사유를 입력해주세요:';
var submitBtn = document.getElementById('rejectSubmitBtn');
if (submitBtn) {
submitBtn.textContent = '거부 제출';
submitBtn.onclick = submitRejectChange;
}
document.getElementById('rejectModal').classList.remove('hidden');
}
async function submitRejectChange() {
if (!currentUserId || isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('거부 사유를 입력해주세요', 'error'); return; }
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/review-respond', 'POST', {
user_id: currentUserId, year: currentYear, month: currentMonth,
action: 'reject', reject_reason: reason
});
if (res && res.success) {
showToast(res.message || '거부 완료', 'success');
closeRejectModal();
loadData();
} else {
showToast(res?.message || '처리 실패', 'error');
}
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function formatChangeDetailsSummary(changeDetails) {
var details = null;
if (!changeDetails) return '';
try { details = typeof changeDetails === 'string' ? JSON.parse(changeDetails) : changeDetails; }
catch (e) { return ''; }
if (details.changes && details.changes.length) {
var items = details.changes.map(function(c) {
var d = c.date ? c.date.substring(5).replace('-', '/') : '';
return d + ' ' + (c.from || '') + '\u2192' + (c.to || '');
});
return items.join(', ');
}
if (details.description) return details.description;
return '';
}
// ESC로 모달 닫기
function handleEscKey(e) {
if (e.key === 'Escape') closeRejectModal();
}
document.addEventListener('keydown', handleEscKey);
window.addEventListener('beforeunload', function() {
document.removeEventListener('keydown', handleEscKey);
});

View File

@@ -0,0 +1,391 @@
/**
* 나의 출근 현황 페이지
* 본인의 출근 기록과 근태 현황을 조회하고 표시합니다
*/
// 전역 상태
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth() + 1;
let attendanceData = [];
let vacationBalance = null;
let monthlyStats = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
initializePage();
});
/**
* 페이지 초기화
*/
function initializePage() {
initializeYearMonthSelects();
setupEventListeners();
loadAttendanceData();
}
/**
* 년도/월 선택 옵션 초기화
*/
function initializeYearMonthSelects() {
const yearSelect = document.getElementById('yearSelect');
const monthSelect = document.getElementById('monthSelect');
// 년도 옵션 (현재 년도 기준 ±2년)
const currentYearValue = new Date().getFullYear();
for (let year = currentYearValue - 2; year <= currentYearValue + 2; year++) {
const option = document.createElement('option');
option.value = year;
option.textContent = `${year}`;
if (year === currentYear) option.selected = true;
yearSelect.appendChild(option);
}
// 월 옵션
for (let month = 1; month <= 12; month++) {
const option = document.createElement('option');
option.value = month;
option.textContent = `${month}`;
if (month === currentMonth) option.selected = true;
monthSelect.appendChild(option);
}
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 조회 버튼
document.getElementById('loadAttendance').addEventListener('click', () => {
currentYear = parseInt(document.getElementById('yearSelect').value);
currentMonth = parseInt(document.getElementById('monthSelect').value);
loadAttendanceData();
});
// 탭 전환
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tabName = e.currentTarget.dataset.tab;
switchTab(tabName);
});
});
// 달력 네비게이션
document.getElementById('prevMonth').addEventListener('click', () => {
changeMonth(-1);
});
document.getElementById('nextMonth').addEventListener('click', () => {
changeMonth(1);
});
}
/**
* 출근 데이터 로드
*/
async function loadAttendanceData() {
try {
showLoading();
// 병렬로 데이터 로드
const [attendanceRes, vacationRes, statsRes] = await Promise.all([
window.apiGet(`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`),
window.apiGet(`/users/me/vacation-balance?year=${currentYear}`),
window.apiGet(`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`)
]);
attendanceData = attendanceRes.data || attendanceRes || [];
vacationBalance = vacationRes.data || vacationRes;
monthlyStats = statsRes.data || statsRes;
// UI 업데이트
updateStats();
renderTable();
renderCalendar();
} catch (error) {
console.error('출근 데이터 로드 실패:', error);
showError('출근 데이터를 불러오는데 실패했습니다.');
}
}
/**
* 통계 업데이트
*/
function updateStats() {
// 총 근무시간 (API는 month_hours 반환)
const totalHours = monthlyStats?.month_hours || monthlyStats?.total_work_hours || 0;
document.getElementById('totalHours').textContent = `${totalHours}시간`;
// 근무일수
const totalDays = monthlyStats?.work_days || 0;
document.getElementById('totalDays').textContent = `${totalDays}`;
// 잔여 연차
const remaining = vacationBalance?.remaining_annual_leave ||
(vacationBalance?.total_annual_leave || 0) - (vacationBalance?.used_annual_leave || 0);
document.getElementById('remainingLeave').textContent = `${remaining}`;
}
/**
* 테이블 렌더링
*/
function renderTable() {
const tbody = document.getElementById('attendanceTableBody');
tbody.innerHTML = '';
if (!attendanceData || attendanceData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell">출근 기록이 없습니다.</td></tr>';
return;
}
attendanceData.forEach(record => {
const tr = document.createElement('tr');
tr.className = `attendance-row ${getStatusClass(record.attendance_type_code || record.type_code)}`;
tr.onclick = () => showDetailModal(record);
const date = new Date(record.record_date);
const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
tr.innerHTML = `
<td>${formatDate(record.record_date)}</td>
<td>${dayOfWeek}</td>
<td>${record.check_in_time || '-'}</td>
<td>${record.check_out_time || '-'}</td>
<td>${record.total_work_hours ? `${record.total_work_hours}h` : '-'}</td>
<td><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></td>
<td class="notes-cell">${record.notes || '-'}</td>
`;
tbody.appendChild(tr);
});
}
/**
* 달력 렌더링
*/
function renderCalendar() {
const calendarTitle = document.getElementById('calendarTitle');
const calendarGrid = document.getElementById('calendarGrid');
calendarTitle.textContent = `${currentYear}${currentMonth}`;
// 달력 그리드 초기화
calendarGrid.innerHTML = '';
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
weekdays.forEach(day => {
const dayHeader = document.createElement('div');
dayHeader.className = 'calendar-day-header';
dayHeader.textContent = day;
calendarGrid.appendChild(dayHeader);
});
// 해당 월의 첫날과 마지막 날
const firstDay = new Date(currentYear, currentMonth - 1, 1);
const lastDay = new Date(currentYear, currentMonth, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay();
// 출근 데이터를 날짜별로 매핑
const attendanceMap = {};
if (attendanceData) {
attendanceData.forEach(record => {
const date = new Date(record.record_date);
const day = date.getDate();
attendanceMap[day] = record;
});
}
// 빈 칸 (이전 달)
for (let i = 0; i < startDayOfWeek; i++) {
const emptyCell = document.createElement('div');
emptyCell.className = 'calendar-day empty';
calendarGrid.appendChild(emptyCell);
}
// 날짜 칸
for (let day = 1; day <= daysInMonth; day++) {
const dayCell = document.createElement('div');
dayCell.className = 'calendar-day';
const record = attendanceMap[day];
if (record) {
dayCell.classList.add('has-record', getStatusClass(record.attendance_type_code || record.type_code));
dayCell.onclick = () => showDetailModal(record);
}
dayCell.innerHTML = `
<div class="calendar-day-number">${day}</div>
${record ? `<div class="calendar-day-status">${getStatusIcon(record)}</div>` : ''}
`;
calendarGrid.appendChild(dayCell);
}
}
/**
* 탭 전환
*/
function switchTab(tabName) {
// 탭 버튼 활성화 토글
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// 탭 컨텐츠 토글
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
if (tabName === 'list') {
document.getElementById('listView').classList.add('active');
} else if (tabName === 'calendar') {
document.getElementById('calendarView').classList.add('active');
}
}
/**
* 월 변경
*/
function changeMonth(offset) {
currentMonth += offset;
if (currentMonth < 1) {
currentMonth = 12;
currentYear--;
} else if (currentMonth > 12) {
currentMonth = 1;
currentYear++;
}
// Select 박스 업데이트
document.getElementById('yearSelect').value = currentYear;
document.getElementById('monthSelect').value = currentMonth;
loadAttendanceData();
}
/**
* 상세 모달 표시
*/
function showDetailModal(record) {
const modal = document.getElementById('detailModal');
const modalBody = document.getElementById('modalBody');
const modalTitle = document.getElementById('modalTitle');
const date = new Date(record.record_date);
modalTitle.textContent = `${formatDate(record.record_date)} 출근 상세`;
modalBody.innerHTML = `
<div class="detail-grid">
<div class="detail-item">
<label>날짜</label>
<div>${formatDate(record.record_date)}</div>
</div>
<div class="detail-item">
<label>출근 상태</label>
<div><span class="status-badge ${getStatusClass(record.attendance_type_code || record.type_code)}">${getStatusText(record)}</span></div>
</div>
<div class="detail-item">
<label>출근 시간</label>
<div>${record.check_in_time || '기록 없음'}</div>
</div>
<div class="detail-item">
<label>퇴근 시간</label>
<div>${record.check_out_time || '기록 없음'}</div>
</div>
<div class="detail-item">
<label>총 근무 시간</label>
<div>${record.total_work_hours ? `${record.total_work_hours} 시간` : '계산 불가'}</div>
</div>
${record.vacation_type_name ? `
<div class="detail-item">
<label>휴가 유형</label>
<div>${record.vacation_type_name}</div>
</div>
` : ''}
${record.notes ? `
<div class="detail-item full-width">
<label>비고</label>
<div>${record.notes}</div>
</div>
` : ''}
</div>
`;
modal.style.display = 'block';
}
/**
* 모달 닫기
*/
function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none';
}
// 모달 외부 클릭 시 닫기
window.onclick = function(event) {
const modal = document.getElementById('detailModal');
if (event.target === modal) {
closeDetailModal();
}
};
/**
* 유틸리티 함수들
*/
function formatDate(dateString) {
const date = new Date(dateString);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}/${day}`;
}
function getStatusClass(typeCode) {
const typeMap = {
'NORMAL': 'normal',
'LATE': 'late',
'EARLY_LEAVE': 'early',
'ABSENT': 'absent',
'VACATION': 'vacation'
};
return typeMap[typeCode] || 'normal';
}
function getStatusText(record) {
if (record.vacation_type_name) {
return record.vacation_type_name;
}
return record.attendance_type_name || record.type_name || '정상';
}
function getStatusIcon(record) {
const typeCode = record.attendance_type_code || record.type_code;
const iconMap = {
'NORMAL': '✓',
'LATE': '⚠',
'EARLY_LEAVE': '⏰',
'ABSENT': '✗',
'VACATION': '🌴'
};
return iconMap[typeCode] || '✓';
}
function showLoading() {
const tbody = document.getElementById('attendanceTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="loading-cell">데이터를 불러오는 중...</td></tr>';
}
function showError(message) {
const tbody = document.getElementById('attendanceTableBody');
const safeMsg = (window.escapeHtml ? window.escapeHtml(message) : message.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])));
tbody.innerHTML = `<tr><td colspan="7" class="error-cell">${safeMsg}</td></tr>`;
// 통계 초기화
document.getElementById('totalHours').textContent = '-';
document.getElementById('totalDays').textContent = '-';
document.getElementById('remainingLeave').textContent = '-';
}

View File

@@ -0,0 +1,187 @@
// My Dashboard - 나의 대시보드 JavaScript
import './api-config.js';
// 전역 변수
let currentYear = new Date().getFullYear();
let currentMonth = new Date().getMonth() + 1;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
await loadUserInfo();
await loadVacationBalance();
await loadMonthlyCalendar();
await loadWorkHoursStats();
await loadRecentReports();
});
// 사용자 정보 로드
async function loadUserInfo() {
try {
const response = await apiCall('/users/me', 'GET');
const user = response.data || response;
document.getElementById('userName').textContent = user.name || '사용자';
document.getElementById('department').textContent = user.department || '-';
document.getElementById('jobType').textContent = user.job_type || '-';
document.getElementById('hireDate').textContent = user.hire_date || '-';
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
}
}
// 연차 정보 로드
async function loadVacationBalance() {
try {
const response = await apiCall('/users/me/vacation-balance', 'GET');
const balance = response.data || response;
const total = balance.total_annual_leave || 15;
const used = balance.used_annual_leave || 0;
const remaining = total - used;
document.getElementById('totalLeave').textContent = total;
document.getElementById('usedLeave').textContent = used;
document.getElementById('remainingLeave').textContent = remaining;
// 프로그레스 바 업데이트
const percentage = (used / total) * 100;
document.getElementById('vacationProgress').style.width = `${percentage}%`;
} catch (error) {
console.error('연차 정보 로드 실패:', error);
}
}
// 월별 캘린더 로드
async function loadMonthlyCalendar() {
try {
const response = await apiCall(
`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`,
'GET'
);
const records = response.data || response;
renderCalendar(currentYear, currentMonth, records);
document.getElementById('currentMonth').textContent = `${currentYear}${currentMonth}`;
} catch (error) {
console.error('캘린더 로드 실패:', error);
renderCalendar(currentYear, currentMonth, []);
}
}
// 캘린더 렌더링
function renderCalendar(year, month, records) {
const calendar = document.getElementById('calendar');
const firstDay = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
let html = '';
// 요일 헤더
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
weekdays.forEach(day => {
html += `<div class="calendar-header">${day}</div>`;
});
// 빈 칸
for (let i = 0; i < firstDay; i++) {
html += '<div class="calendar-day empty"></div>';
}
// 날짜
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const record = Array.isArray(records) ? records.find(r => r.record_date === dateStr) : null;
let statusClass = '';
if (record) {
const typeCode = record.attendance_type_code || record.type_code || '';
statusClass = typeCode.toLowerCase();
}
html += `
<div class="calendar-day ${statusClass}" title="${dateStr}">
<span class="day-number">${day}</span>
</div>
`;
}
calendar.innerHTML = html;
}
// 근무 시간 통계 로드
async function loadWorkHoursStats() {
try {
const response = await apiCall(
`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`,
'GET'
);
const stats = response.data || response;
document.getElementById('monthHours').textContent = stats.month_hours || 0;
document.getElementById('workDays').textContent = stats.work_days || 0;
} catch (error) {
console.error('근무 시간 통계 로드 실패:', error);
}
}
// 최근 작업 보고서 로드
async function loadRecentReports() {
try {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString().split('T')[0];
const response = await apiCall(
`/users/me/work-reports?startDate=${startDate}&endDate=${endDate}`,
'GET'
);
const reports = response.data || response;
const list = document.getElementById('recentReportsList');
if (!Array.isArray(reports) || reports.length === 0) {
list.innerHTML = '<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>';
return;
}
list.innerHTML = reports.map(r => `
<div class="report-item">
<span class="date">${r.report_date}</span>
<span class="project">${r.project_name || 'N/A'}</span>
<span class="hours">${r.work_hours}시간</span>
</div>
`).join('');
} catch (error) {
console.error('최근 작업 보고서 로드 실패:', error);
}
}
// 이전 달
function previousMonth() {
currentMonth--;
if (currentMonth < 1) {
currentMonth = 12;
currentYear--;
}
loadMonthlyCalendar();
loadWorkHoursStats();
}
// 다음 달
function nextMonth() {
currentMonth++;
if (currentMonth > 12) {
currentMonth = 1;
currentYear++;
}
loadMonthlyCalendar();
loadWorkHoursStats();
}
// 전역 함수 노출
window.previousMonth = previousMonth;
window.nextMonth = nextMonth;
window.loadMonthlyCalendar = loadMonthlyCalendar;

View File

@@ -0,0 +1,400 @@
/**
* my-monthly-confirm.js — 작업자 월간 근무 확인 (모바일 캘린더)
*/
var DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
var currentYear, currentMonth;
var isProcessing = false;
var selectedCell = null;
var currentConfStatus = null; // 현재 confirmation 상태
var pendingChanges = {}; // 수정 내역 { 'YYYY-MM-DD': { from: '반차', to: '정시', hours: 8 } }
var loadedRecords = []; // 로드된 daily_records
// ===== Init =====
document.addEventListener('DOMContentLoaded', function() {
var now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
var params = new URLSearchParams(location.search);
if (params.get('year')) currentYear = parseInt(params.get('year'));
if (params.get('month')) currentMonth = parseInt(params.get('month'));
setTimeout(function() {
var user = typeof getCurrentUser === 'function' ? getCurrentUser() : window.currentUser;
if (!user) return;
window._mmcUser = user;
updateMonthLabel();
loadData();
}, 500);
});
function updateMonthLabel() {
document.getElementById('monthLabel').textContent = currentYear + '년 ' + currentMonth + '월';
}
function changeMonth(delta) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
selectedCell = null;
updateMonthLabel();
loadData();
}
// ===== Data Load =====
async function loadData() {
var calWrap = document.getElementById('tableWrap');
calWrap.innerHTML = '<div class="mmc-skeleton"></div><div class="mmc-skeleton"></div>';
try {
var user = window._mmcUser || (typeof getCurrentUser === 'function' ? getCurrentUser() : null) || {};
var userId = user.user_id || user.id;
var [recordsRes, balanceRes] = await Promise.all([
window.apiCall('/monthly-comparison/my-records?year=' + currentYear + '&month=' + currentMonth),
window.apiCall('/vacation-balances/worker/' + userId + '/year/' + currentYear).catch(function() { return { success: true, data: [] }; })
]);
if (!recordsRes || !recordsRes.success) {
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-calendar-xmark text-2xl text-gray-300"></i><p>데이터가 없습니다</p></div>';
document.getElementById('bottomActions').classList.add('hidden');
return;
}
var data = recordsRes.data;
renderUserInfo(data.user);
renderCalendar(data.daily_records || []);
renderSummaryCards(data.daily_records || []);
loadedRecords = data.daily_records || [];
currentConfStatus = data.confirmation ? data.confirmation.status : 'pending';
pendingChanges = {};
renderVacationBalance(balanceRes.data || []);
renderConfirmStatus(data.confirmation);
} catch (e) {
calWrap.innerHTML = '<div class="mmc-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>네트워크 오류</p></div>';
}
}
// ===== Render =====
function renderUserInfo(user) {
if (!user) return;
document.getElementById('userName').textContent = user.worker_name || user.name || '-';
document.getElementById('userDept').textContent =
(user.job_type ? user.job_type + ' · ' : '') + (user.department_name || '');
}
// 셀 텍스트 판정
// 8h 기준 고정 (scheduled_hours 미존재 — 단축근무 미대응)
function getCellInfo(r) {
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
var vacType = r.attendance ? r.attendance.vacation_type : null;
var isHoliday = r.is_holiday;
if (vacType) return { text: vacType, cls: 'vac', detail: vacType };
if (isHoliday && hrs <= 0) return { text: '휴무', cls: 'off', detail: r.holiday_name || '휴무' };
if (isHoliday && hrs > 0) return { text: '특 ' + hrs + 'h', cls: 'special', detail: '특근 ' + hrs + '시간' };
if (hrs === 8) return { text: '정시', cls: 'normal', detail: '정시근로 8시간' };
if (hrs > 8) return { text: '+' + (hrs - 8) + 'h', cls: 'overtime', detail: '연장근로 ' + hrs + '시간 (+' + (hrs - 8) + ')' };
if (hrs > 0) return { text: hrs + 'h', cls: 'partial', detail: hrs + '시간 근무' };
return { text: '-', cls: 'none', detail: '미입력' };
}
function renderCalendar(records) {
var el = document.getElementById('tableWrap');
if (!records.length) {
el.innerHTML = '<div class="mmc-empty"><p>해당 월 데이터가 없습니다</p></div>';
return;
}
// 날짜별 맵
var recMap = {};
records.forEach(function(r) { recMap[parseInt(r.date.substring(8))] = r; });
var firstDay = new Date(currentYear, currentMonth - 1, 1).getDay();
var daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
// 헤더
var html = '<div class="cal-grid">';
html += '<div class="cal-header">';
DAYS_KR.forEach(function(d, i) {
var cls = i === 0 ? ' sun' : i === 6 ? ' sat' : '';
html += '<div class="cal-dow' + cls + '">' + d + '</div>';
});
html += '</div>';
// 셀
html += '<div class="cal-body">';
// 빈 셀 (월 시작 전)
for (var i = 0; i < firstDay; i++) {
html += '<div class="cal-cell empty"></div>';
}
for (var day = 1; day <= daysInMonth; day++) {
var r = recMap[day];
var info = r ? getCellInfo(r) : { text: '-', cls: 'none', detail: '데이터 없음' };
var dow = (firstDay + day - 1) % 7;
var dowCls = dow === 0 ? ' sun' : dow === 6 ? ' sat' : '';
html += '<div class="cal-cell ' + info.cls + dowCls + '" onclick="selectDay(' + day + ')">';
html += '<span class="cal-day">' + day + '</span>';
html += '<span class="cal-val">' + escHtml(info.text) + '</span>';
html += '</div>';
}
html += '</div></div>';
// 상세 영역
html += '<div class="cal-detail" id="calDetail"></div>';
el.innerHTML = html;
}
function selectDay(day) {
selectedCell = day;
var el = document.getElementById('calDetail');
var cells = document.querySelectorAll('.cal-cell');
cells.forEach(function(c) { c.classList.remove('selected'); });
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells[day - 1]) allCells[day - 1].classList.add('selected');
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
var d = new Date(currentYear, currentMonth - 1, day);
var dow = DAYS_KR[d.getDay()];
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
var currentVal = record ? getCellInfo(record).text : '-';
var html = '<div class="cal-detail-inner">';
html += '<strong>' + currentMonth + '/' + day + ' (' + dow + ')</strong> — ' + escHtml(currentVal);
// review_sent 상태에서만 수정 드롭다운 표시
if (currentConfStatus === 'review_sent') {
var changed = pendingChanges[dateStr];
html += '<div class="cal-edit-row">';
html += '<select id="editType-' + day + '" onchange="onCellChange(' + day + ')" class="cal-edit-select">';
html += '<option value="">변경 없음</option>';
html += '<option value="정시"' + (changed && changed.to === '정시' ? ' selected' : '') + '>정시 (8h)</option>';
html += '<option value="연차"' + (changed && changed.to === '연차' ? ' selected' : '') + '>연차 (0h)</option>';
html += '<option value="반차"' + (changed && changed.to === '반차' ? ' selected' : '') + '>반차 (4h)</option>';
html += '<option value="반반차"' + (changed && changed.to === '반반차' ? ' selected' : '') + '>반반차 (6h)</option>';
html += '<option value="조퇴"' + (changed && changed.to === '조퇴' ? ' selected' : '') + '>조퇴 (2h)</option>';
html += '<option value="휴무"' + (changed && changed.to === '휴무' ? ' selected' : '') + '>휴무 (0h)</option>';
html += '</select>';
if (changed) html += ' <span class="cal-changed-badge">수정</span>';
html += '</div>';
}
html += '</div>';
el.innerHTML = html;
el.style.display = 'block';
updateChangeRequestBtn();
}
function onCellChange(day) {
var dateStr = currentYear + '-' + String(currentMonth).padStart(2, '0') + '-' + String(day).padStart(2, '0');
var sel = document.getElementById('editType-' + day);
var newType = sel ? sel.value : '';
var record = loadedRecords.find(function(r) { return parseInt(r.date.substring(8)) === day; });
var currentType = record ? getCellInfo(record).text : '-';
if (newType && newType !== currentType) {
var hoursMap = { '정시': 8, '연차': 0, '반차': 4, '반반차': 6, '조퇴': 2, '휴무': 0 };
pendingChanges[dateStr] = { from: currentType, to: newType, hours: hoursMap[newType] || 0 };
// 셀에 수정 뱃지
var allCells = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells[day - 1]) allCells[day - 1].classList.add('changed');
} else {
delete pendingChanges[dateStr];
var allCells2 = document.querySelectorAll('.cal-cell:not(.empty)');
if (allCells2[day - 1]) allCells2[day - 1].classList.remove('changed');
}
updateChangeRequestBtn();
// 상세 영역 재렌더
selectDay(day);
}
function updateChangeRequestBtn() {
var rejectBtn = document.getElementById('rejectBtn');
if (!rejectBtn) return;
var changeCount = Object.keys(pendingChanges).length;
if (currentConfStatus === 'review_sent' && changeCount > 0) {
rejectBtn.disabled = false;
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청 (' + changeCount + '건)';
} else if (currentConfStatus === 'review_sent') {
rejectBtn.disabled = true;
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
}
}
function renderSummaryCards(records) {
var workDays = 0, overtimeHours = 0, vacDays = 0;
records.forEach(function(r) {
var hrs = r.attendance ? parseFloat(r.attendance.total_work_hours) || 0 : 0;
var vacType = r.attendance ? r.attendance.vacation_type : null;
var isHoliday = r.is_holiday;
if (!isHoliday && (hrs > 0 || vacType)) workDays++;
if (hrs > 8) overtimeHours += (hrs - 8);
if (vacType) {
var vd = r.attendance.vacation_days ? parseFloat(r.attendance.vacation_days) : 0;
if (vd > 0) { vacDays += vd; }
else {
// fallback: vacation_type 이름으로 차감일수 매핑
var deductMap = { '연차': 1, '반차': 0.5, '반반차': 0.25, '조퇴': 0.75, '병가': 1 };
vacDays += deductMap[vacType] || 1;
}
}
});
var el = document.getElementById('summaryCards');
if (!el) return;
el.innerHTML =
'<div class="mmc-sum-card"><div class="mmc-sum-num">' + workDays + '</div><div class="mmc-sum-label">근무일</div></div>' +
'<div class="mmc-sum-card"><div class="mmc-sum-num ot">' + fmtNum(overtimeHours) + 'h</div><div class="mmc-sum-label">연장근로</div></div>' +
'<div class="mmc-sum-card"><div class="mmc-sum-num vac">' + fmtNum(vacDays) + '일</div><div class="mmc-sum-label">연차</div></div>';
}
function renderVacationBalance(balances) {
var el = document.getElementById('vacationCards');
var total = 0, used = 0;
if (Array.isArray(balances)) {
balances.forEach(function(b) {
total += parseFloat(b.total_days || 0);
used += parseFloat(b.used_days || 0);
});
}
var remaining = total - used;
el.innerHTML =
'<div class="mmc-vac-title">연차 현황</div>' +
'<div class="mmc-vac-grid">' +
'<div class="mmc-vac-card"><div class="mmc-vac-num">' + fmtNum(total) + '</div><div class="mmc-vac-label">부여</div></div>' +
'<div class="mmc-vac-card"><div class="mmc-vac-num used">' + fmtNum(used) + '</div><div class="mmc-vac-label">사용</div></div>' +
'<div class="mmc-vac-card"><div class="mmc-vac-num remain">' + fmtNum(remaining) + '</div><div class="mmc-vac-label">잔여</div></div>' +
'</div>';
}
function renderConfirmStatus(conf) {
var actions = document.getElementById('bottomActions');
var statusEl = document.getElementById('confirmedStatus');
var badge = document.getElementById('statusBadge');
var confirmBtn = document.getElementById('confirmBtn');
var rejectBtn = document.getElementById('rejectBtn');
var status = conf ? conf.status : 'pending';
// 기본: 버튼 숨김 + 상태 숨김
actions.classList.add('hidden');
statusEl.classList.add('hidden');
if (status === 'pending') {
badge.textContent = '검토대기';
badge.className = 'mmc-status-badge pending';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '관리자 검토 대기 중입니다';
} else if (status === 'review_sent') {
badge.textContent = '확인요청';
badge.className = 'mmc-status-badge review_sent';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>확인 완료';
rejectBtn.innerHTML = '<i class="fas fa-edit mr-2"></i>수정요청';
rejectBtn.disabled = true; // 수정 내역 없으면 비활성화
rejectBtn.onclick = function() { submitChangeRequest(); };
} else if (status === 'confirmed') {
badge.textContent = '확인완료';
badge.className = 'mmc-status-badge confirmed';
statusEl.classList.remove('hidden');
var dt = conf.confirmed_at ? new Date(conf.confirmed_at).toLocaleDateString('ko') : '';
document.getElementById('confirmedText').textContent = dt + ' 확인 완료';
} else if (status === 'change_request') {
badge.textContent = '수정요청';
badge.className = 'mmc-status-badge change_request';
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '수정요청이 제출되었습니다. 관리자 확인 대기 중';
} else if (status === 'rejected') {
badge.textContent = '반려';
badge.className = 'mmc-status-badge rejected';
actions.classList.remove('hidden');
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-2"></i>동의(재확인)';
rejectBtn.classList.add('hidden');
statusEl.classList.remove('hidden');
document.getElementById('confirmedText').textContent = '반려 사유: ' + (conf.reject_reason || '-') + '\n반려 사유를 확인하고 동의하시면 확인 완료 버튼을 눌러주세요.';
}
}
function openChangeRequestModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
// 모달 제목/버튼 수정요청용으로 변경
var header = document.querySelector('.mmc-modal-header span');
if (header) header.innerHTML = '<i class="fas fa-edit text-blue-500 mr-2"></i>수정요청';
var submitBtn = document.querySelector('.mmc-modal-submit');
if (submitBtn) submitBtn.textContent = '수정요청 제출';
var desc = document.querySelector('.mmc-modal-desc');
if (desc) desc.textContent = '수정이 필요한 내용을 입력해주세요:';
var note = document.querySelector('.mmc-modal-note');
if (note) note.innerHTML = '<i class="fas fa-info-circle text-blue-400 mr-1"></i>수정요청 시 관리자에게 알림이 전달됩니다.';
}
// ===== Actions =====
async function confirmMonth() {
if (isProcessing) return;
if (!confirm(currentYear + '년 ' + currentMonth + '월 근무 내역을 확인하시겠습니까?')) return;
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'confirmed'
});
if (res && res.success) { showToast(res.message || '확인 완료', 'success'); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
async function submitChangeRequest() {
if (isProcessing) return;
var changeCount = Object.keys(pendingChanges).length;
if (changeCount === 0) { showToast('수정 내역이 없습니다', 'error'); return; }
if (!confirm(changeCount + '건의 수정요청을 제출하시겠습니까?')) return;
isProcessing = true;
try {
var changes = Object.keys(pendingChanges).map(function(date) {
return { date: date, from: pendingChanges[date].from, to: pendingChanges[date].to };
});
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'change_request',
change_details: { changes: changes }
});
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
function openRejectModal() {
document.getElementById('rejectReason').value = '';
document.getElementById('rejectModal').classList.remove('hidden');
}
function closeRejectModal() { document.getElementById('rejectModal').classList.add('hidden'); }
async function submitReject() {
if (isProcessing) return;
var reason = document.getElementById('rejectReason').value.trim();
if (!reason) { showToast('수정 내용을 입력해주세요', 'error'); return; }
isProcessing = true;
try {
var res = await window.apiCall('/monthly-comparison/confirm', 'POST', {
year: currentYear, month: currentMonth, status: 'change_request',
change_details: { description: reason }
});
if (res && res.success) { showToast(res.message || '수정요청 완료', 'success'); closeRejectModal(); loadData(); }
else { showToast(res && res.message || '처리 실패', 'error'); }
} catch (e) { showToast('네트워크 오류', 'error'); }
finally { isProcessing = false; }
}
// ===== Helpers =====
function fmtNum(v) { var n = parseFloat(v) || 0; return n % 1 === 0 ? n.toString() : n.toFixed(1); }
function escHtml(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// showToast — tkfb-core.js의 전역 showToast 사용 (재정의 불필요)
function handleEscKey(e) { if (e.key === 'Escape') closeRejectModal(); }
document.addEventListener('keydown', handleEscKey);
window.addEventListener('beforeunload', function() { document.removeEventListener('keydown', handleEscKey); });

View File

@@ -0,0 +1,121 @@
// js/my-profile.js
// 내 프로필 페이지 JavaScript
import { API, getAuthHeaders, ensureAuthenticated } from '/js/api-config.js';
// 인증 확인
const token = ensureAuthenticated();
// 권한 레벨 한글 매핑
const accessLevelMap = {
worker: '작업자',
group_leader: '그룹장',
support_team: '지원팀',
admin: '관리자',
system: '시스템 관리자'
};
// 프로필 데이터 로드
async function loadProfile() {
try {
// 먼저 로컬 스토리지에서 기본 정보 표시
const storedUser = JSON.parse(localStorage.getItem('sso_user') || '{}');
if (storedUser) {
updateProfileUI(storedUser);
}
// API에서 최신 정보 가져오기
const res = await fetch(`${API}/auth/me`, {
headers: getAuthHeaders()
});
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const userData = await res.json();
// 로컬 스토리지 업데이트
const updatedUser = {
...storedUser,
...userData
};
localStorage.setItem('sso_user', JSON.stringify(updatedUser));
// UI 업데이트
updateProfileUI(userData);
} catch (error) {
console.error('프로필 로딩 실패:', error);
showError('프로필 정보를 불러오는데 실패했습니다.');
}
}
// 프로필 UI 업데이트
function updateProfileUI(user) {
// 헤더 정보
const avatar = document.getElementById('profileAvatar');
if (avatar && user.name) {
// 이름의 첫 글자를 아바타로 사용
const initial = user.name.charAt(0).toUpperCase();
if (initial.match(/[A-Z가-힣]/)) {
avatar.textContent = initial;
}
}
document.getElementById('profileName').textContent = user.name || user.username || '사용자';
document.getElementById('profileRole').textContent = accessLevelMap[user.access_level] || user.access_level || '역할 미지정';
// 기본 정보
document.getElementById('userId').textContent = user.user_id || '-';
document.getElementById('username').textContent = user.username || '-';
document.getElementById('fullName').textContent = user.name || '-';
document.getElementById('accessLevel').textContent = accessLevelMap[user.access_level] || user.access_level || '-';
document.getElementById('workerId').textContent = user.user_id || '연결되지 않음';
// 날짜 포맷팅
if (user.created_at) {
const createdDate = new Date(user.created_at);
document.getElementById('createdAt').textContent = formatDate(createdDate);
}
if (user.last_login_at) {
const lastLoginDate = new Date(user.last_login_at);
document.getElementById('lastLogin').textContent = formatDateTime(lastLoginDate);
} else {
document.getElementById('lastLogin').textContent = '첫 로그인';
}
// 이메일
document.getElementById('email').textContent = user.email || '등록되지 않음';
}
// 날짜 포맷팅 함수
function formatDate(date) {
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function formatDateTime(date) {
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// 에러 표시
function showError(message) {
// 간단한 알림으로 처리
alert('❌ ' + message);
}
// 페이지 로드 시 실행
document.addEventListener('DOMContentLoaded', () => {
loadProfile();
});

View File

@@ -0,0 +1,14 @@
// js/navigation.js — ES6 모듈
// tkfb-core.js (non-module)에서 window에 등록한 함수들에 위임
export function redirectToLogin() {
if (window.getLoginUrl) {
window.location.href = window.getLoginUrl();
} else {
window.location.href = '/login.html';
}
}
export function redirectToDefaultDashboard(redirectUrl) {
window.location.href = redirectUrl || '/';
}

View File

@@ -0,0 +1,222 @@
/**
* 부적합 현황 페이지 JavaScript
* category_type=nonconformity 고정 필터
*/
const API_BASE = window.API_BASE_URL || 'http://localhost:30005/api';
const CATEGORY_TYPE = 'nonconformity';
// 상태 한글 변환
const STATUS_LABELS = {
reported: '신고',
received: '접수',
in_progress: '처리중',
completed: '완료',
closed: '종료'
};
// DOM 요소
let issueList;
let filterStatus, filterStartDate, filterEndDate;
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
issueList = document.getElementById('issueList');
filterStatus = document.getElementById('filterStatus');
filterStartDate = document.getElementById('filterStartDate');
filterEndDate = document.getElementById('filterEndDate');
// 필터 이벤트 리스너
filterStatus.addEventListener('change', loadIssues);
filterStartDate.addEventListener('change', loadIssues);
filterEndDate.addEventListener('change', loadIssues);
// 데이터 로드
await Promise.all([loadStats(), loadIssues()]);
});
/**
* 통계 로드 (부적합만)
*/
async function loadStats() {
try {
const response = await fetch(`${API_BASE}/work-issues/stats/summary?category_type=${CATEGORY_TYPE}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
});
if (!response.ok) {
document.getElementById('statsGrid').style.display = 'none';
return;
}
const data = await response.json();
if (data.success && data.data) {
document.getElementById('statReported').textContent = data.data.reported || 0;
document.getElementById('statReceived').textContent = data.data.received || 0;
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
document.getElementById('statCompleted').textContent = data.data.completed || 0;
}
} catch (error) {
console.error('통계 로드 실패:', error);
document.getElementById('statsGrid').style.display = 'none';
}
}
/**
* 부적합 목록 로드
*/
async function loadIssues() {
try {
// 필터 파라미터 구성 (category_type 고정)
const params = new URLSearchParams();
params.append('category_type', CATEGORY_TYPE);
if (filterStatus.value) params.append('status', filterStatus.value);
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('sso_token')}` }
});
if (!response.ok) throw new Error('목록 조회 실패');
const data = await response.json();
if (data.success) {
renderIssues(data.data || []);
}
} catch (error) {
console.error('부적합 목록 로드 실패:', error);
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
<p>잠시 후 다시 시도해주세요.</p>
</div>
`;
}
}
/**
* 부적합 목록 렌더링
*/
function renderIssues(issues) {
if (issues.length === 0) {
issueList.innerHTML = `
<div class="empty-state">
<div class="empty-state-title">등록된 부적합 신고가 없습니다</div>
<p>새로운 부적합을 신고하려면 '부적합 신고' 버튼을 클릭하세요.</p>
</div>
`;
return;
}
const baseUrl = (window.API_BASE_URL || 'http://localhost:30005').replace('/api', '');
issueList.innerHTML = issues.map(issue => {
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
// 위치 정보 (escaped)
let location = escapeHtml(issue.custom_location || '');
if (issue.factory_name) {
location = escapeHtml(issue.factory_name);
if (issue.workplace_name) {
location += ` - ${escapeHtml(issue.workplace_name)}`;
}
}
// 신고 제목 (항목명 또는 카테고리명)
const title = escapeHtml(issue.issue_item_name || issue.issue_category_name || '부적합 신고');
const categoryName = escapeHtml(issue.issue_category_name || '부적합');
// 사진 목록
const photos = [
issue.photo_path1,
issue.photo_path2,
issue.photo_path3,
issue.photo_path4,
issue.photo_path5
].filter(Boolean);
// 안전한 값들
const safeReportId = parseInt(issue.report_id) || 0;
const validStatuses = ['reported', 'received', 'in_progress', 'completed', 'closed'];
const safeStatus = validStatuses.includes(issue.status) ? issue.status : 'reported';
const reporterName = escapeHtml(issue.reporter_full_name || issue.reporter_name || '-');
const assignedName = issue.assigned_full_name ? escapeHtml(issue.assigned_full_name) : '';
return `
<div class="issue-card" onclick="viewIssue(${safeReportId})">
<div class="issue-header">
<span class="issue-id">#${safeReportId}</span>
<span class="issue-status ${safeStatus}">${STATUS_LABELS[issue.status] || escapeHtml(issue.status || '-')}</span>
</div>
<div class="issue-title">
<span class="issue-category-badge">${categoryName}</span>
${title}
</div>
<div class="issue-meta">
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
${reporterName}
</span>
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${reportDate}
</span>
${location ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
${location}
</span>
` : ''}
${assignedName ? `
<span class="issue-meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
담당: ${assignedName}
</span>
` : ''}
</div>
${photos.length > 0 ? `
<div class="issue-photos">
${photos.slice(0, 3).map(p => `
<img src="${baseUrl}${encodeURI(p)}" alt="신고 사진" loading="lazy">
`).join('')}
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* 상세 보기
*/
function viewIssue(reportId) {
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}&from=nonconformity`;
}

View File

@@ -0,0 +1,245 @@
/**
* 생산팀 대시보드 — Sprint 003
*/
const PAGE_ICONS = {
'dashboard': 'fa-home',
'work.tbm': 'fa-clipboard-list',
'work.report_create': 'fa-file-alt',
'work.analysis': 'fa-chart-bar',
'work.nonconformity': 'fa-exclamation-triangle',
'work.schedule': 'fa-calendar-alt',
'work.meetings': 'fa-users',
'work.daily_status': 'fa-chart-bar',
'work.proxy_input': 'fa-user-edit',
'factory.repair_management': 'fa-tools',
'inspection.daily_patrol': 'fa-route',
'inspection.checkin': 'fa-user-check',
'inspection.work_status': 'fa-briefcase',
'purchase.request': 'fa-shopping-cart',
'purchase.analysis': 'fa-chart-line',
'attendance.monthly': 'fa-calendar',
'attendance.vacation_request': 'fa-paper-plane',
'attendance.vacation_management': 'fa-cog',
'attendance.vacation_allocation': 'fa-plus-circle',
'attendance.annual_overview': 'fa-chart-pie',
'attendance.monthly_comparison': 'fa-scale-balanced',
'admin.user_management': 'fa-users-cog',
'admin.projects': 'fa-project-diagram',
'admin.tasks': 'fa-tasks',
'admin.workplaces': 'fa-building',
'admin.equipments': 'fa-cogs',
'admin.departments': 'fa-sitemap',
'admin.notifications': 'fa-bell',
'admin.attendance_report': 'fa-clipboard-check',
};
// 내 메뉴에서 제외 (대시보드에서 직접 확인)
const HIDDEN_PAGES = ['dashboard', 'attendance.my_vacation_info'];
const CATEGORY_COLORS = {
'작업 관리': '#3b82f6',
'공장 관리': '#f59e0b',
'소모품 관리': '#10b981',
'근태 관리': '#8b5cf6',
'시스템 관리': '#6b7280',
};
const DEFAULT_COLOR = '#06b6d4';
function isExpired(expiresAt) {
if (!expiresAt) return false;
const today = new Date(); today.setHours(0,0,0,0);
const exp = new Date(expiresAt); exp.setHours(0,0,0,0);
return today > exp;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function fmtDays(n) { return n % 1 === 0 ? n.toString() : n.toFixed(1); }
let _dashboardData = null;
async function initDashboard() {
showSkeleton();
try {
const result = await api('/dashboard/my-summary');
if (!result.success) throw new Error(result.message || '데이터 로드 실패');
_dashboardData = result.data;
renderDashboard(result.data);
} catch (err) {
showError(err.message);
}
}
function renderDashboard(data) {
const { user, vacation, overtime, quick_access } = data;
const card = document.getElementById('profileCard');
const initial = (user.worker_name || user.name || '?').charAt(0);
const vacRemaining = vacation.remaining_days;
const vacTotal = vacation.total_days;
const vacUsed = vacation.used_days;
const vacPct = vacTotal > 0 ? Math.round((vacUsed / Math.max(vacTotal, 1)) * 100) : 0;
const vacColor = vacRemaining >= 5 ? 'green' : vacRemaining >= 3 ? 'yellow' : 'red';
const otHours = overtime.total_overtime_hours;
const otDays = overtime.overtime_days;
card.innerHTML = `
<button onclick="doLogout()" class="pd-logout-btn" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
<div class="pd-profile-header">
<div class="pd-avatar">${escHtml(initial)}</div>
<div>
<div class="pd-profile-name">${escHtml(user.worker_name || user.name)}</div>
<div class="pd-profile-sub">${escHtml(user.job_type || '')}${user.job_type ? ' · ' : ''}${escHtml(user.department_name)}
<a href="/pages/profile/password.html" style="margin-left:8px;color:rgba(255,255,255,0.7);font-size:11px;text-decoration:underline" title="비밀번호 변경"><i class="fas fa-key" style="margin-right:2px"></i>비밀번호 변경</a>
</div>
</div>
</div>
<div class="pd-info-list">
<div class="pd-info-row" onclick="openVacDetailModal()">
<div class="pd-info-left">
<i class="fas fa-umbrella-beach pd-info-icon"></i>
<span class="pd-info-label">연차</span>
</div>
${vacTotal > 0 ? `
<div class="pd-info-right">
<span class="pd-info-value">잔여 <strong>${fmtDays(vacRemaining)}일</strong></span>
<span class="pd-info-sub">/ ${fmtDays(vacTotal)}일</span>
<i class="fas fa-chevron-right pd-info-arrow"></i>
</div>
` : `
<div class="pd-info-right">
<span class="pd-info-sub">미등록</span>
<i class="fas fa-chevron-right pd-info-arrow"></i>
</div>
`}
</div>
${vacTotal > 0 ? `<div class="pd-progress-bar" style="margin:0 12px 8px"><div class="pd-progress-fill pd-progress-${vacColor}" style="width:${Math.min(vacPct, 100)}%"></div></div>` : ''}
<div class="pd-info-row">
<div class="pd-info-left">
<i class="fas fa-clock pd-info-icon"></i>
<span class="pd-info-label">연장근로</span>
</div>
<div class="pd-info-right">
<span class="pd-info-value"><strong>${otHours.toFixed(1)}h</strong></span>
<span class="pd-info-sub">이번달 ${otDays}일</span>
</div>
</div>
</div>
`;
renderGrid('deptPagesGrid', 'deptPagesSection', quick_access.department_pages);
renderGrid('personalPagesGrid', 'personalPagesSection', quick_access.personal_pages);
renderGrid('adminPagesGrid', 'adminPagesSection', quick_access.admin_pages);
}
function openVacDetailModal() {
if (!_dashboardData) return;
const { vacation } = _dashboardData;
const details = vacation.details || [];
const groups = {};
details.forEach(d => {
const bt = d.balance_type || 'AUTO';
if (!groups[bt]) groups[bt] = { total: 0, used: 0, remaining: 0, expires_at: d.expires_at, items: [] };
groups[bt].total += d.total;
groups[bt].used += d.used;
groups[bt].remaining += d.remaining;
if (d.expires_at) groups[bt].expires_at = d.expires_at;
groups[bt].items.push(d);
});
const LABELS = { CARRY_OVER: '이월연차', AUTO: '정기연차', MANUAL: '추가부여', LONG_SERVICE: '장기근속', COMPANY_GRANT: '경조사/특별' };
const ORDER = ['CARRY_OVER', 'AUTO', 'MANUAL', 'LONG_SERVICE', 'COMPANY_GRANT'];
let html = '';
ORDER.forEach(bt => {
const g = groups[bt];
if (!g || (g.total === 0 && g.used === 0)) return;
const label = LABELS[bt] || bt;
const expired = bt === 'CARRY_OVER' && isExpired(g.expires_at);
const lapsed = expired ? Math.max(0, g.total - g.used) : 0;
html += `<div class="pd-detail-row">
<span class="pd-detail-label">${label}</span>
<span class="pd-detail-value">
${g.total !== 0 ? `배정 ${fmtDays(g.total)}` : ''}
${g.used > 0 ? ` · 사용 ${fmtDays(g.used)}` : ''}
${expired && lapsed > 0 ? ` · <span style="color:#9ca3af;text-decoration:line-through">만료 ${fmtDays(lapsed)}</span>` : ''}
${!expired && g.remaining !== 0 ? ` · 잔여 <strong>${fmtDays(g.remaining)}</strong>` : ''}
</span>
</div>`;
});
if (!html) html = '<div style="text-align:center;padding:20px;color:rgba(255,255,255,0.6)">연차 정보가 없습니다</div>';
html += `<div class="pd-detail-total">
<span>합계</span>
<span>배정 ${fmtDays(vacation.total_days)} · 사용 ${fmtDays(vacation.used_days)} · 잔여 <strong>${fmtDays(vacation.remaining_days)}</strong></span>
</div>`;
document.getElementById('vacDetailContent').innerHTML = html;
document.getElementById('vacDetailModal').classList.add('active');
}
function closeVacDetail() {
document.getElementById('vacDetailModal').classList.remove('active');
}
function renderGrid(gridId, sectionId, pages) {
const grid = document.getElementById(gridId);
const section = document.getElementById(sectionId);
if (!pages || pages.length === 0) { section.classList.add('hidden'); return; }
section.classList.remove('hidden');
const filtered = pages.filter(p => !HIDDEN_PAGES.includes(p.page_key));
if (filtered.length === 0) { section.classList.add('hidden'); return; }
grid.innerHTML = filtered.map(p => {
const icon = PAGE_ICONS[p.page_key] || p.icon || 'fa-circle';
const color = CATEGORY_COLORS[p.category] || DEFAULT_COLOR;
return `<a href="${escHtml(p.page_path)}" class="pd-grid-item">
<div class="pd-grid-icon" style="background:${color}">
<i class="fas ${icon}"></i>
</div>
<span class="pd-grid-label">${escHtml(p.page_name)}</span>
</a>`;
}).join('');
}
function showSkeleton() {
const card = document.getElementById('profileCard');
card.innerHTML = `
<div class="pd-profile-header">
<div class="pd-skeleton" style="width:48px;height:48px;border-radius:50%"></div>
<div style="flex:1">
<div class="pd-skeleton" style="width:100px;height:18px;margin-bottom:6px"></div>
<div class="pd-skeleton" style="width:140px;height:14px"></div>
</div>
</div>
<div class="pd-skeleton" style="height:50px;margin-top:12px"></div>
<div class="pd-skeleton" style="height:50px;margin-top:6px"></div>
`;
['deptPagesGrid'].forEach(id => {
const g = document.getElementById(id);
if (g) g.innerHTML = Array(8).fill('<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div class="pd-skeleton" style="width:52px;height:52px;border-radius:14px"></div><div class="pd-skeleton" style="width:40px;height:12px"></div></div>').join('');
});
}
function showError(msg) {
document.getElementById('profileCard').innerHTML = `
<div class="pd-error">
<i class="fas fa-exclamation-circle"></i>
<p>${escHtml(msg || '정보를 불러올 수 없습니다.')}</p>
<button class="pd-error-btn" onclick="initDashboard()"><i class="fas fa-redo mr-1"></i>새로고침</button>
</div>
`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 300));
} else {
setTimeout(initDashboard, 300);
}

View File

@@ -0,0 +1,43 @@
// /js/project-analysis-api.js
import { apiGet } from './api-helper.js';
/**
* 분석 페이지에 필요한 모든 초기 데이터(마스터 데이터)를 병렬로 가져옵니다.
* 이 데이터는 필터 옵션을 채우는 데 사용됩니다.
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
*/
export async function getMasterData() {
try {
const [allWorkers, projects, tasks] = await Promise.all([
apiGet('/workers'),
apiGet('/projects'),
apiGet('/tasks')
]);
// 활성화된 작업자만 필터링
const workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
return { workers, projects, tasks };
} catch (error) {
console.error('마스터 데이터 로딩 실패:', error);
throw new Error('필터링에 필요한 데이터를 불러오는 데 실패했습니다.');
}
}
/**
* 지정된 기간의 모든 분석 데이터를 백엔드에서 직접 가져옵니다.
* @param {string} startDate - 시작일 (YYYY-MM-DD)
* @param {string} endDate - 종료일 (YYYY-MM-DD)
* @returns {Promise<object>} - 요약, 집계, 상세 데이터가 모두 포함된 분석 결과 객체
*/
export async function getAnalysisReport(startDate, endDate) {
try {
const analysisData = await apiGet(`/analysis?startDate=${startDate}&endDate=${endDate}`);
return analysisData;
} catch (error) {
console.error('분석 보고서 데이터 로딩 실패:', error);
throw new Error(`분석 데이터를 불러오는 데 실패했습니다: ${error.message}`);
}
}

View File

@@ -0,0 +1,170 @@
// /js/project-analysis-ui.js
const DOM = {
// 기간 설정
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
// 카드 및 필터
analysisCard: document.getElementById('analysisCard'),
summaryCards: document.getElementById('summaryCards'),
projectFilter: document.getElementById('projectFilter'),
workerFilter: document.getElementById('workerFilter'),
taskFilter: document.getElementById('taskFilter'),
// 탭
tabButtons: document.querySelectorAll('.tab-button'),
tabContents: document.querySelectorAll('.analysis-content'),
// 테이블 본문
projectTableBody: document.getElementById('projectTableBody'),
workerTableBody: document.getElementById('workerTableBody'),
taskTableBody: document.getElementById('taskTableBody'),
detailTableBody: document.getElementById('detailTableBody'),
};
/**
* 날짜 input 값을 YYYY-MM-DD 형식의 문자열로 반환
* @param {Date} date - 날짜 객체
* @returns {string} - 포맷된 날짜 문자열
*/
const formatDate = (date) => date.toISOString().split('T')[0];
/**
* UI상의 날짜 선택기를 기본값(이번 달)으로 설정합니다.
*/
export function setDefaultDates() {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
DOM.startDate.value = formatDate(firstDay);
DOM.endDate.value = formatDate(lastDay);
}
/**
* 분석 실행 전후의 UI 상태를 관리합니다 (로딩 표시 등)
* @param {'loading' | 'data' | 'no-data' | 'error'} state - UI 상태
*/
export function setUIState(state) {
const projectCols = 5;
const detailCols = 8;
const messages = {
loading: '📊 데이터 분석 중...',
'no-data': '해당 기간에 분석할 데이터가 없습니다.',
error: '오류가 발생했습니다. 다시 시도해주세요.',
};
if (state === 'data') {
DOM.analysisCard.style.display = 'block';
} else {
const message = messages[state];
const html = `<tr><td colspan="${projectCols}" class="${state}">${message}</td></tr>`;
const detailHtml = `<tr><td colspan="${detailCols}" class="${state}">${message}</td></tr>`;
DOM.projectTableBody.innerHTML = html;
DOM.workerTableBody.innerHTML = html;
DOM.taskTableBody.innerHTML = html;
DOM.detailTableBody.innerHTML = detailHtml;
DOM.summaryCards.innerHTML = '';
DOM.analysisCard.style.display = 'block';
}
}
/**
* 마스터 데이터를 기반으로 필터 옵션을 채웁니다.
* @param {{workers: Array, projects: Array, tasks: Array}} masterData - 마스터 데이터
*/
export function updateFilterOptions(masterData) {
const createOptions = (items, key, value) => {
let html = '<option value="">전체</option>';
items.forEach(item => {
html += `<option value="${item[key]}">${item[value]}</option>`;
});
return html;
};
DOM.projectFilter.innerHTML = createOptions(masterData.projects, 'project_id', 'project_name');
DOM.workerFilter.innerHTML = createOptions(masterData.workers, 'user_id', 'worker_name');
DOM.taskFilter.innerHTML = createOptions(masterData.tasks, 'task_id', 'category');
}
/**
* 요약 카드 데이터를 렌더링합니다.
* @param {object} summary - 요약 데이터
*/
export function renderSummary(summary) {
DOM.summaryCards.innerHTML = `
<div class="summary-card"><h4>총 투입 시간</h4><div class="value">${(summary.totalHours || 0).toFixed(1)}h</div></div>
<div class="summary-card"><h4>참여 프로젝트</h4><div class="value">${summary.totalProjects || 0}개</div></div>
<div class="summary-card"><h4>참여 인원</h4><div class="value">${summary.totalWorkers || 0}명</div></div>
<div class="summary-card"><h4>작업 분류</h4><div class="value">${summary.totalTasks || 0}개</div></div>
`;
}
/**
* 집계된 데이터를 받아 테이블을 렌더링하는 범용 함수
* @param {HTMLElement} tableBodyEl - 렌더링할 테이블의 tbody 요소
* @param {Array} data - 집계된 데이터 배열
* @param {function} rowRenderer - 각 행을 렌더링하는 함수
*/
function renderTable(tableBodyEl, data, rowRenderer) {
if (!data || data.length === 0) {
tableBodyEl.innerHTML = '<tr><td colspan="5" class="no-data">데이터가 없습니다</td></tr>';
return;
}
tableBodyEl.innerHTML = data.map(rowRenderer).join('');
}
/**
* 집계된 데이터를 기반으로 모든 분석 테이블을 렌더링합니다.
* @param {object} analysis - 프로젝트/작업자/작업별 집계 데이터
*/
export function renderAnalysisTables(analysis) {
renderTable(DOM.projectTableBody, analysis.byProject, (p, i) => `
<tr><td>${i + 1}</td><td class="project-col" title="${p.name}">${p.name}</td><td class="hours-col">${p.hours}h</td>
<td>${p.percentage}%</td><td>${p.participants}명</td></tr>`);
renderTable(DOM.workerTableBody, analysis.byWorker, (w, i) => `
<tr><td>${i + 1}</td><td class="worker-col">${w.name}</td><td class="hours-col">${w.hours}h</td>
<td>${w.percentage}%</td><td>${w.participants}개</td></tr>`);
renderTable(DOM.taskTableBody, analysis.byTask, (t, i) => `
<tr><td>${i + 1}</td><td class="task-col" title="${t.name}">${t.name}</td><td class="hours-col">${t.hours}h</td>
<td>${t.percentage}%</td><td>${t.participants}명</td></tr>`);
}
/**
* 상세 내역 테이블을 렌더링합니다.
* @param {Array} detailData - 필터링된 상세 데이터
*/
export function renderDetailTable(detailData) {
if (!detailData || detailData.length === 0) {
DOM.detailTableBody.innerHTML = '<tr><td colspan="8" class="no-data">데이터가 없습니다</td></tr>';
return;
}
DOM.detailTableBody.innerHTML = detailData.map((item, index) => `
<tr><td>${index + 1}</td><td>${formatDate(new Date(item.date))}</td>
<td class="project-col" title="${item.project_name}">${item.project_name}</td>
<td class="worker-col">${item.worker_name}</td><td class="task-col" title="${item.task_category}">${item.task_category}</td>
<td>${item.work_details || '정상근무'}</td>
<td class="hours-col">${item.work_hours}h</td>
<td title="${item.memo || '-'}">${(item.memo || '-').substring(0, 20)}</td></tr>`
).join('');
}
/**
* 탭 UI를 제어합니다.
* @param {string} tabName - 활성화할 탭의 이름
*/
export function switchTab(tabName) {
DOM.tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabName));
DOM.tabContents.forEach(content => content.classList.toggle('active', content.id === `${tabName}Tab`));
}
/**
* 사용자로부터 현재 필터 값을 가져옵니다.
* @returns {{project: string, worker: string, task: string}}
*/
export function getCurrentFilters() {
return {
project: DOM.projectFilter.value,
worker: DOM.workerFilter.value,
task: DOM.taskFilter.value,
};
}

View File

@@ -0,0 +1,106 @@
// /js/project-analysis.js
import { getMasterData, getAnalysisReport } from './project-analysis-api.js';
import {
setDefaultDates,
setUIState,
updateFilterOptions,
renderSummary,
renderAnalysisTables,
renderDetailTable,
switchTab,
} from './project-analysis-ui.js';
// DOM 요소 참조 (이벤트 리스너 설정용)
const DOM = {
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
analyzeBtn: document.getElementById('analyzeBtn'),
quickMonthBtn: document.getElementById('quickMonth'),
quickLastMonthBtn: document.getElementById('quickLastMonth'),
// 필터 버튼은 현재 아무 기능도 하지 않으므로 주석 처리 또는 제거 가능
// applyFilterBtn: document.getElementById('applyFilter'),
tabButtons: document.querySelectorAll('.tab-button'),
};
/**
* 분석 실행 버튼 클릭 이벤트 핸들러
*/
async function handleAnalysis() {
const startDate = DOM.startDate.value;
const endDate = DOM.endDate.value;
if (!startDate || !endDate || startDate > endDate) {
alert('올바른 분석 기간을 설정해주세요.');
return;
}
setUIState('loading');
try {
const analysisResult = await getAnalysisReport(startDate, endDate);
if (!analysisResult.summary.totalHours) {
setUIState('no-data');
return;
}
renderSummary(analysisResult.summary);
renderAnalysisTables(analysisResult);
renderDetailTable(analysisResult.details);
setUIState('data');
} catch (error) {
console.error('분석 처리 중 오류:', error);
setUIState('error');
alert(error.message);
}
}
/**
* 빠른 날짜 설정 버튼 핸들러
*/
function handleQuickDate(monthType) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const firstDay = monthType === 'this' ? new Date(year, month, 1) : new Date(year, month - 1, 1);
const lastDay = monthType === 'this' ? new Date(year, month + 1, 0) : new Date(year, month, 0);
DOM.startDate.value = firstDay.toISOString().split('T')[0];
DOM.endDate.value = lastDay.toISOString().split('T')[0];
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
DOM.analyzeBtn.addEventListener('click', handleAnalysis);
DOM.quickMonthBtn.addEventListener('click', () => handleQuickDate('this'));
DOM.quickLastMonthBtn.addEventListener('click', () => handleQuickDate('last'));
DOM.tabButtons.forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// 프론트엔드 필터링은 제거되었으므로 관련 이벤트 리스너는 주석 처리합니다.
// DOM.applyFilterBtn.addEventListener('click', ...);
}
/**
* 페이지 초기화 함수
*/
async function initialize() {
setDefaultDates();
setupEventListeners();
try {
const masterData = await getMasterData();
updateFilterOptions(masterData);
await handleAnalysis();
} catch (error) {
alert(error.message);
setUIState('error');
}
}
// 초기화 실행
document.addEventListener('DOMContentLoaded', initialize);

View File

@@ -0,0 +1,546 @@
// 프로젝트 관리 페이지 JavaScript
// 전역 변수
let allProjects = [];
let filteredProjects = [];
let currentEditingProject = null;
let currentStatusFilter = 'all'; // 'all', 'active', 'inactive'
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
loadProjects();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
// 검색 입력 이벤트
setupSearchInput();
}
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
function updateCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
});
}
}
// 검색 입력 설정
function setupSearchInput() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
searchProjects();
});
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchProjects();
}
});
}
}
// 프로젝트 목록 로드
async function loadProjects() {
try {
const response = await apiCall('/projects', 'GET');
// API 응답이 { success: true, data: [...] } 형태인 경우 처리
let projectData = [];
if (response && response.success && Array.isArray(response.data)) {
projectData = response.data;
} else if (Array.isArray(response)) {
projectData = response;
} else {
console.warn('프로젝트 데이터가 배열이 아닙니다:', response);
projectData = [];
}
allProjects = projectData;
// 초기 필터 적용
applyAllFilters();
updateStatCardActiveState();
} catch (error) {
console.error('프로젝트 로딩 오류:', error);
showToast('프로젝트 목록을 불러오는데 실패했습니다.', 'error');
allProjects = [];
filteredProjects = [];
renderProjects();
}
}
// 프로젝트 목록 렌더링
function renderProjects() {
const projectsGrid = document.getElementById('projectsGrid');
const emptyState = document.getElementById('emptyState');
if (!projectsGrid || !emptyState) return;
if (filteredProjects.length === 0) {
projectsGrid.style.display = 'none';
emptyState.style.display = 'block';
return;
}
projectsGrid.style.display = 'grid';
emptyState.style.display = 'none';
const projectsHtml = filteredProjects.map(project => {
// 프로젝트 상태 아이콘 및 텍스트
const statusMap = {
'planning': { icon: '', text: '계획', color: '#6b7280' },
'active': { icon: '', text: '진행중', color: '#10b981' },
'completed': { icon: '', text: '완료', color: '#3b82f6' },
'cancelled': { icon: '', text: '취소', color: '#ef4444' }
};
const validStatuses = ['planning', 'active', 'completed', 'cancelled'];
const safeProjectStatus = validStatuses.includes(project.project_status) ? project.project_status : 'active';
const status = statusMap[safeProjectStatus];
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
const isInactive = project.is_active === 0 || project.is_active === false || project.is_active === 'false';
// XSS 방지를 위한 안전한 값
const safeProjectId = parseInt(project.project_id) || 0;
const safeJobNo = escapeHtml(project.job_no || 'Job No. 없음');
const safeProjectName = escapeHtml(project.project_name || '-');
const safePm = escapeHtml(project.pm || '-');
const safeSite = escapeHtml(project.site || '-');
return `
<div class="project-card ${isInactive ? 'inactive' : ''}" onclick="editProject(${safeProjectId})">
${isInactive ? '<div class="inactive-overlay"><span class="inactive-badge"> 비활성화됨</span></div>' : ''}
<div class="project-header">
<div class="project-info">
<div class="project-job-no">${safeJobNo}</div>
<h3 class="project-name">
${safeProjectName}
${isInactive ? '<span class="inactive-label">(비활성)</span>' : ''}
</h3>
<div class="project-meta">
<div class="meta-row">
<span class="meta-label">상태</span>
<span class="meta-value" style="color: ${status.color}; font-weight: 600;">${status.icon} ${status.text}</span>
</div>
<div class="meta-row">
<span class="meta-label">계약일</span>
<span class="meta-value">${project.contract_date ? formatDate(project.contract_date) : '-'}</span>
</div>
<div class="meta-row">
<span class="meta-label">납기일</span>
<span class="meta-value">${project.due_date ? formatDate(project.due_date) : '-'}</span>
</div>
<div class="meta-row">
<span class="meta-label">PM</span>
<span class="meta-value">${safePm}</span>
</div>
<div class="meta-row">
<span class="meta-label">현장</span>
<span class="meta-value">${safeSite}</span>
</div>
${isInactive ? '<div class="inactive-notice"> 작업보고서에서 숨김</div>' : ''}
</div>
</div>
<div class="project-actions">
<button class="btn-edit" onclick="event.stopPropagation(); editProject(${safeProjectId})" title="수정">
수정
</button>
<button class="btn-delete" onclick="event.stopPropagation(); confirmDeleteProject(${safeProjectId})" title="삭제">
삭제
</button>
</div>
</div>
</div>
`;
}).join('');
projectsGrid.innerHTML = projectsHtml;
}
// 프로젝트 통계 업데이트
function updateProjectStats() {
const activeProjects = filteredProjects.filter(p => p.is_active === 1 || p.is_active === true);
const inactiveProjects = filteredProjects.filter(p => p.is_active === 0 || p.is_active === false);
const activeProjectsElement = document.getElementById('activeProjects');
const inactiveProjectsElement = document.getElementById('inactiveProjects');
const totalProjectsElement = document.getElementById('totalProjects');
if (activeProjectsElement) {
activeProjectsElement.textContent = activeProjects.length;
}
if (inactiveProjectsElement) {
inactiveProjectsElement.textContent = inactiveProjects.length;
}
if (totalProjectsElement) {
totalProjectsElement.textContent = filteredProjects.length;
}
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// 상태별 필터링
function filterByStatus(status) {
currentStatusFilter = status;
// 통계 카드 활성화 상태 업데이트
updateStatCardActiveState();
// 필터링 적용
applyAllFilters();
}
// 통계 카드 활성화 상태 업데이트
function updateStatCardActiveState() {
// 모든 통계 카드에서 active 클래스 제거
document.querySelectorAll('.stat-item').forEach(item => {
item.classList.remove('active');
});
// 현재 선택된 필터에 active 클래스 추가
const activeCard = document.querySelector(`.${currentStatusFilter === 'active' ? 'active-stat' :
currentStatusFilter === 'inactive' ? 'inactive-stat' : 'total-stat'}`);
if (activeCard) {
activeCard.classList.add('active');
}
}
// 모든 필터 적용 (검색 + 상태)
function applyAllFilters() {
const searchInput = document.getElementById('searchInput');
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
// 1단계: 상태 필터링
let statusFiltered = [...allProjects];
if (currentStatusFilter === 'active') {
statusFiltered = allProjects.filter(p => p.is_active === 1 || p.is_active === true);
} else if (currentStatusFilter === 'inactive') {
statusFiltered = allProjects.filter(p => p.is_active === 0 || p.is_active === false);
}
// 2단계: 검색 필터링
if (!searchTerm) {
filteredProjects = statusFiltered;
} else {
filteredProjects = statusFiltered.filter(project =>
project.project_name.toLowerCase().includes(searchTerm) ||
(project.job_no && project.job_no.toLowerCase().includes(searchTerm)) ||
(project.pm && project.pm.toLowerCase().includes(searchTerm)) ||
(project.site && project.site.toLowerCase().includes(searchTerm))
);
}
renderProjects();
updateProjectStats();
}
// 프로젝트 검색 (기존 함수 수정)
function searchProjects() {
applyAllFilters();
}
// 프로젝트 필터링
function filterProjects() {
const statusFilter = document.getElementById('statusFilter');
const selectedStatus = statusFilter ? statusFilter.value : '';
// 현재는 상태 필드가 없으므로 기본 필터링만 적용
searchProjects();
}
// 프로젝트 정렬
function sortProjects() {
const sortBy = document.getElementById('sortBy');
const sortField = sortBy ? sortBy.value : 'created_at';
filteredProjects.sort((a, b) => {
switch (sortField) {
case 'project_name':
return a.project_name.localeCompare(b.project_name);
case 'due_date':
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date) - new Date(b.due_date);
case 'created_at':
default:
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
}
});
renderProjects();
}
// 프로젝트 목록 새로고침
async function refreshProjectList() {
const refreshBtn = document.querySelector('.btn-secondary');
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
refreshBtn.innerHTML = '<span class="btn-icon"></span>새로고침 중...';
refreshBtn.disabled = true;
await loadProjects();
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
} else {
await loadProjects();
}
showToast('프로젝트 목록이 새로고침되었습니다.', 'success');
}
// 프로젝트 모달 열기
function openProjectModal(project = null) {
const modal = document.getElementById('projectModal');
const modalTitle = document.getElementById('modalTitle');
const deleteBtn = document.getElementById('deleteProjectBtn');
if (!modal) return;
currentEditingProject = project;
if (project) {
// 수정 모드
modalTitle.textContent = '프로젝트 수정';
deleteBtn.style.display = 'inline-flex';
// 폼에 데이터 채우기
document.getElementById('projectId').value = project.project_id;
document.getElementById('jobNo').value = project.job_no || '';
document.getElementById('projectName').value = project.project_name || '';
document.getElementById('contractDate').value = project.contract_date || '';
document.getElementById('dueDate').value = project.due_date || '';
document.getElementById('deliveryMethod').value = project.delivery_method || '';
document.getElementById('site').value = project.site || '';
document.getElementById('pm').value = project.pm || '';
document.getElementById('projectStatus').value = project.project_status || 'active';
document.getElementById('completedDate').value = project.completed_date || '';
// is_active 값 처리 (DB에서 0/1로 오는 경우 대비)
const isActiveValue = project.is_active === 1 || project.is_active === true || project.is_active === 'true';
document.getElementById('isActive').checked = isActiveValue;
} else {
// 신규 등록 모드
modalTitle.textContent = '새 프로젝트 등록';
deleteBtn.style.display = 'none';
// 폼 초기화
document.getElementById('projectForm').reset();
document.getElementById('projectId').value = '';
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 첫 번째 입력 필드에 포커스
setTimeout(() => {
const firstInput = document.getElementById('jobNo');
if (firstInput) firstInput.focus();
}, 100);
}
// 프로젝트 모달 닫기
function closeProjectModal() {
const modal = document.getElementById('projectModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
currentEditingProject = null;
}
}
// 프로젝트 편집
function editProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (project) {
openProjectModal(project);
} else {
showToast('프로젝트를 찾을 수 없습니다.', 'error');
}
}
// 프로젝트 저장
async function saveProject() {
try {
const form = document.getElementById('projectForm');
const formData = new FormData(form);
const projectData = {
job_no: document.getElementById('jobNo').value.trim(),
project_name: document.getElementById('projectName').value.trim(),
contract_date: document.getElementById('contractDate').value || null,
due_date: document.getElementById('dueDate').value || null,
delivery_method: document.getElementById('deliveryMethod').value || null,
site: document.getElementById('site').value.trim() || null,
pm: document.getElementById('pm').value.trim() || null,
project_status: document.getElementById('projectStatus').value || 'active',
completed_date: document.getElementById('completedDate').value || null,
is_active: document.getElementById('isActive').checked ? 1 : 0
};
// 필수 필드 검증
if (!projectData.job_no || !projectData.project_name) {
showToast('Job No.와 프로젝트명은 필수 입력 항목입니다.', 'error');
return;
}
const projectId = document.getElementById('projectId').value;
let response;
if (projectId) {
// 수정
response = await apiCall(`/projects/${projectId}`, 'PUT', projectData);
} else {
// 신규 등록
response = await apiCall('/projects', 'POST', projectData);
}
if (response && (response.success || response.project_id)) {
const action = projectId ? '수정' : '등록';
showToast(`프로젝트가 성공적으로 ${action}되었습니다.`, 'success');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('프로젝트 저장 오류:', error);
showToast(error.message || '프로젝트 저장 중 오류가 발생했습니다.', 'error');
}
}
// 프로젝트 삭제 확인
function confirmDeleteProject(projectId) {
const project = allProjects.find(p => p.project_id === projectId);
if (!project) {
showToast('프로젝트를 찾을 수 없습니다.', 'error');
return;
}
if (confirm(`"${project.project_name}" 프로젝트를 정말 삭제하시겠습니까?\n\n 삭제된 프로젝트는 복구할 수 없습니다.`)) {
deleteProjectById(projectId);
}
}
// 프로젝트 삭제 (수정 모드에서)
function deleteProject() {
if (currentEditingProject) {
confirmDeleteProject(currentEditingProject.project_id);
}
}
// 프로젝트 삭제 실행
async function deleteProjectById(projectId) {
try {
const response = await apiCall(`/projects/${projectId}`, 'DELETE');
if (response && response.success) {
showToast('프로젝트가 성공적으로 삭제되었습니다.', 'success');
closeProjectModal();
await loadProjects();
} else {
throw new Error(response?.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('프로젝트 삭제 오류:', error);
showToast(error.message || '프로젝트 삭제 중 오류가 발생했습니다.', 'error');
}
}
// showToast → api-base.js 전역 사용
// 전역 함수로 노출
window.openProjectModal = openProjectModal;
window.closeProjectModal = closeProjectModal;
window.editProject = editProject;
window.saveProject = saveProject;
window.deleteProject = deleteProject;
window.confirmDeleteProject = confirmDeleteProject;
window.searchProjects = searchProjects;
window.filterProjects = filterProjects;
window.sortProjects = sortProjects;
window.refreshProjectList = refreshProjectList;
window.filterByStatus = filterByStatus;

View File

@@ -0,0 +1,262 @@
/**
* proxy-input.js — 대리입력 리뉴얼
* Step 1: 날짜 선택 → 작업자 목록 (체크박스)
* Step 2: 공통 입력 1개 → 선택된 전원 일괄 적용
*/
let currentDate = '';
let allWorkers = [];
let selectedIds = new Set();
let projects = [];
let workTypes = [];
let defectCategories = []; // { category_id, category_name, items: [{ item_id, item_name }] }
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
currentDate = new Date().toISOString().substring(0, 10);
document.getElementById('dateInput').value = currentDate;
setTimeout(async () => {
await loadDropdownData();
await loadWorkers();
}, 500);
});
async function loadDropdownData() {
try {
const [pRes, wRes] = await Promise.all([
window.apiCall('/projects'),
window.apiCall('/daily-work-reports/work-types')
]);
projects = (pRes.data || pRes || []).filter(p => p.is_active !== 0);
workTypes = (wRes.data || wRes || []).map(w => ({ id: w.id || w.work_type_id, name: w.name || w.work_type_name, ...w }));
// 부적합 대분류/소분류 로드
const cRes = await window.apiCall('/work-issues/categories/type/nonconformity');
const cats = cRes.data || cRes || [];
for (const c of cats) {
const iRes = await window.apiCall('/work-issues/items/category/' + c.category_id);
defectCategories.push({
category_id: c.category_id,
category_name: c.category_name,
items: (iRes.data || iRes || [])
});
}
} catch (e) { console.warn('드롭다운 로드 실패:', e); }
}
// ===== Step 1: Worker List =====
async function loadWorkers() {
currentDate = document.getElementById('dateInput').value;
if (!currentDate) return;
const list = document.getElementById('workerList');
list.innerHTML = '<div class="pi-skeleton"></div><div class="pi-skeleton"></div>';
selectedIds.clear();
updateEditButton();
try {
const res = await window.apiCall('/proxy-input/daily-status?date=' + currentDate);
if (!res.success) throw new Error(res.message);
allWorkers = res.data.workers || [];
const s = res.data.summary || {};
document.getElementById('totalNum').textContent = s.total_active_workers || allWorkers.length;
document.getElementById('doneNum').textContent = s.report_completed || 0;
document.getElementById('missingNum').textContent = s.report_missing || 0;
document.getElementById('vacNum').textContent = allWorkers.filter(w => w.vacation_type_code === 'ANNUAL_FULL').length;
renderWorkerList();
} catch (e) {
list.innerHTML = '<div class="pi-empty"><i class="fas fa-exclamation-triangle text-2xl text-red-300"></i><p>데이터 로드 실패</p></div>';
}
}
function renderWorkerList() {
const list = document.getElementById('workerList');
if (!allWorkers.length) {
list.innerHTML = '<div class="pi-empty"><p>작업자가 없습니다</p></div>';
return;
}
// 부서별 그룹핑
const byDept = {};
allWorkers.forEach(w => {
const dept = w.department_name || '미배정';
if (!byDept[dept]) byDept[dept] = [];
byDept[dept].push(w);
});
let html = '';
Object.keys(byDept).sort().forEach(dept => {
html += `<div class="pi-dept-label">${esc(dept)}</div>`;
byDept[dept].forEach(w => {
const isFullVac = w.vacation_type_code === 'ANNUAL_FULL';
const hasVac = !!w.vacation_type_code;
const vacBadge = isFullVac ? '<span class="pi-badge vac">연차</span>'
: hasVac ? `<span class="pi-badge vac-half">${esc(w.vacation_type_name)}</span>` : '';
const doneBadge = w.has_report ? `<span class="pi-badge done">${w.total_report_hours}h</span>` : '<span class="pi-badge missing">미입력</span>';
html += `
<label class="pi-worker ${isFullVac ? 'disabled' : ''}">
<input type="checkbox" class="pi-check" value="${w.user_id}"
${isFullVac ? 'disabled' : ''}
onchange="onWorkerCheck(${w.user_id}, this.checked)">
<div class="pi-worker-info">
<span class="pi-worker-name">${esc(w.worker_name)}</span>
<span class="pi-worker-job">${esc(w.job_type || '')}</span>
</div>
<div class="pi-worker-badges">${vacBadge}${doneBadge}</div>
</label>`;
});
});
list.innerHTML = html;
}
function onWorkerCheck(userId, checked) {
if (checked) selectedIds.add(userId);
else selectedIds.delete(userId);
updateEditButton();
}
function toggleSelectAll(checked) {
allWorkers.forEach(w => {
if (w.vacation_type_code === 'ANNUAL_FULL') return;
const cb = document.querySelector(`.pi-check[value="${w.user_id}"]`);
if (cb) { cb.checked = checked; onWorkerCheck(w.user_id, checked); }
});
}
function updateEditButton() {
const btn = document.getElementById('editBtn');
const n = selectedIds.size;
btn.disabled = n === 0;
document.getElementById('editBtnText').textContent = n > 0 ? `선택 작업자 편집 (${n}명)` : '작업자를 선택하세요';
}
// ===== Step 2: Bulk Edit (공통 입력 1개) =====
function openEditMode() {
if (selectedIds.size === 0) return;
const selected = allWorkers.filter(w => selectedIds.has(w.user_id));
document.getElementById('editTitle').textContent = `일괄 편집 (${selected.length}명)`;
// 프로젝트/공종 드롭다운 채우기
const projSel = document.getElementById('bulkProject');
projSel.innerHTML = '<option value="">프로젝트 선택 *</option>' + projects.map(p => `<option value="${p.project_id}">${esc(p.project_name)}</option>`).join('');
const typeSel = document.getElementById('bulkWorkType');
typeSel.innerHTML = '<option value="">공종 선택 *</option>' + workTypes.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
// 적용 대상 목록
document.getElementById('targetWorkers').innerHTML = selected.map(w =>
`<span class="pi-target-chip">${esc(w.worker_name)}</span>`
).join('');
// 기본값
document.getElementById('bulkHours').value = '8';
document.getElementById('bulkDefect').value = '0';
document.getElementById('bulkNote').value = '';
document.getElementById('step1').classList.add('hidden');
document.getElementById('step2').classList.remove('hidden');
}
function closeEditMode() {
document.getElementById('step2').classList.add('hidden');
document.getElementById('step1').classList.remove('hidden');
}
// ===== Save =====
async function saveAll() {
const projId = document.getElementById('bulkProject').value;
const wtypeId = document.getElementById('bulkWorkType').value;
const hours = parseFloat(document.getElementById('bulkHours').value) || 0;
const defect = parseFloat(document.getElementById('bulkDefect').value) || 0;
const note = document.getElementById('bulkNote').value.trim();
if (!projId || !wtypeId) {
showToast('프로젝트와 공종을 선택하세요', 'error');
return;
}
if (hours <= 0) {
showToast('근무시간을 입력하세요', 'error');
return;
}
if (defect > hours) {
showToast('부적합 시간이 근무시간을 초과합니다', 'error');
return;
}
const defectCategoryId = defect > 0 ? (parseInt(document.getElementById('bulkDefectCategory').value) || null) : null;
const defectItemId = defect > 0 ? (parseInt(document.getElementById('bulkDefectItem').value) || null) : null;
if (defect > 0 && !defectCategoryId) {
showToast('부적합 대분류를 선택하세요', 'error');
return;
}
const btn = document.getElementById('saveBtn');
btn.disabled = true;
document.getElementById('saveBtnText').textContent = '저장 중...';
const entries = Array.from(selectedIds).map(uid => ({
user_id: uid,
project_id: parseInt(projId),
work_type_id: parseInt(wtypeId),
work_hours: hours,
defect_hours: defect,
defect_category_id: defectCategoryId,
defect_item_id: defectItemId,
note: note,
start_time: '08:00',
end_time: '17:00',
work_status_id: defect > 0 ? 2 : 1
}));
try {
const res = await window.apiCall('/proxy-input', 'POST', {
session_date: currentDate,
entries
});
if (res.success) {
showToast(res.message || `${entries.length}명 저장 완료`, 'success');
closeEditMode();
selectedIds.clear();
updateEditButton();
await loadWorkers();
} else {
showToast(res.message || '저장 실패', 'error');
}
} catch (e) {
showToast('저장 실패: ' + (e.message || e), 'error');
}
btn.disabled = false;
document.getElementById('saveBtnText').textContent = '전체 저장';
}
// ===== Defect Category/Item =====
function onDefectChange() {
const val = parseFloat(document.getElementById('bulkDefect').value) || 0;
const row = document.getElementById('defectCategoryRow');
if (val > 0) {
row.classList.remove('hidden');
const catSel = document.getElementById('bulkDefectCategory');
if (catSel.options.length <= 1) {
catSel.innerHTML = '<option value="">부적합 대분류 *</option>' +
defectCategories.map(c => `<option value="${c.category_id}">${esc(c.category_name)}</option>`).join('');
}
} else {
row.classList.add('hidden');
}
}
function onDefectCategoryChange() {
const catId = parseInt(document.getElementById('bulkDefectCategory').value);
const itemSel = document.getElementById('bulkDefectItem');
const cat = defectCategories.find(c => c.category_id === catId);
itemSel.innerHTML = '<option value="">소분류 *</option>' +
(cat ? cat.items.map(i => `<option value="${i.item_id}">${esc(i.item_name)}</option>`).join('') : '');
}
function esc(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }

View File

@@ -0,0 +1,701 @@
/* schedule.js — Gantt chart with row virtualization */
let ganttData = { entries: [], dependencies: [], milestones: [] };
let projects = [];
let phases = [];
let templates = [];
let allRows = []; // flat row data for virtualization
let collapseState = {}; // { projectCode: bool }
let ncCache = {}; // { projectId: [issues] }
const ROW_HEIGHT = 32;
const BUFFER_ROWS = 5;
const DAY_WIDTHS = { month: 24, quarter: 12, year: 3 };
let currentZoom = 'quarter';
let currentYear = new Date().getFullYear();
let canEdit = false;
/* ===== Init ===== */
document.addEventListener('DOMContentLoaded', async () => {
const ok = await initAuth();
if (!ok) return;
document.querySelector('.fade-in').classList.add('visible');
// Check edit permission (support_team+)
const role = currentUser?.role || '';
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
if (canEdit) {
document.getElementById('btnAddEntry').classList.remove('hidden');
document.getElementById('btnBatchAdd').classList.remove('hidden');
document.getElementById('btnAddMilestone').classList.remove('hidden');
const btnGen = document.getElementById('btnGenTemplate');
if (btnGen) btnGen.classList.remove('hidden');
}
// Load collapse state
try {
const saved = localStorage.getItem('gantt_collapse');
if (saved) collapseState = JSON.parse(saved);
} catch {}
// Year select
const sel = document.getElementById('yearSelect');
for (let y = currentYear - 2; y <= currentYear + 2; y++) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y;
if (y === currentYear) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', () => { currentYear = parseInt(sel.value); loadGantt(); });
// Zoom buttons
document.querySelectorAll('.zoom-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.zoom-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentZoom = btn.dataset.zoom;
renderGantt();
});
});
// Toolbar buttons
document.getElementById('btnAddEntry').addEventListener('click', () => openEntryModal());
document.getElementById('btnBatchAdd').addEventListener('click', () => openBatchModal());
document.getElementById('btnAddMilestone').addEventListener('click', () => openMilestoneModal());
await loadMasterData();
await loadGantt();
});
async function loadMasterData() {
try {
const [projRes, phaseRes, tmplRes] = await Promise.all([
api('/projects'), api('/schedule/phases'), api('/schedule/templates')
]);
projects = projRes.data || [];
phases = phaseRes.data || [];
templates = tmplRes.data || [];
} catch (err) { showToast('마스터 데이터 로드 실패', 'error'); }
}
async function loadGantt() {
try {
const res = await api(`/schedule/entries/gantt?year=${currentYear}`);
ganttData = res.data;
// Preload NC data
const projectIds = [...new Set(ganttData.entries.map(e => e.project_id))];
await Promise.all(projectIds.map(async pid => {
try {
const ncRes = await api(`/schedule/nonconformance?project_id=${pid}`);
ncCache[pid] = ncRes.data || [];
} catch { ncCache[pid] = []; }
}));
renderGantt();
} catch (err) { showToast('공정표 데이터 로드 실패: ' + err.message, 'error'); }
}
/* ===== Build flat row array ===== */
function buildRows() {
allRows = [];
// Group entries by project, then by phase
const byProject = {};
ganttData.entries.forEach(e => {
if (!byProject[e.project_code]) byProject[e.project_code] = { project_id: e.project_id, project_name: e.project_name, code: e.project_code, phases: {} };
const p = byProject[e.project_code];
if (!p.phases[e.phase_name]) p.phases[e.phase_name] = { phase_id: e.phase_id, color: e.phase_color, order: e.phase_order, entries: [] };
p.phases[e.phase_name].entries.push(e);
});
// Also add milestones-only projects
ganttData.milestones.forEach(m => {
if (!byProject[m.project_code]) byProject[m.project_code] = { project_id: m.project_id, project_name: m.project_name, code: m.project_code, phases: {} };
});
const sortedProjects = Object.values(byProject).sort((a, b) => a.code.localeCompare(b.code));
for (const proj of sortedProjects) {
const collapsed = collapseState[proj.code] === true;
allRows.push({ type: 'project', code: proj.code, label: `${proj.code} ${proj.project_name}`, project_id: proj.project_id, collapsed });
if (!collapsed) {
const sortedPhases = Object.entries(proj.phases).sort((a, b) => a[1].order - b[1].order);
for (const [phaseName, phaseData] of sortedPhases) {
allRows.push({ type: 'phase', label: phaseName, color: phaseData.color });
for (const entry of phaseData.entries) {
allRows.push({ type: 'task', entry, color: phaseData.color });
}
}
// Milestones for this project
const projMilestones = ganttData.milestones.filter(m => m.project_id === proj.project_id);
if (projMilestones.length > 0) {
allRows.push({ type: 'milestone-header', label: '◆ 마일스톤', milestones: projMilestones });
}
// NC row
const ncList = ncCache[proj.project_id] || [];
if (ncList.length > 0) {
allRows.push({ type: 'nc', label: `⚠ 부적합 (${ncList.length})`, project_id: proj.project_id, count: ncList.length });
}
}
}
}
/* ===== Render ===== */
function renderGantt() {
buildRows();
const container = document.getElementById('ganttContainer');
const wrapper = document.getElementById('ganttWrapper');
const dayWidth = DAY_WIDTHS[currentZoom];
// Calculate total days in year
const yearStart = new Date(currentYear, 0, 1);
const yearEnd = new Date(currentYear, 11, 31);
const totalDays = Math.ceil((yearEnd - yearStart) / 86400000) + 1;
const timelineWidth = totalDays * dayWidth;
container.style.setProperty('--day-width', dayWidth + 'px');
container.style.width = (250 + timelineWidth) + 'px';
// Build month header
let headerHtml = '<div class="gantt-month-header"><div class="gantt-label"><div class="label-content font-semibold text-sm text-gray-600">프로젝트 / 단계 / 작업</div></div><div class="gantt-timeline">';
for (let m = 0; m < 12; m++) {
const daysInMonth = new Date(currentYear, m + 1, 0).getDate();
const monthWidth = daysInMonth * dayWidth;
const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
headerHtml += `<div class="gantt-day month-label" style="flex: 0 0 ${monthWidth}px;">${monthNames[m]}</div>`;
}
headerHtml += '</div></div>';
// Virtual scroll container
const totalHeight = allRows.length * ROW_HEIGHT;
let rowsHtml = `<div style="height:${totalHeight}px;position:relative;" id="ganttVirtualBody"></div>`;
container.innerHTML = headerHtml + rowsHtml;
// Today marker
const today = new Date();
if (today.getFullYear() === currentYear) {
const todayOffset = dayOfYear(today) - 1;
const markerLeft = 250 + todayOffset * dayWidth;
const marker = document.createElement('div');
marker.className = 'today-marker';
marker.style.left = markerLeft + 'px';
container.appendChild(marker);
}
// Setup virtual scroll
const virtualBody = document.getElementById('ganttVirtualBody');
const onScroll = () => renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays);
wrapper.addEventListener('scroll', onScroll);
renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays);
// Scroll to today
if (today.getFullYear() === currentYear) {
const todayOffset = dayOfYear(today) - 1;
const scrollTo = Math.max(0, todayOffset * dayWidth - wrapper.clientWidth / 2 + 250);
wrapper.scrollLeft = scrollTo;
}
}
function renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays) {
const scrollTop = wrapper.scrollTop - 30; // account for header
const viewHeight = wrapper.clientHeight;
const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS);
const endIdx = Math.min(allRows.length, Math.ceil((scrollTop + viewHeight) / ROW_HEIGHT) + BUFFER_ROWS);
let html = '';
for (let i = startIdx; i < endIdx; i++) {
const row = allRows[i];
const top = i * ROW_HEIGHT;
html += renderRow(row, top, dayWidth, totalDays);
}
virtualBody.innerHTML = html;
}
function renderRow(row, top, dayWidth, totalDays) {
const style = `position:absolute;top:${top}px;width:100%;height:${ROW_HEIGHT}px;`;
if (row.type === 'project') {
const arrowClass = row.collapsed ? 'collapsed' : '';
return `<div class="gantt-row project-row" style="${style}">
<div class="gantt-label"><div class="label-content collapse-toggle ${arrowClass}" onclick="toggleProject('${row.code}')">
<span class="arrow">▼</span>${escapeHtml(row.label)}
</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;"></div>
</div>`;
}
if (row.type === 'phase') {
return `<div class="gantt-row phase-row" style="${style}">
<div class="gantt-label"><div class="label-content"><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${row.color};margin-right:6px;"></span>${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;"></div>
</div>`;
}
if (row.type === 'task') {
const e = row.entry;
const bar = calcBar(e.start_date, e.end_date, dayWidth);
const statusColors = { planned: '0.6', in_progress: '0.85', completed: '1', delayed: '0.9' };
const opacity = statusColors[e.status] || '0.7';
const barHtml = bar ? `<div class="gantt-bar" style="left:${bar.left}px;width:${bar.width}px;background:${row.color};opacity:${opacity};"
onclick="showBarDetail(${e.entry_id})" title="${escapeHtml(e.task_name)}&#10;${formatDate(e.start_date)}~${formatDate(e.end_date)}&#10;진행률: ${e.progress}%">
<div class="gantt-bar-progress" style="width:${e.progress}%;background:#fff;"></div>
${bar.width > 50 ? `<div class="gantt-bar-label">${escapeHtml(e.task_name)}</div>` : ''}
</div>` : '';
return `<div class="gantt-row task-row" style="${style}">
<div class="gantt-label"><div class="label-content" title="${escapeHtml(e.task_name)}">${escapeHtml(e.task_name)}${e.assignee ? ` <span class="text-gray-400 text-xs">(${escapeHtml(e.assignee)})</span>` : ''}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${barHtml}</div>
</div>`;
}
if (row.type === 'milestone-header') {
let markers = '';
for (const m of row.milestones) {
const pos = calcPos(m.milestone_date, dayWidth);
if (pos !== null) {
const mColor = m.status === 'completed' ? '#10B981' : m.status === 'missed' ? '#EF4444' : '#7C3AED';
markers += `<div class="milestone-marker" style="left:${pos - 7}px;background:${mColor};" title="${escapeHtml(m.milestone_name)}&#10;${formatDate(m.milestone_date)}" onclick="showMilestoneDetail(${m.milestone_id})"></div>`;
}
}
return `<div class="gantt-row milestone-row" style="${style}">
<div class="gantt-label"><div class="label-content">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${markers}</div>
</div>`;
}
if (row.type === 'nc') {
// Place NC badges by date
const ncList = ncCache[row.project_id] || [];
let badges = '';
const byMonth = {};
ncList.forEach(nc => {
const d = nc.report_date ? new Date(nc.report_date) : null;
if (d && d.getFullYear() === currentYear) {
const m = d.getMonth();
byMonth[m] = (byMonth[m] || 0) + 1;
}
});
for (const [m, cnt] of Object.entries(byMonth)) {
const monthStart = new Date(currentYear, parseInt(m), 15);
const pos = calcPos(monthStart, dayWidth);
if (pos !== null) {
badges += `<div class="nc-badge" style="left:${pos - 10}px;" onclick="showNcPopup(${row.project_id})">${cnt}</div>`;
}
}
return `<div class="gantt-row nc-row" style="${style}">
<div class="gantt-label"><div class="label-content" style="cursor:pointer;" onclick="showNcPopup(${row.project_id})">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${badges}</div>
</div>`;
}
return '';
}
/* ===== Helpers ===== */
function dayOfYear(d) {
const start = new Date(d.getFullYear(), 0, 1);
return Math.ceil((d - start) / 86400000) + 1;
}
function calcBar(startStr, endStr, dayWidth) {
const s = new Date(startStr);
const e = new Date(endStr);
if (s.getFullYear() > currentYear || e.getFullYear() < currentYear) return null;
const yearStart = new Date(currentYear, 0, 1);
const yearEnd = new Date(currentYear, 11, 31);
const clampStart = s < yearStart ? yearStart : s;
const clampEnd = e > yearEnd ? yearEnd : e;
const startDay = Math.ceil((clampStart - yearStart) / 86400000);
const endDay = Math.ceil((clampEnd - yearStart) / 86400000) + 1;
return { left: startDay * dayWidth, width: Math.max((endDay - startDay) * dayWidth, 4) };
}
function calcPos(dateStr, dayWidth) {
const d = new Date(dateStr);
if (d.getFullYear() !== currentYear) return null;
const yearStart = new Date(currentYear, 0, 1);
const offset = Math.ceil((d - yearStart) / 86400000);
return offset * dayWidth;
}
/* ===== Interactions ===== */
function toggleProject(code) {
collapseState[code] = !collapseState[code];
localStorage.setItem('gantt_collapse', JSON.stringify(collapseState));
renderGantt();
}
function showBarDetail(entryId) {
const entry = ganttData.entries.find(e => e.entry_id === entryId);
if (!entry) return;
const popup = document.getElementById('barDetailPopup');
document.getElementById('barDetailTitle').textContent = entry.task_name;
const statusLabels = { planned: '계획', in_progress: '진행중', completed: '완료', delayed: '지연', cancelled: '취소' };
document.getElementById('barDetailContent').innerHTML = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">공정 단계</span><span>${escapeHtml(entry.phase_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">기간</span><span>${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">진행률</span><span>${entry.progress}%</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[entry.status] || entry.status}</span></div>
${entry.assignee ? `<div class="flex justify-between"><span class="text-gray-500">담당자</span><span>${escapeHtml(entry.assignee)}</span></div>` : ''}
${entry.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(entry.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openEntryModal(${entryId})" class="px-3 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">수정</button>`;
}
document.getElementById('barDetailActions').innerHTML = actions;
popup.classList.remove('hidden');
}
function showMilestoneDetail(milestoneId) {
const m = ganttData.milestones.find(ms => ms.milestone_id === milestoneId);
if (!m) return;
const popup = document.getElementById('barDetailPopup');
const typeLabels = { deadline: '납기', review: '검토', inspection: '검사', delivery: '출하', meeting: '회의', other: '기타' };
const statusLabels = { upcoming: '예정', completed: '완료', missed: '미달성' };
document.getElementById('barDetailTitle').textContent = '◆ ' + m.milestone_name;
document.getElementById('barDetailContent').innerHTML = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">날짜</span><span>${formatDate(m.milestone_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">유형</span><span>${typeLabels[m.milestone_type] || m.milestone_type}</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[m.status] || m.status}</span></div>
${m.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(m.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openMilestoneModal(${milestoneId})" class="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">수정</button>`;
}
document.getElementById('barDetailActions').innerHTML = actions;
popup.classList.remove('hidden');
}
function showNcPopup(projectId) {
const list = ncCache[projectId] || [];
const proj = projects.find(p => p.project_id === projectId);
document.getElementById('ncPopupTitle').textContent = `부적합 현황 - ${proj ? proj.job_no : ''}`;
const statusLabels = { reported: '신고', received: '접수', reviewing: '검토중', in_progress: '처리중', completed: '완료' };
let html = '';
if (list.length === 0) {
html = '<p class="text-gray-500 text-sm">부적합 내역이 없습니다.</p>';
} else {
html = '<table class="data-table"><thead><tr><th>일자</th><th>분류</th><th>내용</th><th>상태</th></tr></thead><tbody>';
for (const nc of list) {
html += `<tr>
<td>${formatDate(nc.report_date)}</td>
<td>${escapeHtml(nc.category || '-')}</td>
<td class="max-w-[200px] truncate">${escapeHtml(nc.description || '-')}</td>
<td><span class="badge ${nc.review_status === 'completed' ? 'badge-green' : 'badge-amber'}">${statusLabels[nc.review_status] || nc.review_status}</span></td>
</tr>`;
}
html += '</tbody></table>';
}
document.getElementById('ncPopupContent').innerHTML = html;
document.getElementById('ncPopup').classList.remove('hidden');
}
/* ===== Entry Modal ===== */
function openEntryModal(editId) {
const modal = document.getElementById('entryModal');
const isEdit = !!editId;
document.getElementById('entryModalTitle').textContent = isEdit ? '공정표 항목 수정' : '공정표 항목 추가';
// Populate dropdowns
populateSelect('entryProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
populateSelect('entryPhase', phases, 'phase_id', p => p.phase_name);
// Template select (populated on phase change)
const phaseSelect = document.getElementById('entryPhase');
phaseSelect.addEventListener('change', () => loadTemplateOptions('entryTemplate', phaseSelect.value));
if (phases.length > 0) loadTemplateOptions('entryTemplate', phaseSelect.value);
// Template → task name
document.getElementById('entryTemplate').addEventListener('change', function() {
if (this.value) {
const tmpl = templates.find(t => t.template_id === parseInt(this.value));
if (tmpl) {
document.getElementById('entryTaskName').value = tmpl.task_name;
// Auto-fill duration
const startDate = document.getElementById('entryStartDate').value;
if (startDate && tmpl.default_duration_days) {
const end = new Date(startDate);
end.setDate(end.getDate() + tmpl.default_duration_days);
document.getElementById('entryEndDate').value = end.toISOString().split('T')[0];
}
}
}
});
// Dependencies (all entries for the selected project)
const depSelect = document.getElementById('entryDependencies');
depSelect.innerHTML = '';
if (isEdit) {
const entry = ganttData.entries.find(e => e.entry_id === editId);
if (!entry) return;
document.getElementById('entryId').value = editId;
document.getElementById('entryProject').value = entry.project_id;
document.getElementById('entryPhase').value = entry.phase_id;
document.getElementById('entryTaskName').value = entry.task_name;
document.getElementById('entryStartDate').value = formatDate(entry.start_date);
document.getElementById('entryEndDate').value = formatDate(entry.end_date);
document.getElementById('entryAssignee').value = entry.assignee || '';
document.getElementById('entryProgress').value = entry.progress;
document.getElementById('entryStatus').value = entry.status;
document.getElementById('entryNotes').value = entry.notes || '';
// Load dependencies
const projEntries = ganttData.entries.filter(e => e.project_id === entry.project_id && e.entry_id !== editId);
const deps = ganttData.dependencies.filter(d => d.entry_id === editId).map(d => d.depends_on_entry_id);
projEntries.forEach(e => {
const opt = document.createElement('option');
opt.value = e.entry_id;
opt.textContent = `${e.phase_name} > ${e.task_name}`;
opt.selected = deps.includes(e.entry_id);
depSelect.appendChild(opt);
});
} else {
document.getElementById('entryId').value = '';
document.getElementById('entryTaskName').value = '';
document.getElementById('entryStartDate').value = '';
document.getElementById('entryEndDate').value = '';
document.getElementById('entryAssignee').value = '';
document.getElementById('entryProgress').value = '0';
document.getElementById('entryStatus').value = 'planned';
document.getElementById('entryNotes').value = '';
}
modal.classList.remove('hidden');
}
function closeEntryModal() { document.getElementById('entryModal').classList.add('hidden'); }
async function saveEntry() {
const entryId = document.getElementById('entryId').value;
const taskName = document.getElementById('entryTaskName').value.trim();
if (!taskName) { showToast('작업명을 입력해주세요.', 'error'); return; }
const data = {
project_id: document.getElementById('entryProject').value,
phase_id: document.getElementById('entryPhase').value,
task_name: taskName,
start_date: document.getElementById('entryStartDate').value,
end_date: document.getElementById('entryEndDate').value,
assignee: document.getElementById('entryAssignee').value || null,
progress: parseInt(document.getElementById('entryProgress').value) || 0,
status: document.getElementById('entryStatus').value,
notes: document.getElementById('entryNotes').value || null
};
try {
if (entryId) {
await api(`/schedule/entries/${entryId}`, { method: 'PUT', body: JSON.stringify(data) });
// Update dependencies
const depSelect = document.getElementById('entryDependencies');
const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value));
const existingDeps = ganttData.dependencies.filter(d => d.entry_id === parseInt(entryId)).map(d => d.depends_on_entry_id);
// Add new
for (const depId of selectedDeps) {
if (!existingDeps.includes(depId)) {
await api(`/schedule/entries/${entryId}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) });
}
}
// Remove old
for (const depId of existingDeps) {
if (!selectedDeps.includes(depId)) {
await api(`/schedule/entries/${entryId}/dependencies/${depId}`, { method: 'DELETE' });
}
}
showToast('공정표 항목이 수정되었습니다.');
} else {
const res = await api('/schedule/entries', { method: 'POST', body: JSON.stringify(data) });
// Add dependencies for new entry
const depSelect = document.getElementById('entryDependencies');
const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value));
for (const depId of selectedDeps) {
await api(`/schedule/entries/${res.data.entry_id}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) });
}
showToast('공정표 항목이 추가되었습니다.');
}
closeEntryModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Batch Modal ===== */
function openBatchModal() {
populateSelect('batchProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
populateSelect('batchPhase', phases, 'phase_id', p => p.phase_name);
document.getElementById('batchStartDate').value = '';
document.getElementById('batchTemplateList').innerHTML = '';
loadBatchTemplates();
document.getElementById('batchModal').classList.remove('hidden');
}
function closeBatchModal() { document.getElementById('batchModal').classList.add('hidden'); }
function loadBatchTemplates() {
const phaseId = parseInt(document.getElementById('batchPhase').value);
const filtered = templates.filter(t => t.phase_id === phaseId);
const list = document.getElementById('batchTemplateList');
if (filtered.length === 0) { list.innerHTML = '<p class="text-gray-500 text-sm">해당 단계에 템플릿이 없습니다.</p>'; return; }
list.innerHTML = filtered.map((t, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<input type="checkbox" id="btmpl_${t.template_id}" checked class="w-4 h-4">
<span class="flex-1 text-sm">${escapeHtml(t.task_name)}</span>
<span class="text-xs text-gray-400">${t.default_duration_days}일</span>
<input type="date" id="btmpl_start_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
<span class="text-xs text-gray-400">~</span>
<input type="date" id="btmpl_end_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
</div>
`).join('');
recalcBatchDates();
}
function recalcBatchDates() {
const baseStart = document.getElementById('batchStartDate').value;
if (!baseStart) return;
const phaseId = parseInt(document.getElementById('batchPhase').value);
const filtered = templates.filter(t => t.phase_id === phaseId);
let cursor = new Date(baseStart);
for (const t of filtered) {
const startEl = document.getElementById(`btmpl_start_${t.template_id}`);
const endEl = document.getElementById(`btmpl_end_${t.template_id}`);
if (startEl && endEl) {
startEl.value = cursor.toISOString().split('T')[0];
const endDate = new Date(cursor);
endDate.setDate(endDate.getDate() + t.default_duration_days);
endEl.value = endDate.toISOString().split('T')[0];
cursor = new Date(endDate);
}
}
}
async function saveBatchEntries() {
const projectId = document.getElementById('batchProject').value;
const phaseId = document.getElementById('batchPhase').value;
const filtered = templates.filter(t => t.phase_id === parseInt(phaseId));
const entries = [];
for (const t of filtered) {
const cb = document.getElementById(`btmpl_${t.template_id}`);
if (!cb || !cb.checked) continue;
const startDate = document.getElementById(`btmpl_start_${t.template_id}`)?.value;
const endDate = document.getElementById(`btmpl_end_${t.template_id}`)?.value;
if (!startDate || !endDate) { showToast(`${t.task_name}의 날짜를 입력해주세요.`, 'error'); return; }
entries.push({ task_name: t.task_name, start_date: startDate, end_date: endDate, display_order: t.display_order });
}
if (entries.length === 0) { showToast('추가할 항목이 없습니다.', 'error'); return; }
try {
await api('/schedule/entries/batch', { method: 'POST', body: JSON.stringify({ project_id: projectId, phase_id: phaseId, entries }) });
showToast(`${entries.length}개 항목이 일괄 추가되었습니다.`);
closeBatchModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Milestone Modal ===== */
function openMilestoneModal(editId) {
const modal = document.getElementById('milestoneModal');
const isEdit = !!editId;
document.getElementById('milestoneModalTitle').textContent = isEdit ? '마일스톤 수정' : '마일스톤 추가';
populateSelect('milestoneProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`);
if (isEdit) {
const m = ganttData.milestones.find(ms => ms.milestone_id === editId);
if (!m) return;
document.getElementById('milestoneId').value = editId;
document.getElementById('milestoneProject').value = m.project_id;
document.getElementById('milestoneName').value = m.milestone_name;
document.getElementById('milestoneDate').value = formatDate(m.milestone_date);
document.getElementById('milestoneType').value = m.milestone_type;
document.getElementById('milestoneStatus').value = m.status;
document.getElementById('milestoneNotes').value = m.notes || '';
// Load entry options for project
loadMilestoneEntries(m.project_id, m.entry_id);
} else {
document.getElementById('milestoneId').value = '';
document.getElementById('milestoneName').value = '';
document.getElementById('milestoneDate').value = '';
document.getElementById('milestoneType').value = 'deadline';
document.getElementById('milestoneStatus').value = 'upcoming';
document.getElementById('milestoneNotes').value = '';
document.getElementById('milestoneEntry').innerHTML = '<option value="">없음</option>';
}
// Update entry list on project change
document.getElementById('milestoneProject').onchange = function() { loadMilestoneEntries(this.value); };
modal.classList.remove('hidden');
}
function loadMilestoneEntries(projectId, selectedEntryId) {
const sel = document.getElementById('milestoneEntry');
sel.innerHTML = '<option value="">없음</option>';
const projEntries = ganttData.entries.filter(e => e.project_id === parseInt(projectId));
projEntries.forEach(e => {
const opt = document.createElement('option');
opt.value = e.entry_id;
opt.textContent = `${e.phase_name} > ${e.task_name}`;
if (selectedEntryId && e.entry_id === selectedEntryId) opt.selected = true;
sel.appendChild(opt);
});
}
function closeMilestoneModal() { document.getElementById('milestoneModal').classList.add('hidden'); }
async function saveMilestone() {
const milestoneId = document.getElementById('milestoneId').value;
const data = {
project_id: document.getElementById('milestoneProject').value,
milestone_name: document.getElementById('milestoneName').value.trim(),
milestone_date: document.getElementById('milestoneDate').value,
milestone_type: document.getElementById('milestoneType').value,
status: document.getElementById('milestoneStatus').value,
entry_id: document.getElementById('milestoneEntry').value || null,
notes: document.getElementById('milestoneNotes').value || null
};
if (!data.milestone_name || !data.milestone_date) { showToast('마일스톤명과 날짜를 입력해주세요.', 'error'); return; }
try {
if (milestoneId) {
await api(`/schedule/milestones/${milestoneId}`, { method: 'PUT', body: JSON.stringify(data) });
showToast('마일스톤이 수정되었습니다.');
} else {
await api('/schedule/milestones', { method: 'POST', body: JSON.stringify(data) });
showToast('마일스톤이 추가되었습니다.');
}
closeMilestoneModal();
await loadGantt();
} catch (err) { showToast(err.message, 'error'); }
}
/* ===== Utility ===== */
function populateSelect(selectId, items, valueField, labelFn) {
const sel = document.getElementById(selectId);
const oldVal = sel.value;
sel.innerHTML = items.map(item => `<option value="${item[valueField]}">${escapeHtml(labelFn(item))}</option>`).join('');
if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal;
}
function loadTemplateOptions(selectId, phaseId) {
const sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">직접 입력</option>';
templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => {
sel.innerHTML += `<option value="${t.template_id}">${escapeHtml(t.task_name)} (${t.default_duration_days}일)</option>`;
});
}

View File

@@ -0,0 +1,39 @@
/**
* SSO Token Relay — 인앱 브라우저(카카오톡 등) 서브도메인 쿠키 미공유 대응
*
* Canonical source: shared/frontend/sso-relay.js
* 전 서비스 동일 코드 — 수정 시 아래 파일 <20><><EFBFBD>체 갱신 필요:
* system1-factory/web/js/sso-relay.js
* system2-report/web/js/sso-relay.js
* system3-nonconformance/web/static/js/sso-relay.js
* user-management/web/static/js/sso-relay.js
* tkpurchase/web/static/js/sso-relay.js
* tksafety/web/static/js/sso-relay.js
* tksupport/web/static/js/sso-relay.js
*
* 동작: URL hash에 _sso= 파라미터가 있으면 토큰을 로컬 쿠키+localStorage에 설정하고 hash를 제거.
* gateway/dashboard.html에서 로그인 성공 후 redirect URL에 #_sso=<token>을 붙여 전달.
*/
(function() {
var hash = location.hash;
if (!hash || hash.indexOf('_sso=') === -1) return;
var match = hash.match(/[#&]_sso=([^&]*)/);
if (!match) return;
var token = decodeURIComponent(match[1]);
if (!token) return;
// 로컬(1st-party) 쿠키 설정
var cookie = 'sso_token=' + encodeURIComponent(token) + '; path=/; max-age=604800';
if (location.hostname.indexOf('technicalkorea.net') !== -1) {
cookie += '; domain=.technicalkorea.net; secure; samesite=lax';
}
document.cookie = cookie;
// localStorage 폴백
try { localStorage.setItem('sso_token', token); } catch (e) {}
// URL에서 hash 제거
history.replaceState(null, '', location.pathname + location.search);
})();

View File

@@ -0,0 +1,885 @@
// System Dashboard JavaScript
import { apiRequest } from './api-helper.js';
import { getCurrentUser } from './auth.js';
// 전역 변수
let systemData = {
users: [],
logs: [],
systemStatus: {}
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initializeSystemDashboard();
setupEventListeners();
});
// Setup event listeners
function setupEventListeners() {
// Add event listeners to all data-action buttons
const actionButtons = document.querySelectorAll('[data-action]');
actionButtons.forEach(button => {
const action = button.getAttribute('data-action');
switch(action) {
case 'account-management':
button.addEventListener('click', openAccountManagement);
break;
case 'system-logs':
button.addEventListener('click', openSystemLogs);
break;
case 'database-management':
button.addEventListener('click', openDatabaseManagement);
break;
case 'system-settings':
button.addEventListener('click', openSystemSettings);
break;
case 'backup-management':
button.addEventListener('click', openBackupManagement);
break;
case 'monitoring':
button.addEventListener('click', openMonitoring);
break;
case 'close-modal':
button.addEventListener('click', () => closeModal('account-modal'));
break;
}
});
}
// Initialize system dashboard
async function initializeSystemDashboard() {
try {
// Load user info
await loadUserInfo();
// Load system status
await loadSystemStatus();
// Load user statistics
await loadUserStats();
// Load recent activities
await loadRecentActivities();
// Setup auto-refresh (every 30 seconds)
setInterval(refreshSystemStatus, 30000);
} catch (error) {
console.error(' System dashboard initialization error:', error);
showNotification('Error loading system dashboard', 'error');
}
}
// 사용자 정보 로드
async function loadUserInfo() {
try {
const user = getCurrentUser();
if (user && user.name) {
document.getElementById('user-name').textContent = user.name;
}
} catch (error) {
console.error('사용자 정보 로드 오류:', error);
}
}
// 시스템 상태 로드
async function loadSystemStatus() {
try {
// 서버 상태 확인
const serverStatus = await checkServerStatus();
updateServerStatus(serverStatus);
// 데이터베이스 상태 확인
const dbStatus = await checkDatabaseStatus();
updateDatabaseStatus(dbStatus);
// 시스템 알림 확인
const alerts = await getSystemAlerts();
updateSystemAlerts(alerts);
} catch (error) {
console.error('시스템 상태 로드 오류:', error);
}
}
// 서버 상태 확인
async function checkServerStatus() {
try {
const response = await apiRequest('/api/system/status', 'GET');
return response.success ? 'online' : 'offline';
} catch (error) {
return 'offline';
}
}
// 데이터베이스 상태 확인
async function checkDatabaseStatus() {
try {
const response = await apiRequest('/api/system/db-status', 'GET');
return response;
} catch (error) {
return { status: 'error', connections: 0 };
}
}
// 시스템 알림 가져오기
async function getSystemAlerts() {
try {
const response = await apiRequest('/api/system/alerts', 'GET');
return response.alerts || [];
} catch (error) {
return [];
}
}
// 서버 상태 업데이트
function updateServerStatus(status) {
const serverCheckTime = document.getElementById('server-check-time');
const statusElements = document.querySelectorAll('.status-value');
if (serverCheckTime) {
serverCheckTime.textContent = new Date().toLocaleTimeString('ko-KR');
}
// 서버 상태 표시 업데이트 로직 추가
}
// 데이터베이스 상태 업데이트
function updateDatabaseStatus(dbStatus) {
const dbConnections = document.getElementById('db-connections');
if (dbConnections && dbStatus.connections !== undefined) {
dbConnections.textContent = dbStatus.connections;
}
}
// 시스템 알림 업데이트
function updateSystemAlerts(alerts) {
const systemAlerts = document.getElementById('system-alerts');
if (systemAlerts) {
systemAlerts.textContent = alerts.length;
systemAlerts.className = `status-value ${alerts.length > 0 ? 'warning' : 'online'}`;
}
}
// 사용자 통계 로드
async function loadUserStats() {
try {
const response = await apiRequest('/api/system/users/stats', 'GET');
if (response.success) {
const activeUsers = document.getElementById('active-users');
const totalUsers = document.getElementById('total-users');
if (activeUsers) activeUsers.textContent = response.data.active || 0;
if (totalUsers) totalUsers.textContent = response.data.total || 0;
}
} catch (error) {
console.error('사용자 통계 로드 오류:', error);
}
}
// 최근 활동 로드
async function loadRecentActivities() {
try {
const response = await apiRequest('/api/system/recent-activities', 'GET');
if (response.success && response.data) {
displayRecentActivities(response.data);
}
} catch (error) {
console.error('최근 활동 로드 오류:', error);
displayDefaultActivities();
}
}
// 최근 활동 표시
function displayRecentActivities(activities) {
const container = document.getElementById('recent-activities');
if (!container) return;
if (!activities || activities.length === 0) {
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">최근 활동이 없습니다.</p>';
return;
}
const html = activities.map(activity => `
<div class="activity-item">
<div class="activity-icon">
<i class="fas ${getActivityIcon(activity.type)}"></i>
</div>
<div class="activity-info">
<h4>${activity.title}</h4>
<p>${activity.description}</p>
</div>
<div class="activity-time">
${formatTimeAgo(activity.created_at)}
</div>
</div>
`).join('');
container.innerHTML = html;
}
// 기본 활동 표시 (데이터 로드 실패 시)
function displayDefaultActivities() {
const container = document.getElementById('recent-activities');
if (!container) return;
const defaultActivities = [
{
type: 'system',
title: '시스템 시작',
description: '시스템이 정상적으로 시작되었습니다.',
created_at: new Date().toISOString()
}
];
displayRecentActivities(defaultActivities);
}
// 활동 타입에 따른 아이콘 반환
function getActivityIcon(type) {
const icons = {
'login': 'fa-sign-in-alt',
'user_create': 'fa-user-plus',
'user_update': 'fa-user-edit',
'user_delete': 'fa-user-minus',
'system': 'fa-cog',
'database': 'fa-database',
'backup': 'fa-download',
'error': 'fa-exclamation-triangle'
};
return icons[type] || 'fa-info-circle';
}
// 시간 포맷팅 (몇 분 전, 몇 시간 전 등)
function formatTimeAgo(dateString) {
const now = new Date();
const date = new Date(dateString);
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return '방금 전';
} else if (diffInSeconds < 3600) {
return `${Math.floor(diffInSeconds / 60)}분 전`;
} else if (diffInSeconds < 86400) {
return `${Math.floor(diffInSeconds / 3600)}시간 전`;
} else {
return `${Math.floor(diffInSeconds / 86400)}일 전`;
}
}
// 시스템 상태 새로고침
async function refreshSystemStatus() {
try {
await loadSystemStatus();
await loadUserStats();
} catch (error) {
console.error('시스템 상태 새로고침 오류:', error);
}
}
// Open account management
function openAccountManagement() {
const modal = document.getElementById('account-modal');
const content = document.getElementById('account-management-content');
console.log('Modal element:', modal);
console.log('Content element:', content);
if (modal && content) {
// Load account management content
loadAccountManagementContent(content);
modal.style.display = 'block';
} else {
console.error(' Modal or content element not found');
}
}
// 계정 관리 컨텐츠 로드
async function loadAccountManagementContent(container) {
try {
container.innerHTML = `
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i> 로딩 중...
</div>
`;
// 사용자 목록 로드
const response = await apiRequest('/api/system/users', 'GET');
if (response.success) {
displayAccountManagement(container, response.data);
} else {
throw new Error(response.error || '사용자 목록을 불러올 수 없습니다.');
}
} catch (error) {
console.error('계정 관리 컨텐츠 로드 오류:', error);
container.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-triangle"></i>
<p>계정 정보를 불러오는 중 오류가 발생했습니다.</p>
<button class="btn btn-primary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
다시 시도
</button>
</div>
`;
}
}
// 계정 관리 화면 표시
function displayAccountManagement(container, users) {
const html = `
<div class="account-management">
<div class="account-header">
<h4><i class="fas fa-users"></i> 사용자 계정 관리</h4>
<button class="btn btn-primary" onclick="openCreateUserForm()">
<i class="fas fa-plus"></i> 새 사용자
</button>
</div>
<div class="account-filters">
<input type="text" id="user-search" placeholder="사용자 검색..." onkeyup="filterUsers()">
<select id="role-filter" onchange="filterUsers()">
<option value="">모든 권한</option>
<option value="system">시스템</option>
<option value="admin">관리자</option>
<option value="leader">그룹장</option>
<option value="user">사용자</option>
</select>
</div>
<div class="users-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>사용자명</th>
<th>이름</th>
<th>권한</th>
<th>상태</th>
<th>마지막 로그인</th>
<th>작업</th>
</tr>
</thead>
<tbody id="users-tbody">
${generateUsersTableRows(users)}
</tbody>
</table>
</div>
</div>
`;
container.innerHTML = html;
systemData.users = users;
}
// 사용자 테이블 행 생성
function generateUsersTableRows(users) {
if (!users || users.length === 0) {
return '<tr><td colspan="7" style="text-align: center; padding: 2rem;">등록된 사용자가 없습니다.</td></tr>';
}
return users.map(user => `
<tr data-user-id="${user.user_id}">
<td>${user.user_id}</td>
<td>${user.username}</td>
<td>${user.name || '-'}</td>
<td>
<span class="role-badge role-${user.role}">
${getRoleDisplayName(user.role)}
</span>
</td>
<td>
<span class="status-badge ${user.is_active ? 'active' : 'inactive'}">
${user.is_active ? '활성' : '비활성'}
</span>
</td>
<td>${user.last_login_at ? formatDate(user.last_login_at) : '없음'}</td>
<td class="action-buttons">
<button class="btn-small btn-edit" onclick="editUser(${user.user_id})" title="수정">
<i class="fas fa-edit"></i>
</button>
<button class="btn-small btn-delete" onclick="deleteUser(${user.user_id})" title="삭제">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
// 권한 표시명 반환
function getRoleDisplayName(role) {
const roleNames = {
'system': '시스템',
'admin': '관리자',
'leader': '그룹장',
'user': '사용자'
};
return roleNames[role] || role;
}
// 날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('ko-KR');
}
// 시스템 로그 열기
function openSystemLogs() {
console.log('시스템 로그 버튼 클릭됨');
const modal = document.getElementById('account-modal');
const content = document.getElementById('account-management-content');
if (modal && content) {
loadSystemLogsContent(content);
modal.style.display = 'block';
}
}
// 시스템 로그 컨텐츠 로드
async function loadSystemLogsContent(container) {
try {
container.innerHTML = `
<div class="system-logs">
<h4><i class="fas fa-file-alt"></i> 시스템 로그</h4>
<div class="log-filters">
<select id="log-type-filter">
<option value="">모든 로그</option>
<option value="login">로그인</option>
<option value="activity">활동</option>
<option value="error">오류</option>
</select>
<input type="date" id="log-date-filter">
<button class="btn btn-primary" onclick="filterLogs()">
<i class="fas fa-search"></i> 검색
</button>
</div>
<div class="logs-container">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i> 로그 로딩 중...
</div>
</div>
</div>
`;
// 로그 데이터 로드
await loadLogsData();
} catch (error) {
console.error('시스템 로그 로드 오류:', error);
container.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-triangle"></i>
<p>시스템 로그를 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
}
// 로그 데이터 로드
async function loadLogsData() {
try {
const response = await apiRequest('/api/system/logs/activity', 'GET');
const logsContainer = document.querySelector('.logs-container');
if (response.success && response.data) {
displayLogs(response.data, logsContainer);
} else {
logsContainer.innerHTML = '<p>로그 데이터가 없습니다.</p>';
}
} catch (error) {
console.error('로그 데이터 로드 오류:', error);
document.querySelector('.logs-container').innerHTML = '<p>로그 데이터를 불러올 수 없습니다.</p>';
}
}
// 로그 표시
function displayLogs(logs, container) {
if (!logs || logs.length === 0) {
container.innerHTML = '<p>표시할 로그가 없습니다.</p>';
return;
}
const html = `
<table class="logs-table">
<thead>
<tr>
<th>시간</th>
<th>유형</th>
<th>사용자</th>
<th>내용</th>
</tr>
</thead>
<tbody>
${logs.map(log => `
<tr>
<td>${formatDate(log.created_at)}</td>
<td><span class="log-type ${log.type}">${log.type}</span></td>
<td>${log.username || '-'}</td>
<td>${log.description}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
container.innerHTML = html;
}
// 로그 필터링
function filterLogs() {
console.log('로그 필터링 실행');
// 실제 구현은 추후 추가
showNotification('로그 필터링 기능은 개발 중입니다.', 'info');
}
// 데이터베이스 관리 열기
function openDatabaseManagement() {
console.log('데이터베이스 관리 버튼 클릭됨');
showNotification('데이터베이스 관리 기능은 개발 중입니다.', 'info');
}
// 시스템 설정 열기
function openSystemSettings() {
console.log('시스템 설정 버튼 클릭됨');
showNotification('시스템 설정 기능은 개발 중입니다.', 'info');
}
// 백업 관리 열기
function openBackupManagement() {
console.log('백업 관리 버튼 클릭됨');
showNotification('백업 관리 기능은 개발 중입니다.', 'info');
}
// 모니터링 열기
function openMonitoring() {
console.log('모니터링 버튼 클릭됨');
showNotification('모니터링 기능은 개발 중입니다.', 'info');
}
// 모달 닫기
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
}
}
// 로그아웃
function logout() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
window.location.href = '/';
}
}
// 알림 표시
function showNotification(message, type = 'info') {
// 간단한 알림 표시 (나중에 토스트 라이브러리로 교체 가능)
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
// 사용자 편집
async function editUser(userId) {
try {
// 사용자 정보 가져오기
const response = await apiRequest(`/api/system/users`, 'GET');
if (!response.success) {
throw new Error('사용자 정보를 가져올 수 없습니다.');
}
const user = response.data.find(u => u.user_id === userId);
if (!user) {
throw new Error('해당 사용자를 찾을 수 없습니다.');
}
// 편집 폼 표시
showUserEditForm(user);
} catch (error) {
console.error('사용자 편집 오류:', error);
showNotification('사용자 정보를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 편집 폼 표시
function showUserEditForm(user) {
const formHtml = `
<div class="user-edit-form">
<h4><i class="fas fa-user-edit"></i> 사용자 정보 수정</h4>
<form id="edit-user-form">
<div class="form-group">
<label for="edit-username">사용자명</label>
<input type="text" id="edit-username" value="${user.username}" disabled>
</div>
<div class="form-group">
<label for="edit-name">이름</label>
<input type="text" id="edit-name" value="${user.name || ''}" required>
</div>
<div class="form-group">
<label for="edit-email">이메일</label>
<input type="email" id="edit-email" value="${user.email || ''}">
</div>
<div class="form-group">
<label for="edit-role">권한</label>
<select id="edit-role" required>
<option value="system" ${user.role === 'system' ? 'selected' : ''}>시스템</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>관리자</option>
<option value="leader" ${user.role === 'leader' ? 'selected' : ''}>그룹장</option>
<option value="user" ${user.role === 'user' ? 'selected' : ''}>사용자</option>
</select>
</div>
<div class="form-group">
<label for="edit-is-active">상태</label>
<select id="edit-is-active" required>
<option value="1" ${user.is_active ? 'selected' : ''}>활성</option>
<option value="0" ${!user.is_active ? 'selected' : ''}>비활성</option>
</select>
</div>
<div class="form-group">
<label for="edit-worker-id">작업자 ID</label>
<input type="number" id="edit-worker-id" value="${user.user_id || ''}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 저장
</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('account-modal')">
<i class="fas fa-times"></i> 취소
</button>
</div>
</form>
</div>
`;
const container = document.getElementById('account-management-content');
container.innerHTML = formHtml;
// 폼 제출 이벤트 리스너
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
await updateUser(user.user_id);
});
}
// 사용자 정보 업데이트
async function updateUser(userId) {
try {
const formData = {
name: document.getElementById('edit-name').value,
email: document.getElementById('edit-email').value || null,
role: document.getElementById('edit-role').value,
access_level: document.getElementById('edit-role').value,
is_active: parseInt(document.getElementById('edit-is-active').value),
user_id: document.getElementById('edit-worker-id').value || null
};
const response = await apiRequest(`/api/system/users/${userId}`, 'PUT', formData);
if (response.success) {
showNotification('사용자 정보가 성공적으로 업데이트되었습니다.', 'success');
closeModal('account-modal');
// 계정 관리 다시 로드
setTimeout(() => openAccountManagement(), 500);
} else {
throw new Error(response.error || '업데이트에 실패했습니다.');
}
} catch (error) {
console.error('사용자 업데이트 오류:', error);
showNotification('사용자 정보 업데이트 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 삭제
async function deleteUser(userId) {
try {
// 사용자 정보 가져오기
const response = await apiRequest(`/api/system/users`, 'GET');
if (!response.success) {
throw new Error('사용자 정보를 가져올 수 없습니다.');
}
const user = response.data.find(u => u.user_id === userId);
if (!user) {
throw new Error('해당 사용자를 찾을 수 없습니다.');
}
// 삭제 확인
if (!confirm(`정말로 사용자 '${user.username}'를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
return;
}
// 사용자 삭제 요청
const deleteResponse = await apiRequest(`/api/system/users/${userId}`, 'DELETE');
if (deleteResponse.success) {
showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
// 계정 관리 다시 로드
setTimeout(() => {
const container = document.getElementById('account-management-content');
if (container) {
loadAccountManagementContent(container);
}
}, 500);
} else {
throw new Error(deleteResponse.error || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('사용자 삭제 오류:', error);
showNotification('사용자 삭제 중 오류가 발생했습니다.', 'error');
}
}
// 새 사용자 생성 폼 열기
function openCreateUserForm() {
const formHtml = `
<div class="user-create-form">
<h4><i class="fas fa-user-plus"></i> 새 사용자 생성</h4>
<form id="create-user-form">
<div class="form-group">
<label for="create-username">사용자명 *</label>
<input type="text" id="create-username" required>
</div>
<div class="form-group">
<label for="create-password">비밀번호 *</label>
<input type="password" id="create-password" required minlength="6">
</div>
<div class="form-group">
<label for="create-name">이름 *</label>
<input type="text" id="create-name" required>
</div>
<div class="form-group">
<label for="create-email">이메일</label>
<input type="email" id="create-email">
</div>
<div class="form-group">
<label for="create-role">권한 *</label>
<select id="create-role" required>
<option value="">권한 선택</option>
<option value="system">시스템</option>
<option value="admin">관리자</option>
<option value="leader">그룹장</option>
<option value="user">사용자</option>
</select>
</div>
<div class="form-group">
<label for="create-worker-id">작업자 ID</label>
<input type="number" id="create-worker-id">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus"></i> 생성
</button>
<button type="button" class="btn btn-secondary" onclick="loadAccountManagementContent(document.getElementById('account-management-content'))">
<i class="fas fa-arrow-left"></i> 돌아가기
</button>
</div>
</form>
</div>
`;
const container = document.getElementById('account-management-content');
container.innerHTML = formHtml;
// 폼 제출 이벤트 리스너
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
await createUser();
});
}
// 새 사용자 생성
async function createUser() {
try {
const formData = {
username: document.getElementById('create-username').value,
password: document.getElementById('create-password').value,
name: document.getElementById('create-name').value,
email: document.getElementById('create-email').value || null,
role: document.getElementById('create-role').value,
access_level: document.getElementById('create-role').value,
user_id: document.getElementById('create-worker-id').value || null
};
const response = await apiRequest('/api/system/users', 'POST', formData);
if (response.success) {
showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
// 계정 관리 목록으로 돌아가기
setTimeout(() => {
const container = document.getElementById('account-management-content');
loadAccountManagementContent(container);
}, 500);
} else {
throw new Error(response.error || '사용자 생성에 실패했습니다.');
}
} catch (error) {
console.error('사용자 생성 오류:', error);
showNotification('사용자 생성 중 오류가 발생했습니다.', 'error');
}
}
// 사용자 필터링
function filterUsers() {
const searchTerm = document.getElementById('user-search').value.toLowerCase();
const roleFilter = document.getElementById('role-filter').value;
const rows = document.querySelectorAll('#users-tbody tr');
rows.forEach(row => {
const username = row.cells[1].textContent.toLowerCase();
const name = row.cells[2].textContent.toLowerCase();
const role = row.querySelector('.role-badge').textContent.toLowerCase();
const matchesSearch = username.includes(searchTerm) || name.includes(searchTerm);
const matchesRole = !roleFilter || role.includes(roleFilter);
row.style.display = matchesSearch && matchesRole ? '' : 'none';
});
}
// 모달 관련 함수들만 전역으로 노출 (동적으로 생성되는 HTML에서 사용)
window.closeModal = closeModal;
window.editUser = editUser;
window.deleteUser = deleteUser;
window.openCreateUserForm = openCreateUserForm;
window.filterUsers = filterUsers;
window.filterLogs = filterLogs;
// 테스트용 전역 함수
window.testFunction = function() {
alert('테스트 함수가 정상적으로 작동합니다!');
};
// 모달 외부 클릭 시 닫기
window.onclick = function(event) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
}
});
};

View File

@@ -0,0 +1,481 @@
// task-management.js - 작업 관리 페이지 JavaScript
// 전역 변수
let workTypes = []; // 공정 목록
let tasks = []; // 작업 목록
let currentWorkTypeId = ''; // 현재 선택된 공정 ID
let currentEditingTask = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', async () => {
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
await loadAllData();
});
// 전체 데이터 로드
async function loadAllData() {
try {
// 공정 목록 로드 (work_types 조회 - 코드 관리 API 사용)
await loadWorkTypes();
// 작업 목록 로드
await loadTasks();
} catch (error) {
console.error(' 데이터 로드 오류:', error);
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
window.loadAllData = loadAllData;
// 공정 목록 로드
async function loadWorkTypes() {
try {
// 작업 유형(공정) 목록 조회
const response = await window.apiCall('/daily-work-reports/work-types');
if (response && response.success) {
workTypes = response.data || [];
} else {
workTypes = [];
}
renderWorkTypeTabs();
populateWorkTypeSelect();
} catch (error) {
console.error(' 공정 목록 조회 오류:', error);
// API 오류 시에도 빈 배열로 처리
workTypes = [];
renderWorkTypeTabs();
}
}
// 작업 목록 로드
async function loadTasks() {
try {
const response = await window.apiCall('/tasks');
if (response && response.success) {
tasks = response.data || [];
} else {
tasks = [];
}
renderTasks();
updateStatistics();
} catch (error) {
console.error(' 작업 목록 조회 오류:', error);
showToast('작업 목록을 불러오는 중 오류가 발생했습니다.', 'error');
tasks = [];
renderTasks();
}
}
// 공정 탭 렌더링
function renderWorkTypeTabs() {
const tabsContainer = document.getElementById('workTypeTabs');
let tabsHtml = `
<button class="tab-btn ${currentWorkTypeId === '' ? 'active' : ''}"
data-work-type="" onclick="switchWorkType('')">
<span class="tab-icon">📋</span>
전체 (${tasks.length})
</button>
`;
workTypes.forEach(workType => {
const count = tasks.filter(t => t.work_type_id === workType.id).length;
const isActive = currentWorkTypeId === workType.id;
const safeId = parseInt(workType.id) || 0;
tabsHtml += `
<button class="tab-btn ${isActive ? 'active' : ''}"
data-work-type="${safeId}"
onclick="switchWorkType(${safeId})"
style="position: relative; padding-right: 3rem;">
<span class="tab-icon">🔧</span>
${escapeHtml(workType.name)} (${parseInt(count) || 0})
<span onclick="event.stopPropagation(); editWorkType(${safeId});"
style="position: absolute; right: 0.5rem; padding: 0.25rem 0.5rem; opacity: 0.7; cursor: pointer; font-size: 0.75rem;"
title="공정 수정">
✏️
</span>
</button>
`;
});
tabsContainer.innerHTML = tabsHtml;
}
// 공정 전환
function switchWorkType(workTypeId) {
currentWorkTypeId = workTypeId === '' ? '' : parseInt(workTypeId);
renderWorkTypeTabs();
renderTasks();
updateStatistics();
}
window.switchWorkType = switchWorkType;
// 작업 목록 렌더링
function renderTasks() {
const grid = document.getElementById('taskGrid');
// 현재 선택된 공정으로 필터링
let filteredTasks = tasks;
if (currentWorkTypeId !== '') {
filteredTasks = tasks.filter(t => t.work_type_id === currentWorkTypeId);
}
if (filteredTasks.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon">📋</div>
<h3>등록된 작업이 없습니다</h3>
<p>"작업 추가" 버튼을 눌러 새로운 작업을 등록하세요</p>
</div>
`;
return;
}
grid.innerHTML = filteredTasks.map(task => createTaskCard(task)).join('');
}
// 작업 카드 생성
function createTaskCard(task) {
const statusBadge = task.is_active
? '<span class="badge" style="background: #dcfce7; color: #166534;">활성</span>'
: '<span class="badge" style="background: #f3f4f6; color: #6b7280;">비활성</span>';
const safeTaskId = parseInt(task.task_id) || 0;
return `
<div class="code-card" onclick="editTask(${safeTaskId})">
<div class="code-card-header">
<h3 class="code-name">${escapeHtml(task.task_name)}</h3>
${statusBadge}
</div>
<div class="code-info">
<div class="info-item">
<span class="info-label">소속 공정</span>
<span class="info-value">${escapeHtml(task.work_type_name || '-')}</span>
</div>
${task.category ? `
<div class="info-item">
<span class="info-label">카테고리</span>
<span class="info-value">${escapeHtml(task.category)}</span>
</div>
` : ''}
</div>
${task.description ? `
<div class="code-description">
${escapeHtml(task.description)}
</div>
` : ''}
<div class="code-meta">
<span>등록: ${escapeHtml(formatDate(task.created_at))}</span>
</div>
</div>
`;
}
// 통계 업데이트
function updateStatistics() {
let filteredTasks = tasks;
if (currentWorkTypeId !== '') {
filteredTasks = tasks.filter(t => t.work_type_id === currentWorkTypeId);
}
const activeCount = filteredTasks.filter(t => t.is_active).length;
document.getElementById('totalCount').textContent = filteredTasks.length;
document.getElementById('activeCount').textContent = activeCount;
}
// 새로고침
function refreshTasks() {
loadAllData();
showToast('데이터를 새로고침했습니다.', 'success');
}
window.refreshTasks = refreshTasks;
// ==================== 작업 모달 ====================
// 작업 모달 열기 (신규)
function openTaskModal() {
currentEditingTask = null;
document.getElementById('taskModalTitle').textContent = '작업 추가';
document.getElementById('taskForm').reset();
document.getElementById('taskId').value = '';
document.getElementById('taskIsActive').checked = true;
// 공정 선택 드롭다운 채우기
populateWorkTypeSelect();
// 현재 선택된 공정이 있으면 자동 선택
if (currentWorkTypeId !== '') {
document.getElementById('taskWorkTypeId').value = currentWorkTypeId;
}
document.getElementById('deleteTaskBtn').style.display = 'none';
document.getElementById('taskModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openTaskModal = openTaskModal;
// 작업 편집
async function editTask(taskId) {
try {
const response = await window.apiCall(`/tasks/${taskId}`);
if (response && response.success) {
currentEditingTask = response.data;
document.getElementById('taskModalTitle').textContent = '작업 수정';
document.getElementById('taskId').value = currentEditingTask.task_id;
document.getElementById('taskWorkTypeId').value = currentEditingTask.work_type_id || '';
document.getElementById('taskName').value = currentEditingTask.task_name;
document.getElementById('taskDescription').value = currentEditingTask.description || '';
document.getElementById('taskIsActive').checked = currentEditingTask.is_active;
document.getElementById('deleteTaskBtn').style.display = 'block';
document.getElementById('taskModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
} catch (error) {
console.error(' 작업 조회 오류:', error);
showToast('작업 정보를 불러올 수 없습니다.', 'error');
}
}
window.editTask = editTask;
// 작업 모달 닫기
function closeTaskModal() {
document.getElementById('taskModal').style.display = 'none';
document.body.style.overflow = 'auto';
currentEditingTask = null;
}
window.closeTaskModal = closeTaskModal;
// 공정 선택 드롭다운 채우기
function populateWorkTypeSelect() {
const select = document.getElementById('taskWorkTypeId');
select.innerHTML = '<option value="">공정 선택...</option>' +
workTypes.map(wt => `
<option value="${escapeHtml(String(wt.id))}">${escapeHtml(wt.name)}${wt.category ? ' (' + escapeHtml(wt.category) + ')' : ''}</option>
`).join('');
}
// 작업 저장
async function saveTask() {
const taskId = document.getElementById('taskId').value;
const taskData = {
work_type_id: parseInt(document.getElementById('taskWorkTypeId').value) || null,
task_name: document.getElementById('taskName').value.trim(),
description: document.getElementById('taskDescription').value.trim() || null,
is_active: document.getElementById('taskIsActive').checked ? 1 : 0
};
if (!taskData.task_name) {
showToast('작업명을 입력해주세요.', 'error');
return;
}
try {
let response;
if (taskId) {
// 수정
response = await window.apiCall(`/tasks/${taskId}`, 'PUT', taskData);
} else {
// 신규
response = await window.apiCall('/tasks', 'POST', taskData);
}
if (response && response.success) {
showToast(taskId ? '작업이 수정되었습니다.' : '작업이 추가되었습니다.', 'success');
closeTaskModal();
await loadAllData();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error(' 작업 저장 오류:', error);
showToast('작업 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveTask = saveTask;
// 작업 삭제
async function deleteTask() {
if (!currentEditingTask) return;
if (!confirm(`"${currentEditingTask.task_name}" 작업을 삭제하시겠습니까?`)) {
return;
}
try {
const response = await window.apiCall(`/tasks/${currentEditingTask.task_id}`, 'DELETE');
if (response && response.success) {
showToast('작업이 삭제되었습니다.', 'success');
closeTaskModal();
await loadAllData();
} else {
throw new Error(response.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error(' 작업 삭제 오류:', error);
showToast('작업 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteTask = deleteTask;
// ==================== 유틸리티 ====================
// 날짜 포맷
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
// showToast → api-base.js 전역 사용
// ==================== 공정 관리 ====================
let currentEditingWorkType = null;
// 공정 모달 열기 (신규)
function openWorkTypeModal() {
currentEditingWorkType = null;
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
document.getElementById('workTypeId').value = '';
document.getElementById('workTypeName').value = '';
document.getElementById('workTypeCategory').value = '';
document.getElementById('workTypeDescription').value = '';
document.getElementById('deleteWorkTypeBtn').style.display = 'none';
document.getElementById('workTypeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
window.openWorkTypeModal = openWorkTypeModal;
// 공정 수정 모달 열기
async function editWorkType(workTypeId) {
try {
const workType = workTypes.find(wt => wt.id === workTypeId);
if (!workType) {
showToast('공정 정보를 찾을 수 없습니다.', 'error');
return;
}
currentEditingWorkType = workType;
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
document.getElementById('workTypeId').value = workType.id;
document.getElementById('workTypeName').value = workType.name || '';
document.getElementById('workTypeCategory').value = workType.category || '';
document.getElementById('workTypeDescription').value = workType.description || '';
document.getElementById('deleteWorkTypeBtn').style.display = 'block';
document.getElementById('workTypeModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (error) {
console.error(' 공정 조회 오류:', error);
showToast('공정 정보를 불러올 수 없습니다.', 'error');
}
}
window.editWorkType = editWorkType;
// 공정 모달 닫기
function closeWorkTypeModal() {
document.getElementById('workTypeModal').style.display = 'none';
document.body.style.overflow = 'auto';
currentEditingWorkType = null;
}
window.closeWorkTypeModal = closeWorkTypeModal;
// 공정 저장
async function saveWorkType() {
const workTypeId = document.getElementById('workTypeId').value;
const workTypeData = {
name: document.getElementById('workTypeName').value.trim(),
category: document.getElementById('workTypeCategory').value.trim() || null,
description: document.getElementById('workTypeDescription').value.trim() || null
};
if (!workTypeData.name) {
showToast('공정명을 입력해주세요.', 'error');
return;
}
try {
let response;
if (workTypeId) {
// 수정
response = await window.apiCall(`/daily-work-reports/work-types/${workTypeId}`, 'PUT', workTypeData);
} else {
// 신규
response = await window.apiCall('/daily-work-reports/work-types', 'POST', workTypeData);
}
if (response && response.success) {
showToast(workTypeId ? '공정이 수정되었습니다.' : '공정이 추가되었습니다.', 'success');
closeWorkTypeModal();
await loadAllData();
} else {
throw new Error(response.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error(' 공정 저장 오류:', error);
showToast('공정 저장 중 오류가 발생했습니다.', 'error');
}
}
window.saveWorkType = saveWorkType;
// 공정 삭제
async function deleteWorkType() {
if (!currentEditingWorkType) return;
// 이 공정에 속한 작업이 있는지 확인
const relatedTasks = tasks.filter(t => t.work_type_id === currentEditingWorkType.id);
if (relatedTasks.length > 0) {
showToast(`이 공정에 ${relatedTasks.length}개의 작업이 연결되어 있어 삭제할 수 없습니다.`, 'error');
return;
}
if (!confirm(`"${currentEditingWorkType.name}" 공정을 삭제하시겠습니까?`)) {
return;
}
try {
const response = await window.apiCall(`/daily-work-reports/work-types/${currentEditingWorkType.id}`, 'DELETE');
if (response && response.success) {
showToast('공정이 삭제되었습니다.', 'success');
closeWorkTypeModal();
await loadAllData();
} else {
throw new Error(response.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error(' 공정 삭제 오류:', error);
showToast('공정 삭제 중 오류가 발생했습니다.', 'error');
}
}
window.deleteWorkType = deleteWorkType;

View File

@@ -0,0 +1,625 @@
/**
* TBM 모바일 위자드 - tbm-create.js
* 3단계 위자드로 TBM 세션을 생성하는 모바일 전용 페이지 로직
* Step 1: 작업자 선택, Step 2: 프로젝트+공정 선택, Step 3: 확인
* (작업/작업장은 생성 후 세부 편집 단계에서 입력)
*/
(function() {
'use strict';
// ==================== 위자드 상태 ====================
const W = {
step: 1,
totalSteps: 3,
sessionDate: null,
leaderId: null,
leaderName: '',
workers: new Set(), // user_id Set
workerNames: {}, // { user_id: worker_name }
projectId: null,
projectName: '',
workTypeId: null,
workTypeName: '',
showAddWorkType: false,
todayAssignments: null // 당일 배정 현황 캐시
};
const esc = window.escapeHtml || function(s) { return s || ''; };
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
// apiCall이 준비될 때까지 대기
await waitForApi();
// 초기 데이터 로드
await window.TbmAPI.loadInitialData();
// 기본 정보 자동 설정
W.sessionDate = window.TbmUtils.getTodayKST();
var user = window.TbmState.getUser();
if (user) {
var uid = user.user_id || user.id;
if (uid) {
var worker = window.TbmState.allWorkers.find(function(w) { return String(w.user_id) === String(uid); });
if (worker) {
W.leaderId = worker.user_id;
W.leaderName = worker.worker_name;
} else {
W.leaderId = uid;
W.leaderName = user.name || '';
}
} else {
W.leaderName = user.name || '';
}
}
// 로딩 해제
document.getElementById('loadingOverlay').style.display = 'none';
// 첫 스텝 렌더링
renderStep(1);
updateIndicator();
updateNav();
} catch (error) {
console.error('초기화 오류:', error);
document.getElementById('loadingOverlay').style.display = 'none';
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
// waitForApi → api-base.js 전역 사용
// ==================== 네비게이션 ====================
window.nextStep = function() {
console.log('[TBM Create] nextStep called, current step:', W.step, 'workTypeId:', W.workTypeId);
if (!validateStep(W.step)) return;
if (W.step < W.totalSteps) {
W.step++;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.prevStep = function() {
console.log('[TBM Create] prevStep called, current step:', W.step);
if (W.step > 1) {
W.step--;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.goBack = function() {
if (W.step > 1) {
window.prevStep();
} else {
window.location.href = '/pages/work/tbm-mobile.html';
}
};
function updateIndicator() {
var steps = document.querySelectorAll('#stepIndicator .step');
var lines = document.querySelectorAll('#stepIndicator .step-line');
steps.forEach(function(el, i) {
el.classList.remove('active', 'completed');
if (i + 1 === W.step) {
el.classList.add('active');
} else if (i + 1 < W.step) {
el.classList.add('completed');
}
});
lines.forEach(function(el, i) {
el.style.background = (i + 1 < W.step) ? '#10b981' : '#e5e7eb';
});
}
// 네비게이션 버튼: 단일 핸들러 (DOM 교체 없이 상태 기반 분기)
var _navAction = { prev: null, next: null };
function updateNav() {
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
if (W.step === 1) {
prevBtn.style.visibility = 'hidden';
_navAction.prev = null;
} else {
prevBtn.style.visibility = 'visible';
_navAction.prev = window.prevStep;
}
if (W.step === W.totalSteps) {
nextBtn.className = 'nav-btn nav-btn-save';
nextBtn.textContent = '저장';
_navAction.next = saveWizard;
} else {
nextBtn.className = 'nav-btn nav-btn-next';
nextBtn.innerHTML = '다음 &#8594;';
_navAction.next = window.nextStep;
}
nextBtn.disabled = false;
}
// 한번만 등록하는 이벤트 리스너
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('prevBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.prev) _navAction.prev();
});
document.getElementById('nextBtn').addEventListener('click', function(e) {
e.preventDefault();
if (_navAction.next) _navAction.next();
});
});
// ==================== 유효성 검사 ====================
function validateStep(step) {
switch (step) {
case 1: // 작업자 선택
if (W.workers.size === 0) {
showToast('최소 1명의 작업자를 선택해주세요.', 'warning');
return false;
}
return true;
case 2: // 프로젝트 + 공정
if (!W.workTypeId) {
showToast('공정을 선택해주세요.', 'warning');
return false;
}
return true;
default:
return true;
}
}
// ==================== 스텝 렌더링 ====================
function renderStep(step) {
var container = document.getElementById('stepContainer');
switch (step) {
case 1: renderStepWorkers(container); break;
case 2: renderStepProjectAndWorkType(container); break;
case 3: renderStepConfirm(container); break;
}
}
// --- Step 1: 작업자 선택 ---
async function renderStepWorkers(container) {
var workers = window.TbmState.allWorkers;
// 당일 배정 현황 로드 (첫 로드 시)
if (!W.todayAssignments) {
try {
var today = window.TbmUtils.getTodayKST();
var res = await window.apiCall('/tbm/sessions/date/' + today + '/assignments');
if (res && res.success) {
W.todayAssignments = {};
res.data.forEach(function(a) {
if (a.sessions && a.sessions.length > 0) {
W.todayAssignments[a.user_id] = a;
}
});
} else {
W.todayAssignments = {};
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
W.todayAssignments = {};
}
}
var workerCards = workers.map(function(w) {
var selected = W.workers.has(w.user_id) ? ' selected' : '';
var assignment = W.todayAssignments[w.user_id];
var assigned = assignment && assignment.sessions && assignment.sessions.length > 0;
var badgeHtml = '';
var disabledClass = '';
var onclick = 'toggleWorker(' + w.user_id + ')';
if (assigned) {
// 이미 배정됨 - 선택 불가
var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', ');
badgeHtml = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">배정됨 - ' + esc(leaderNames) + ' TBM</div>';
disabledClass = ' disabled';
onclick = '';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') +
' data-wid="' + w.user_id + '"' +
' style="' + (assigned ? 'opacity:0.5; pointer-events:none;' : '') + '">' +
'<div class="worker-check">&#10003;</div>' +
'<div class="worker-info">' +
'<div class="worker-name">' + esc(w.worker_name) + '</div>' +
'<div class="worker-type">' + esc(w.job_type || '작업자') + '</div>' +
badgeHtml +
'</div>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">1</span>작업자 선택</div>' +
'<div class="select-all-bar">' +
'<span class="count" id="workerCount">' + W.workers.size + '명 선택</span>' +
'<button type="button" class="select-all-btn" onclick="toggleAllWorkers()">' +
(W.workers.size === workers.length ? '전체 해제' : '전체 선택') +
'</button>' +
'</div>' +
'<div class="worker-grid">' + workerCards + '</div>' +
'</div>';
}
window.toggleWorker = function(workerId) {
// 이미 배정된 작업자는 선택 불가
var a = W.todayAssignments && W.todayAssignments[workerId];
if (a && a.sessions && a.sessions.length > 0) return;
if (W.workers.has(workerId)) {
W.workers.delete(workerId);
delete W.workerNames[workerId];
} else {
W.workers.add(workerId);
var w = window.TbmState.allWorkers.find(function(x) { return x.user_id === workerId; });
if (w) W.workerNames[workerId] = w.worker_name;
}
var card = document.querySelector('[data-wid="' + workerId + '"]');
if (card) card.classList.toggle('selected');
var countEl = document.getElementById('workerCount');
if (countEl) countEl.textContent = W.workers.size + '명 선택';
};
window.toggleAllWorkers = function() {
var workers = window.TbmState.allWorkers;
var availableWorkers = workers.filter(function(w) {
var a = W.todayAssignments && W.todayAssignments[w.user_id];
return !(a && a.sessions && a.sessions.length > 0);
});
if (W.workers.size === availableWorkers.length) {
W.workers.clear();
W.workerNames = {};
} else {
availableWorkers.forEach(function(w) {
W.workers.add(w.user_id);
W.workerNames[w.user_id] = w.worker_name;
});
}
renderStepWorkers(document.getElementById('stepContainer'));
};
// --- Step 2: 프로젝트 + 공정 선택 (통합) ---
function renderStepProjectAndWorkType(container) {
var projects = window.TbmState.allProjects;
var workTypes = window.TbmState.allWorkTypes;
// 프로젝트 선택 UI
var skipSelected = W.projectId === null ? ' selected' : '';
var projectItems = projects.map(function(p) {
var selected = W.projectId === p.project_id ? ' selected' : '';
return '<div class="list-item' + selected + '" data-action="selectProject" data-project-id="' + p.project_id + '" data-project-name="' + esc(p.project_name) + '">' +
'<div class="item-title">' + esc(p.project_name) + '</div>' +
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
'</div>';
}).join('');
// 공정 pill 버튼
var pillHtml = workTypes.map(function(wt) {
var selected = W.workTypeId === wt.id ? ' selected' : '';
return '<button type="button" class="pill-btn' + selected + '" data-action="selectWorkType" data-wt-id="' + wt.id + '" data-wt-name="' + esc(wt.name) + '">' + esc(wt.name) + '</button>';
}).join('');
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
// 공정 인라인 추가 폼
var addWorkTypeFormHtml = '';
if (W.showAddWorkType) {
addWorkTypeFormHtml =
'<div class="inline-add-form" id="addWorkTypeForm">' +
'<input type="text" id="newWorkTypeName" placeholder="새 공정명 입력" autocomplete="off">' +
'<div class="inline-add-btns">' +
'<button type="button" class="btn-cancel" onclick="cancelAddWorkType()">취소</button>' +
'<button type="button" class="btn-save" id="btnSaveWorkType" onclick="saveNewWorkType()">저장</button>' +
'</div>' +
'</div>';
}
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
'<div class="list-item-skip' + skipSelected + '" data-action="selectProject" data-project-id="" data-project-name="">' +
'선택 안함' +
'</div>' +
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>공정 선택 <span style="font-size:0.75rem;font-weight:400;color:#ef4444;">(필수)</span></div>' +
'<div class="pill-grid">' + pillHtml + '</div>' +
addWorkTypeFormHtml +
'</div>';
// 자동 포커스
if (W.showAddWorkType) {
var inp = document.getElementById('newWorkTypeName');
if (inp) {
setTimeout(function() { inp.focus(); }, 50);
inp.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); saveNewWorkType(); }
if (e.key === 'Escape') { cancelAddWorkType(); }
};
}
}
// Event delegation for project/workType selection
container.onclick = function(e) {
var el = e.target.closest('[data-action]');
if (!el) return;
var action = el.getAttribute('data-action');
if (action === 'selectProject') {
var pid = el.getAttribute('data-project-id');
selectProject(pid ? parseInt(pid) : null, el.getAttribute('data-project-name') || '');
} else if (action === 'selectWorkType') {
selectWorkType(parseInt(el.getAttribute('data-wt-id')), el.getAttribute('data-wt-name') || '');
}
};
}
window.selectProject = function(projectId, projectName) {
W.projectId = projectId;
W.projectName = projectName || '';
// Update project list items
document.querySelectorAll('#stepContainer .list-item, #stepContainer .list-item-skip').forEach(function(el) {
el.classList.remove('selected');
});
if (projectId === null) {
var skipEl = document.querySelector('#stepContainer .list-item-skip');
if (skipEl) skipEl.classList.add('selected');
} else {
document.querySelectorAll('#stepContainer .list-item').forEach(function(el) {
var title = el.querySelector('.item-title');
if (title && title.textContent === projectName) {
el.classList.add('selected');
}
});
}
};
window.selectWorkType = function(id, name) {
console.log('[TBM Create] selectWorkType:', id, name);
W.workTypeId = id;
W.workTypeName = name;
// Update pill buttons
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
el.classList.remove('selected');
});
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
if (el.textContent === name) {
el.classList.add('selected');
}
});
};
// --- Step 2: 인라인 추가 (공정) ---
window.toggleAddWorkType = function() {
W.showAddWorkType = !W.showAddWorkType;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.cancelAddWorkType = function() {
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.saveNewWorkType = async function() {
var inp = document.getElementById('newWorkTypeName');
var btn = document.getElementById('btnSaveWorkType');
if (!inp || !btn) return;
var name = inp.value.trim();
if (!name) {
showToast('공정명을 입력해주세요.', 'warning');
inp.focus();
return;
}
var exists = window.TbmState.allWorkTypes.some(function(wt) {
return wt.name.toLowerCase() === name.toLowerCase();
});
if (exists) {
showToast('이미 존재하는 공정명입니다.', 'warning');
inp.focus();
return;
}
btn.disabled = true;
btn.textContent = '저장 중...';
try {
var response = await window.apiCall('/daily-work-reports/work-types', 'POST', { name: name });
if (!response || !response.success) {
throw new Error(response?.message || '공정 추가 실패');
}
var newItem = response.data;
window.TbmState.allWorkTypes.push(newItem);
W.workTypeId = newItem.id;
W.workTypeName = newItem.name;
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
showToast('\'' + name + '\' 공정이 추가되었습니다.', 'success');
} catch (error) {
console.error('공정 추가 오류:', error);
showToast('공정 추가 중 오류: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = '저장';
}
};
// --- Step 3: 확인 ---
function renderStepConfirm(container) {
var dateDisplay = window.TbmUtils.formatDateFull(W.sessionDate);
// 작업자 이름 목록
var workerNameList = [];
W.workers.forEach(function(wid) {
workerNameList.push(W.workerNames[wid] || '작업자');
});
var summaryHtml =
'<div class="summary-card">' +
'<div class="summary-row"><span class="summary-label">날짜</span><span class="summary-value">' + esc(dateDisplay) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">입력자</span><span class="summary-value">' + esc(W.leaderName || '(미설정)') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">프로젝트</span><span class="summary-value">' + esc(W.projectName || '선택 안함') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">공정</span><span class="summary-value">' + esc(W.workTypeName) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">작업자</span><span class="summary-value">' + W.workers.size + '명</span></div>' +
'</div>';
// 작업자 목록 (간단 표시)
var workerListHtml = workerNameList.map(function(name) {
return '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:#f9fafb;border-radius:0.5rem;margin-bottom:0.25rem;">' +
'<span style="font-size:0.875rem;font-weight:500;color:#1f2937;">' + esc(name) + '</span>' +
'<span style="font-size:0.6875rem;color:#9ca3af;margin-left:auto;">세부 미입력</span>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">3</span>확인</div>' +
summaryHtml +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title">작업자 목록</div>' +
'<div style="padding:0.5rem;background:#fff7ed;border:1px solid #fed7aa;border-radius:0.5rem;margin-bottom:0.75rem;font-size:0.8125rem;color:#c2410c;">' +
'저장 후 TBM 카드를 탭하면 작업자별 작업/작업장을 입력할 수 있습니다.' +
'</div>' +
workerListHtml +
'</div>';
}
// ==================== 저장 ====================
var _saving = false;
async function saveWizard() {
if (_saving) return;
_saving = true;
// 로딩 오버레이 표시
var overlay = document.getElementById('loadingOverlay');
var loadingText = document.getElementById('loadingText');
if (overlay) {
if (loadingText) loadingText.textContent = '저장 중...';
overlay.style.display = 'flex';
}
// 저장 버튼 비활성화
var saveBtn = document.getElementById('nextBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
}
try {
var leaderId = W.leaderId ? parseInt(W.leaderId) : null;
// 1. TBM 세션 생성
var sessionData = {
session_date: W.sessionDate,
leader_user_id: leaderId
};
var response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
var sessionId = response.data.session_id;
// 2. 팀원 일괄 추가 (task_id, workplace_id = null)
var members = [];
W.workers.forEach(function(wid) {
members.push({
user_id: wid,
project_id: W.projectId,
work_type_id: W.workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
var teamResponse = await window.apiCall(
'/tbm/sessions/' + sessionId + '/team/batch',
'POST',
{ members: members }
);
if (!teamResponse || !teamResponse.success) {
var err = new Error(teamResponse?.message || '팀원 추가 실패');
if (teamResponse && teamResponse.duplicates) err.duplicates = teamResponse.duplicates;
err._sessionId = sessionId;
throw err;
}
showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success');
// 3. tbm-mobile.html로 이동
setTimeout(function() {
window.location.href = '/pages/work/tbm-mobile.html';
}, 1000);
} catch (error) {
console.error('TBM 저장 오류:', error);
// 409 중복 배정 에러 처리
if (error.duplicates && error.duplicates.length > 0) {
// 고아 세션 삭제
if (error._sessionId) {
try { await window.apiCall('/tbm/sessions/' + error._sessionId, 'DELETE'); } catch(e) {}
}
// 중복 작업자 자동 해제
error.duplicates.forEach(function(d) {
W.workers.delete(d.user_id);
delete W.workerNames[d.user_id];
});
// 배정 현황 캐시 갱신
W.todayAssignments = null;
// Step 1로 복귀
W.step = 1;
renderStep(1);
updateIndicator();
updateNav();
showToast(error.message, 'error');
} else {
showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error');
}
if (overlay) overlay.style.display = 'none';
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '저장';
}
_saving = false;
}
}
// ==================== 토스트 (로컬) ====================
function showToast(message, type) {
if (window.showToast && typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
console.log('[Toast] ' + type + ': ' + message);
}
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
/**
* TBM - API Client
* TBM 관련 모든 API 호출을 관리
*/
class TbmAPI {
constructor() {
this.state = window.TbmState;
this.utils = window.TbmUtils;
console.log('[TbmAPI] 초기화 완료');
}
/**
* 초기 데이터 로드 (작업자, 프로젝트, 안전 체크리스트, 공정, 작업, 작업장)
*/
async loadInitialData() {
try {
// 현재 로그인한 사용자 정보 가져오기
const userInfo = JSON.parse(localStorage.getItem('sso_user') || '{}');
this.state.currentUser = userInfo;
// 병렬로 데이터 로드
await Promise.all([
this.loadWorkers(),
this.loadProjects(),
this.loadSafetyChecks(),
this.loadWorkTypes(),
this.loadTasks(),
this.loadWorkplaces(),
this.loadWorkplaceCategories()
]);
} catch (error) {
console.error(' 초기 데이터 로드 오류:', error);
window.showToast?.('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 작업자 목록 로드 (생산팀 소속만)
*/
async loadWorkers() {
try {
const response = await window.apiCall('/workers?limit=1000&department_id=1');
if (response) {
let workers = Array.isArray(response) ? response : (response.data || []);
// 활성 상태인 작업자만 필터링
workers = workers.filter(w => w.status === 'active' && w.employment_status === 'employed');
this.state.allWorkers = workers;
return workers;
}
} catch (error) {
console.error(' 작업자 로딩 오류:', error);
throw error;
}
}
/**
* 프로젝트 목록 로드 (활성 프로젝트만)
*/
async loadProjects() {
try {
const response = await window.apiCall('/projects?is_active=1');
if (response) {
const projects = Array.isArray(response) ? response : (response.data || []);
this.state.allProjects = projects.filter(p =>
p.is_active === 1 || p.is_active === true || p.is_active === '1'
);
return this.state.allProjects;
}
} catch (error) {
console.error(' 프로젝트 로딩 오류:', error);
throw error;
}
}
/**
* 안전 체크리스트 로드
*/
async loadSafetyChecks() {
try {
const response = await window.apiCall('/tbm/safety-checks');
if (response && response.success) {
this.state.allSafetyChecks = response.data;
return this.state.allSafetyChecks;
}
} catch (error) {
console.error(' 안전 체크리스트 로딩 오류:', error);
}
}
/**
* 공정(Work Types) 목록 로드
*/
async loadWorkTypes() {
try {
const response = await window.apiCall('/daily-work-reports/work-types');
if (response && response.success) {
this.state.allWorkTypes = response.data || [];
return this.state.allWorkTypes;
}
} catch (error) {
console.error(' 공정 로딩 오류:', error);
}
}
/**
* 작업(Tasks) 목록 로드
*/
async loadTasks() {
try {
const response = await window.apiCall('/tasks/active/list');
if (response && response.success) {
this.state.allTasks = response.data || [];
return this.state.allTasks;
}
} catch (error) {
console.error(' 작업 로딩 오류:', error);
}
}
/**
* 작업장 목록 로드
*/
async loadWorkplaces() {
try {
const response = await window.apiCall('/workplaces?is_active=true');
if (response && response.success) {
this.state.allWorkplaces = response.data || [];
return this.state.allWorkplaces;
}
} catch (error) {
console.error(' 작업장 로딩 오류:', error);
}
}
/**
* 작업장 카테고리 로드
*/
async loadWorkplaceCategories() {
try {
const response = await window.apiCall('/workplaces/categories/active/list');
if (response && response.success) {
this.state.allWorkplaceCategories = response.data || [];
return this.state.allWorkplaceCategories;
}
} catch (error) {
console.error(' 작업장 카테고리 로딩 오류:', error);
}
}
/**
* 오늘의 TBM만 로드 (TBM 입력 탭용)
*/
async loadTodayOnlyTbm() {
const today = this.utils.getTodayKST();
try {
const response = await window.apiCall(`/tbm/sessions/date/${today}`);
if (response && response.success) {
this.state.todaySessions = response.data || [];
} else {
this.state.todaySessions = [];
}
return this.state.todaySessions;
} catch (error) {
console.error(' 오늘 TBM 조회 오류:', error);
window.showToast?.('오늘 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.todaySessions = [];
return [];
}
}
/**
* 최근 TBM을 날짜별로 그룹화하여 로드
*/
async loadRecentTbmGroupedByDate() {
try {
const today = new Date();
const dates = [];
// 최근 N일의 날짜 생성
for (let i = 0; i < this.state.loadedDaysCount; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
dates.push(dateStr);
}
// 각 날짜의 TBM 로드
this.state.dateGroupedSessions = {};
this.state.allLoadedSessions = [];
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
const results = await Promise.all(promises);
results.forEach((response, index) => {
const date = dates[index];
if (response && response.success && response.data && response.data.length > 0) {
const sessions = response.data;
if (sessions.length > 0) {
this.state.dateGroupedSessions[date] = sessions;
this.state.allLoadedSessions = this.state.allLoadedSessions.concat(sessions);
}
}
});
return this.state.dateGroupedSessions;
} catch (error) {
console.error(' TBM 날짜별 로드 오류:', error);
window.showToast?.('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.dateGroupedSessions = {};
return {};
}
}
/**
* 특정 날짜의 TBM 세션 목록 로드
*/
async loadTbmSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
this.state.allSessions = response.data || [];
} else {
this.state.allSessions = [];
}
return this.state.allSessions;
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
window.showToast?.('TBM 세션을 불러오는 중 오류가 발생했습니다.', 'error');
this.state.allSessions = [];
return [];
}
}
/**
* TBM 세션 생성
*/
async createTbmSession(sessionData) {
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
return response;
} catch (error) {
console.error(' TBM 세션 생성 오류:', error);
throw error;
}
}
/**
* TBM 세션 정보 조회
*/
async getSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`);
if (!response || !response.success) {
throw new Error(response?.message || '세션 조회 실패');
}
return response.data;
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 조회
*/
async getTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 조회 실패');
}
return response.data || [];
} catch (error) {
console.error(' TBM 팀원 조회 오류:', error);
throw error;
}
}
/**
* TBM 팀원 일괄 추가
*/
async addTeamMembers(sessionId, members) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team/batch`,
'POST',
{ members }
);
if (!response || !response.success) {
const err = new Error(response?.message || '팀원 추가 실패');
if (response && response.duplicates) err.duplicates = response.duplicates;
throw err;
}
return response;
} catch (error) {
console.error(' TBM 팀원 추가 오류:', error);
throw error;
}
}
/**
* TBM 팀원 전체 삭제
*/
async clearTeamMembers(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/team/clear`, 'DELETE');
return response;
} catch (error) {
console.error(' TBM 팀원 삭제 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 조회
*/
async getSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
return response?.data || [];
} catch (error) {
console.error(' 안전 체크 조회 오류:', error);
return [];
}
}
/**
* TBM 안전 체크 (필터링된) 조회
*/
async getFilteredSafetyChecks(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
if (!response || !response.success) {
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
}
return response.data;
} catch (error) {
console.error(' 필터링된 안전 체크 조회 오류:', error);
throw error;
}
}
/**
* TBM 안전 체크 저장
*/
async saveSafetyChecks(sessionId, records) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/safety`,
'POST',
{ records }
);
if (!response || !response.success) {
throw new Error(response?.message || '저장 실패');
}
return response;
} catch (error) {
console.error(' 안전 체크 저장 오류:', error);
throw error;
}
}
/**
* TBM 세션 완료 처리
*/
async completeTbmSession(sessionId, endTime) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/complete`,
'POST',
{ end_time: endTime }
);
if (!response || !response.success) {
throw new Error(response?.message || '완료 처리 실패');
}
return response;
} catch (error) {
console.error(' TBM 완료 처리 오류:', error);
throw error;
}
}
/**
* 작업 인계 저장
*/
async saveHandover(handoverData) {
try {
const response = await window.apiCall('/tbm/handovers', 'POST', handoverData);
if (!response || !response.success) {
throw new Error(response?.message || '인계 요청 실패');
}
return response;
} catch (error) {
console.error(' 작업 인계 저장 오류:', error);
throw error;
}
}
/**
* 카테고리별 작업장 로드
*/
async loadWorkplacesByCategory(categoryId) {
try {
const response = await window.apiCall(`/workplaces?category_id=${categoryId}`);
if (!response || !response.success || !response.data) {
return [];
}
return response.data;
} catch (error) {
console.error(' 작업장 로드 오류:', error);
return [];
}
}
/**
* 작업장 지도 영역 로드
*/
async loadMapRegions(categoryId) {
try {
const response = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`);
if (response && response.success) {
this.state.mapRegions = response.data || [];
return this.state.mapRegions;
}
return [];
} catch (error) {
console.error(' 지도 영역 로드 오류:', error);
return [];
}
}
/**
* TBM 세션 삭제
*/
async deleteSession(sessionId) {
try {
const response = await window.apiCall(`/tbm/sessions/${sessionId}`, 'DELETE');
if (!response || !response.success) {
throw new Error(response?.message || '삭제 실패');
}
return response;
} catch (error) {
console.error(' TBM 세션 삭제 오류:', error);
throw error;
}
}
/**
* TBM 팀원 분할 배정
*/
async splitAssignment(sessionId, splitData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team/split`,
'POST',
splitData
);
if (!response || !response.success) {
throw new Error(response?.message || '분할 실패');
}
return response;
} catch (error) {
console.error(' 분할 배정 오류:', error);
throw error;
}
}
/**
* TBM 팀원 단일 추가/수정 (POST /team)
*/
async updateTeamMember(sessionId, memberData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/team`,
'POST',
memberData
);
if (!response || !response.success) {
throw new Error(response?.message || '팀원 수정 실패');
}
return response;
} catch (error) {
console.error(' 팀원 수정 오류:', error);
throw error;
}
}
/**
* TBM 인원 이동 (분할→이동 / 빼오기)
*/
async transfer(transferData) {
try {
const response = await window.apiCall('/tbm/transfers', 'POST', transferData);
if (!response || !response.success) {
throw new Error(response?.message || '이동 실패');
}
return response;
} catch (error) {
console.error(' TBM 이동 오류:', error);
throw error;
}
}
/**
* 작업 생성
*/
async createTask(taskData) {
try {
const response = await window.apiCall('/tasks', 'POST', taskData);
if (!response || !response.success) {
throw new Error(response?.message || '작업 생성 실패');
}
return response;
} catch (error) {
console.error(' 작업 생성 오류:', error);
throw error;
}
}
/**
* 활성 작업장 전체 목록 (active/list)
*/
async loadActiveWorkplacesList() {
try {
const response = await window.apiCall('/workplaces/active/list');
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' 활성 작업장 목록 오류:', error);
return [];
}
}
/**
* 당일 배정 현황 조회
*/
async loadTodayAssignments(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}/assignments`);
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' 배정 현황 조회 오류:', error);
return [];
}
}
/**
* 특정 날짜의 TBM 세션 조회 (raw - 상태 변경 없음)
*/
async fetchSessionsByDate(date) {
try {
const response = await window.apiCall(`/tbm/sessions/date/${date}`);
if (response && response.success) {
return response.data || [];
}
return [];
} catch (error) {
console.error(' TBM 세션 조회 오류:', error);
return [];
}
}
/**
* TBM 세션 완료 처리 (근태 정보 포함)
*/
async completeTbmWithAttendance(sessionId, attendanceData) {
try {
const response = await window.apiCall(
`/tbm/sessions/${sessionId}/complete`,
'POST',
{ attendance: attendanceData }
);
if (!response || !response.success) {
throw new Error(response?.message || '완료 처리 실패');
}
return response;
} catch (error) {
console.error(' TBM 완료 처리 오류:', error);
throw error;
}
}
}
// 전역 인스턴스 생성
window.TbmAPI = new TbmAPI();
// 하위 호환성: 기존 함수들
window.loadInitialData = () => window.TbmAPI.loadInitialData();
window.loadTodayOnlyTbm = () => window.TbmAPI.loadTodayOnlyTbm();
window.loadTodayTbm = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadAllTbm = () => {
window.TbmState.loadedDaysCount = 30;
return window.TbmAPI.loadRecentTbmGroupedByDate();
};
window.loadRecentTbmGroupedByDate = () => window.TbmAPI.loadRecentTbmGroupedByDate();
window.loadTbmSessionsByDate = (date) => window.TbmAPI.loadTbmSessionsByDate(date);
window.loadWorkplaceCategories = () => window.TbmAPI.loadWorkplaceCategories();
window.loadWorkplacesByCategory = (categoryId) => window.TbmAPI.loadWorkplacesByCategory(categoryId);
// 더 많은 날짜 로드
window.loadMoreTbmDays = async function() {
window.TbmState.loadedDaysCount += 7;
await window.TbmAPI.loadRecentTbmGroupedByDate();
window.showToast?.(`최근 ${window.TbmState.loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
};
window.deleteTbmSession = (sessionId) => window.TbmAPI.deleteSession(sessionId);
window.fetchSessionsByDate = (date) => window.TbmAPI.fetchSessionsByDate(date);
console.log('[Module] tbm/api.js 로드 완료');

View File

@@ -0,0 +1,322 @@
/**
* TBM - State Manager
* TBM 페이지의 전역 상태 관리 (BaseState 상속)
*/
class TbmState extends BaseState {
constructor() {
super();
// 세션 데이터
this.allSessions = [];
this.todaySessions = [];
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
this.loadedDaysCount = 7;
// 마스터 데이터
this.allWorkers = [];
this.allProjects = [];
this.allWorkTypes = [];
this.allTasks = [];
this.allSafetyChecks = [];
this.allWorkplaces = [];
this.allWorkplaceCategories = [];
// 현재 상태
this.currentUser = null;
this.currentSessionId = null;
this.currentTab = 'tbm-input';
// 작업자 관련
this.selectedWorkers = new Set();
this.workerTaskList = [];
this.selectedWorkersInModal = new Set();
this.currentEditingTaskLine = null;
// 작업장 선택 관련
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
// 일괄 설정 관련
this.isBulkMode = false;
this.bulkSelectedWorkers = new Set();
// 지도 관련
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
console.log('[TbmState] 초기화 완료');
}
/**
* Admin 여부 확인
*/
isAdminUser() {
const user = this.getUser();
if (!user) return false;
const role = (user.role || '').toLowerCase();
return role === 'admin' || role === 'system admin' || role === 'system';
}
/**
* 탭 변경
*/
setCurrentTab(tab) {
const prevTab = this.currentTab;
this.currentTab = tab;
this.notifyListeners('currentTab', tab, prevTab);
}
/**
* 작업자 목록에 추가
*/
addWorkerToList(worker) {
this.workerTaskList.push({
user_id: worker.user_id,
worker_name: worker.worker_name,
job_type: worker.job_type,
tasks: [this.createEmptyTaskLine()]
});
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
/**
* 빈 작업 라인 생성
*/
createEmptyTaskLine() {
return {
task_line_id: window.CommonUtils.generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
workplace_category_id: null,
workplace_id: null,
workplace_category_name: '',
workplace_name: '',
work_detail: null,
is_present: true
};
}
/**
* 작업자에 작업 라인 추가
*/
addTaskLineToWorker(workerIndex) {
if (this.workerTaskList[workerIndex]) {
this.workerTaskList[workerIndex].tasks.push(this.createEmptyTaskLine());
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업 라인 제거
*/
removeTaskLine(workerIndex, taskIndex) {
if (this.workerTaskList[workerIndex]?.tasks) {
this.workerTaskList[workerIndex].tasks.splice(taskIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
}
}
/**
* 작업자 제거
*/
removeWorkerFromList(workerIndex) {
const removed = this.workerTaskList.splice(workerIndex, 1);
this.notifyListeners('workerTaskList', this.workerTaskList, null);
return removed[0];
}
/**
* 작업장 선택 초기화
*/
resetWorkplaceSelection() {
this.selectedCategory = null;
this.selectedWorkplace = null;
this.selectedCategoryName = '';
this.selectedWorkplaceName = '';
this.mapCanvas = null;
this.mapCtx = null;
this.mapImage = null;
this.mapRegions = [];
}
/**
* 일괄 설정 초기화
*/
resetBulkSettings() {
this.isBulkMode = false;
this.bulkSelectedWorkers.clear();
}
/**
* 날짜별 세션 그룹화
*/
groupSessionsByDate(sessions) {
this.dateGroupedSessions = {};
this.allLoadedSessions = [];
sessions.forEach(session => {
const date = window.CommonUtils.formatDate(session.session_date);
if (!this.dateGroupedSessions[date]) {
this.dateGroupedSessions[date] = [];
}
this.dateGroupedSessions[date].push(session);
this.allLoadedSessions.push(session);
});
}
/**
* 상태 초기화
*/
reset() {
this.workerTaskList = [];
this.selectedWorkers.clear();
this.selectedWorkersInModal.clear();
this.currentEditingTaskLine = null;
this.resetWorkplaceSelection();
this.resetBulkSettings();
}
/**
* 디버그 출력
*/
debug() {
console.log('[TbmState] 현재 상태:', {
allSessions: this.allSessions.length,
todaySessions: this.todaySessions.length,
allWorkers: this.allWorkers.length,
allProjects: this.allProjects.length,
workerTaskList: this.workerTaskList.length,
currentTab: this.currentTab
});
}
}
// 전역 인스턴스 생성
window.TbmState = new TbmState();
// 하위 호환성을 위한 전역 변수 프록시
const tbmStateProxy = window.TbmState;
Object.defineProperties(window, {
allSessions: {
get: () => tbmStateProxy.allSessions,
set: (v) => { tbmStateProxy.allSessions = v; }
},
todaySessions: {
get: () => tbmStateProxy.todaySessions,
set: (v) => { tbmStateProxy.todaySessions = v; }
},
allWorkers: {
get: () => tbmStateProxy.allWorkers,
set: (v) => { tbmStateProxy.allWorkers = v; }
},
allProjects: {
get: () => tbmStateProxy.allProjects,
set: (v) => { tbmStateProxy.allProjects = v; }
},
allWorkTypes: {
get: () => tbmStateProxy.allWorkTypes,
set: (v) => { tbmStateProxy.allWorkTypes = v; }
},
allTasks: {
get: () => tbmStateProxy.allTasks,
set: (v) => { tbmStateProxy.allTasks = v; }
},
allSafetyChecks: {
get: () => tbmStateProxy.allSafetyChecks,
set: (v) => { tbmStateProxy.allSafetyChecks = v; }
},
allWorkplaces: {
get: () => tbmStateProxy.allWorkplaces,
set: (v) => { tbmStateProxy.allWorkplaces = v; }
},
allWorkplaceCategories: {
get: () => tbmStateProxy.allWorkplaceCategories,
set: (v) => { tbmStateProxy.allWorkplaceCategories = v; }
},
currentUser: {
get: () => tbmStateProxy.currentUser,
set: (v) => { tbmStateProxy.currentUser = v; }
},
currentSessionId: {
get: () => tbmStateProxy.currentSessionId,
set: (v) => { tbmStateProxy.currentSessionId = v; }
},
selectedWorkers: {
get: () => tbmStateProxy.selectedWorkers,
set: (v) => { tbmStateProxy.selectedWorkers = v; }
},
workerTaskList: {
get: () => tbmStateProxy.workerTaskList,
set: (v) => { tbmStateProxy.workerTaskList = v; }
},
selectedWorkersInModal: {
get: () => tbmStateProxy.selectedWorkersInModal,
set: (v) => { tbmStateProxy.selectedWorkersInModal = v; }
},
currentEditingTaskLine: {
get: () => tbmStateProxy.currentEditingTaskLine,
set: (v) => { tbmStateProxy.currentEditingTaskLine = v; }
},
selectedCategory: {
get: () => tbmStateProxy.selectedCategory,
set: (v) => { tbmStateProxy.selectedCategory = v; }
},
selectedWorkplace: {
get: () => tbmStateProxy.selectedWorkplace,
set: (v) => { tbmStateProxy.selectedWorkplace = v; }
},
selectedCategoryName: {
get: () => tbmStateProxy.selectedCategoryName,
set: (v) => { tbmStateProxy.selectedCategoryName = v; }
},
selectedWorkplaceName: {
get: () => tbmStateProxy.selectedWorkplaceName,
set: (v) => { tbmStateProxy.selectedWorkplaceName = v; }
},
isBulkMode: {
get: () => tbmStateProxy.isBulkMode,
set: (v) => { tbmStateProxy.isBulkMode = v; }
},
bulkSelectedWorkers: {
get: () => tbmStateProxy.bulkSelectedWorkers,
set: (v) => { tbmStateProxy.bulkSelectedWorkers = v; }
},
dateGroupedSessions: {
get: () => tbmStateProxy.dateGroupedSessions,
set: (v) => { tbmStateProxy.dateGroupedSessions = v; }
},
allLoadedSessions: {
get: () => tbmStateProxy.allLoadedSessions,
set: (v) => { tbmStateProxy.allLoadedSessions = v; }
},
loadedDaysCount: {
get: () => tbmStateProxy.loadedDaysCount,
set: (v) => { tbmStateProxy.loadedDaysCount = v; }
},
mapRegions: {
get: () => tbmStateProxy.mapRegions,
set: (v) => { tbmStateProxy.mapRegions = v; }
},
mapCanvas: {
get: () => tbmStateProxy.mapCanvas,
set: (v) => { tbmStateProxy.mapCanvas = v; }
},
mapCtx: {
get: () => tbmStateProxy.mapCtx,
set: (v) => { tbmStateProxy.mapCtx = v; }
},
mapImage: {
get: () => tbmStateProxy.mapImage,
set: (v) => { tbmStateProxy.mapImage = v; }
}
});
console.log('[Module] tbm/state.js 로드 완료');

View File

@@ -0,0 +1,127 @@
/**
* TBM - Utilities
* TBM 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class TbmUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[TbmUtils] 초기화 완료');
}
// --- CommonUtils 위임 ---
getTodayKST() { return this._common.getTodayKST(); }
formatDate(dateString) { return this._common.formatDate(dateString); }
getDayOfWeek(dateString) { return this._common.getDayOfWeek(dateString); }
isToday(dateString) { return this._common.isToday(dateString); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
// --- TBM 전용 ---
/**
* 날짜 표시용 포맷 (MM월 DD일)
*/
formatDateDisplay(dateString) {
if (!dateString) return '';
const [year, month, day] = dateString.split('-');
return `${parseInt(month)}${parseInt(day)}`;
}
/**
* 날짜를 연/월/일/요일 형식으로 포맷
*/
formatDateFull(dateString) {
if (!dateString) return '';
const [year, month, day] = dateString.split('-');
const dayName = this._common.getDayOfWeek(dateString);
return `${year}${parseInt(month)}${parseInt(day)}일 (${dayName})`;
}
/**
* 현재 시간을 HH:MM 형식으로 반환
*/
getCurrentTime() {
return new Date().toTimeString().slice(0, 5);
}
/**
* 날씨 조건명 반환
*/
getWeatherConditionName(code) {
const names = {
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
/**
* 날씨 아이콘 반환
*/
getWeatherIcon(code) {
const icons = {
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
return icons[code] || '🌤️';
}
/**
* 카테고리명 반환
*/
getCategoryName(category) {
const names = {
'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경', 'EMERGENCY': '비상 대응',
'WEATHER': '날씨', 'TASK': '작업'
};
return names[category] || category;
}
/**
* 상태 배지 HTML 반환
*/
getStatusBadge(status) {
const badges = {
'draft': '<span class="tbm-card-status draft">진행중</span>',
'completed': '<span class="tbm-card-status completed">완료</span>',
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
};
return badges[status] || '';
}
}
// 전역 인스턴스 생성
window.TbmUtils = new TbmUtils();
// 하위 호환성: TBM 전용 유틸 (showToast, formatDate, waitForApi, generateUUID는 api-base.js 전역)
window.getTodayKST = () => window.TbmUtils.getTodayKST();
// 카테고리별 그룹화
window.groupChecksByCategory = function(checks) {
return checks.reduce((acc, check) => {
const category = check.check_category || 'OTHER';
if (!acc[category]) acc[category] = [];
acc[category].push(check);
return acc;
}, {});
};
// 작업별 그룹화
window.groupChecksByTask = function(checks) {
return checks.reduce((acc, check) => {
const taskId = check.task_id || 0;
const taskName = check.task_name || '기타 작업';
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
acc[taskId].items.push(check);
return acc;
}, {});
};
// Admin 사용자 확인
window.isAdminUser = function() {
return window.TbmState?.isAdminUser() || false;
};
console.log('[Module] tbm/utils.js 로드 완료');

View File

@@ -0,0 +1,93 @@
// /js/user-dashboard.js
import { getUser } from './auth.js';
import { apiGet } from './api-helper.js'; // 개선된 api-helper를 사용합니다.
/**
* API를 호출하여 오늘의 작업 일정을 불러와 화면에 표시합니다.
*/
async function loadTodaySchedule() {
const scheduleContainer = document.getElementById('today-schedule');
scheduleContainer.innerHTML = '<p>📅 오늘의 작업 일정을 불러오는 중...</p>';
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>';
}
}
/**
* 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,855 @@
/**
* vacation-allocation.js
* 휴가 발생 입력 페이지 로직
*/
import { API_BASE_URL } from './api-config.js';
// 전역 변수
let workers = [];
let vacationTypes = [];
let currentWorkerBalances = [];
/**
* 페이지 초기화
*/
document.addEventListener('DOMContentLoaded', async () => {
// 관리자 권한 체크
const user = JSON.parse(localStorage.getItem('sso_user') || '{}');
console.log('Current user:', user);
console.log('Role ID:', user.role_id, 'Role:', user.role);
// role이 'Admin'이거나 role_id가 1 또는 2인 경우 허용
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
if (!isAdmin) {
console.error('Access denied. User:', user);
alert('관리자만 접근할 수 있습니다');
window.location.href = '/pages/dashboard.html';
return;
}
await loadInitialData();
initializeYearSelectors();
initializeTabNavigation();
initializeEventListeners();
});
/**
* 초기 데이터 로드
*/
async function loadInitialData() {
await Promise.all([
loadWorkers(),
loadVacationTypes()
]);
}
/**
* 작업자 목록 로드
*/
async function loadWorkers() {
try {
const token = localStorage.getItem('sso_token');
console.log('Loading workers... Token:', token ? 'exists' : 'missing');
const response = await fetch(`${API_BASE_URL}/api/workers`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('Workers API Response status:', response.status);
if (!response.ok) {
const errorData = await response.json();
console.error('Workers API Error:', errorData);
throw new Error(errorData.message || '작업자 목록 로드 실패');
}
const result = await response.json();
console.log('Workers data:', result);
workers = result.data || [];
if (workers.length === 0) {
console.warn('No workers found in database');
showToast('등록된 작업자가 없습니다', 'warning');
return;
}
// 개별 입력 탭 - 작업자 셀렉트 박스 (부서별 그룹)
const selectWorker = document.getElementById('individualWorker');
const byDept = {};
workers.forEach(worker => {
const dept = worker.department_name || '부서 미지정';
if (!byDept[dept]) byDept[dept] = [];
byDept[dept].push(worker);
});
Object.keys(byDept).sort().forEach(dept => {
const group = document.createElement('optgroup');
group.label = dept;
byDept[dept].forEach(worker => {
const option = document.createElement('option');
option.value = worker.user_id;
option.textContent = `${worker.worker_name} (${worker.employment_status === 'employed' ? '재직' : '퇴사'})`;
group.appendChild(option);
});
selectWorker.appendChild(group);
});
console.log(`Loaded ${workers.length} workers successfully`);
} catch (error) {
console.error('작업자 로드 오류:', error);
showToast(`작업자 목록을 불러오는데 실패했습니다: ${error.message}`, 'error');
}
}
/**
* 휴가 유형 목록 로드
*/
async function loadVacationTypes() {
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-types`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('휴가 유형 로드 실패');
const result = await response.json();
vacationTypes = result.data || [];
// 개별 입력 탭 - 휴가 유형 셀렉트 박스
const selectType = document.getElementById('individualVacationType');
vacationTypes.forEach(type => {
const option = document.createElement('option');
option.value = type.id;
option.textContent = `${type.type_name} ${type.is_special ? '(특별)' : ''}`;
selectType.appendChild(option);
});
// 특별 휴가 관리 탭 테이블 로드
loadSpecialTypesTable();
} catch (error) {
console.error('휴가 유형 로드 오류:', error);
showToast('휴가 유형을 불러오는데 실패했습니다', 'error');
}
}
/**
* 연도 셀렉터 초기화
*/
function initializeYearSelectors() {
const currentYear = new Date().getFullYear();
const yearSelectors = ['individualYear', 'bulkYear'];
yearSelectors.forEach(selectorId => {
const select = document.getElementById(selectorId);
for (let year = currentYear - 1; year <= currentYear + 2; year++) {
const option = document.createElement('option');
option.value = year;
option.textContent = `${year}`;
if (year === currentYear) {
option.selected = true;
}
select.appendChild(option);
}
});
}
/**
* 탭 네비게이션 초기화
*/
function initializeTabNavigation() {
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.dataset.tab;
switchTab(tabName);
});
});
}
/**
* 탭 전환
*/
function switchTab(tabName) {
// 탭 버튼 활성화
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// 탭 콘텐츠 표시
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`tab-${tabName}`).classList.add('active');
}
/**
* 이벤트 리스너 초기화
*/
function initializeEventListeners() {
// === 탭 1: 개별 입력 ===
document.getElementById('individualWorker').addEventListener('change', loadWorkerBalances);
document.getElementById('autoCalculateBtn').addEventListener('click', autoCalculateAnnualLeave);
document.getElementById('individualSubmitBtn').addEventListener('click', submitIndividualVacation);
document.getElementById('individualResetBtn').addEventListener('click', resetIndividualForm);
// === 탭 2: 일괄 입력 ===
document.getElementById('bulkPreviewBtn').addEventListener('click', previewBulkAllocation);
document.getElementById('bulkSubmitBtn').addEventListener('click', submitBulkAllocation);
// === 탭 3: 특별 휴가 관리 ===
document.getElementById('addSpecialTypeBtn').addEventListener('click', () => openVacationTypeModal());
// 모달 닫기
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', closeModals);
});
// 모달 폼 제출
document.getElementById('vacationTypeForm').addEventListener('submit', submitVacationType);
document.getElementById('editBalanceForm').addEventListener('submit', submitEditBalance);
}
// =============================================================================
// 탭 1: 개별 입력
// =============================================================================
/**
* 작업자의 기존 휴가 잔액 로드
*/
async function loadWorkerBalances() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
if (!workerId) {
document.getElementById('individualTableBody').innerHTML = `
<tr><td colspan="8" class="loading-state"><p>작업자를 선택하세요</p></td></tr>
`;
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/worker/${workerId}/year/${year}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('휴가 잔액 로드 실패');
const result = await response.json();
currentWorkerBalances = result.data || [];
updateWorkerBalancesTable();
} catch (error) {
console.error('휴가 잔액 로드 오류:', error);
showToast('휴가 잔액을 불러오는데 실패했습니다', 'error');
}
}
/**
* 작업자 휴가 잔액 테이블 업데이트
*/
function updateWorkerBalancesTable() {
const tbody = document.getElementById('individualTableBody');
if (currentWorkerBalances.length === 0) {
tbody.innerHTML = `
<tr><td colspan="8" class="loading-state"><p>등록된 휴가가 없습니다</p></td></tr>
`;
return;
}
tbody.innerHTML = currentWorkerBalances.map(balance => `
<tr>
<td>${balance.worker_name || '-'}</td>
<td>${balance.year}</td>
<td>${balance.type_name} ${balance.is_special ? '<span class="badge badge-info">특별</span>' : ''}</td>
<td>${balance.total_days}일</td>
<td>${balance.used_days}일</td>
<td>${balance.remaining_days}일</td>
<td>${balance.notes || '-'}</td>
<td class="action-buttons">
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editBalance(${balance.id})">✏️</button>
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteBalance(${balance.id})">🗑️</button>
</td>
</tr>
`).join('');
}
/**
* 자동 계산 (연차만 해당)
*/
async function autoCalculateAnnualLeave() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
const typeId = document.getElementById('individualVacationType').value;
if (!workerId) {
showToast('작업자를 선택하세요', 'warning');
return;
}
// 선택한 휴가 유형이 ANNUAL인지 확인
const selectedType = vacationTypes.find(t => t.id == typeId);
if (!selectedType || selectedType.type_code !== 'ANNUAL') {
showToast('연차(ANNUAL) 유형만 자동 계산이 가능합니다', 'warning');
return;
}
// 작업자의 입사일 조회
const worker = workers.find(w => w.user_id == workerId);
if (!worker || !worker.hire_date) {
showToast('작업자의 입사일 정보가 없습니다', 'error');
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: workerId,
hire_date: worker.hire_date,
year: year
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '자동 계산 실패');
}
// 계산 결과 표시
const resultDiv = document.getElementById('autoCalculateResult');
resultDiv.innerHTML = `
<strong>자동 계산 완료</strong><br>
입사일: ${worker.hire_date}<br>
계산된 연차: ${result.data.calculated_days}일<br>
아래 "총 부여 일수"에 자동으로 입력됩니다.
`;
resultDiv.style.display = 'block';
// 폼에 자동 입력
document.getElementById('individualTotalDays').value = result.data.calculated_days;
document.getElementById('individualNotes').value = `근속년수 기반 자동 계산 (입사일: ${worker.hire_date})`;
showToast(result.message, 'success');
// 기존 데이터 새로고침
await loadWorkerBalances();
} catch (error) {
console.error('자동 계산 오류:', error);
showToast(error.message, 'error');
}
}
/**
* 개별 휴가 제출
*/
async function submitIndividualVacation() {
const workerId = document.getElementById('individualWorker').value;
const year = document.getElementById('individualYear').value;
const typeId = document.getElementById('individualVacationType').value;
const totalDays = document.getElementById('individualTotalDays').value;
const usedDays = document.getElementById('individualUsedDays').value || 0;
const notes = document.getElementById('individualNotes').value;
if (!workerId || !year || !typeId || !totalDays) {
showToast('필수 항목을 모두 입력하세요', 'warning');
return;
}
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: workerId,
vacation_type_id: typeId,
year: year,
total_days: parseFloat(totalDays),
used_days: parseFloat(usedDays),
notes: notes
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '저장 실패');
}
showToast('휴가가 등록되었습니다', 'success');
resetIndividualForm();
await loadWorkerBalances();
} catch (error) {
console.error('휴가 등록 오류:', error);
showToast(error.message, 'error');
}
}
/**
* 개별 입력 폼 초기화
*/
function resetIndividualForm() {
document.getElementById('individualVacationType').value = '';
document.getElementById('individualTotalDays').value = '';
document.getElementById('individualUsedDays').value = '0';
document.getElementById('individualNotes').value = '';
document.getElementById('autoCalculateResult').style.display = 'none';
}
/**
* 휴가 수정 (전역 함수로 노출)
*/
window.editBalance = function(balanceId) {
const balance = currentWorkerBalances.find(b => b.id === balanceId);
if (!balance) return;
document.getElementById('editBalanceId').value = balance.id;
document.getElementById('editTotalDays').value = balance.total_days;
document.getElementById('editUsedDays').value = balance.used_days;
document.getElementById('editNotes').value = balance.notes || '';
document.getElementById('editBalanceModal').classList.add('active');
};
/**
* 휴가 삭제 (전역 함수로 노출)
*/
window.deleteBalance = async function(balanceId) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '삭제 실패');
}
showToast('삭제되었습니다', 'success');
await loadWorkerBalances();
} catch (error) {
console.error('삭제 오류:', error);
showToast(error.message, 'error');
}
};
/**
* 휴가 수정 제출
*/
async function submitEditBalance(e) {
e.preventDefault();
const balanceId = document.getElementById('editBalanceId').value;
const totalDays = document.getElementById('editTotalDays').value;
const usedDays = document.getElementById('editUsedDays').value;
const notes = document.getElementById('editNotes').value;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
total_days: parseFloat(totalDays),
used_days: parseFloat(usedDays),
notes: notes
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '수정 실패');
}
showToast('수정되었습니다', 'success');
closeModals();
await loadWorkerBalances();
} catch (error) {
console.error('수정 오류:', error);
showToast(error.message, 'error');
}
}
// =============================================================================
// 탭 2: 일괄 입력
// =============================================================================
let bulkPreviewData = [];
/**
* 일괄 할당 미리보기
*/
async function previewBulkAllocation() {
const year = document.getElementById('bulkYear').value;
const employmentStatus = document.getElementById('bulkEmploymentStatus').value;
// 필터링된 작업자 목록
let targetWorkers = workers;
if (employmentStatus === 'employed') {
targetWorkers = workers.filter(w => w.employment_status === 'employed');
}
// ANNUAL 유형 찾기
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
if (!annualType) {
showToast('ANNUAL 휴가 유형이 없습니다', 'error');
return;
}
// 미리보기 데이터 생성
bulkPreviewData = targetWorkers.map(worker => {
const hireDate = worker.hire_date;
if (!hireDate) {
return {
user_id: worker.user_id,
worker_name: worker.worker_name,
hire_date: '-',
years_worked: '-',
calculated_days: 0,
reason: '입사일 정보 없음',
status: 'error'
};
}
const calculatedDays = calculateAnnualLeaveDays(hireDate, year);
const yearsWorked = calculateYearsWorked(hireDate, year);
return {
user_id: worker.user_id,
worker_name: worker.worker_name,
hire_date: hireDate,
years_worked: yearsWorked,
calculated_days: calculatedDays,
reason: getCalculationReason(yearsWorked, calculatedDays),
status: 'ready'
};
});
updateBulkPreviewTable();
document.getElementById('bulkPreviewSection').style.display = 'block';
document.getElementById('bulkSubmitBtn').disabled = false;
}
/**
* 연차 일수 계산 (한국 근로기준법)
*/
function calculateAnnualLeaveDays(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
// 1년 미만: 월 1일
if (monthsDiff < 12) {
return Math.floor(monthsDiff);
}
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additionalDays = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additionalDays, 25);
}
/**
* 근속년수 계산
*/
function calculateYearsWorked(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
return (monthsDiff / 12).toFixed(1);
}
/**
* 계산 근거 생성
*/
function getCalculationReason(yearsWorked, days) {
const years = parseFloat(yearsWorked);
if (years < 1) {
return `입사 ${Math.floor(years * 12)}개월 (월 1일)`;
}
if (days === 25) {
return '최대 25일 (근속 3년 이상)';
}
return `근속 ${Math.floor(years)}년 (15일 + ${days - 15}일)`;
}
/**
* 일괄 미리보기 테이블 업데이트
*/
function updateBulkPreviewTable() {
const tbody = document.getElementById('bulkPreviewTableBody');
tbody.innerHTML = bulkPreviewData.map(item => {
const statusBadge = item.status === 'error'
? '<span class="badge badge-error">오류</span>'
: '<span class="badge badge-success">준비</span>';
return `
<tr>
<td>${item.worker_name}</td>
<td>${item.hire_date}</td>
<td>${item.years_worked}년</td>
<td>${item.calculated_days}일</td>
<td>${item.reason}</td>
<td>${statusBadge}</td>
</tr>
`;
}).join('');
}
/**
* 일괄 할당 제출
*/
async function submitBulkAllocation() {
const year = document.getElementById('bulkYear').value;
// 오류가 없는 항목만 필터링
const validItems = bulkPreviewData.filter(item => item.status !== 'error' && item.calculated_days > 0);
if (validItems.length === 0) {
showToast('생성할 항목이 없습니다', 'warning');
return;
}
if (!confirm(`${validItems.length}명의 연차를 생성하시겠습니까?`)) {
return;
}
// ANNUAL 유형 찾기
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
let successCount = 0;
let failCount = 0;
for (const item of validItems) {
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: item.user_id,
hire_date: item.hire_date,
year: year
})
});
if (response.ok) {
successCount++;
} else {
failCount++;
}
} catch (error) {
failCount++;
}
}
showToast(`완료: ${successCount}건 성공, ${failCount}건 실패`, successCount > 0 ? 'success' : 'error');
// 미리보기 초기화
document.getElementById('bulkPreviewSection').style.display = 'none';
document.getElementById('bulkSubmitBtn').disabled = true;
bulkPreviewData = [];
}
// =============================================================================
// 탭 3: 특별 휴가 관리
// =============================================================================
/**
* 특별 휴가 유형 테이블 로드
*/
function loadSpecialTypesTable() {
const tbody = document.getElementById('specialTypesTableBody');
if (vacationTypes.length === 0) {
tbody.innerHTML = `
<tr><td colspan="7" class="loading-state"><p>등록된 휴가 유형이 없습니다</p></td></tr>
`;
return;
}
tbody.innerHTML = vacationTypes.map(type => `
<tr>
<td>${type.type_name}</td>
<td>${type.type_code}</td>
<td>${type.priority}</td>
<td>${type.is_special ? '<span class="badge badge-info">특별</span>' : '-'}</td>
<td>${type.is_system ? '<span class="badge badge-warning">시스템</span>' : '-'}</td>
<td>${type.description || '-'}</td>
<td class="action-buttons">
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>✏️</button>
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>🗑️</button>
</td>
</tr>
`).join('');
}
/**
* 휴가 유형 모달 열기
*/
function openVacationTypeModal(typeId = null) {
const modal = document.getElementById('vacationTypeModal');
const form = document.getElementById('vacationTypeForm');
form.reset();
if (typeId) {
const type = vacationTypes.find(t => t.id === typeId);
if (!type) return;
document.getElementById('modalTitle').textContent = '휴가 유형 수정';
document.getElementById('modalTypeId').value = type.id;
document.getElementById('modalTypeName').value = type.type_name;
document.getElementById('modalTypeCode').value = type.type_code;
document.getElementById('modalPriority').value = type.priority;
document.getElementById('modalIsSpecial').checked = type.is_special === 1;
document.getElementById('modalDescription').value = type.description || '';
} else {
document.getElementById('modalTitle').textContent = '휴가 유형 추가';
document.getElementById('modalTypeId').value = '';
}
modal.classList.add('active');
}
/**
* 휴가 유형 수정 (전역 함수)
*/
window.editVacationType = function(typeId) {
openVacationTypeModal(typeId);
};
/**
* 휴가 유형 삭제 (전역 함수)
*/
window.deleteVacationType = async function(typeId) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem('sso_token');
const response = await fetch(`${API_BASE_URL}/api/vacation-types/${typeId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '삭제 실패');
}
showToast('삭제되었습니다', 'success');
await loadVacationTypes();
} catch (error) {
console.error('삭제 오류:', error);
showToast(error.message, 'error');
}
};
/**
* 휴가 유형 제출
*/
async function submitVacationType(e) {
e.preventDefault();
const typeId = document.getElementById('modalTypeId').value;
const typeName = document.getElementById('modalTypeName').value;
const typeCode = document.getElementById('modalTypeCode').value;
const priority = document.getElementById('modalPriority').value;
const isSpecial = document.getElementById('modalIsSpecial').checked ? 1 : 0;
const description = document.getElementById('modalDescription').value;
const data = {
type_name: typeName,
type_code: typeCode.toUpperCase(),
priority: parseInt(priority),
is_special: isSpecial,
description: description
};
try {
const token = localStorage.getItem('sso_token');
const url = typeId
? `${API_BASE_URL}/api/vacation-types/${typeId}`
: `${API_BASE_URL}/api/vacation-types`;
const response = await fetch(url, {
method: typeId ? 'PUT' : 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '저장 실패');
}
showToast(typeId ? '수정되었습니다' : '추가되었습니다', 'success');
closeModals();
await loadVacationTypes();
} catch (error) {
console.error('저장 오류:', error);
showToast(error.message, 'error');
}
}
// =============================================================================
// 공통 함수
// =============================================================================
/**
* 모달 닫기
*/
function closeModals() {
document.querySelectorAll('.modal').forEach(modal => {
modal.classList.remove('active');
});
}
// showToast → api-base.js 전역 사용

View File

@@ -0,0 +1,241 @@
/**
* 휴가 관리 공통 함수
* 모든 휴가 관련 페이지에서 사용하는 공통 함수 모음
*/
// 전역 변수
window.VacationCommon = {
workers: [],
vacationTypes: [],
currentUser: null
};
/**
* 작업자 목록 로드
*/
async function loadWorkers() {
try {
const response = await axios.get('/workers?limit=100');
if (response.data.success) {
window.VacationCommon.workers = response.data.data.filter(w => w.employment_status === 'employed');
return window.VacationCommon.workers;
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
throw error;
}
}
/**
* 휴가 유형 목록 로드
*/
async function loadVacationTypes() {
try {
const response = await axios.get('/attendance/vacation-types');
if (response.data.success) {
window.VacationCommon.vacationTypes = response.data.data;
return window.VacationCommon.vacationTypes;
}
} catch (error) {
console.error('휴가 유형 로드 오류:', error);
throw error;
}
}
/**
* 현재 사용자 정보 가져오기
*/
function getCurrentUser() {
if (!window.VacationCommon.currentUser) {
window.VacationCommon.currentUser = JSON.parse(localStorage.getItem('sso_user'));
}
return window.VacationCommon.currentUser;
}
/**
* 휴가 신청 목록 렌더링
*/
function renderVacationRequests(requests, containerId, showActions = false, actionType = 'approval') {
const container = document.getElementById(containerId);
if (!requests || requests.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>휴가 신청 내역이 없습니다.</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>일수</th>
<th>상태</th>
<th>사유</th>
${showActions ? '<th>관리</th>' : ''}
</tr>
</thead>
<tbody>
${requests.map(request => {
const validStatuses = ['pending', 'approved', 'rejected'];
const safeStatus = validStatuses.includes(request.status) ? request.status : 'pending';
const statusClass = safeStatus === 'pending' ? 'status-pending' :
safeStatus === 'approved' ? 'status-approved' : 'status-rejected';
const statusText = safeStatus === 'pending' ? '대기' :
safeStatus === 'approved' ? '승인' : '거부';
const workerName = escapeHtml(request.worker_name || '알 수 없음');
const typeName = escapeHtml(request.vacation_type_name || request.type_name || '알 수 없음');
const reasonText = escapeHtml(request.reason || '-');
const daysUsed = parseFloat(request.days_used) || 0;
return `
<tr>
<td><strong>${workerName}</strong></td>
<td>${typeName}</td>
<td>${escapeHtml(request.start_date || '-')}</td>
<td>${escapeHtml(request.end_date || '-')}</td>
<td>${daysUsed}일</td>
<td>
<span class="status-badge ${statusClass}">
${statusText}
</span>
</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${reasonText}">
${reasonText}
</td>
${showActions ? renderActionButtons(request, actionType) : ''}
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
/**
* 액션 버튼 렌더링
*/
function renderActionButtons(request, actionType) {
const safeRequestId = parseInt(request.request_id) || 0;
if (actionType === 'approval' && request.status === 'pending') {
return `
<td>
<div style="display: flex; gap: 0.5rem;">
<button class="btn-small btn-success" onclick="approveVacationRequest(${safeRequestId})" title="승인">
</button>
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${safeRequestId})" title="거부">
</button>
</div>
</td>
`;
} else if (actionType === 'delete' && request.status === 'pending') {
return `
<td>
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${safeRequestId})" title="삭제">
삭제
</button>
</td>
`;
}
return '<td>-</td>';
}
/**
* 휴가 신청 승인
*/
async function approveVacationRequest(requestId) {
if (!confirm('이 휴가 신청을 승인하시겠습니까?')) {
return;
}
try {
const response = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (response.data.success) {
alert('휴가 신청이 승인되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('승인 오류:', error);
alert(error.response?.data?.message || '승인 중 오류가 발생했습니다.');
return false;
}
}
/**
* 휴가 신청 거부
*/
async function rejectVacationRequest(requestId) {
const reason = prompt('거부 사유를 입력하세요:');
if (!reason) {
return;
}
try {
const response = await axios.patch(`/vacation-requests/${requestId}/reject`, {
review_note: reason
});
if (response.data.success) {
alert('휴가 신청이 거부되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('거부 오류:', error);
alert(error.response?.data?.message || '거부 중 오류가 발생했습니다.');
return false;
}
}
/**
* 휴가 신청 삭제
*/
async function deleteVacationRequest(requestId) {
if (!confirm('이 휴가 신청을 삭제하시겠습니까?')) {
return;
}
try {
const response = await axios.delete(`/vacation-requests/${requestId}`);
if (response.data.success) {
alert('휴가 신청이 삭제되었습니다.');
// 페이지 새로고침 이벤트 발생
window.dispatchEvent(new Event('vacation-updated'));
return true;
}
} catch (error) {
console.error('삭제 오류:', error);
alert(error.response?.data?.message || '삭제 중 오류가 발생했습니다.');
return false;
}
}
/**
* axios 설정 대기
*/
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}

View File

@@ -0,0 +1,712 @@
// 작업 분석 페이지 JavaScript
// API 설정 import
import './api-config.js';
// 전역 변수
let currentMode = 'period';
let currentTab = 'worker';
let analysisData = null;
let projectChart = null;
let errorByProjectChart = null;
let errorTimelineChart = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
initializePage();
loadInitialData();
});
// 페이지 초기화
function initializePage() {
// 시간 업데이트 시작
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
// 사용자 정보 업데이트
updateUserInfo();
// 프로필 메뉴 토글
setupProfileMenu();
// 로그아웃 버튼
setupLogoutButton();
// 기본 날짜 설정은 HTML에서 처리됨 (새로운 UI)
}
// 현재 시간 업데이트 (시 분 초 형식으로 고정)
function updateCurrentTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.getElementById('timeValue');
if (timeElement) {
timeElement.textContent = timeString;
}
}
// 사용자 정보 업데이트 - navbar/sidebar는 app-init.js에서 공통 처리
function updateUserInfo() {
// app-init.js가 navbar 사용자 정보를 처리하므로 여기서는 아무것도 하지 않음
}
// 프로필 메뉴 설정
function setupProfileMenu() {
const userProfile = document.getElementById('userProfile');
const profileMenu = document.getElementById('profileMenu');
if (userProfile && profileMenu) {
userProfile.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = profileMenu.style.display === 'block';
profileMenu.style.display = isVisible ? 'none' : 'block';
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', function() {
profileMenu.style.display = 'none';
});
}
}
// 로그아웃 버튼 설정
function setupLogoutButton() {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function() {
if (confirm('로그아웃 하시겠습니까?')) {
if (window.clearSSOAuth) window.clearSSOAuth();
window.location.href = window.getLoginUrl ? window.getLoginUrl() : '/login';
}
});
}
}
// 초기 데이터 로드
async function loadInitialData() {
try {
// 프로젝트 목록 로드
const projects = await apiCall('/projects/active/list', 'GET');
const projectData = Array.isArray(projects) ? projects : (projects.data || []);
// 프로젝트 필터 옵션 업데이트
updateProjectFilters(projectData);
} catch (error) {
console.error('초기 데이터 로딩 오류:', error);
showToast('초기 데이터를 불러오는데 실패했습니다.', 'error');
}
}
// 프로젝트 필터 업데이트
function updateProjectFilters(projects) {
const projectFilter = document.getElementById('projectFilter');
const projectModeSelect = document.getElementById('projectModeSelect');
if (projectFilter) {
projectFilter.innerHTML = '<option value="">전체 프로젝트</option>';
projects.forEach(project => {
projectFilter.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
}
if (projectModeSelect) {
projectModeSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
projectModeSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
}
}
// 분석 모드 전환
function switchAnalysisMode(mode) {
currentMode = mode;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
// 모드 콘텐츠 표시/숨김
document.querySelectorAll('.analysis-mode').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${mode}-mode`).classList.add('active');
}
// 분석 탭 전환
function switchAnalysisTab(tab) {
currentTab = tab;
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.analysis-tab').forEach(tabBtn => {
tabBtn.classList.remove('active');
});
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
// 탭 콘텐츠 표시/숨김
document.querySelectorAll('.analysis-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tab}-analysis`).classList.add('active');
}
// 기간별 분석 로드
async function loadPeriodAnalysis() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const projectId = document.getElementById('projectFilter').value;
if (!startDate || !endDate) {
showToast('시작일과 종료일을 모두 선택해주세요.', 'error');
return;
}
if (new Date(startDate) > new Date(endDate)) {
showToast('시작일이 종료일보다 늦을 수 없습니다.', 'error');
return;
}
showLoading(true);
try {
// API 호출 파라미터 구성
const params = new URLSearchParams({
start: startDate,
end: endDate
});
if (projectId) {
params.append('project_id', projectId);
}
// 여러 API를 병렬로 호출하여 종합 분석 데이터 구성
const [statsRes, workerStatsRes, projectStatsRes, errorAnalysisRes] = await Promise.all([
apiCall(`/work-analysis/stats?${params}`, 'GET').catch(err => {
console.error(' stats API 오류:', err);
return { data: null };
}),
apiCall(`/work-analysis/worker-stats?${params}`, 'GET').catch(err => {
console.error(' worker-stats API 오류:', err);
return { data: [] };
}),
apiCall(`/work-analysis/project-stats?${params}`, 'GET').catch(err => {
console.error(' project-stats API 오류:', err);
return { data: [] };
}),
apiCall(`/work-analysis/error-analysis?${params}`, 'GET').catch(err => {
console.error(' error-analysis API 오류:', err);
return { data: {} };
})
]);
console.log(' - stats:', statsRes);
console.log(' - worker-stats:', workerStatsRes);
console.log(' - project-stats:', projectStatsRes);
console.log(' - error-analysis:', errorAnalysisRes);
// 종합 분석 데이터 구성
analysisData = {
summary: statsRes.data || statsRes,
workerStats: workerStatsRes.data || workerStatsRes,
projectStats: projectStatsRes.data || projectStatsRes,
errorStats: errorAnalysisRes.data || errorAnalysisRes
};
// 결과 표시
displayPeriodAnalysis(analysisData);
// 결과 섹션 표시
document.getElementById('periodResults').style.display = 'block';
showToast('분석이 완료되었습니다.', 'success');
} catch (error) {
console.error('기간별 분석 오류:', error);
showToast('분석 중 오류가 발생했습니다.', 'error');
} finally {
showLoading(false);
}
}
// 기간별 분석 결과 표시
function displayPeriodAnalysis(data) {
// 요약 통계 업데이트
updateSummaryStats(data.summary || {});
// 작업자별 분석 표시
displayWorkerAnalysis(data.workerStats || []);
// 프로젝트별 분석 표시
displayProjectAnalysis(data.projectStats || []);
// 오류 분석 표시 (전체 분석 데이터도 함께 전달)
displayErrorAnalysis(data.errorStats || {}, data);
}
// 요약 통계 업데이트
function updateSummaryStats(summary) {
// API 응답 구조에 맞게 필드명 조정
document.getElementById('totalHours').textContent = `${summary.totalHours || summary.total_hours || 0}h`;
document.getElementById('totalWorkers').textContent = `${summary.activeworkers || summary.activeWorkers || summary.total_workers || 0}`;
document.getElementById('totalProjects').textContent = `${summary.activeProjects || summary.active_projects || summary.total_projects || 0}`;
document.getElementById('errorRate').textContent = `${summary.errorRate || summary.error_rate || 0}%`;
}
// 작업자별 분석 표시
function displayWorkerAnalysis(workerStats) {
const grid = document.getElementById('workerAnalysisGrid');
if (!workerStats || (Array.isArray(workerStats) && workerStats.length === 0)) {
grid.innerHTML = `
<div class="empty-state">
<div class="empty-icon">👥</div>
<h3>분석할 작업자 데이터가 없습니다.</h3>
<p>선택한 기간에 등록된 작업이 없습니다.</p>
</div>
`;
return;
}
let gridHtml = '';
workerStats.forEach(worker => {
const workerName = worker.worker_name || worker.name || '알 수 없음';
const totalHours = worker.total_hours || worker.totalHours || 0;
gridHtml += `
<div class="worker-card">
<div class="worker-header">
<div class="worker-info">
<div class="worker-avatar">${workerName.charAt(0)}</div>
<div class="worker-name">${workerName}</div>
</div>
<div class="worker-total-hours">${totalHours}h</div>
</div>
<div class="worker-projects">
`;
// API 응답 구조에 따라 프로젝트 데이터 처리
const projects = worker.projects || worker.project_details || [];
if (projects.length > 0) {
projects.forEach(project => {
const projectName = project.project_name || project.name || '프로젝트';
gridHtml += `
<div class="project-item">
<div class="project-name">${projectName}</div>
<div class="work-items">
`;
const works = project.works || project.work_details || project.tasks || [];
if (works.length > 0) {
works.forEach(work => {
const workName = work.work_name || work.task_name || work.name || '작업';
const workHours = work.hours || work.total_hours || work.work_hours || 0;
gridHtml += `
<div class="work-item">
<div class="work-name">${workName}</div>
<div class="work-hours">${workHours}h</div>
</div>
`;
});
} else {
gridHtml += `
<div class="work-item">
<div class="work-name">총 작업시간</div>
<div class="work-hours">${project.total_hours || project.hours || 0}h</div>
</div>
`;
}
gridHtml += `
</div>
</div>
`;
});
} else {
gridHtml += `
<div class="project-item">
<div class="project-name">전체 작업</div>
<div class="work-items">
<div class="work-item">
<div class="work-name">총 작업시간</div>
<div class="work-hours">${totalHours}h</div>
</div>
</div>
</div>
`;
}
gridHtml += `
</div>
</div>
`;
});
grid.innerHTML = gridHtml;
}
// 프로젝트별 분석 표시
function displayProjectAnalysis(projectStats) {
const detailsContainer = document.getElementById('projectDetails');
if (projectStats && projectStats.length > 0) {
}
if (!projectStats || projectStats.length === 0) {
detailsContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📁</div>
<h3>분석할 프로젝트 데이터가 없습니다.</h3>
</div>
`;
return;
}
// 프로젝트 상세 정보 표시
let detailsHtml = '';
// 전체 시간 계산 (퍼센트 계산용)
const totalAllHours = projectStats.reduce((sum, p) => {
return sum + (p.totalHours || p.total_hours || p.hours || 0);
}, 0);
projectStats.forEach(project => {
const projectName = project.project_name || project.name || project.projectName || '프로젝트';
const totalHours = project.totalHours || project.total_hours || project.hours || 0;
// 퍼센트 계산
let percentage = project.percentage || project.percent || 0;
if (percentage === 0 && totalAllHours > 0) {
percentage = Math.round((totalHours / totalAllHours) * 100);
}
detailsHtml += `
<div class="project-detail-card">
<div class="project-detail-header">
<div class="project-detail-name">${projectName}</div>
<div class="project-percentage">${percentage}%</div>
</div>
<div class="project-hours">${totalHours}시간</div>
</div>
`;
});
detailsContainer.innerHTML = detailsHtml;
// 차트 업데이트
updateProjectChart(projectStats);
}
// 프로젝트 차트 업데이트
function updateProjectChart(projectStats) {
const ctx = document.getElementById('projectChart');
if (projectChart) {
projectChart.destroy();
}
const labels = projectStats.map(p => p.project_name || p.name || p.projectName || '프로젝트');
const data = projectStats.map(p => p.totalHours || p.total_hours || p.hours || 0);
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
projectChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, data.length),
borderWidth: 2,
borderColor: '#ffffff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
}
}
}
});
}
// 오류 분석 표시
function displayErrorAnalysis(errorStats, allData) {
// errorStats가 배열인 경우 첫 번째 요소 사용
let errorData = errorStats;
if (Array.isArray(errorStats) && errorStats.length > 0) {
errorData = errorStats[0];
}
// 오류 요약 업데이트 - 실제 데이터 구조에 맞게 수정
const errorHours = errorData.totalHours || errorData.total_hours || errorData.error_hours || 0;
// 전체 작업 시간에서 오류 시간을 빼서 정규 시간 계산
// 요약 통계에서 전체 시간을 가져와서 계산
const totalHours = allData && allData.summary ? allData.summary.totalHours : 0;
const normalHours = Math.max(0, totalHours - errorHours);
document.getElementById('normalHours').textContent = `${normalHours}h`;
document.getElementById('errorHours').textContent = `${errorHours}h`;
// 프로젝트별 에러율 차트
if (errorStats.projectErrorRates) {
updateErrorByProjectChart(errorStats.projectErrorRates);
}
// 일별 오류 추이 차트
if (errorStats.dailyErrorTrend) {
updateErrorTimelineChart(errorStats.dailyErrorTrend);
}
// 오류 유형별 분석
if (errorStats.errorTypes) {
displayErrorTypes(errorStats.errorTypes);
}
}
// 프로젝트별 에러율 차트 업데이트
function updateErrorByProjectChart(projectErrorRates) {
const ctx = document.getElementById('errorByProjectChart');
if (errorByProjectChart) {
errorByProjectChart.destroy();
}
const labels = projectErrorRates.map(p => p.project_name);
const data = projectErrorRates.map(p => p.error_rate);
errorByProjectChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '에러율 (%)',
data: data,
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
},
plugins: {
legend: {
display: false
}
}
}
});
}
// 일별 오류 추이 차트 업데이트
function updateErrorTimelineChart(dailyErrorTrend) {
const ctx = document.getElementById('errorTimelineChart');
if (errorTimelineChart) {
errorTimelineChart.destroy();
}
const labels = dailyErrorTrend.map(d => formatDate(new Date(d.date)));
const errorData = dailyErrorTrend.map(d => d.error_count);
const totalData = dailyErrorTrend.map(d => d.total_count);
errorTimelineChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '총 작업',
data: totalData,
borderColor: 'rgba(59, 130, 246, 1)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true
},
{
label: '오류 작업',
data: errorData,
borderColor: 'rgba(239, 68, 68, 1)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
position: 'top'
}
}
}
});
}
// 오류 유형별 분석 표시
function displayErrorTypes(errorTypes) {
const container = document.getElementById('errorTypesAnalysis');
if (!errorTypes || errorTypes.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<h3>오류 유형 데이터가 없습니다.</h3>
</div>
`;
return;
}
let html = '<h4>🔍 오류 유형별 상세 분석</h4>';
errorTypes.forEach(errorType => {
html += `
<div class="error-type-item">
<div class="error-type-info">
<div class="error-type-icon">⚠️</div>
<div class="error-type-name">${errorType.error_name}</div>
</div>
<div class="error-type-stats">
<div class="error-type-count">${errorType.count}건</div>
<div class="error-type-percentage">${errorType.percentage}%</div>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 프로젝트별 분석 로드
async function loadProjectAnalysis() {
const projectId = document.getElementById('projectModeSelect').value;
const startDate = document.getElementById('projectStartDate').value;
const endDate = document.getElementById('projectEndDate').value;
if (!projectId) {
showToast('프로젝트를 선택해주세요.', 'error');
return;
}
showLoading(true);
try {
// API 호출 파라미터 구성
const params = new URLSearchParams({
project_id: projectId
});
if (startDate) params.append('start', startDate);
if (endDate) params.append('end', endDate);
// 프로젝트별 상세 분석 데이터 로드
const response = await apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET');
const projectAnalysisData = response.data || response;
// 결과 표시
displayProjectModeAnalysis(projectAnalysisData);
// 결과 섹션 표시
document.getElementById('projectModeResults').style.display = 'block';
showToast('프로젝트 분석이 완료되었습니다.', 'success');
} catch (error) {
console.error('프로젝트별 분석 오류:', error);
showToast('프로젝트 분석 중 오류가 발생했습니다.', 'error');
} finally {
showLoading(false);
}
}
// 프로젝트별 분석 결과 표시
function displayProjectModeAnalysis(data) {
const container = document.getElementById('projectModeResults');
// 프로젝트별 분석 결과 HTML 생성
let html = `
<div class="project-mode-analysis">
<h3>📁 ${data.project_name} 분석 결과</h3>
<!-- 프로젝트별 상세 분석 내용 -->
</div>
`;
container.innerHTML = html;
}
// 로딩 상태 표시/숨김
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.style.display = show ? 'flex' : 'none';
}
}
// 날짜 포맷팅
function formatDate(date) {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// showToast → api-base.js 전역 사용
// 전역 함수로 노출
window.switchAnalysisMode = switchAnalysisMode;
window.switchAnalysisTab = switchAnalysisTab;
window.loadPeriodAnalysis = loadPeriodAnalysis;
window.loadProjectAnalysis = loadProjectAnalysis;

View File

@@ -0,0 +1,225 @@
/**
* Work Analysis API Client Module
* 작업 분석 관련 모든 API 호출을 관리하는 모듈
*/
class WorkAnalysisAPIClient {
constructor() {
this.baseURL = window.API_BASE_URL || 'http://localhost:30005/api';
}
/**
* 기본 API 호출 메서드
* @param {string} endpoint - API 엔드포인트
* @param {string} method - HTTP 메서드
* @param {Object} data - 요청 데이터
* @returns {Promise<Object>} API 응답
*/
async apiCall(endpoint, method = 'GET', data = null) {
try {
const config = {
method,
headers: {
'Content-Type': 'application/json',
}
};
if (data && method !== 'GET') {
config.body = JSON.stringify(data);
}
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP ${response.status}`);
}
return result;
} catch (error) {
console.error(` API 실패: ${this.baseURL}${endpoint}`, error);
throw error;
}
}
/**
* 날짜 범위 파라미터 생성
* @param {string} startDate - 시작일
* @param {string} endDate - 종료일
* @param {Object} additionalParams - 추가 파라미터
* @returns {URLSearchParams} URL 파라미터
*/
createDateParams(startDate, endDate, additionalParams = {}) {
const params = new URLSearchParams({
start: startDate,
end: endDate,
...additionalParams
});
return params;
}
// ========== 기본 통계 API ==========
/**
* 기본 통계 조회
*/
async getBasicStats(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/stats?${params}`);
}
/**
* 일별 추이 조회
*/
async getDailyTrend(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/daily-trend?${params}`);
}
/**
* 작업자별 통계 조회
*/
async getWorkerStats(startDate, endDate, projectId = null) {
const params = this.createDateParams(startDate, endDate,
projectId ? { project_id: projectId } : {}
);
return await this.apiCall(`/work-analysis/worker-stats?${params}`);
}
/**
* 프로젝트별 통계 조회
*/
async getProjectStats(startDate, endDate) {
const params = this.createDateParams(startDate, endDate);
return await this.apiCall(`/work-analysis/project-stats?${params}`);
}
// ========== 상세 분석 API ==========
/**
* 프로젝트별-작업유형별 분석
*/
async getProjectWorkTypeAnalysis(startDate, endDate, limit = 2000) {
const params = this.createDateParams(startDate, endDate, { limit });
return await this.apiCall(`/work-analysis/project-worktype-analysis?${params}`);
}
/**
* 최근 작업 데이터 조회
*/
async getRecentWork(startDate, endDate, limit = 2000) {
const params = this.createDateParams(startDate, endDate, { limit });
return await this.apiCall(`/work-analysis/recent-work?${params}`);
}
/**
* 오류 분석 데이터 조회
*/
async getErrorAnalysis(startDate, endDate) {
const params = this.createDateParams(startDate, endDate);
return await this.apiCall(`/work-analysis/error-analysis?${params}`);
}
// ========== 배치 API 호출 ==========
/**
* 여러 API를 병렬로 호출
* @param {Array} apiCalls - API 호출 배열
* @returns {Promise<Array>} 결과 배열
*/
async batchCall(apiCalls) {
const promises = apiCalls.map(async ({ name, method, ...args }) => {
try {
const result = await this[method](...args);
return { name, success: true, data: result };
} catch (error) {
console.warn(` ${name} API 오류:`, error);
return { name, success: false, error: error.message, data: null };
}
});
const results = await Promise.all(promises);
return results.reduce((acc, result) => {
acc[result.name] = result;
return acc;
}, {});
}
/**
* 차트 데이터를 위한 배치 호출
*/
async getChartData(startDate, endDate, projectId = null) {
return await this.batchCall([
{
name: 'dailyTrend',
method: 'getDailyTrend',
startDate,
endDate,
projectId
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate,
endDate,
projectId
},
{
name: 'projectStats',
method: 'getProjectStats',
startDate,
endDate
},
{
name: 'errorAnalysis',
method: 'getErrorAnalysis',
startDate,
endDate
}
]);
}
/**
* 프로젝트 분포 분석을 위한 배치 호출
*/
async getProjectDistributionData(startDate, endDate) {
return await this.batchCall([
{
name: 'projectWorkType',
method: 'getProjectWorkTypeAnalysis',
startDate,
endDate
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate,
endDate
},
{
name: 'recentWork',
method: 'getRecentWork',
startDate,
endDate
}
]);
}
}
// 전역 인스턴스 생성
window.WorkAnalysisAPI = new WorkAnalysisAPIClient();
// 하위 호환성을 위한 전역 함수
window.apiCall = (endpoint, method, data) => {
return window.WorkAnalysisAPI.apiCall(endpoint, method, data);
};
// Export는 브라우저 환경에서 제거됨

View File

@@ -0,0 +1,443 @@
/**
* Work Analysis Chart Renderer Module
* 작업 분석 차트 렌더링을 담당하는 모듈
*/
class WorkAnalysisChartRenderer {
constructor() {
this.charts = new Map(); // 차트 인스턴스 관리
this.dataProcessor = window.WorkAnalysisDataProcessor;
this.defaultColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
}
// ========== 차트 관리 ==========
/**
* 기존 차트 제거
* @param {string} chartId - 차트 ID
*/
destroyChart(chartId) {
if (this.charts.has(chartId)) {
this.charts.get(chartId).destroy();
this.charts.delete(chartId);
}
}
/**
* 모든 차트 제거
*/
destroyAllCharts() {
this.charts.forEach((chart, id) => {
chart.destroy();
});
this.charts.clear();
}
/**
* 차트 생성 및 등록
* @param {string} chartId - 차트 ID
* @param {HTMLCanvasElement} canvas - 캔버스 요소
* @param {Object} config - 차트 설정
* @returns {Chart} 생성된 차트 인스턴스
*/
createChart(chartId, canvas, config) {
// 기존 차트가 있으면 제거
this.destroyChart(chartId);
const chart = new Chart(canvas, config);
this.charts.set(chartId, chart);
return chart;
}
// ========== 시계열 차트 ==========
/**
* 시계열 차트 렌더링 (기간별 작업 현황)
* @param {string} startDate - 시작일
* @param {string} endDate - 종료일
* @param {string} projectId - 프로젝트 ID (선택사항)
*/
async renderTimeSeriesChart(startDate, endDate, projectId = '') {
try {
const api = window.WorkAnalysisAPI;
const dailyTrendResponse = await api.getDailyTrend(startDate, endDate, projectId);
if (!dailyTrendResponse.success || !dailyTrendResponse.data) {
throw new Error('일별 추이 데이터를 가져올 수 없습니다');
}
const canvas = document.getElementById('workStatusChart');
if (!canvas) {
console.error(' workStatusChart 캔버스를 찾을 수 없습니다');
return;
}
const chartData = this.dataProcessor.processTimeSeriesData(dailyTrendResponse.data);
const config = {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 2,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '작업시간 (h)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: '작업자 수 (명)'
},
grid: {
drawOnChartArea: false,
},
}
},
plugins: {
title: {
display: true,
text: '일별 작업 현황'
},
legend: {
display: true,
position: 'top'
}
}
}
};
this.createChart('workStatus', canvas, config);
} catch (error) {
console.error(' 시계열 차트 렌더링 실패:', error);
this._showChartError('workStatusChart', '시계열 차트를 불러올 수 없습니다');
}
}
// ========== 스택 바 차트 ==========
/**
* 스택 바 차트 렌더링 (프로젝트별 → 작업유형별)
* @param {Array} projectData - 프로젝트 데이터
*/
renderStackedBarChart(projectData) {
const canvas = document.getElementById('projectDistributionChart');
if (!canvas) {
console.error(' projectDistributionChart 캔버스를 찾을 수 없습니다');
return;
}
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
this._showChartError('projectDistributionChart', '프로젝트 데이터가 없습니다');
return;
}
// 데이터 변환
const { labels, datasets } = this._processStackedBarData(projectData.projects);
const config = {
type: 'bar',
data: {
labels,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 2,
scales: {
x: {
stacked: true,
title: {
display: true,
text: '프로젝트'
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: '작업시간 (h)'
}
}
},
plugins: {
title: {
display: true,
text: '프로젝트별 작업유형 분포'
},
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return `${context[0].label}`;
},
label: function(context) {
const workType = context.dataset.label;
const hours = context.parsed.y;
const percentage = ((hours / projectData.totalHours) * 100).toFixed(1);
return `${workType}: ${hours}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('projectDistribution', canvas, config);
}
/**
* 스택 바 차트 데이터 처리
*/
_processStackedBarData(projects) {
// 모든 작업유형 수집
const allWorkTypes = new Set();
projects.forEach(project => {
project.workTypes.forEach(wt => {
allWorkTypes.add(wt.work_type_name);
});
});
const workTypeArray = Array.from(allWorkTypes);
const labels = projects.map(p => p.project_name);
// 작업유형별 데이터셋 생성
const datasets = workTypeArray.map((workTypeName, index) => {
const data = projects.map(project => {
const workType = project.workTypes.find(wt => wt.work_type_name === workTypeName);
return workType ? workType.totalHours : 0;
});
return {
label: workTypeName,
data,
backgroundColor: this.defaultColors[index % this.defaultColors.length],
borderColor: this.defaultColors[index % this.defaultColors.length],
borderWidth: 1
};
});
return { labels, datasets };
}
// ========== 도넛 차트 ==========
/**
* 도넛 차트 렌더링 (작업자별 성과)
* @param {Array} workerData - 작업자 데이터
*/
renderWorkerPerformanceChart(workerData) {
const canvas = document.getElementById('workerPerformanceChart');
if (!canvas) {
console.error(' workerPerformanceChart 캔버스를 찾을 수 없습니다');
return;
}
if (!workerData || workerData.length === 0) {
this._showChartError('workerPerformanceChart', '작업자 데이터가 없습니다');
return;
}
const chartData = this.dataProcessor.processDonutChartData(
workerData.map(worker => ({
name: worker.worker_name,
hours: worker.totalHours
}))
);
const config = {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 1,
plugins: {
title: {
display: true,
text: '작업자별 작업시간 분포'
},
legend: {
display: true,
position: 'right'
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label;
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('workerPerformance', canvas, config);
}
// ========== 오류 분석 차트 ==========
/**
* 오류 분석 차트 렌더링
* @param {Array} errorData - 오류 데이터
*/
renderErrorAnalysisChart(errorData) {
const canvas = document.getElementById('errorAnalysisChart');
if (!canvas) {
console.error(' errorAnalysisChart 캔버스를 찾을 수 없습니다');
return;
}
if (!errorData || errorData.length === 0) {
this._showChartError('errorAnalysisChart', '오류 데이터가 없습니다');
return;
}
// 오류가 있는 데이터만 필터링
const errorItems = errorData.filter(item =>
item.error_count > 0 || (item.errorDetails && item.errorDetails.length > 0)
);
if (errorItems.length === 0) {
this._showChartError('errorAnalysisChart', '오류가 발생한 항목이 없습니다');
return;
}
const chartData = this.dataProcessor.processDonutChartData(
errorItems.map(item => ({
name: item.project_name || item.name,
hours: item.errorHours || item.error_count
}))
);
const config = {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 1,
plugins: {
title: {
display: true,
text: '프로젝트별 오류 분포'
},
legend: {
display: true,
position: 'bottom'
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label;
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value}h (${percentage}%)`;
}
}
}
}
}
};
this.createChart('errorAnalysis', canvas, config);
}
// ========== 유틸리티 ==========
/**
* 차트 오류 표시
* @param {string} canvasId - 캔버스 ID
* @param {string} message - 오류 메시지
*/
_showChartError(canvasId, message) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const container = canvas.parentElement;
if (container) {
container.innerHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #666;
text-align: center;
">
<div style="font-size: 3rem; margin-bottom: 1rem;">📊</div>
<div style="font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;">차트를 표시할 수 없습니다</div>
<div style="font-size: 0.9rem;">${message}</div>
</div>
`;
}
}
/**
* 차트 리사이즈
*/
resizeCharts() {
this.charts.forEach((chart, id) => {
try {
chart.resize();
} catch (error) {
console.warn(' 차트 리사이즈 실패:', id, error);
}
});
}
/**
* 차트 상태 확인
*/
getChartStatus() {
const status = {};
this.charts.forEach((chart, id) => {
status[id] = {
type: chart.config.type,
datasetCount: chart.data.datasets.length,
dataPointCount: chart.data.labels ? chart.data.labels.length : 0
};
});
return status;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisChartRenderer = new WorkAnalysisChartRenderer();
// 윈도우 리사이즈 이벤트 리스너
window.addEventListener('resize', () => {
window.WorkAnalysisChartRenderer.resizeCharts();
});
// Export는 브라우저 환경에서 제거됨

View File

@@ -0,0 +1,349 @@
/**
* Work Analysis Data Processor Module
* 작업 분석 데이터 가공 및 변환을 담당하는 모듈
*/
class WorkAnalysisDataProcessor {
// ========== 유틸리티 함수 ==========
/**
* 주말 여부 확인
* @param {string} dateString - 날짜 문자열
* @returns {boolean} 주말 여부
*/
isWeekendDate(dateString) {
const date = new Date(dateString);
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // 일요일(0) 또는 토요일(6)
}
/**
* 연차/휴무 프로젝트 여부 확인
* @param {string} projectName - 프로젝트명
* @returns {boolean} 연차/휴무 여부
*/
isVacationProject(projectName) {
if (!projectName) return false;
const vacationKeywords = ['연차', '휴무', '휴가', '병가', '특별휴가'];
return vacationKeywords.some(keyword => projectName.includes(keyword));
}
/**
* 날짜 포맷팅 (간단한 형식)
* @param {string} dateString - 날짜 문자열
* @returns {string} 포맷된 날짜
*/
formatSimpleDate(dateString) {
if (!dateString) return '';
return dateString.split('T')[0]; // 시간 부분 제거
}
// ========== 프로젝트 분포 데이터 처리 ==========
/**
* 프로젝트별 데이터 집계
* @param {Array} recentWorkData - 최근 작업 데이터
* @returns {Object} 집계된 프로젝트 데이터
*/
aggregateProjectData(recentWorkData) {
if (!recentWorkData || recentWorkData.length === 0) {
return { projects: [], totalHours: 0 };
}
const projectMap = new Map();
let vacationData = null;
recentWorkData.forEach(work => {
const isWeekend = this.isWeekendDate(work.report_date);
const isVacation = this.isVacationProject(work.project_name);
// 주말 연차는 제외
if (isWeekend && isVacation) {
return;
}
if (isVacation) {
// 연차/휴무 통합 처리
if (!vacationData) {
vacationData = {
project_id: 'vacation',
project_name: '연차/휴무',
job_no: null,
totalHours: 0,
workTypes: new Map()
};
}
this._addWorkToProject(vacationData, work, '연차/휴무');
} else {
// 일반 프로젝트 처리
const projectKey = work.project_id || 'unknown';
if (!projectMap.has(projectKey)) {
projectMap.set(projectKey, {
project_id: projectKey,
project_name: work.project_name || `프로젝트 ${projectKey}`,
job_no: work.job_no,
totalHours: 0,
workTypes: new Map()
});
}
const project = projectMap.get(projectKey);
this._addWorkToProject(project, work);
}
});
// 결과 배열 생성
const projects = Array.from(projectMap.values());
if (vacationData && vacationData.totalHours > 0) {
projects.push(vacationData);
}
// 작업유형을 배열로 변환하고 정렬
projects.forEach(project => {
project.workTypes = Array.from(project.workTypes.values())
.sort((a, b) => b.totalHours - a.totalHours);
});
// 프로젝트를 총 시간 순으로 정렬 (연차/휴무는 맨 아래)
projects.sort((a, b) => {
if (a.project_id === 'vacation') return 1;
if (b.project_id === 'vacation') return -1;
return b.totalHours - a.totalHours;
});
const totalHours = projects.reduce((sum, p) => sum + p.totalHours, 0);
return { projects, totalHours };
}
/**
* 프로젝트에 작업 데이터 추가 (내부 헬퍼)
*/
_addWorkToProject(project, work, overrideWorkTypeName = null) {
const hours = parseFloat(work.work_hours) || 0;
project.totalHours += hours;
const workTypeKey = work.work_type_id || 'unknown';
const workTypeName = overrideWorkTypeName || work.work_type_name || `작업유형 ${workTypeKey}`;
if (!project.workTypes.has(workTypeKey)) {
project.workTypes.set(workTypeKey, {
work_type_id: workTypeKey,
work_type_name: workTypeName,
totalHours: 0
});
}
project.workTypes.get(workTypeKey).totalHours += hours;
}
// ========== 오류 분석 데이터 처리 ==========
/**
* 작업 형태별 오류 데이터 집계
* @param {Array} recentWorkData - 최근 작업 데이터
* @returns {Array} 집계된 오류 데이터
*/
aggregateErrorData(recentWorkData) {
if (!recentWorkData || recentWorkData.length === 0) {
return [];
}
const workTypeMap = new Map();
let vacationData = null;
recentWorkData.forEach(work => {
const isWeekend = this.isWeekendDate(work.report_date);
const isVacation = this.isVacationProject(work.project_name);
// 주말 연차는 완전히 제외
if (isWeekend && isVacation) {
return;
}
if (isVacation) {
// 모든 연차/휴무를 하나로 통합
if (!vacationData) {
vacationData = {
project_id: 'vacation',
project_name: '연차/휴무',
job_no: null,
work_type_id: 'vacation',
work_type_name: '연차/휴무',
regularHours: 0,
errorHours: 0,
errorDetails: new Map(),
isVacation: true
};
}
this._addWorkToErrorData(vacationData, work);
} else {
// 일반 프로젝트 처리
const workTypeKey = work.work_type_id || 'unknown';
const combinedKey = `${work.project_id || 'unknown'}_${workTypeKey}`;
if (!workTypeMap.has(combinedKey)) {
workTypeMap.set(combinedKey, {
project_id: work.project_id,
project_name: work.project_name || `프로젝트 ${work.project_id}`,
job_no: work.job_no,
work_type_id: workTypeKey,
work_type_name: work.work_type_name || `작업유형 ${workTypeKey}`,
regularHours: 0,
errorHours: 0,
errorDetails: new Map(),
isVacation: false
});
}
const workTypeData = workTypeMap.get(combinedKey);
this._addWorkToErrorData(workTypeData, work);
}
});
// 결과 배열 생성
const result = Array.from(workTypeMap.values());
// 연차/휴무 데이터가 있으면 추가
if (vacationData && (vacationData.regularHours > 0 || vacationData.errorHours > 0)) {
result.push(vacationData);
}
// 최종 데이터 처리
const processedResult = result.map(wt => ({
...wt,
totalHours: wt.regularHours + wt.errorHours,
errorRate: wt.regularHours + wt.errorHours > 0 ?
((wt.errorHours / (wt.regularHours + wt.errorHours)) * 100).toFixed(1) : '0.0',
errorDetails: Array.from(wt.errorDetails.entries()).map(([type, hours]) => ({
type, hours
}))
})).filter(wt => wt.totalHours > 0) // 시간이 있는 것만 표시
.sort((a, b) => {
// 연차/휴무를 맨 아래로
if (a.isVacation && !b.isVacation) return 1;
if (!a.isVacation && b.isVacation) return -1;
// 같은 프로젝트 내에서는 오류 시간 순으로 정렬
if (a.project_id === b.project_id) {
return b.errorHours - a.errorHours;
}
// 다른 프로젝트는 프로젝트명 순으로 정렬
return (a.project_name || '').localeCompare(b.project_name || '');
});
return processedResult;
}
/**
* 작업 데이터를 오류 분석 데이터에 추가 (내부 헬퍼)
*/
_addWorkToErrorData(workTypeData, work) {
const hours = parseFloat(work.work_hours) || 0;
if (work.work_status === 'error' || work.error_type_id) {
workTypeData.errorHours += hours;
// 오류 유형별 세분화
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
if (!workTypeData.errorDetails.has(errorTypeName)) {
workTypeData.errorDetails.set(errorTypeName, 0);
}
workTypeData.errorDetails.set(errorTypeName,
workTypeData.errorDetails.get(errorTypeName) + hours
);
} else {
workTypeData.regularHours += hours;
}
}
// ========== 차트 데이터 처리 ==========
/**
* 시계열 차트 데이터 변환
* @param {Array} dailyData - 일별 데이터
* @returns {Object} 차트 데이터
*/
processTimeSeriesData(dailyData) {
if (!dailyData || dailyData.length === 0) {
return { labels: [], datasets: [] };
}
const labels = dailyData.map(item => this.formatSimpleDate(item.date));
const hours = dailyData.map(item => item.total_hours || 0);
const workers = dailyData.map(item => item.worker_count || 0);
return {
labels,
datasets: [
{
label: '총 작업시간',
data: hours,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
},
{
label: '참여 작업자 수',
data: workers,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
yAxisID: 'y1'
}
]
};
}
/**
* 도넛 차트 데이터 변환
* @param {Array} projectData - 프로젝트 데이터
* @returns {Object} 차트 데이터
*/
processDonutChartData(projectData) {
if (!projectData || projectData.length === 0) {
return { labels: [], datasets: [] };
}
const labels = projectData.map(item => item.project_name || item.name);
const data = projectData.map(item => item.total_hours || item.hours || 0);
const colors = this._generateColors(data.length);
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 2,
borderColor: '#ffffff'
}]
};
}
/**
* 색상 생성 헬퍼
*/
_generateColors(count) {
const baseColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1'
];
const colors = [];
for (let i = 0; i < count; i++) {
colors.push(baseColors[i % baseColors.length]);
}
return colors;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisDataProcessor = new WorkAnalysisDataProcessor();
// Export는 브라우저 환경에서 제거됨

View File

@@ -0,0 +1,596 @@
/**
* Work Analysis Main Controller Module
* 작업 분석 페이지의 메인 컨트롤러 - 모든 모듈을 조율하고 사용자 상호작용을 처리
*/
class WorkAnalysisMainController {
constructor() {
this.api = window.WorkAnalysisAPI;
this.state = window.WorkAnalysisState;
this.dataProcessor = window.WorkAnalysisDataProcessor;
this.tableRenderer = window.WorkAnalysisTableRenderer;
this.chartRenderer = window.WorkAnalysisChartRenderer;
this.init();
}
/**
* 초기화
*/
init() {
this.setupEventListeners();
this.setupStateListeners();
this.initializeUI();
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 기간 확정 버튼
const confirmButton = document.getElementById('confirmPeriodBtn');
if (confirmButton) {
confirmButton.addEventListener('click', () => this.handlePeriodConfirm());
}
// 분석 모드 탭
document.querySelectorAll('[data-mode]').forEach(button => {
button.addEventListener('click', (e) => {
const mode = e.target.dataset.mode;
this.handleModeChange(mode);
});
});
// 분석 탭 네비게이션
document.querySelectorAll('[data-tab]').forEach(button => {
button.addEventListener('click', (e) => {
const tabId = e.target.dataset.tab;
this.handleTabChange(tabId);
});
});
// 개별 분석 실행 버튼들
this.setupAnalysisButtons();
// 날짜 입력 필드
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
if (startDateInput && endDateInput) {
[startDateInput, endDateInput].forEach(input => {
input.addEventListener('change', () => this.handleDateChange());
});
}
}
/**
* 개별 분석 버튼 설정
*/
setupAnalysisButtons() {
const buttons = [
{ selector: 'button[onclick*="analyzeWorkStatus"]', handler: () => this.analyzeWorkStatus() },
{ selector: 'button[onclick*="analyzeProjectDistribution"]', handler: () => this.analyzeProjectDistribution() },
{ selector: 'button[onclick*="analyzeWorkerPerformance"]', handler: () => this.analyzeWorkerPerformance() },
{ selector: 'button[onclick*="analyzeErrorAnalysis"]', handler: () => this.analyzeErrorAnalysis() }
];
buttons.forEach(({ selector, handler }) => {
const button = document.querySelector(selector);
if (button) {
// 기존 onclick 제거하고 새 이벤트 리스너 추가
button.removeAttribute('onclick');
button.addEventListener('click', handler);
}
});
}
/**
* 상태 리스너 설정
*/
setupStateListeners() {
// 기간 확정 상태 변경 시 UI 업데이트
this.state.subscribe('periodConfirmed', (newState, prevState) => {
this.updateAnalysisButtons(newState.isAnalysisEnabled);
if (newState.confirmedPeriod.confirmed && !prevState.confirmedPeriod.confirmed) {
this.showAnalysisTabs();
}
});
// 로딩 상태 변경 시 UI 업데이트
this.state.subscribe('loadingState', (newState) => {
if (newState.isLoading) {
this.showLoading(newState.loadingMessage);
} else {
this.hideLoading();
}
});
// 탭 변경 시 UI 업데이트
this.state.subscribe('tabChange', (newState) => {
this.updateActiveTab(newState.currentTab);
});
// 에러 발생 시 처리
this.state.subscribe('errorOccurred', (newState) => {
if (newState.lastError) {
this.handleError(newState.lastError);
}
});
}
/**
* UI 초기화
*/
initializeUI() {
// 기본 날짜 설정
const currentState = this.state.getState();
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
if (startDateInput && currentState.confirmedPeriod.start) {
startDateInput.value = currentState.confirmedPeriod.start;
}
if (endDateInput && currentState.confirmedPeriod.end) {
endDateInput.value = currentState.confirmedPeriod.end;
}
// 분석 버튼 초기 상태 설정
this.updateAnalysisButtons(false);
// 분석 탭 숨김
this.hideAnalysisTabs();
}
// ========== 이벤트 핸들러 ==========
/**
* 기간 확정 처리
*/
async handlePeriodConfirm() {
try {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
this.state.confirmPeriod(startDate, endDate);
this.showToast('기간이 확정되었습니다', 'success');
} catch (error) {
console.error(' 기간 확정 처리 오류:', error);
this.state.setError(error);
this.showToast(error.message, 'error');
}
}
/**
* 분석 모드 변경 처리
*/
handleModeChange(mode) {
try {
this.state.setAnalysisMode(mode);
this.updateModeButtons(mode);
// 캐시 초기화
this.state.clearCache();
} catch (error) {
this.state.setError(error);
}
}
/**
* 탭 변경 처리
*/
handleTabChange(tabId) {
this.state.setCurrentTab(tabId);
}
/**
* 날짜 변경 처리
*/
handleDateChange() {
// 날짜가 변경되면 기간 확정 상태 해제
this.state.updateState({
confirmedPeriod: {
...this.state.getState().confirmedPeriod,
confirmed: false
},
isAnalysisEnabled: false
});
this.updateAnalysisButtons(false);
this.hideAnalysisTabs();
}
// ========== 분석 실행 ==========
/**
* 기본 통계 로드
*/
async loadBasicStats() {
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('기본 통계를 로딩 중입니다...');
const statsResponse = await this.api.getBasicStats(start, end);
if (statsResponse.success && statsResponse.data) {
const stats = statsResponse.data;
// 정상/오류 시간 계산
const totalHours = stats.totalHours || 0;
const errorReports = stats.errorRate || 0;
const errorHours = Math.round(totalHours * (errorReports / 100));
const normalHours = totalHours - errorHours;
const cardData = {
totalHours: totalHours,
normalHours: normalHours,
errorHours: errorHours,
workerCount: stats.activeWorkers || stats.activeworkers || 0,
errorRate: errorReports
};
this.state.setCache('basicStats', cardData);
this.updateResultCards(cardData);
} else {
// 기본값으로 카드 업데이트
const defaultData = {
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
};
this.updateResultCards(defaultData);
console.warn(' 기본 통계 데이터가 없어서 기본값으로 설정');
}
} catch (error) {
console.error(' 기본 통계 로드 실패:', error);
// 에러 시에도 기본값으로 카드 업데이트
const defaultData = {
totalHours: 0,
normalHours: 0,
errorHours: 0,
workerCount: 0,
errorRate: 0
};
this.updateResultCards(defaultData);
} finally {
this.state.stopLoading();
}
}
/**
* 기간별 작업 현황 분석
*/
async analyzeWorkStatus() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('기간별 작업 현황을 분석 중입니다...');
// 실제 API 호출
const batchData = await this.api.batchCall([
{
name: 'projectWorkType',
method: 'getProjectWorkTypeAnalysis',
startDate: start,
endDate: end
},
{
name: 'workerStats',
method: 'getWorkerStats',
startDate: start,
endDate: end
},
{
name: 'recentWork',
method: 'getRecentWork',
startDate: start,
endDate: end,
limit: 2000
}
]);
// 데이터 처리
const recentWorkData = batchData.recentWork?.success ? batchData.recentWork.data.data : [];
const workerData = batchData.workerStats?.success ? batchData.workerStats.data.data : [];
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
// 테이블 렌더링
this.tableRenderer.renderWorkStatusTable(projectData, workerData, recentWorkData);
this.showToast('기간별 작업 현황 분석이 완료되었습니다', 'success');
} catch (error) {
console.error(' 기간별 작업 현황 분석 오류:', error);
this.state.setError(error);
this.showToast('기간별 작업 현황 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 프로젝트별 분포 분석
*/
async analyzeProjectDistribution() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('프로젝트별 분포를 분석 중입니다...');
// 실제 API 호출
const distributionData = await this.api.getProjectDistributionData(start, end);
// 데이터 처리
const recentWorkData = distributionData.recentWork?.success ? distributionData.recentWork.data.data : [];
const workerData = distributionData.workerStats?.success ? distributionData.workerStats.data.data : [];
const projectData = this.dataProcessor.aggregateProjectData(recentWorkData);
// 테이블 렌더링
this.tableRenderer.renderProjectDistributionTable(projectData, workerData);
this.showToast('프로젝트별 분포 분석이 완료되었습니다', 'success');
} catch (error) {
console.error(' 프로젝트별 분포 분석 오류:', error);
this.state.setError(error);
this.showToast('프로젝트별 분포 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 작업자별 성과 분석
*/
async analyzeWorkerPerformance() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('작업자별 성과를 분석 중입니다...');
const workerStatsResponse = await this.api.getWorkerStats(start, end);
if (workerStatsResponse.success && workerStatsResponse.data) {
this.chartRenderer.renderWorkerPerformanceChart(workerStatsResponse.data);
this.showToast('작업자별 성과 분석이 완료되었습니다', 'success');
} else {
throw new Error('작업자 데이터를 가져올 수 없습니다');
}
} catch (error) {
console.error(' 작업자별 성과 분석 오류:', error);
this.state.setError(error);
this.showToast('작업자별 성과 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
/**
* 오류 분석
*/
async analyzeErrorAnalysis() {
if (!this.state.canAnalyze()) {
this.showToast('기간을 먼저 확정해주세요', 'warning');
return;
}
const currentState = this.state.getState();
const { start, end } = currentState.confirmedPeriod;
try {
this.state.startLoading('오류 분석을 진행 중입니다...');
// 병렬로 API 호출
const [recentWorkResponse, errorAnalysisResponse] = await Promise.all([
this.api.getRecentWork(start, end, 2000),
this.api.getErrorAnalysis(start, end)
]);
if (recentWorkResponse.success && recentWorkResponse.data) {
this.tableRenderer.renderErrorAnalysisTable(recentWorkResponse.data);
this.showToast('오류 분석이 완료되었습니다', 'success');
} else {
throw new Error('작업 데이터를 가져올 수 없습니다');
}
} catch (error) {
console.error(' 오류 분석 실패:', error);
this.state.setError(error);
this.showToast('오류 분석에 실패했습니다', 'error');
} finally {
this.state.stopLoading();
}
}
// ========== UI 업데이트 ==========
/**
* 결과 카드 업데이트
*/
updateResultCards(stats) {
const cards = {
totalHours: stats.totalHours || 0,
normalHours: stats.normalHours || 0,
errorHours: stats.errorHours || 0,
workerCount: stats.activeWorkers || 0,
errorRate: stats.errorRate || 0
};
Object.entries(cards).forEach(([key, value]) => {
const element = document.getElementById(key);
if (element) {
element.textContent = typeof value === 'number' ?
(key.includes('Rate') ? `${value}%` : value.toLocaleString()) : value;
}
});
}
/**
* 분석 버튼 상태 업데이트
*/
updateAnalysisButtons(enabled) {
const buttons = document.querySelectorAll('.chart-analyze-btn');
buttons.forEach(button => {
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.5';
});
}
/**
* 분석 탭 표시
*/
showAnalysisTabs() {
const tabNavigation = document.getElementById('analysisTabNavigation');
if (tabNavigation) {
tabNavigation.style.display = 'block';
}
}
/**
* 분석 탭 숨김
*/
hideAnalysisTabs() {
const tabNavigation = document.getElementById('analysisTabNavigation');
if (tabNavigation) {
tabNavigation.style.display = 'none';
}
}
/**
* 활성 탭 업데이트
*/
updateActiveTab(tabId) {
// 탭 버튼 업데이트
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
if (button.dataset.tab === tabId) {
button.classList.add('active');
}
});
// 탭 컨텐츠 업데이트
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
if (content.id === `${tabId}-tab`) {
content.classList.add('active');
}
});
}
/**
* 모드 버튼 업데이트
*/
updateModeButtons(mode) {
document.querySelectorAll('[data-mode]').forEach(button => {
button.classList.remove('active');
if (button.dataset.mode === mode) {
button.classList.add('active');
}
});
}
/**
* 로딩 표시
*/
showLoading(message = '분석 중입니다...') {
const loadingElement = document.getElementById('loadingState');
if (loadingElement) {
const textElement = loadingElement.querySelector('.loading-text');
if (textElement) {
textElement.textContent = message;
}
loadingElement.style.display = 'flex';
}
}
/**
* 로딩 숨김
*/
hideLoading() {
const loadingElement = document.getElementById('loadingState');
if (loadingElement) {
loadingElement.style.display = 'none';
}
}
/**
* 토스트 메시지 표시
*/
showToast(message, type = 'info') {
// 간단한 토스트 구현 (실제로는 더 정교한 토스트 라이브러리 사용 권장)
if (type === 'error') {
alert(`${message}`);
} else if (type === 'success') {
} else if (type === 'warning') {
alert(`⚠️ ${message}`);
}
}
/**
* 에러 처리
*/
handleError(errorInfo) {
console.error(' 에러 발생:', errorInfo);
this.showToast(errorInfo.message, 'error');
}
// ========== 유틸리티 ==========
/**
* 컨트롤러 상태 디버그
*/
debug() {
console.log('- API 클라이언트:', this.api);
console.log('- 상태 관리자:', this.state.getState());
console.log('- 차트 상태:', this.chartRenderer.getChartStatus());
}
}
// 전역 인스턴스 생성 및 초기화
document.addEventListener('DOMContentLoaded', () => {
window.WorkAnalysisMainController = new WorkAnalysisMainController();
});
// Export는 브라우저 환경에서 제거됨

View File

@@ -0,0 +1,261 @@
/**
* Work Analysis Module Loader
* 작업 분석 모듈들을 순서대로 로드하고 초기화하는 로더
*/
class WorkAnalysisModuleLoader {
constructor() {
this.modules = [
{ name: 'API Client', path: '/js/work-analysis/api-client.js', loaded: false },
{ name: 'Data Processor', path: '/js/work-analysis/data-processor.js', loaded: false },
{ name: 'State Manager', path: '/js/work-analysis/state-manager.js', loaded: false },
{ name: 'Table Renderer', path: '/js/work-analysis/table-renderer.js', loaded: false },
{ name: 'Chart Renderer', path: '/js/work-analysis/chart-renderer.js', loaded: false },
{ name: 'Main Controller', path: '/js/work-analysis/main-controller.js', loaded: false }
];
this.loadingPromise = null;
}
/**
* 모든 모듈 로드
* @returns {Promise} 로딩 완료 Promise
*/
async loadAll() {
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadingPromise = this._loadModules();
return this.loadingPromise;
}
/**
* 모듈들을 순차적으로 로드
*/
async _loadModules() {
try {
// 의존성 순서대로 로드
for (const module of this.modules) {
await this._loadModule(module);
}
this._onAllModulesLoaded();
} catch (error) {
console.error(' 모듈 로딩 실패:', error);
this._onLoadingError(error);
throw error;
}
}
/**
* 개별 모듈 로드
* @param {Object} module - 모듈 정보
*/
async _loadModule(module) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = module.path;
script.type = 'text/javascript';
script.onload = () => {
module.loaded = true;
resolve();
};
script.onerror = (error) => {
console.error(` 로딩 실패: ${module.name}`, error);
reject(new Error(`Failed to load ${module.name}: ${module.path}`));
};
document.head.appendChild(script);
});
}
/**
* 모든 모듈 로딩 완료 시 호출
*/
_onAllModulesLoaded() {
// 전역 변수 확인
const requiredGlobals = [
'WorkAnalysisAPI',
'WorkAnalysisDataProcessor',
'WorkAnalysisState',
'WorkAnalysisTableRenderer',
'WorkAnalysisChartRenderer'
];
const missingGlobals = requiredGlobals.filter(name => !window[name]);
if (missingGlobals.length > 0) {
console.warn(' 일부 전역 객체가 누락됨:', missingGlobals);
}
// 하위 호환성을 위한 전역 함수들 설정
this._setupLegacyFunctions();
// 모듈 로딩 완료 이벤트 발생
window.dispatchEvent(new CustomEvent('workAnalysisModulesLoaded', {
detail: { modules: this.modules }
}));
}
/**
* 하위 호환성을 위한 전역 함수 설정
*/
_setupLegacyFunctions() {
// 기존 HTML에서 사용하던 함수들을 새 모듈 시스템으로 연결
const legacyFunctions = {
// 기간 확정
confirmPeriod: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.handlePeriodConfirm();
}
},
// 분석 모드 변경
switchAnalysisMode: (mode) => {
if (window.WorkAnalysisState) {
window.WorkAnalysisState.setAnalysisMode(mode);
}
},
// 탭 변경
switchTab: (tabId) => {
if (window.WorkAnalysisState) {
window.WorkAnalysisState.setCurrentTab(tabId);
}
},
// 개별 분석 함수들
analyzeWorkStatus: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeWorkStatus();
}
},
analyzeProjectDistribution: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeProjectDistribution();
}
},
analyzeWorkerPerformance: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeWorkerPerformance();
}
},
analyzeErrorAnalysis: () => {
if (window.WorkAnalysisMainController) {
window.WorkAnalysisMainController.analyzeErrorAnalysis();
}
}
};
// 전역 함수로 등록
Object.assign(window, legacyFunctions);
}
/**
* 로딩 에러 처리
*/
_onLoadingError(error) {
// 에러 UI 표시
const container = document.querySelector('.analysis-container');
if (container) {
const errorHTML = `
<div style="
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
text-align: center;
color: #ef4444;
">
<div style="font-size: 4rem; margin-bottom: 1rem;">⚠️</div>
<h2 style="margin-bottom: 1rem;">모듈 로딩 실패</h2>
<p style="margin-bottom: 2rem; color: #666;">
작업 분석 시스템을 로드하는 중 오류가 발생했습니다.<br>
페이지를 새로고침하거나 관리자에게 문의하세요.
</p>
<button onclick="location.reload()" style="
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
">
페이지 새로고침
</button>
<details style="margin-top: 2rem; text-align: left; max-width: 600px;">
<summary style="cursor: pointer; color: #666;">기술적 세부사항</summary>
<pre style="
background: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
overflow-x: auto;
font-size: 0.875rem;
">${error.message}</pre>
</details>
</div>
`;
container.innerHTML = errorHTML;
}
}
/**
* 로딩 상태 확인
* @returns {Object} 로딩 상태 정보
*/
getLoadingStatus() {
const total = this.modules.length;
const loaded = this.modules.filter(m => m.loaded).length;
return {
total,
loaded,
percentage: Math.round((loaded / total) * 100),
isComplete: loaded === total,
modules: this.modules.map(m => ({
name: m.name,
loaded: m.loaded
}))
};
}
/**
* 특정 모듈 로딩 상태 확인
* @param {string} moduleName - 모듈명
* @returns {boolean} 로딩 완료 여부
*/
isModuleLoaded(moduleName) {
const module = this.modules.find(m => m.name === moduleName);
return module ? module.loaded : false;
}
}
// 전역 인스턴스 생성
window.WorkAnalysisModuleLoader = new WorkAnalysisModuleLoader();
// 자동 로딩 시작 (DOM이 준비되면)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
window.WorkAnalysisModuleLoader.loadAll();
});
} else {
// DOM이 이미 준비된 경우 즉시 로딩
window.WorkAnalysisModuleLoader.loadAll();
}
// Export는 브라우저 환경에서 제거됨

Some files were not shown because too many files have changed in this diff Show More