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>
@@ -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>
|
||||
1303
system1-factory/web/public/css/daily-patrol.css
Normal file
292
system1-factory/web/public/css/daily-status.css
Normal 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; }
|
||||
}
|
||||
1318
system1-factory/web/public/css/daily-work-report-mobile.css
Normal file
2389
system1-factory/web/public/css/daily-work-report.css
Normal file
509
system1-factory/web/public/css/equipment-detail.css
Normal 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);
|
||||
}
|
||||
}
|
||||
361
system1-factory/web/public/css/equipment-management.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1040
system1-factory/web/public/css/mobile.css
Normal file
4011
system1-factory/web/public/css/modern-dashboard.css
Normal file
381
system1-factory/web/public/css/monthly-comparison.css
Normal 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; }
|
||||
170
system1-factory/web/public/css/my-monthly-confirm.css
Normal 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; } }
|
||||
113
system1-factory/web/public/css/production-dashboard.css
Normal 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); } }
|
||||
85
system1-factory/web/public/css/proxy-input.css
Normal 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; } }
|
||||
425
system1-factory/web/public/css/purchase-mobile.css
Normal 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; }
|
||||
805
system1-factory/web/public/css/tbm-mobile.css
Normal 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;
|
||||
}
|
||||
1407
system1-factory/web/public/css/tbm.css
Normal file
472
system1-factory/web/public/css/vacation-allocation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1687
system1-factory/web/public/css/work-analysis.css
Normal file
108
system1-factory/web/public/css/work-report.css
Normal 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;
|
||||
}
|
||||
1427
system1-factory/web/public/css/workplace-management.css
Normal file
1492
system1-factory/web/public/css/zone-detail.css
Normal file
BIN
system1-factory/web/public/img/favicon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
system1-factory/web/public/img/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
system1-factory/web/public/img/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
system1-factory/web/public/img/login-bg.jpeg
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
system1-factory/web/public/img/logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
system1-factory/web/public/img/technicalkorea Logo.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
28071
system1-factory/web/public/img/technicalkorea_Logo.ai
Normal file
28071
system1-factory/web/public/img/technicalkorea_Logo.eps
Normal file
3390
system1-factory/web/public/img/technicalkorea_Logo.svg
Normal file
|
After Width: | Height: | Size: 369 KiB |
30
system1-factory/web/public/index.html
Normal 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>
|
||||
248
system1-factory/web/public/js/api-base.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
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);
|
||||
})();
|
||||
205
system1-factory/web/public/js/api-config.js
Normal 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 };
|
||||
1020
system1-factory/web/public/js/attendance-validation.js
Normal file
176
system1-factory/web/public/js/attendance.js
Normal 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);
|
||||
55
system1-factory/web/public/js/auth.js
Normal 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';
|
||||
}
|
||||
59
system1-factory/web/public/js/calendar.js
Normal 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);
|
||||
}
|
||||
|
||||
130
system1-factory/web/public/js/change-password.js
Normal 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' ? '❌ ' : '✅ ') + 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
56
system1-factory/web/public/js/common/base-state.js
Normal 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 로드 완료');
|
||||
144
system1-factory/web/public/js/common/utils.js
Normal 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 로드 완료');
|
||||
42
system1-factory/web/public/js/config.js
Normal 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,
|
||||
}
|
||||
};
|
||||
71
system1-factory/web/public/js/daily-issue-api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
103
system1-factory/web/public/js/daily-issue-ui.js
Normal 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 = '등록';
|
||||
}
|
||||
}
|
||||
89
system1-factory/web/public/js/daily-issue.js
Normal 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);
|
||||
1240
system1-factory/web/public/js/daily-patrol.js
Normal file
300
system1-factory/web/public/js/daily-status.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
// ===== 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;
|
||||
}
|
||||
}
|
||||
1574
system1-factory/web/public/js/daily-work-report-mobile.js
Normal file
4069
system1-factory/web/public/js/daily-work-report.js
Normal file
386
system1-factory/web/public/js/daily-work-report/api.js
Normal 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');
|
||||
}
|
||||
};
|
||||
300
system1-factory/web/public/js/daily-work-report/state.js
Normal 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; }
|
||||
}
|
||||
});
|
||||
299
system1-factory/web/public/js/daily-work-report/utils.js
Normal 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?.();
|
||||
}
|
||||
};
|
||||
329
system1-factory/web/public/js/department-management.js
Normal 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';
|
||||
}
|
||||
793
system1-factory/web/public/js/equipment-detail.js
Normal 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})">×</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'
|
||||
});
|
||||
}
|
||||
466
system1-factory/web/public/js/equipment-management.js
Normal 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();
|
||||
}
|
||||
});
|
||||
49
system1-factory/web/public/js/factory-upload.js
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
38
system1-factory/web/public/js/factory-view.js
Normal 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>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
149
system1-factory/web/public/js/group-leader-dashboard.js
Normal 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 };
|
||||
421
system1-factory/web/public/js/issue-category-manage.js
Normal 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);
|
||||
}
|
||||
};
|
||||
49
system1-factory/web/public/js/login.js
Normal 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;
|
||||
}
|
||||
});
|
||||
86
system1-factory/web/public/js/manage-issue.js
Normal 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();
|
||||
});
|
||||
93
system1-factory/web/public/js/manage-pipespec.js
Normal 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);
|
||||
90
system1-factory/web/public/js/manage-project.js
Normal 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);
|
||||
111
system1-factory/web/public/js/manage-worker.js
Normal 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);
|
||||
940
system1-factory/web/public/js/management-dashboard.js
Normal 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;
|
||||
358
system1-factory/web/public/js/meeting-detail.js
Normal 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'); }
|
||||
}
|
||||
106
system1-factory/web/public/js/meetings.js
Normal 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 {}
|
||||
}
|
||||
371
system1-factory/web/public/js/mobile-dashboard.js
Normal 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">▼</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">🛠</span>' +
|
||||
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 · ' + 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">⚠</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">↔</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">▶ 작업</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) + ' · ' + 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">▶ 신고</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' ? '🔴' : '⚠';
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
|
||||
if (desc) html += ' · ' + escapeHtml(desc);
|
||||
html += '</div>';
|
||||
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
|
||||
if (reporter) html += ' → ' + 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">▶ 이동설비</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) + ' → ' + 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();
|
||||
});
|
||||
})();
|
||||
1348
system1-factory/web/public/js/modern-dashboard.js
Normal file
833
system1-factory/web/public/js/monthly-comparison.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
391
system1-factory/web/public/js/my-attendance.js
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 = '-';
|
||||
}
|
||||
187
system1-factory/web/public/js/my-dashboard.js
Normal 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;
|
||||
400
system1-factory/web/public/js/my-monthly-confirm.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
// 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); });
|
||||
121
system1-factory/web/public/js/my-profile.js
Normal 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();
|
||||
});
|
||||
14
system1-factory/web/public/js/navigation.js
Normal 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 || '/';
|
||||
}
|
||||
222
system1-factory/web/public/js/nonconformity-list.js
Normal 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`;
|
||||
}
|
||||
245
system1-factory/web/public/js/production-dashboard.js
Normal 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);
|
||||
}
|
||||
43
system1-factory/web/public/js/project-analysis-api.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
170
system1-factory/web/public/js/project-analysis-ui.js
Normal 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,
|
||||
};
|
||||
}
|
||||
106
system1-factory/web/public/js/project-analysis.js
Normal 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);
|
||||
546
system1-factory/web/public/js/project-management.js
Normal 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;
|
||||
262
system1-factory/web/public/js/proxy-input.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
701
system1-factory/web/public/js/schedule.js
Normal 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)} ${formatDate(e.start_date)}~${formatDate(e.end_date)} 진행률: ${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)} ${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>`;
|
||||
});
|
||||
}
|
||||
39
system1-factory/web/public/js/sso-relay.js
Normal 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);
|
||||
})();
|
||||
885
system1-factory/web/public/js/system-dashboard.js
Normal 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';
|
||||
}
|
||||
});
|
||||
};
|
||||
481
system1-factory/web/public/js/task-management.js
Normal 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;
|
||||
625
system1-factory/web/public/js/tbm-create.js
Normal 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 = '다음 →';
|
||||
_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">✓</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);
|
||||
}
|
||||
|
||||
})();
|
||||
1335
system1-factory/web/public/js/tbm-mobile.js
Normal file
3020
system1-factory/web/public/js/tbm.js
Normal file
624
system1-factory/web/public/js/tbm/api.js
Normal 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 로드 완료');
|
||||
322
system1-factory/web/public/js/tbm/state.js
Normal 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 로드 완료');
|
||||
127
system1-factory/web/public/js/tbm/utils.js
Normal 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 로드 완료');
|
||||
93
system1-factory/web/public/js/user-dashboard.js
Normal 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);
|
||||
855
system1-factory/web/public/js/vacation-allocation.js
Normal 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 전역 사용
|
||||
241
system1-factory/web/public/js/vacation-common.js
Normal 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);
|
||||
});
|
||||
}
|
||||
712
system1-factory/web/public/js/work-analysis.js
Normal 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;
|
||||
225
system1-factory/web/public/js/work-analysis/api-client.js
Normal 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는 브라우저 환경에서 제거됨
|
||||
443
system1-factory/web/public/js/work-analysis/chart-renderer.js
Normal 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는 브라우저 환경에서 제거됨
|
||||
349
system1-factory/web/public/js/work-analysis/data-processor.js
Normal 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는 브라우저 환경에서 제거됨
|
||||
596
system1-factory/web/public/js/work-analysis/main-controller.js
Normal 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는 브라우저 환경에서 제거됨
|
||||
261
system1-factory/web/public/js/work-analysis/module-loader.js
Normal 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는 브라우저 환경에서 제거됨
|
||||