feat: 일일순회점검 시스템 구축 및 관리 기능 개선
- 일일순회점검 시스템 신규 구현 - DB 테이블: patrol_checklist_items, daily_patrol_sessions, patrol_check_records, workplace_items, item_types - API: /api/patrol/* 엔드포인트 - 프론트엔드: 지도 기반 작업장 점검 UI - 설비 관리 기능 개선 - 구매 관련 필드 추가 (구매일, 가격, 공급업체 등) - 설비 코드 자동 생성 (TKP-XXX 형식) - 작업장 관리 개선 - 레이아웃 이미지 업로드 기능 - 마커 위치 저장 기능 - 부서 관리 기능 추가 - 사이드바 네비게이션 카테고리 재구성 - 이미지 401 오류 수정 (정적 파일 경로 처리) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<a href="#" id="dashboardBtn" class="dashboard-btn">
|
||||
<a href="/pages/dashboard.html" id="dashboardBtn" class="dashboard-btn">
|
||||
<span class="btn-icon">📊</span>
|
||||
<span class="btn-text">대시보드</span>
|
||||
</a>
|
||||
|
||||
@@ -46,8 +46,14 @@
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="/pages/attendance/daily.html" class="nav-item" data-page-key="inspection.daily">
|
||||
<span class="nav-text">일일 출퇴근</span>
|
||||
<a href="/pages/inspection/daily-patrol.html" class="nav-item" data-page-key="inspection.daily_patrol">
|
||||
<span class="nav-text">일일순회점검</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/checkin.html" class="nav-item" data-page-key="inspection.checkin">
|
||||
<span class="nav-text">출근 체크</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/work-status.html" class="nav-item" data-page-key="inspection.work_status">
|
||||
<span class="nav-text">근무 현황</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,9 +118,6 @@
|
||||
<a href="/pages/admin/accounts.html" class="nav-item" data-page-key="admin.accounts">
|
||||
<span class="nav-text">계정 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/page-access.html" class="nav-item" data-page-key="admin.page_access">
|
||||
<span class="nav-text">권한 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/workers.html" class="nav-item" data-page-key="admin.workers">
|
||||
<span class="nav-text">작업자 관리</span>
|
||||
</a>
|
||||
@@ -320,8 +323,6 @@
|
||||
|
||||
/* 메인 콘텐츠 여백 */
|
||||
body.has-sidebar .dashboard-container,
|
||||
body.has-sidebar .main-content,
|
||||
body.has-sidebar .page-container,
|
||||
body.has-sidebar .work-report-container,
|
||||
body.has-sidebar .analysis-container,
|
||||
body.has-sidebar > .dashboard-main {
|
||||
@@ -329,15 +330,33 @@ body.has-sidebar > .dashboard-main {
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
/* page-container 사용 시: page-container에만 margin 적용 (main-content 중복 방지) */
|
||||
body.has-sidebar .page-container {
|
||||
margin-left: 260px;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
/* page-container 없이 main-content만 있는 경우 */
|
||||
body.has-sidebar > .main-content {
|
||||
margin-left: 260px;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
body.has-sidebar.sidebar-collapsed .dashboard-container,
|
||||
body.has-sidebar.sidebar-collapsed .main-content,
|
||||
body.has-sidebar.sidebar-collapsed .page-container,
|
||||
body.has-sidebar.sidebar-collapsed .work-report-container,
|
||||
body.has-sidebar.sidebar-collapsed .analysis-container,
|
||||
body.has-sidebar.sidebar-collapsed > .dashboard-main {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
body.has-sidebar.sidebar-collapsed .page-container {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
body.has-sidebar.sidebar-collapsed > .main-content {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-nav {
|
||||
@@ -349,8 +368,8 @@ body.has-sidebar.sidebar-collapsed > .dashboard-main {
|
||||
}
|
||||
|
||||
body.has-sidebar .dashboard-container,
|
||||
body.has-sidebar .main-content,
|
||||
body.has-sidebar .page-container,
|
||||
body.has-sidebar > .main-content,
|
||||
body.has-sidebar .work-report-container,
|
||||
body.has-sidebar .analysis-container,
|
||||
body.has-sidebar > .dashboard-main {
|
||||
|
||||
@@ -165,7 +165,8 @@ body {
|
||||
/* 메인 콘텐츠 영역 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--space-xl);
|
||||
padding: var(--space-lg);
|
||||
padding-left: var(--space-md);
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -784,3 +784,347 @@
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 폴더 트리 스타일 */
|
||||
.folder-tree {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.folder-group {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.folder-header:hover {
|
||||
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.folder-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
background: white;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.folder-toggle {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.folder-content {
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-left: 1rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.page-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.page-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: #007bff;
|
||||
}
|
||||
|
||||
.page-label input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.page-label .page-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.always-access-badge {
|
||||
font-size: 0.65rem;
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 활성화/비활성화 버튼 스타일 */
|
||||
.action-btn.activate {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.activate:hover {
|
||||
background: linear-gradient(135deg, #20c997 0%, #17a2b8 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.deactivate {
|
||||
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.deactivate:hover {
|
||||
background: linear-gradient(135deg, #495057 0%, #343a40 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: linear-gradient(135deg, #c82333 0%, #a71d2a 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
/* 작업자 연결 스타일 */
|
||||
.worker-link-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.linked-worker-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.linked-worker-info .no-worker {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.linked-worker-info .worker-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.linked-worker-info .worker-badge .dept-name {
|
||||
opacity: 0.9;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 작업자 선택 모달 */
|
||||
.worker-select-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.department-list-panel,
|
||||
.worker-list-panel {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.department-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.department-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.department-item.active {
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.department-item .dept-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.department-item .dept-name {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.department-item .dept-count {
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0,0,0,0.1);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.department-item.active .dept-count {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.worker-list {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.worker-list .empty-message {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.worker-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.worker-select-item:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #e9ecef;
|
||||
}
|
||||
|
||||
.worker-select-item.selected {
|
||||
background: #e7f3ff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.worker-select-item .worker-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #007bff 0%, #6610f2 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.worker-select-item .worker-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.worker-select-item .worker-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.worker-select-item .worker-role {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.worker-select-item .select-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.worker-select-item.selected .select-indicator {
|
||||
background: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.worker-select-item .already-linked {
|
||||
font-size: 0.7rem;
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 버튼 크기 조정 */
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
625
web-ui/css/daily-patrol.css
Normal file
625
web-ui/css/daily-patrol.css
Normal file
@@ -0,0 +1,625 @@
|
||||
/* daily-patrol.css - 일일순회점검 페이지 스타일 */
|
||||
|
||||
/* 세션 선택 영역 */
|
||||
.patrol-session-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.patrol-date-time {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.patrol-date-time .form-group {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.patrol-time-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.patrol-time-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 2px solid var(--border-color, #e2e8f0);
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.patrol-time-btn:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.patrol-time-btn.active {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 오늘 점검 현황 요약 */
|
||||
.today-status-summary {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
text-align: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-value.completed {
|
||||
color: var(--success-color, #16a34a);
|
||||
}
|
||||
|
||||
.status-value.pending {
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
/* 점검 영역 */
|
||||
.patrol-area {
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 세션 정보 바 */
|
||||
.session-info-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, var(--primary-color, #3b82f6), #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.session-info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.session-info-label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.session-info-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 150px;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 점검 콘텐츠 영역 */
|
||||
.patrol-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.patrol-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 지도 영역 */
|
||||
.patrol-map-section {
|
||||
padding: 1.5rem;
|
||||
border-right: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.map-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-item.completed .dot {
|
||||
background: var(--success-color, #16a34a);
|
||||
}
|
||||
|
||||
.legend-item.in-progress .dot {
|
||||
background: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.legend-item.pending .dot {
|
||||
background: var(--border-color, #cbd5e1);
|
||||
}
|
||||
|
||||
/* 지도 컨테이너 */
|
||||
.patrol-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.patrol-map-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 작업장 마커 */
|
||||
.workplace-marker {
|
||||
position: absolute;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border: 2px solid var(--border-color, #cbd5e1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workplace-marker:hover {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.workplace-marker.completed {
|
||||
border-color: var(--success-color, #16a34a);
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.workplace-marker.in-progress {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.workplace-marker.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 작업장 목록 (지도 대체) */
|
||||
.workplace-list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.workplace-card {
|
||||
padding: 1rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border: 2px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workplace-card:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
}
|
||||
|
||||
.workplace-card.completed {
|
||||
border-color: var(--success-color, #16a34a);
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.workplace-card.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.workplace-card-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.workplace-card-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* 체크리스트 영역 */
|
||||
.patrol-checklist-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
}
|
||||
|
||||
.checklist-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
background: var(--surface-color, #fff);
|
||||
}
|
||||
|
||||
.checklist-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.checklist-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checklist-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.checklist-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* 체크리스트 카테고리 */
|
||||
.checklist-category {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.checklist-category-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
/* 체크 항목 */
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-color, #fff);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.check-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.check-item.checked {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.check-item-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color, #cbd5e1);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.check-item.checked .check-item-checkbox {
|
||||
background: var(--success-color, #16a34a);
|
||||
border-color: var(--success-color, #16a34a);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.check-item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.check-item-text {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.check-item-required {
|
||||
color: var(--error-color, #dc2626);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.check-item-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* 체크 결과 선택 */
|
||||
.check-result-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.check-result-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-color, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.check-result-btn.good.active {
|
||||
background: #dcfce7;
|
||||
border-color: var(--success-color, #16a34a);
|
||||
color: var(--success-color, #16a34a);
|
||||
}
|
||||
|
||||
.check-result-btn.warning.active {
|
||||
background: #fef3c7;
|
||||
border-color: var(--warning-color, #f59e0b);
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.check-result-btn.bad.active {
|
||||
background: #fee2e2;
|
||||
border-color: var(--error-color, #dc2626);
|
||||
color: var(--error-color, #dc2626);
|
||||
}
|
||||
|
||||
/* 체크리스트 액션 */
|
||||
.checklist-actions {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
background: var(--surface-color, #fff);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 물품 현황 섹션 */
|
||||
.items-section {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.items-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.items-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.items-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: var(--bg-color, #f8fafc);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.items-map-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 물품 마커 */
|
||||
.item-marker {
|
||||
position: absolute;
|
||||
border: 2px solid;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.item-marker:hover {
|
||||
transform: scale(1.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.item-marker.container {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.item-marker.plate {
|
||||
border-color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.item-marker.material {
|
||||
border-color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.item-marker.tool {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.item-marker.other {
|
||||
border-color: #6b7280;
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
}
|
||||
|
||||
/* 물품 범례 */
|
||||
.items-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.item-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.item-legend-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 순회점검 완료 섹션 */
|
||||
.patrol-complete-section {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.patrol-complete-section .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.patrol-session-selector {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.patrol-date-time {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.patrol-date-time .form-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.patrol-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.patrol-map-section {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.patrol-complete-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-info-bar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
361
web-ui/css/equipment-management.css
Normal file
361
web-ui/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;
|
||||
}
|
||||
}
|
||||
1185
web-ui/css/tbm.css
Normal file
1185
web-ui/css/tbm.css
Normal file
File diff suppressed because it is too large
Load Diff
1427
web-ui/css/workplace-management.css
Normal file
1427
web-ui/css/workplace-management.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -70,34 +70,12 @@ async function initializePage() {
|
||||
}
|
||||
|
||||
// ========== 사용자 정보 설정 ========== //
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function setupUserInfo() {
|
||||
const authData = getAuthData();
|
||||
if (authData && authData.user) {
|
||||
currentUser = authData.user;
|
||||
|
||||
// 사용자 이름 설정
|
||||
if (elements.userName) {
|
||||
elements.userName.textContent = currentUser.name || currentUser.username;
|
||||
}
|
||||
|
||||
// 사용자 역할 설정
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자'
|
||||
};
|
||||
if (elements.userRole) {
|
||||
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
|
||||
}
|
||||
|
||||
// 아바타 초기값 설정
|
||||
if (elements.userInitial) {
|
||||
const initial = (currentUser.name || currentUser.username).charAt(0);
|
||||
elements.userInitial.textContent = initial;
|
||||
}
|
||||
|
||||
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
|
||||
console.log('👤 사용자 정보 로드 완료:', currentUser.name, currentUser.role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +227,10 @@ function renderUsersTable() {
|
||||
<button class="action-btn reset-pw" onclick="resetPassword(${user.user_id}, '${user.username}')" title="비밀번호 000000으로 초기화">
|
||||
비번초기화
|
||||
</button>
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
<button class="action-btn ${user.is_active ? 'deactivate' : 'activate'}" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
<button class="action-btn delete" onclick="deleteUser(${user.user_id})">
|
||||
<button class="action-btn delete danger" onclick="permanentDeleteUser(${user.user_id}, '${user.username}')" title="영구 삭제 (복구 불가)">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@@ -325,25 +303,31 @@ function handleFilter(e) {
|
||||
// ========== 모달 관리 ========== //
|
||||
function openAddUserModal() {
|
||||
currentEditingUser = null;
|
||||
|
||||
|
||||
if (elements.modalTitle) {
|
||||
elements.modalTitle.textContent = '새 사용자 추가';
|
||||
}
|
||||
|
||||
|
||||
// 폼 초기화
|
||||
if (elements.userForm) {
|
||||
elements.userForm.reset();
|
||||
}
|
||||
|
||||
|
||||
// 비밀번호 필드 표시
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = true;
|
||||
}
|
||||
|
||||
|
||||
// 작업자 연결 섹션 숨기기 (새 사용자 추가 시)
|
||||
const workerLinkGroup = document.getElementById('workerLinkGroup');
|
||||
if (workerLinkGroup) {
|
||||
workerLinkGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
@@ -373,16 +357,23 @@ function editUser(userId) {
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = roleToValueMap[user.role] || 'user';
|
||||
if (elements.userEmailInput) elements.userEmailInput.value = user.email || '';
|
||||
if (elements.userPhoneInput) elements.userPhoneInput.value = user.phone || '';
|
||||
|
||||
|
||||
// 비밀번호 필드 숨기기 (수정 시에는 선택사항)
|
||||
if (elements.passwordGroup) {
|
||||
elements.passwordGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
if (elements.userPasswordInput) {
|
||||
elements.userPasswordInput.required = false;
|
||||
}
|
||||
|
||||
|
||||
// 작업자 연결 섹션 표시 (수정 시에만)
|
||||
const workerLinkGroup = document.getElementById('workerLinkGroup');
|
||||
if (workerLinkGroup) {
|
||||
workerLinkGroup.style.display = 'block';
|
||||
updateLinkedWorkerDisplay(user);
|
||||
}
|
||||
|
||||
if (elements.userModal) {
|
||||
elements.userModal.style.display = 'flex';
|
||||
}
|
||||
@@ -395,14 +386,29 @@ function closeUserModal() {
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
currentEditingUser = user;
|
||||
|
||||
if (elements.deleteModal) {
|
||||
elements.deleteModal.style.display = 'flex';
|
||||
// 영구 삭제 (Hard Delete)
|
||||
async function permanentDeleteUser(userId, username) {
|
||||
if (!confirm(`⚠️ 경고: "${username}" 사용자를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!\n관련된 모든 데이터(로그인 기록, 권한 설정 등)도 함께 삭제됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이중 확인
|
||||
if (!confirm(`정말로 "${username}"을(를) 영구 삭제하시겠습니까?\n\n[확인]을 누르면 즉시 삭제됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${userId}/permanent`, 'DELETE');
|
||||
|
||||
if (response.success) {
|
||||
showToast(`"${username}" 사용자가 영구 삭제되었습니다.`, 'success');
|
||||
await loadUsers();
|
||||
} else {
|
||||
throw new Error(response.message || '사용자 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 영구 삭제 오류:', error);
|
||||
showToast(`사용자 삭제 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,10 +572,10 @@ function showToast(message, type = 'info', duration = 3000) {
|
||||
|
||||
// ========== 전역 함수 (HTML에서 호출) ========== //
|
||||
window.editUser = editUser;
|
||||
window.deleteUser = deleteUser;
|
||||
window.toggleUserStatus = toggleUserStatus;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
window.permanentDeleteUser = permanentDeleteUser;
|
||||
|
||||
// ========== 페이지 권한 관리 ========== //
|
||||
let allPages = [];
|
||||
@@ -599,75 +605,6 @@ async function loadUserPageAccess(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
function renderPageAccessList(userRole) {
|
||||
const pageAccessList = document.getElementById('pageAccessList');
|
||||
const pageAccessGroup = document.getElementById('pageAccessGroup');
|
||||
|
||||
if (!pageAccessList || !pageAccessGroup) return;
|
||||
|
||||
// Admin 사용자는 권한 설정 불필요
|
||||
if (userRole === 'admin') {
|
||||
pageAccessGroup.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
pageAccessGroup.style.display = 'block';
|
||||
|
||||
// 카테고리별로 페이지 그룹화
|
||||
const pagesByCategory = {
|
||||
'work': [],
|
||||
'admin': [],
|
||||
'common': [],
|
||||
'profile': []
|
||||
};
|
||||
|
||||
allPages.forEach(page => {
|
||||
const category = page.category || 'common';
|
||||
if (pagesByCategory[category]) {
|
||||
pagesByCategory[category].push(page);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'common': '공통',
|
||||
'work': '작업',
|
||||
'admin': '관리',
|
||||
'profile': '프로필'
|
||||
};
|
||||
|
||||
// HTML 생성
|
||||
let html = '';
|
||||
|
||||
Object.keys(pagesByCategory).forEach(category => {
|
||||
const pages = pagesByCategory[category];
|
||||
if (pages.length === 0) return;
|
||||
|
||||
const catName = categoryNames[category] || category;
|
||||
html += '<div class="page-access-category">';
|
||||
html += '<div class="page-access-category-title">' + catName + '</div>';
|
||||
|
||||
pages.forEach(page => {
|
||||
// 프로필과 대시보드는 모든 사용자가 접근 가능하므로 체크박스 비활성화
|
||||
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
|
||||
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
|
||||
|
||||
html += '<div class="page-access-item"><label>';
|
||||
html += '<input type="checkbox" class="page-access-checkbox" ';
|
||||
html += 'data-page-id="' + page.id + '" ';
|
||||
html += 'data-page-key="' + page.page_key + '" ';
|
||||
html += (isChecked ? 'checked ' : '');
|
||||
html += (isAlwaysAccessible ? 'disabled ' : '');
|
||||
html += '>';
|
||||
html += '<span class="page-name">' + page.page_name + '</span>';
|
||||
html += '</label></div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
pageAccessList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 페이지 권한 저장
|
||||
async function savePageAccess(userId, containerId = null) {
|
||||
@@ -701,61 +638,6 @@ async function savePageAccess(userId, containerId = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// editUser 함수를 수정하여 페이지 권한 로드 추가
|
||||
const originalEditUser = window.editUser;
|
||||
window.editUser = async function(userId) {
|
||||
// 페이지 목록이 없으면 로드
|
||||
if (allPages.length === 0) {
|
||||
await loadAllPages();
|
||||
}
|
||||
|
||||
// 원래 editUser 함수 실행
|
||||
if (originalEditUser) {
|
||||
originalEditUser(userId);
|
||||
}
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
await loadUserPageAccess(userId);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) return;
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
const roleToValueMap = {
|
||||
'Admin': 'admin',
|
||||
'System Admin': 'admin',
|
||||
'User': 'user',
|
||||
'Guest': 'user'
|
||||
};
|
||||
const userRole = roleToValueMap[user.role] || 'user';
|
||||
renderPageAccessList(userRole);
|
||||
};
|
||||
|
||||
// saveUser 함수를 수정하여 페이지 권한 저장 추가
|
||||
const originalSaveUser = window.saveUser;
|
||||
window.saveUser = async function() {
|
||||
try {
|
||||
// 원래 saveUser 함수 실행
|
||||
if (originalSaveUser) {
|
||||
await originalSaveUser();
|
||||
}
|
||||
|
||||
// 사용자 편집 시에만 페이지 권한 저장
|
||||
if (currentEditingUser && currentEditingUser.user_id) {
|
||||
const userRole = document.getElementById('userRole')?.value;
|
||||
|
||||
// Admin이 아닌 경우에만 페이지 권한 저장
|
||||
if (userRole !== 'admin') {
|
||||
await savePageAccess(currentEditingUser.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 저장 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -806,66 +688,106 @@ function closePageAccessModal() {
|
||||
currentPageAccessUser = null;
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링 (모달용)
|
||||
// 페이지 권한 체크박스 렌더링 (모달용) - 폴더 구조 형태
|
||||
function renderPageAccessModalList() {
|
||||
const pageAccessList = document.getElementById('pageAccessModalList');
|
||||
if (!pageAccessList) return;
|
||||
|
||||
// 카테고리별로 페이지 그룹화
|
||||
const pagesByCategory = {
|
||||
'work': [],
|
||||
'admin': [],
|
||||
'common': [],
|
||||
'profile': []
|
||||
// 폴더 구조 정의 (page_key 패턴 기준)
|
||||
const folderStructure = {
|
||||
'dashboard': { name: '대시보드', icon: '📊', pages: [] },
|
||||
'work': { name: '작업 관리', icon: '📋', pages: [] },
|
||||
'safety': { name: '안전 관리', icon: '🛡️', pages: [] },
|
||||
'attendance': { name: '근태 관리', icon: '📅', pages: [] },
|
||||
'admin': { name: '시스템 관리', icon: '⚙️', pages: [] },
|
||||
'profile': { name: '내 정보', icon: '👤', pages: [] }
|
||||
};
|
||||
|
||||
// 페이지를 폴더별로 분류
|
||||
allPages.forEach(page => {
|
||||
const category = page.category || 'common';
|
||||
if (pagesByCategory[category]) {
|
||||
pagesByCategory[category].push(page);
|
||||
const pageKey = page.page_key || '';
|
||||
|
||||
if (pageKey === 'dashboard') {
|
||||
folderStructure['dashboard'].pages.push(page);
|
||||
} else if (pageKey.startsWith('work.')) {
|
||||
folderStructure['work'].pages.push(page);
|
||||
} else if (pageKey.startsWith('safety.')) {
|
||||
folderStructure['safety'].pages.push(page);
|
||||
} else if (pageKey.startsWith('attendance.')) {
|
||||
folderStructure['attendance'].pages.push(page);
|
||||
} else if (pageKey.startsWith('admin.')) {
|
||||
folderStructure['admin'].pages.push(page);
|
||||
} else if (pageKey.startsWith('profile.')) {
|
||||
folderStructure['profile'].pages.push(page);
|
||||
}
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'common': '공통',
|
||||
'work': '작업',
|
||||
'admin': '관리',
|
||||
'profile': '프로필'
|
||||
};
|
||||
// HTML 생성 - 폴더 트리 형태
|
||||
let html = '<div class="folder-tree">';
|
||||
|
||||
// HTML 생성
|
||||
let html = '';
|
||||
Object.keys(folderStructure).forEach(folderKey => {
|
||||
const folder = folderStructure[folderKey];
|
||||
if (folder.pages.length === 0) return;
|
||||
|
||||
Object.keys(pagesByCategory).forEach(category => {
|
||||
const pages = pagesByCategory[category];
|
||||
if (pages.length === 0) return;
|
||||
const folderId = 'folder-' + folderKey;
|
||||
|
||||
const catName = categoryNames[category] || category;
|
||||
html += '<div class="page-access-category">';
|
||||
html += '<div class="page-access-category-title">' + catName + '</div>';
|
||||
html += '<div class="folder-group">';
|
||||
html += '<div class="folder-header" onclick="toggleFolder(\'' + folderId + '\')">';
|
||||
html += '<span class="folder-icon">' + folder.icon + '</span>';
|
||||
html += '<span class="folder-name">' + folder.name + '</span>';
|
||||
html += '<span class="folder-count">(' + folder.pages.length + ')</span>';
|
||||
html += '<span class="folder-toggle" id="toggle-' + folderId + '">▼</span>';
|
||||
html += '</div>';
|
||||
|
||||
pages.forEach(page => {
|
||||
html += '<div class="folder-content" id="' + folderId + '">';
|
||||
|
||||
folder.pages.forEach(page => {
|
||||
// 프로필과 대시보드는 모든 사용자가 접근 가능
|
||||
const isAlwaysAccessible = page.page_key === 'dashboard' || page.page_key.startsWith('profile.');
|
||||
const isChecked = userPageAccess.find(p => p.page_id === page.id && p.can_access === 1) || isAlwaysAccessible;
|
||||
|
||||
html += '<div class="page-access-item"><label>';
|
||||
// 파일명만 추출 (page_key에서)
|
||||
const fileName = page.page_key.split('.').pop() || page.page_key;
|
||||
|
||||
html += '<div class="page-item">';
|
||||
html += '<label class="page-label">';
|
||||
html += '<input type="checkbox" class="page-access-checkbox" ';
|
||||
html += 'data-page-id="' + page.id + '" ';
|
||||
html += 'data-page-key="' + page.page_key + '" ';
|
||||
html += (isChecked ? 'checked ' : '');
|
||||
html += (isAlwaysAccessible ? 'disabled ' : '');
|
||||
html += '>';
|
||||
html += '<span class="file-icon">📄</span>';
|
||||
html += '<span class="page-name">' + page.page_name + '</span>';
|
||||
html += '</label></div>';
|
||||
if (isAlwaysAccessible) {
|
||||
html += '<span class="always-access-badge">기본</span>';
|
||||
}
|
||||
html += '</label>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>'; // folder-content
|
||||
html += '</div>'; // folder-group
|
||||
});
|
||||
|
||||
html += '</div>'; // folder-tree
|
||||
|
||||
pageAccessList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 폴더 접기/펼치기
|
||||
function toggleFolder(folderId) {
|
||||
const content = document.getElementById(folderId);
|
||||
const toggle = document.getElementById('toggle-' + folderId);
|
||||
|
||||
if (content && toggle) {
|
||||
const isExpanded = content.style.display !== 'none';
|
||||
content.style.display = isExpanded ? 'none' : 'block';
|
||||
toggle.textContent = isExpanded ? '▶' : '▼';
|
||||
}
|
||||
}
|
||||
window.toggleFolder = toggleFolder;
|
||||
|
||||
// 페이지 권한 저장 (모달용)
|
||||
async function savePageAccessFromModal() {
|
||||
if (!currentPageAccessUser) {
|
||||
@@ -899,3 +821,256 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
saveBtn.addEventListener('click', savePageAccessFromModal);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 작업자 연결 기능 ========== //
|
||||
let departments = [];
|
||||
let selectedWorkerId = null;
|
||||
|
||||
// 연결된 작업자 정보 표시 업데이트
|
||||
function updateLinkedWorkerDisplay(user) {
|
||||
const linkedWorkerInfo = document.getElementById('linkedWorkerInfo');
|
||||
if (!linkedWorkerInfo) return;
|
||||
|
||||
if (user.worker_id && user.worker_name) {
|
||||
linkedWorkerInfo.innerHTML = `
|
||||
<span class="worker-badge">
|
||||
<span class="worker-name">👤 ${user.worker_name}</span>
|
||||
${user.department_name ? `<span class="dept-name">(${user.department_name})</span>` : ''}
|
||||
</span>
|
||||
`;
|
||||
} else {
|
||||
linkedWorkerInfo.innerHTML = '<span class="no-worker">연결된 작업자 없음</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 선택 모달 열기
|
||||
async function openWorkerSelectModal() {
|
||||
if (!currentEditingUser) {
|
||||
showToast('사용자 정보가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedWorkerId = currentEditingUser.worker_id || null;
|
||||
|
||||
// 부서 목록 로드
|
||||
await loadDepartmentsForSelect();
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('workerSelectModal').style.display = 'flex';
|
||||
}
|
||||
window.openWorkerSelectModal = openWorkerSelectModal;
|
||||
|
||||
// 작업자 선택 모달 닫기
|
||||
function closeWorkerSelectModal() {
|
||||
document.getElementById('workerSelectModal').style.display = 'none';
|
||||
selectedWorkerId = null;
|
||||
}
|
||||
window.closeWorkerSelectModal = closeWorkerSelectModal;
|
||||
|
||||
// 부서 목록 로드
|
||||
async function loadDepartmentsForSelect() {
|
||||
try {
|
||||
const response = await window.apiCall('/departments');
|
||||
departments = response.data || response || [];
|
||||
|
||||
renderDepartmentList();
|
||||
} catch (error) {
|
||||
console.error('부서 목록 로드 실패:', error);
|
||||
showToast('부서 목록을 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 부서 목록 렌더링
|
||||
function renderDepartmentList() {
|
||||
const container = document.getElementById('departmentList');
|
||||
if (!container) return;
|
||||
|
||||
if (departments.length === 0) {
|
||||
container.innerHTML = '<div class="empty-message">등록된 부서가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = departments.map(dept => `
|
||||
<div class="department-item" data-dept-id="${dept.department_id}" onclick="selectDepartment(${dept.department_id})">
|
||||
<span class="dept-icon">📁</span>
|
||||
<span class="dept-name">${dept.department_name}</span>
|
||||
<span class="dept-count">${dept.worker_count || 0}명</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 부서 선택
|
||||
async function selectDepartment(departmentId) {
|
||||
// 활성 상태 업데이트
|
||||
document.querySelectorAll('.department-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`.department-item[data-dept-id="${departmentId}"]`)?.classList.add('active');
|
||||
|
||||
// 해당 부서의 작업자 목록 로드
|
||||
await loadWorkersForSelect(departmentId);
|
||||
}
|
||||
window.selectDepartment = selectDepartment;
|
||||
|
||||
// 부서별 작업자 목록 로드
|
||||
async function loadWorkersForSelect(departmentId) {
|
||||
try {
|
||||
const response = await window.apiCall(`/departments/${departmentId}/workers`);
|
||||
const workers = response.data || response || [];
|
||||
|
||||
renderWorkerListForSelect(workers);
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 실패:', error);
|
||||
showToast('작업자 목록을 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 목록 렌더링 (선택용)
|
||||
function renderWorkerListForSelect(workers) {
|
||||
const container = document.getElementById('workerListForSelect');
|
||||
if (!container) return;
|
||||
|
||||
if (workers.length === 0) {
|
||||
container.innerHTML = '<div class="empty-message">이 부서에 작업자가 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 다른 계정에 연결된 작업자 확인을 위해 users 배열 사용
|
||||
const linkedWorkerIds = users
|
||||
.filter(u => u.worker_id && u.user_id !== currentEditingUser?.user_id)
|
||||
.map(u => u.worker_id);
|
||||
|
||||
container.innerHTML = workers.map(worker => {
|
||||
const isSelected = selectedWorkerId === worker.worker_id;
|
||||
const isLinkedToOther = linkedWorkerIds.includes(worker.worker_id);
|
||||
const linkedUser = isLinkedToOther ? users.find(u => u.worker_id === worker.worker_id) : null;
|
||||
|
||||
return `
|
||||
<div class="worker-select-item ${isSelected ? 'selected' : ''} ${isLinkedToOther ? 'disabled' : ''}"
|
||||
onclick="${isLinkedToOther ? '' : `selectWorker(${worker.worker_id}, '${worker.worker_name}')`}">
|
||||
<div class="worker-avatar">${worker.worker_name.charAt(0)}</div>
|
||||
<div class="worker-info">
|
||||
<div class="worker-name">${worker.worker_name}</div>
|
||||
<div class="worker-role">${getJobTypeName(worker.job_type)}</div>
|
||||
</div>
|
||||
${isLinkedToOther ? `<span class="already-linked">${linkedUser?.username} 연결됨</span>` : ''}
|
||||
<div class="select-indicator">${isSelected ? '✓' : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 직책 한글 변환
|
||||
function getJobTypeName(jobType) {
|
||||
const names = {
|
||||
leader: '그룹장',
|
||||
worker: '작업자',
|
||||
admin: '관리자'
|
||||
};
|
||||
return names[jobType] || jobType || '-';
|
||||
}
|
||||
|
||||
// 작업자 선택
|
||||
async function selectWorker(workerId, workerName) {
|
||||
selectedWorkerId = workerId;
|
||||
|
||||
// UI 업데이트
|
||||
document.querySelectorAll('.worker-select-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
item.querySelector('.select-indicator').textContent = '';
|
||||
});
|
||||
|
||||
const selectedItem = document.querySelector(`.worker-select-item[onclick*="${workerId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
selectedItem.querySelector('.select-indicator').textContent = '✓';
|
||||
}
|
||||
|
||||
// 서버에 저장
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', {
|
||||
worker_id: workerId
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// currentEditingUser 업데이트
|
||||
currentEditingUser.worker_id = workerId;
|
||||
currentEditingUser.worker_name = workerName;
|
||||
|
||||
// 부서 정보도 업데이트
|
||||
const dept = departments.find(d =>
|
||||
document.querySelector(`.department-item.active`)?.dataset.deptId == d.department_id
|
||||
);
|
||||
if (dept) {
|
||||
currentEditingUser.department_name = dept.department_name;
|
||||
}
|
||||
|
||||
// users 배열 업데이트
|
||||
const userIndex = users.findIndex(u => u.user_id === currentEditingUser.user_id);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = { ...users[userIndex], ...currentEditingUser };
|
||||
}
|
||||
|
||||
// 표시 업데이트
|
||||
updateLinkedWorkerDisplay(currentEditingUser);
|
||||
|
||||
showToast(`${workerName} 작업자가 연결되었습니다.`, 'success');
|
||||
closeWorkerSelectModal();
|
||||
} else {
|
||||
throw new Error(response.message || '작업자 연결에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 연결 오류:', error);
|
||||
showToast(`작업자 연결 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
window.selectWorker = selectWorker;
|
||||
|
||||
// 작업자 연결 해제
|
||||
async function unlinkWorker() {
|
||||
if (!currentEditingUser) {
|
||||
showToast('사용자 정보가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentEditingUser.worker_id) {
|
||||
showToast('연결된 작업자가 없습니다.', 'warning');
|
||||
closeWorkerSelectModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('작업자 연결을 해제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${currentEditingUser.user_id}`, 'PUT', {
|
||||
worker_id: null
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// currentEditingUser 업데이트
|
||||
currentEditingUser.worker_id = null;
|
||||
currentEditingUser.worker_name = null;
|
||||
currentEditingUser.department_name = null;
|
||||
|
||||
// users 배열 업데이트
|
||||
const userIndex = users.findIndex(u => u.user_id === currentEditingUser.user_id);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = { ...users[userIndex], worker_id: null, worker_name: null, department_name: null };
|
||||
}
|
||||
|
||||
// 표시 업데이트
|
||||
updateLinkedWorkerDisplay(currentEditingUser);
|
||||
|
||||
showToast('작업자 연결이 해제되었습니다.', 'success');
|
||||
closeWorkerSelectModal();
|
||||
} else {
|
||||
throw new Error(response.message || '연결 해제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 연결 해제 오류:', error);
|
||||
showToast(`연결 해제 중 오류가 발생했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
window.unlinkWorker = unlinkWorker;
|
||||
|
||||
58
web-ui/js/api-base.js
Normal file
58
web-ui/js/api-base.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// /js/api-base.js
|
||||
// API 기본 설정 (비모듈 - 빠른 로딩용)
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const API_PORT = 20005;
|
||||
const API_PATH = '/api';
|
||||
|
||||
function getApiBaseUrl() {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
return `${protocol}//${hostname}:${API_PORT}${API_PATH}`;
|
||||
}
|
||||
|
||||
// 전역 API 설정
|
||||
const apiUrl = getApiBaseUrl();
|
||||
window.API_BASE_URL = apiUrl;
|
||||
window.API = apiUrl; // 이전 호환성
|
||||
|
||||
// 인증 헤더 생성
|
||||
window.getAuthHeaders = function() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : ''
|
||||
};
|
||||
};
|
||||
|
||||
// API 호출 헬퍼 (기존 시그니처 유지: endpoint, method, data)
|
||||
// JSON 파싱하여 반환
|
||||
window.apiCall = async function(endpoint, method = 'GET', data = null) {
|
||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||
const config = {
|
||||
method: method,
|
||||
headers: window.getAuthHeaders()
|
||||
};
|
||||
|
||||
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE')) {
|
||||
config.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// 401 Unauthorized 처리
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/index.html';
|
||||
throw new Error('인증이 만료되었습니다.');
|
||||
}
|
||||
|
||||
// JSON 파싱하여 반환
|
||||
return response.json();
|
||||
};
|
||||
|
||||
console.log('✅ API 설정 완료:', window.API_BASE_URL);
|
||||
})();
|
||||
470
web-ui/js/app-init.js
Normal file
470
web-ui/js/app-init.js
Normal file
@@ -0,0 +1,470 @@
|
||||
// /js/app-init.js
|
||||
// 앱 초기화 - 인증, 네비바, 사이드바를 한 번에 로드
|
||||
// 모든 페이지에서 이 하나의 스크립트만 로드하면 됨
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ===== 캐시 설정 =====
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10분
|
||||
const COMPONENT_CACHE_PREFIX = 'component_';
|
||||
|
||||
// ===== 인증 함수 =====
|
||||
function isLoggedIn() {
|
||||
const token = localStorage.getItem('token');
|
||||
return token && token !== 'undefined' && token !== 'null';
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userPageAccess');
|
||||
}
|
||||
|
||||
// ===== 페이지 권한 캐시 =====
|
||||
let pageAccessPromise = null;
|
||||
|
||||
async function getPageAccess(currentUser) {
|
||||
if (!currentUser || !currentUser.user_id) return null;
|
||||
|
||||
// 캐시 확인
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
if (cached) {
|
||||
try {
|
||||
const cacheData = JSON.parse(cached);
|
||||
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
|
||||
return cacheData.pages;
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem('userPageAccess');
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 로딩 중이면 기존 Promise 반환
|
||||
if (pageAccessPromise) return pageAccessPromise;
|
||||
|
||||
// 새로운 API 호출
|
||||
pageAccessPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
const pages = data.data.pageAccess || [];
|
||||
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: pages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
return pages;
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 조회 오류:', error);
|
||||
return null;
|
||||
} finally {
|
||||
pageAccessPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pageAccessPromise;
|
||||
}
|
||||
|
||||
async function getAccessiblePageKeys(currentUser) {
|
||||
const pages = await getPageAccess(currentUser);
|
||||
if (!pages) return [];
|
||||
return pages.filter(p => p.can_access === 1).map(p => p.page_key);
|
||||
}
|
||||
|
||||
// ===== 현재 페이지 키 추출 =====
|
||||
function getCurrentPageKey() {
|
||||
const path = window.location.pathname;
|
||||
if (!path.startsWith('/pages/')) return null;
|
||||
const pagePath = path.substring(7).replace('.html', '');
|
||||
return pagePath.replace(/\//g, '.');
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 로더 =====
|
||||
async function loadComponent(name, selector, processor) {
|
||||
const container = document.querySelector(selector);
|
||||
if (!container) return;
|
||||
|
||||
const paths = {
|
||||
'navbar': '/components/navbar.html',
|
||||
'sidebar-nav': '/components/sidebar-nav.html'
|
||||
};
|
||||
|
||||
const componentPath = paths[name];
|
||||
if (!componentPath) return;
|
||||
|
||||
try {
|
||||
const cacheKey = COMPONENT_CACHE_PREFIX + name;
|
||||
let html = sessionStorage.getItem(cacheKey);
|
||||
|
||||
if (!html) {
|
||||
const response = await fetch(componentPath);
|
||||
if (!response.ok) throw new Error('컴포넌트 로드 실패');
|
||||
html = await response.text();
|
||||
try { sessionStorage.setItem(cacheKey, html); } catch (e) {}
|
||||
}
|
||||
|
||||
if (processor) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
await processor(doc);
|
||||
container.innerHTML = doc.body.innerHTML;
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`컴포넌트 로드 오류 (${name}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 네비바 처리 =====
|
||||
const ROLE_NAMES = {
|
||||
'system admin': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자',
|
||||
'support': '지원팀',
|
||||
'default': '사용자'
|
||||
};
|
||||
|
||||
async function processNavbar(doc, currentUser, accessiblePageKeys) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin';
|
||||
|
||||
if (isAdmin) {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
} else {
|
||||
doc.querySelectorAll('[data-page-key]').forEach(item => {
|
||||
const pageKey = item.getAttribute('data-page-key');
|
||||
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
|
||||
if (!accessiblePageKeys.includes(pageKey)) item.remove();
|
||||
});
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// 사용자 정보 표시
|
||||
const displayName = currentUser.name || currentUser.username;
|
||||
const roleName = ROLE_NAMES[userRole] || ROLE_NAMES.default;
|
||||
|
||||
const setElementText = (id, text) => {
|
||||
const el = doc.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
};
|
||||
|
||||
setElementText('userName', displayName);
|
||||
setElementText('userRole', roleName);
|
||||
setElementText('userInitial', displayName.charAt(0));
|
||||
}
|
||||
|
||||
// ===== 사이드바 처리 =====
|
||||
async function processSidebar(doc, currentUser, accessiblePageKeys) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const accessLevel = (currentUser.access_level || '').toLowerCase();
|
||||
// role 또는 access_level로 관리자 확인
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
|
||||
accessLevel === 'admin' || accessLevel === 'system';
|
||||
|
||||
if (isAdmin) {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
} else {
|
||||
doc.querySelectorAll('[data-page-key]').forEach(item => {
|
||||
const pageKey = item.getAttribute('data-page-key');
|
||||
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) return;
|
||||
if (!accessiblePageKeys.includes(pageKey)) item.style.display = 'none';
|
||||
});
|
||||
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// 현재 페이지 하이라이트
|
||||
const currentPath = window.location.pathname;
|
||||
doc.querySelectorAll('.nav-item').forEach(item => {
|
||||
const href = item.getAttribute('href');
|
||||
if (href && currentPath.includes(href.replace(/^\//, ''))) {
|
||||
item.classList.add('active');
|
||||
const category = item.closest('.nav-category');
|
||||
if (category) category.classList.add('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// 저장된 상태 복원
|
||||
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
const sidebar = doc.querySelector('.sidebar-nav');
|
||||
if (isCollapsed && sidebar) {
|
||||
sidebar.classList.add('collapsed');
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
|
||||
const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]');
|
||||
expandedCategories.forEach(category => {
|
||||
const el = doc.querySelector(`[data-category="${category}"]`);
|
||||
if (el) el.classList.add('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 사이드바 이벤트 설정 =====
|
||||
function setupSidebarEvents() {
|
||||
const sidebar = document.getElementById('sidebarNav');
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
if (!sidebar || !toggle) return;
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
});
|
||||
|
||||
sidebar.querySelectorAll('.nav-category-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const category = header.closest('.nav-category');
|
||||
category.classList.toggle('expanded');
|
||||
|
||||
const expanded = [];
|
||||
sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => {
|
||||
const name = cat.getAttribute('data-category');
|
||||
if (name) expanded.push(name);
|
||||
});
|
||||
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 네비바 이벤트 설정 =====
|
||||
function setupNavbarEvents() {
|
||||
const logoutButton = document.getElementById('logoutBtn');
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', () => {
|
||||
if (confirm('로그아웃 하시겠습니까?')) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 날짜/시간 업데이트 =====
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
const timeEl = document.getElementById('timeValue');
|
||||
if (timeEl) timeEl.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
|
||||
const dateEl = document.getElementById('dateValue');
|
||||
if (dateEl) {
|
||||
const days = ['일', '월', '화', '수', '목', '토'];
|
||||
dateEl.textContent = `${now.getMonth() + 1}월 ${now.getDate()}일 (${days[now.getDay()]})`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 날씨 업데이트 =====
|
||||
const WEATHER_ICONS = { clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥', cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷', cloudy: '⛅', overcast: '☁️' };
|
||||
const WEATHER_NAMES = { clear: '맑음', rain: '비', snow: '눈', heat: '폭염', cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지', cloudy: '구름많음', overcast: '흐림' };
|
||||
|
||||
async function updateWeather() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
// 캐시 확인
|
||||
const cached = sessionStorage.getItem('weatherCache');
|
||||
let result;
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
result = cacheData.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) return;
|
||||
result = await response.json();
|
||||
sessionStorage.setItem('weatherCache', JSON.stringify({ data: result, timestamp: Date.now() }));
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { temperature, conditions } = result.data;
|
||||
const tempEl = document.getElementById('weatherTemp');
|
||||
if (tempEl && temperature != null) tempEl.textContent = `${Math.round(temperature)}°C`;
|
||||
|
||||
const iconEl = document.getElementById('weatherIcon');
|
||||
const descEl = document.getElementById('weatherDesc');
|
||||
if (conditions && conditions.length > 0) {
|
||||
const primary = conditions[0];
|
||||
if (iconEl) iconEl.textContent = WEATHER_ICONS[primary] || '🌤️';
|
||||
if (descEl) descEl.textContent = WEATHER_NAMES[primary] || '맑음';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('날씨 정보 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 메인 초기화 =====
|
||||
async function init() {
|
||||
console.log('🚀 app-init 시작');
|
||||
|
||||
// 1. 인증 확인
|
||||
if (!isLoggedIn()) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = getUser();
|
||||
if (!currentUser || !currentUser.username) {
|
||||
clearAuthData();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ 인증 확인:', currentUser.username);
|
||||
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const accessLevel = (currentUser.access_level || '').toLowerCase();
|
||||
// role 또는 access_level로 관리자 확인
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
|
||||
accessLevel === 'admin' || accessLevel === 'system';
|
||||
|
||||
// 2. 페이지 접근 권한 체크 (Admin은 건너뛰기)
|
||||
let accessiblePageKeys = [];
|
||||
if (!isAdmin) {
|
||||
const pageKey = getCurrentPageKey();
|
||||
if (pageKey && pageKey !== 'dashboard' && !pageKey.startsWith('profile.')) {
|
||||
accessiblePageKeys = await getAccessiblePageKeys(currentUser);
|
||||
if (!accessiblePageKeys.includes(pageKey)) {
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 사이드바 컨테이너 생성 (없으면)
|
||||
let sidebarContainer = document.getElementById('sidebar-container');
|
||||
if (!sidebarContainer) {
|
||||
sidebarContainer = document.createElement('div');
|
||||
sidebarContainer.id = 'sidebar-container';
|
||||
document.body.prepend(sidebarContainer);
|
||||
console.log('📦 사이드바 컨테이너 생성됨');
|
||||
}
|
||||
|
||||
// 4. 네비바와 사이드바 동시 로드
|
||||
console.log('📥 컴포넌트 로딩 시작');
|
||||
await Promise.all([
|
||||
loadComponent('navbar', '#navbar-container', (doc) => processNavbar(doc, currentUser, accessiblePageKeys)),
|
||||
loadComponent('sidebar-nav', '#sidebar-container', (doc) => processSidebar(doc, currentUser, accessiblePageKeys))
|
||||
]);
|
||||
console.log('✅ 컴포넌트 로딩 완료');
|
||||
|
||||
// 5. 이벤트 설정
|
||||
setupNavbarEvents();
|
||||
setupSidebarEvents();
|
||||
document.body.classList.add('has-sidebar');
|
||||
|
||||
// 6. 페이지 전환 로딩 인디케이터 설정
|
||||
setupPageTransitionLoader();
|
||||
|
||||
// 7. 날짜/시간 (비동기)
|
||||
updateDateTime();
|
||||
setInterval(updateDateTime, 1000);
|
||||
|
||||
// 8. 날씨 (백그라운드)
|
||||
setTimeout(updateWeather, 100);
|
||||
|
||||
console.log('✅ app-init 완료');
|
||||
}
|
||||
|
||||
// ===== 페이지 전환 로딩 인디케이터 =====
|
||||
function setupPageTransitionLoader() {
|
||||
// 로딩 바 스타일 추가
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#page-loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
z-index: 99999;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
#page-loader.loading {
|
||||
width: 70%;
|
||||
}
|
||||
#page-loader.done {
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: width 0.2s ease, opacity 0.3s ease 0.2s;
|
||||
}
|
||||
body.page-loading {
|
||||
cursor: wait;
|
||||
}
|
||||
body.page-loading * {
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// 로딩 바 엘리먼트 생성
|
||||
const loader = document.createElement('div');
|
||||
loader.id = 'page-loader';
|
||||
document.body.appendChild(loader);
|
||||
|
||||
// 모든 내부 링크에 클릭 이벤트 추가
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
// 외부 링크, 해시 링크, javascript: 링크 제외
|
||||
if (href.startsWith('http') || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
|
||||
// 새 탭 링크 제외
|
||||
if (link.target === '_blank') return;
|
||||
|
||||
// 로딩 시작
|
||||
loader.classList.remove('done');
|
||||
loader.classList.add('loading');
|
||||
document.body.classList.add('page-loading');
|
||||
});
|
||||
|
||||
// 페이지 떠날 때 완료 표시
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const loader = document.getElementById('page-loader');
|
||||
if (loader) {
|
||||
loader.classList.remove('loading');
|
||||
loader.classList.add('done');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// DOMContentLoaded 시 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// 전역 노출 (필요시)
|
||||
window.appInit = { getUser, clearAuthData, isLoggedIn };
|
||||
})();
|
||||
@@ -48,38 +48,9 @@ function updateCurrentTime() {
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
// app-init.js가 navbar 사용자 정보를 처리
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
// /js/component-loader.js
|
||||
import { config } from './config.js';
|
||||
|
||||
// 캐시 버전 (컴포넌트 변경 시 증가)
|
||||
const CACHE_VERSION = 'v1';
|
||||
|
||||
/**
|
||||
* 컴포넌트 HTML을 캐시에서 가져오거나 fetch
|
||||
*/
|
||||
async function getComponentHtml(componentName, componentPath) {
|
||||
const cacheKey = `component_${componentName}_${CACHE_VERSION}`;
|
||||
|
||||
// 캐시에서 먼저 확인
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 캐시 없으면 fetch
|
||||
const response = await fetch(componentPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`컴포넌트 파일을 불러올 수 없습니다: ${response.statusText}`);
|
||||
}
|
||||
const htmlText = await response.text();
|
||||
|
||||
// 캐시에 저장
|
||||
try {
|
||||
sessionStorage.setItem(cacheKey, htmlText);
|
||||
} catch (e) {
|
||||
// sessionStorage 용량 초과 시 무시
|
||||
}
|
||||
|
||||
return htmlText;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공용 HTML 컴포넌트를 페이지의 특정 위치에 동적으로 로드합니다.
|
||||
* @param {string} componentName - 로드할 컴포넌트의 이름 (e.g., 'sidebar', 'navbar'). config.js의 components 객체에 정의된 키와 일치해야 합니다.
|
||||
@@ -23,20 +55,16 @@ export async function loadComponent(componentName, containerSelector, domProcess
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(componentPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`컴포넌트 파일을 불러올 수 없습니다: ${response.statusText}`);
|
||||
}
|
||||
const htmlText = await response.text();
|
||||
const htmlText = await getComponentHtml(componentName, componentPath);
|
||||
|
||||
if (domProcessor) {
|
||||
// 1. 텍스트를 가상 DOM으로 파싱
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
|
||||
|
||||
// 2. DOM 프로세서(콜백)를 실행하여 DOM 조작
|
||||
await domProcessor(doc);
|
||||
|
||||
|
||||
// 3. 조작된 HTML을 실제 DOM에 삽입
|
||||
container.innerHTML = doc.body.innerHTML;
|
||||
} else {
|
||||
|
||||
732
web-ui/js/daily-patrol.js
Normal file
732
web-ui/js/daily-patrol.js
Normal file
@@ -0,0 +1,732 @@
|
||||
// daily-patrol.js - 일일순회점검 페이지 JavaScript
|
||||
|
||||
// 전역 상태
|
||||
let currentSession = null;
|
||||
let categories = []; // 공장(대분류) 목록
|
||||
let workplaces = []; // 작업장 목록
|
||||
let checklistItems = []; // 체크리스트 항목
|
||||
let checkRecords = {}; // 체크 기록 (workplace_id -> records)
|
||||
let selectedWorkplace = null;
|
||||
let itemTypes = []; // 물품 유형
|
||||
let workplaceItems = []; // 현재 작업장 물품
|
||||
let isItemEditMode = false;
|
||||
|
||||
// 이미지 URL 헬퍼 함수 (정적 파일용 - /api 경로 제외)
|
||||
function getImageUrl(path) {
|
||||
if (!path) return '';
|
||||
// 이미 http로 시작하면 그대로 반환
|
||||
if (path.startsWith('http')) return path;
|
||||
// API_BASE_URL에서 /api 제거하여 정적 파일 서버 URL 생성
|
||||
// /uploads 경로는 인증 없이 접근 가능한 정적 파일 경로
|
||||
const staticUrl = window.API_BASE_URL.replace(/\/api$/, '');
|
||||
return staticUrl + path;
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
// axios 설정 대기
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
// 오늘 날짜 설정
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('patrolDate').value = today;
|
||||
|
||||
// 시간대 버튼 이벤트
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.patrol-time-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
await Promise.all([
|
||||
loadCategories(),
|
||||
loadItemTypes(),
|
||||
loadTodayStatus()
|
||||
]);
|
||||
}
|
||||
|
||||
// 공장(대분류) 목록 로드
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await axios.get('/workplaces/categories');
|
||||
if (response.data.success) {
|
||||
categories = response.data.data;
|
||||
const select = document.getElementById('categorySelect');
|
||||
select.innerHTML = '<option value="">공장 선택...</option>' +
|
||||
categories.map(c => `<option value="${c.category_id}">${c.category_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 유형 로드
|
||||
async function loadItemTypes() {
|
||||
try {
|
||||
const response = await axios.get('/patrol/item-types');
|
||||
if (response.data.success) {
|
||||
itemTypes = response.data.data;
|
||||
renderItemTypesSelect();
|
||||
renderItemsLegend();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('물품 유형 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 오늘 점검 현황 로드
|
||||
async function loadTodayStatus() {
|
||||
try {
|
||||
const response = await axios.get('/patrol/today-status');
|
||||
if (response.data.success) {
|
||||
renderTodayStatus(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘 현황 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 오늘 점검 현황 렌더링
|
||||
function renderTodayStatus(statusList) {
|
||||
const container = document.getElementById('todayStatusSummary');
|
||||
if (!statusList || statusList.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="status-card">
|
||||
<div class="status-label">오전</div>
|
||||
<div class="status-value pending">미점검</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">오후</div>
|
||||
<div class="status-value pending">미점검</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const morning = statusList.find(s => s.patrol_time === 'morning');
|
||||
const afternoon = statusList.find(s => s.patrol_time === 'afternoon');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="status-card">
|
||||
<div class="status-label">오전</div>
|
||||
<div class="status-value ${morning?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${morning ? (morning.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${morning ? `<div class="status-sub">${morning.inspector_name || ''}</div>` : ''}
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-label">오후</div>
|
||||
<div class="status-value ${afternoon?.status === 'completed' ? 'completed' : 'pending'}">
|
||||
${afternoon ? (afternoon.status === 'completed' ? '완료' : '진행중') : '미점검'}
|
||||
</div>
|
||||
${afternoon ? `<div class="status-sub">${afternoon.inspector_name || ''}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 순회점검 시작
|
||||
async function startPatrol() {
|
||||
const patrolDate = document.getElementById('patrolDate').value;
|
||||
const patrolTime = document.querySelector('.patrol-time-btn.active')?.dataset.time;
|
||||
const categoryId = document.getElementById('categorySelect').value;
|
||||
|
||||
if (!patrolDate || !patrolTime || !categoryId) {
|
||||
alert('점검 일자, 시간대, 공장을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 세션 생성 또는 조회
|
||||
const response = await axios.post('/patrol/sessions', {
|
||||
patrol_date: patrolDate,
|
||||
patrol_time: patrolTime,
|
||||
category_id: categoryId
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
currentSession = response.data.data;
|
||||
currentSession.patrol_date = patrolDate;
|
||||
currentSession.patrol_time = patrolTime;
|
||||
currentSession.category_id = categoryId;
|
||||
|
||||
// 작업장 목록 로드
|
||||
await loadWorkplaces(categoryId);
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
await loadChecklistItems(categoryId);
|
||||
|
||||
// 점검 영역 표시
|
||||
document.getElementById('patrolArea').style.display = 'block';
|
||||
renderSessionInfo();
|
||||
renderWorkplaceMap();
|
||||
|
||||
// 시작 버튼 비활성화
|
||||
document.getElementById('startPatrolBtn').textContent = '점검 진행중...';
|
||||
document.getElementById('startPatrolBtn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순회점검 시작 실패:', error);
|
||||
alert('순회점검을 시작할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces(categoryId) {
|
||||
try {
|
||||
const response = await axios.get(`/workplaces?category_id=${categoryId}`);
|
||||
if (response.data.success) {
|
||||
workplaces = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
async function loadChecklistItems(categoryId) {
|
||||
try {
|
||||
const response = await axios.get(`/patrol/checklist?category_id=${categoryId}`);
|
||||
if (response.data.success) {
|
||||
checklistItems = response.data.data.items;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크리스트 항목 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 세션 정보 렌더링
|
||||
function renderSessionInfo() {
|
||||
const container = document.getElementById('sessionInfo');
|
||||
const category = categories.find(c => c.category_id == currentSession.category_id);
|
||||
const checkedCount = Object.values(checkRecords).flat().filter(r => r.is_checked).length;
|
||||
const totalCount = workplaces.length * checklistItems.length;
|
||||
const progress = totalCount > 0 ? Math.round(checkedCount / totalCount * 100) : 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="session-info">
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">점검일자</span>
|
||||
<span class="session-info-value">${formatDate(currentSession.patrol_date)}</span>
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">시간대</span>
|
||||
<span class="session-info-value">${currentSession.patrol_time === 'morning' ? '오전' : '오후'}</span>
|
||||
</div>
|
||||
<div class="session-info-item">
|
||||
<span class="session-info-label">공장</span>
|
||||
<span class="session-info-value">${category?.category_name || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-text">${progress}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 작업장 지도/목록 렌더링
|
||||
function renderWorkplaceMap() {
|
||||
const mapContainer = document.getElementById('patrolMapContainer');
|
||||
const listContainer = document.getElementById('workplaceListContainer');
|
||||
const category = categories.find(c => c.category_id == currentSession.category_id);
|
||||
|
||||
// 지도 이미지가 있으면 지도 표시
|
||||
if (category?.layout_image) {
|
||||
mapContainer.innerHTML = `<img src="${getImageUrl(category.layout_image)}" alt="${category.category_name} 지도">`;
|
||||
mapContainer.style.display = 'block';
|
||||
listContainer.style.display = 'none';
|
||||
|
||||
// 작업장 마커 추가
|
||||
workplaces.forEach(wp => {
|
||||
if (wp.x_percent && wp.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'workplace-marker';
|
||||
marker.style.left = `${wp.x_percent}%`;
|
||||
marker.style.top = `${wp.y_percent}%`;
|
||||
marker.textContent = wp.workplace_name;
|
||||
marker.dataset.workplaceId = wp.workplace_id;
|
||||
marker.onclick = () => selectWorkplace(wp.workplace_id);
|
||||
|
||||
// 점검 상태에 따른 스타일
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
if (records && records.some(r => r.is_checked)) {
|
||||
marker.classList.add(records.every(r => r.is_checked) ? 'completed' : 'in-progress');
|
||||
}
|
||||
|
||||
mapContainer.appendChild(marker);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 지도 없으면 카드 목록으로 표시
|
||||
mapContainer.style.display = 'none';
|
||||
listContainer.style.display = 'grid';
|
||||
|
||||
listContainer.innerHTML = workplaces.map(wp => {
|
||||
const records = checkRecords[wp.workplace_id];
|
||||
const isCompleted = records && records.length > 0 && records.every(r => r.is_checked);
|
||||
const isInProgress = records && records.some(r => r.is_checked);
|
||||
|
||||
return `
|
||||
<div class="workplace-card ${isCompleted ? 'completed' : ''} ${selectedWorkplace?.workplace_id === wp.workplace_id ? 'selected' : ''}"
|
||||
data-workplace-id="${wp.workplace_id}"
|
||||
onclick="selectWorkplace(${wp.workplace_id})">
|
||||
<div class="workplace-card-name">${wp.workplace_name}</div>
|
||||
<div class="workplace-card-status">
|
||||
${isCompleted ? '점검완료' : (isInProgress ? '점검중' : '미점검')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업장 선택
|
||||
async function selectWorkplace(workplaceId) {
|
||||
selectedWorkplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
|
||||
// 마커/카드 선택 상태 업데이트
|
||||
document.querySelectorAll('.workplace-marker, .workplace-card').forEach(el => {
|
||||
el.classList.remove('selected');
|
||||
if (el.dataset.workplaceId == workplaceId) {
|
||||
el.classList.add('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// 기존 체크 기록 로드
|
||||
if (!checkRecords[workplaceId]) {
|
||||
try {
|
||||
const response = await axios.get(`/patrol/sessions/${currentSession.session_id}/records?workplace_id=${workplaceId}`);
|
||||
if (response.data.success) {
|
||||
checkRecords[workplaceId] = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크 기록 로드 실패:', error);
|
||||
checkRecords[workplaceId] = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 체크리스트 렌더링
|
||||
renderChecklist(workplaceId);
|
||||
|
||||
// 물품 현황 로드 및 표시
|
||||
await loadWorkplaceItems(workplaceId);
|
||||
|
||||
// 액션 버튼 표시
|
||||
document.getElementById('checklistActions').style.display = 'flex';
|
||||
}
|
||||
|
||||
// 체크리스트 렌더링
|
||||
function renderChecklist(workplaceId) {
|
||||
const header = document.getElementById('checklistHeader');
|
||||
const content = document.getElementById('checklistContent');
|
||||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
|
||||
header.innerHTML = `
|
||||
<h3>${workplace?.workplace_name || ''} 체크리스트</h3>
|
||||
<p class="checklist-subtitle">각 항목을 점검하고 체크해주세요</p>
|
||||
`;
|
||||
|
||||
// 카테고리별 그룹화
|
||||
const grouped = {};
|
||||
checklistItems.forEach(item => {
|
||||
if (!grouped[item.check_category]) {
|
||||
grouped[item.check_category] = [];
|
||||
}
|
||||
grouped[item.check_category].push(item);
|
||||
});
|
||||
|
||||
const records = checkRecords[workplaceId] || [];
|
||||
|
||||
content.innerHTML = Object.entries(grouped).map(([category, items]) => `
|
||||
<div class="checklist-category">
|
||||
<div class="checklist-category-title">${getCategoryName(category)}</div>
|
||||
${items.map(item => {
|
||||
const record = records.find(r => r.check_item_id === item.item_id);
|
||||
const isChecked = record?.is_checked;
|
||||
const checkResult = record?.check_result;
|
||||
|
||||
return `
|
||||
<div class="check-item ${isChecked ? 'checked' : ''}"
|
||||
data-item-id="${item.item_id}"
|
||||
onclick="toggleCheckItem(${workplaceId}, ${item.item_id})">
|
||||
<div class="check-item-checkbox">
|
||||
${isChecked ? '✓' : ''}
|
||||
</div>
|
||||
<div class="check-item-content">
|
||||
<div class="check-item-text">
|
||||
${item.check_item}
|
||||
${item.is_required ? '<span class="check-item-required">*</span>' : ''}
|
||||
</div>
|
||||
${isChecked ? `
|
||||
<div class="check-result-selector" onclick="event.stopPropagation()">
|
||||
<button class="check-result-btn good ${checkResult === 'good' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'good')">양호</button>
|
||||
<button class="check-result-btn warning ${checkResult === 'warning' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'warning')">주의</button>
|
||||
<button class="check-result-btn bad ${checkResult === 'bad' ? 'active' : ''}"
|
||||
onclick="setCheckResult(${workplaceId}, ${item.item_id}, 'bad')">불량</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 카테고리명 변환
|
||||
function getCategoryName(code) {
|
||||
const names = {
|
||||
'SAFETY': '안전',
|
||||
'ORGANIZATION': '정리정돈',
|
||||
'EQUIPMENT': '설비',
|
||||
'ENVIRONMENT': '환경'
|
||||
};
|
||||
return names[code] || code;
|
||||
}
|
||||
|
||||
// 체크 항목 토글
|
||||
function toggleCheckItem(workplaceId, itemId) {
|
||||
if (!checkRecords[workplaceId]) {
|
||||
checkRecords[workplaceId] = [];
|
||||
}
|
||||
|
||||
const records = checkRecords[workplaceId];
|
||||
const existingIndex = records.findIndex(r => r.check_item_id === itemId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
records[existingIndex].is_checked = !records[existingIndex].is_checked;
|
||||
if (!records[existingIndex].is_checked) {
|
||||
records[existingIndex].check_result = null;
|
||||
}
|
||||
} else {
|
||||
records.push({
|
||||
check_item_id: itemId,
|
||||
is_checked: true,
|
||||
check_result: 'good',
|
||||
note: null
|
||||
});
|
||||
}
|
||||
|
||||
renderChecklist(workplaceId);
|
||||
renderWorkplaceMap();
|
||||
renderSessionInfo();
|
||||
}
|
||||
|
||||
// 체크 결과 설정
|
||||
function setCheckResult(workplaceId, itemId, result) {
|
||||
const records = checkRecords[workplaceId];
|
||||
const record = records.find(r => r.check_item_id === itemId);
|
||||
if (record) {
|
||||
record.check_result = result;
|
||||
renderChecklist(workplaceId);
|
||||
}
|
||||
}
|
||||
|
||||
// 임시 저장
|
||||
async function saveChecklistDraft() {
|
||||
if (!selectedWorkplace) return;
|
||||
|
||||
try {
|
||||
const records = checkRecords[selectedWorkplace.workplace_id] || [];
|
||||
await axios.post(`/patrol/sessions/${currentSession.session_id}/records/batch`, {
|
||||
workplace_id: selectedWorkplace.workplace_id,
|
||||
records: records
|
||||
});
|
||||
alert('임시 저장되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('임시 저장 실패:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 후 다음
|
||||
async function saveChecklist() {
|
||||
if (!selectedWorkplace) return;
|
||||
|
||||
try {
|
||||
const records = checkRecords[selectedWorkplace.workplace_id] || [];
|
||||
await axios.post(`/patrol/sessions/${currentSession.session_id}/records/batch`, {
|
||||
workplace_id: selectedWorkplace.workplace_id,
|
||||
records: records
|
||||
});
|
||||
|
||||
// 다음 미점검 작업장으로 이동
|
||||
const currentIndex = workplaces.findIndex(w => w.workplace_id === selectedWorkplace.workplace_id);
|
||||
const nextWorkplace = workplaces.slice(currentIndex + 1).find(w => {
|
||||
const records = checkRecords[w.workplace_id];
|
||||
return !records || records.length === 0 || !records.every(r => r.is_checked);
|
||||
});
|
||||
|
||||
if (nextWorkplace) {
|
||||
selectWorkplace(nextWorkplace.workplace_id);
|
||||
} else {
|
||||
alert('모든 작업장 점검이 완료되었습니다!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 순회점검 완료
|
||||
async function completePatrol() {
|
||||
if (!currentSession) return;
|
||||
|
||||
// 미점검 작업장 확인
|
||||
const uncheckedCount = workplaces.filter(w => {
|
||||
const records = checkRecords[w.workplace_id];
|
||||
return !records || records.length === 0;
|
||||
}).length;
|
||||
|
||||
if (uncheckedCount > 0) {
|
||||
if (!confirm(`아직 ${uncheckedCount}개 작업장이 미점검 상태입니다. 그래도 완료하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const notes = document.getElementById('patrolNotes').value;
|
||||
if (notes) {
|
||||
await axios.patch(`/patrol/sessions/${currentSession.session_id}/notes`, { notes });
|
||||
}
|
||||
|
||||
await axios.patch(`/patrol/sessions/${currentSession.session_id}/complete`);
|
||||
|
||||
alert('순회점검이 완료되었습니다.');
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error('순회점검 완료 실패:', error);
|
||||
alert('순회점검 완료에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 물품 현황 ====================
|
||||
|
||||
// 작업장 물품 로드
|
||||
async function loadWorkplaceItems(workplaceId) {
|
||||
try {
|
||||
const response = await axios.get(`/patrol/workplaces/${workplaceId}/items`);
|
||||
if (response.data.success) {
|
||||
workplaceItems = response.data.data;
|
||||
renderItemsSection(workplaceId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('물품 로드 실패:', error);
|
||||
workplaceItems = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 섹션 렌더링
|
||||
function renderItemsSection(workplaceId) {
|
||||
const section = document.getElementById('itemsSection');
|
||||
const workplace = workplaces.find(w => w.workplace_id === workplaceId);
|
||||
const container = document.getElementById('itemsMapContainer');
|
||||
|
||||
document.getElementById('selectedWorkplaceName').textContent = workplace?.workplace_name || '';
|
||||
|
||||
// 작업장 레이아웃 이미지가 있으면 표시
|
||||
if (workplace?.layout_image) {
|
||||
container.innerHTML = `<img src="${getImageUrl(workplace.layout_image)}" alt="${workplace.workplace_name}">`;
|
||||
|
||||
// 물품 마커 추가
|
||||
workplaceItems.forEach(item => {
|
||||
if (item.x_percent && item.y_percent) {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = `item-marker ${item.item_type}`;
|
||||
marker.style.left = `${item.x_percent}%`;
|
||||
marker.style.top = `${item.y_percent}%`;
|
||||
marker.style.width = `${item.width_percent || 5}%`;
|
||||
marker.style.height = `${item.height_percent || 5}%`;
|
||||
marker.innerHTML = item.icon || getItemTypeIcon(item.item_type);
|
||||
marker.title = `${item.item_name || item.type_name} (${item.quantity}개)`;
|
||||
marker.dataset.itemId = item.item_id;
|
||||
marker.onclick = () => openItemModal(item);
|
||||
container.appendChild(marker);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '<p style="padding: 2rem; text-align: center; color: #64748b;">작업장 레이아웃 이미지가 없습니다.</p>';
|
||||
}
|
||||
|
||||
section.style.display = 'block';
|
||||
}
|
||||
|
||||
// 물품 유형 아이콘
|
||||
function getItemTypeIcon(typeCode) {
|
||||
const icons = {
|
||||
'container': '📦',
|
||||
'plate': '🔲',
|
||||
'material': '🧱',
|
||||
'tool': '🔧',
|
||||
'other': '📍'
|
||||
};
|
||||
return icons[typeCode] || '📍';
|
||||
}
|
||||
|
||||
// 물품 유형 셀렉트 렌더링
|
||||
function renderItemTypesSelect() {
|
||||
const select = document.getElementById('itemType');
|
||||
if (!select) return;
|
||||
select.innerHTML = itemTypes.map(t =>
|
||||
`<option value="${t.type_code}">${t.icon} ${t.type_name}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 물품 범례 렌더링
|
||||
function renderItemsLegend() {
|
||||
const container = document.getElementById('itemsLegend');
|
||||
if (!container) return;
|
||||
container.innerHTML = itemTypes.map(t => `
|
||||
<div class="item-legend-item">
|
||||
<div class="item-legend-icon" style="background: ${t.color}20; border: 1px solid ${t.color};">
|
||||
${t.icon}
|
||||
</div>
|
||||
<span>${t.type_name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 편집 모드 토글
|
||||
function toggleItemEditMode() {
|
||||
isItemEditMode = !isItemEditMode;
|
||||
document.getElementById('itemEditModeText').textContent = isItemEditMode ? '편집모드 종료' : '편집모드';
|
||||
|
||||
if (isItemEditMode) {
|
||||
// 지도 클릭으로 물품 추가
|
||||
const container = document.getElementById('itemsMapContainer');
|
||||
container.style.cursor = 'crosshair';
|
||||
container.onclick = (e) => {
|
||||
if (e.target === container || e.target.tagName === 'IMG') {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(2);
|
||||
const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(2);
|
||||
openItemModal(null, x, y);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const container = document.getElementById('itemsMapContainer');
|
||||
container.style.cursor = 'default';
|
||||
container.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 모달 열기
|
||||
function openItemModal(item = null, x = null, y = null) {
|
||||
const modal = document.getElementById('itemModal');
|
||||
const title = document.getElementById('itemModalTitle');
|
||||
const deleteBtn = document.getElementById('deleteItemBtn');
|
||||
|
||||
if (item) {
|
||||
title.textContent = '물품 수정';
|
||||
document.getElementById('itemId').value = item.item_id;
|
||||
document.getElementById('itemType').value = item.item_type;
|
||||
document.getElementById('itemName').value = item.item_name || '';
|
||||
document.getElementById('itemQuantity').value = item.quantity || 1;
|
||||
deleteBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
title.textContent = '물품 추가';
|
||||
document.getElementById('itemForm').reset();
|
||||
document.getElementById('itemId').value = '';
|
||||
document.getElementById('itemId').dataset.x = x;
|
||||
document.getElementById('itemId').dataset.y = y;
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 물품 모달 닫기
|
||||
function closeItemModal() {
|
||||
document.getElementById('itemModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// 물품 저장
|
||||
async function saveItem() {
|
||||
if (!selectedWorkplace) return;
|
||||
|
||||
const itemId = document.getElementById('itemId').value;
|
||||
const data = {
|
||||
item_type: document.getElementById('itemType').value,
|
||||
item_name: document.getElementById('itemName').value,
|
||||
quantity: parseInt(document.getElementById('itemQuantity').value) || 1,
|
||||
patrol_session_id: currentSession?.session_id
|
||||
};
|
||||
|
||||
// 새 물품일 경우 위치 추가
|
||||
if (!itemId) {
|
||||
data.x_percent = parseFloat(document.getElementById('itemId').dataset.x);
|
||||
data.y_percent = parseFloat(document.getElementById('itemId').dataset.y);
|
||||
data.width_percent = 5;
|
||||
data.height_percent = 5;
|
||||
}
|
||||
|
||||
try {
|
||||
if (itemId) {
|
||||
await axios.put(`/patrol/items/${itemId}`, data);
|
||||
} else {
|
||||
await axios.post(`/patrol/workplaces/${selectedWorkplace.workplace_id}/items`, data);
|
||||
}
|
||||
|
||||
closeItemModal();
|
||||
await loadWorkplaceItems(selectedWorkplace.workplace_id);
|
||||
} catch (error) {
|
||||
console.error('물품 저장 실패:', error);
|
||||
alert('물품 저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 물품 삭제
|
||||
async function deleteItem() {
|
||||
const itemId = document.getElementById('itemId').value;
|
||||
if (!itemId) return;
|
||||
|
||||
if (!confirm('이 물품을 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/patrol/items/${itemId}`);
|
||||
closeItemModal();
|
||||
await loadWorkplaceItems(selectedWorkplace.workplace_id);
|
||||
} catch (error) {
|
||||
console.error('물품 삭제 실패:', error);
|
||||
alert('물품 삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 유틸리티 함수
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
|
||||
}
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeItemModal();
|
||||
}
|
||||
});
|
||||
@@ -1738,8 +1738,8 @@ async function loadData() {
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
console.log('Workers API 호출 중... (통합 API 사용)');
|
||||
// 모든 작업자 1000명까지 조회
|
||||
const data = await window.apiCall(`${window.API}/workers?limit=1000`);
|
||||
// 생산팀 소속 작업자만 조회
|
||||
const data = await window.apiCall(`/workers?limit=1000&department_id=1`);
|
||||
const allWorkers = Array.isArray(data) ? data : (data.data || data.workers || []);
|
||||
|
||||
// 작업 보고서에 표시할 작업자만 필터링
|
||||
@@ -1760,7 +1760,7 @@ async function loadWorkers() {
|
||||
async function loadProjects() {
|
||||
try {
|
||||
console.log('Projects API 호출 중... (활성 프로젝트만)');
|
||||
const data = await window.apiCall(`${window.API}/projects/active/list`);
|
||||
const data = await window.apiCall(`/projects/active/list`);
|
||||
projects = Array.isArray(data) ? data : (data.data || data.projects || []);
|
||||
console.log('✅ 활성 프로젝트 로드 성공:', projects.length);
|
||||
} catch (error) {
|
||||
@@ -1771,7 +1771,7 @@ async function loadProjects() {
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`${window.API}/daily-work-reports/work-types`);
|
||||
const data = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workTypes = data;
|
||||
console.log('✅ 작업 유형 API 사용 (통합 설정)');
|
||||
@@ -1790,7 +1790,7 @@ async function loadWorkTypes() {
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const data = await window.apiCall(`${window.API}/daily-work-reports/work-status-types`);
|
||||
const data = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
workStatusTypes = data;
|
||||
console.log('✅ 업무 상태 유형 API 사용 (통합 설정)');
|
||||
@@ -1809,7 +1809,7 @@ async function loadWorkStatusTypes() {
|
||||
async function loadErrorTypes() {
|
||||
// 레거시 에러 유형 로드 (호환성)
|
||||
try {
|
||||
const data = await window.apiCall(`${window.API}/daily-work-reports/error-types`);
|
||||
const data = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
errorTypes = data;
|
||||
}
|
||||
@@ -2268,7 +2268,7 @@ async function saveWorkReport() {
|
||||
console.log('🔄 전송 데이터 JSON:', JSON.stringify(requestData, null, 2));
|
||||
|
||||
try {
|
||||
const result = await window.apiCall(`${window.API}/daily-work-reports`, 'POST', requestData);
|
||||
const result = await window.apiCall(`/daily-work-reports`, 'POST', requestData);
|
||||
|
||||
console.log('✅ 저장 성공:', result);
|
||||
totalSaved++;
|
||||
@@ -2370,7 +2370,7 @@ async function loadTodayWorkers() {
|
||||
|
||||
console.log(`🔒 본인 입력분만 조회 (통합 API): ${API}/daily-work-reports?${queryParams}`);
|
||||
|
||||
const rawData = await window.apiCall(`${window.API}/daily-work-reports?${queryParams}`);
|
||||
const rawData = await window.apiCall(`/daily-work-reports?${queryParams}`);
|
||||
console.log('📊 당일 작업 데이터 (통합 API):', rawData);
|
||||
|
||||
let data = [];
|
||||
@@ -2505,7 +2505,7 @@ async function editWorkItem(workId) {
|
||||
// 1. 기존 데이터 조회 (통합 API 사용)
|
||||
showMessage('작업 정보를 불러오는 중... (통합 API)', 'loading');
|
||||
|
||||
const workData = await window.apiCall(`${window.API}/daily-work-reports/${workId}`);
|
||||
const workData = await window.apiCall(`/daily-work-reports/${workId}`);
|
||||
console.log('수정할 작업 데이터 (통합 API):', workData);
|
||||
|
||||
// 2. 수정 모달 표시
|
||||
@@ -2644,7 +2644,7 @@ async function saveEditedWork() {
|
||||
|
||||
showMessage('작업을 수정하는 중... (통합 API)', 'loading');
|
||||
|
||||
const result = await window.apiCall(`${window.API}/daily-work-reports/${editingWorkId}`, {
|
||||
const result = await window.apiCall(`/daily-work-reports/${editingWorkId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
@@ -2673,7 +2673,7 @@ async function deleteWorkItem(workId) {
|
||||
showMessage('작업을 삭제하는 중... (통합 API)', 'loading');
|
||||
|
||||
// 개별 항목 삭제 API 호출 (본인 작성분만 삭제 가능) - 통합 API 사용
|
||||
const result = await window.apiCall(`${window.API}/daily-work-reports/my-entry/${workId}`, {
|
||||
const result = await window.apiCall(`/daily-work-reports/my-entry/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
|
||||
339
web-ui/js/department-management.js
Normal file
339
web-ui/js/department-management.js
Normal file
@@ -0,0 +1,339 @@
|
||||
// department-management.js
|
||||
// 부서 관리 페이지 JavaScript
|
||||
|
||||
let departments = [];
|
||||
let selectedDepartmentId = null;
|
||||
let selectedWorkers = new Set();
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForApiConfig();
|
||||
await loadDepartments();
|
||||
});
|
||||
|
||||
// API 설정 로드 대기
|
||||
async function waitForApiConfig() {
|
||||
let retryCount = 0;
|
||||
while (!window.apiCall && retryCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
if (!window.apiCall) {
|
||||
console.error('API 설정 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// 부서 목록 로드
|
||||
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.worker_id) ? 'selected' : ''}"
|
||||
onclick="toggleWorkerSelection(${worker.worker_id})">
|
||||
<div class="worker-info-row">
|
||||
<input type="checkbox" ${selectedWorkers.has(worker.worker_id) ? 'checked' : ''}
|
||||
onclick="event.stopPropagation(); toggleWorkerSelection(${worker.worker_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';
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
// 설비 관리 페이지 JavaScript
|
||||
|
||||
let equipments = [];
|
||||
let allEquipments = []; // 필터링 전 전체 데이터
|
||||
let workplaces = [];
|
||||
let equipmentTypes = [];
|
||||
let currentEquipment = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// axios 설정이 완료될 때까지 대기
|
||||
await waitForAxiosConfig();
|
||||
await loadInitialData();
|
||||
});
|
||||
@@ -22,11 +22,10 @@ function waitForAxiosConfig() {
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
// 최대 5초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
if (!axios.defaults.baseURL) {
|
||||
console.error('⚠️ Axios 설정 시간 초과');
|
||||
console.error('Axios 설정 시간 초과');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
@@ -52,7 +51,9 @@ async function loadEquipments() {
|
||||
try {
|
||||
const response = await axios.get('/equipments');
|
||||
if (response.data.success) {
|
||||
equipments = response.data.data;
|
||||
allEquipments = response.data.data;
|
||||
equipments = [...allEquipments];
|
||||
renderStats();
|
||||
renderEquipmentList();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -71,7 +72,6 @@ async function loadWorkplaces() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 목록 로드 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,26 +85,69 @@ async function loadEquipmentTypes() {
|
||||
}
|
||||
} 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 =>
|
||||
`<option value="${w.workplace_id}">${w.category_name} - ${w.workplace_name}</option>`
|
||||
`<option value="${w.workplace_id}">${w.category_name ? w.category_name + ' - ' : ''}${w.workplace_name}</option>`
|
||||
).join('');
|
||||
|
||||
filterWorkplace.innerHTML = '<option value="">전체</option>' + workplaceOptions;
|
||||
modalWorkplace.innerHTML = '<option value="">선택 안함</option>' + workplaceOptions;
|
||||
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 =>
|
||||
`<option value="${type}">${type}</option>`
|
||||
).join('');
|
||||
@@ -117,7 +160,7 @@ function renderEquipmentList() {
|
||||
|
||||
if (equipments.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="eq-empty-state">
|
||||
<p>등록된 설비가 없습니다.</p>
|
||||
<button class="btn btn-primary" onclick="openEquipmentModal()">설비 추가하기</button>
|
||||
</div>
|
||||
@@ -126,47 +169,56 @@ function renderEquipmentList() {
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>설비 코드</th>
|
||||
<th>설비명</th>
|
||||
<th>유형</th>
|
||||
<th>작업장</th>
|
||||
<th>제조사</th>
|
||||
<th>모델명</th>
|
||||
<th>상태</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${equipments.map(equipment => `
|
||||
<div class="eq-result-count">
|
||||
<span>검색 결과 <strong>${equipments.length}건</strong></span>
|
||||
</div>
|
||||
<div class="eq-table-wrapper">
|
||||
<table class="eq-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td><strong>${equipment.equipment_code}</strong></td>
|
||||
<td>${equipment.equipment_name}</td>
|
||||
<td>${equipment.equipment_type || '-'}</td>
|
||||
<td>${equipment.workplace_name || '-'}</td>
|
||||
<td>${equipment.manufacturer || '-'}</td>
|
||||
<td>${equipment.model_name || '-'}</td>
|
||||
<td>
|
||||
<span class="status-badge status-${equipment.status}">
|
||||
${getStatusText(equipment.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-small btn-primary" onclick="editEquipment(${equipment.equipment_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteEquipment(${equipment.equipment_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
${equipments.map(eq => `
|
||||
<tr>
|
||||
<td class="eq-col-code">${eq.equipment_code || '-'}</td>
|
||||
<td class="eq-col-name" title="${eq.equipment_name || ''}">${eq.equipment_name || '-'}</td>
|
||||
<td class="eq-col-model" title="${eq.model_name || ''}">${eq.model_name || '-'}</td>
|
||||
<td class="eq-col-spec" title="${eq.specifications || ''}">${eq.specifications || '-'}</td>
|
||||
<td>${eq.manufacturer || '-'}</td>
|
||||
<td>${eq.supplier || '-'}</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-${eq.status}">
|
||||
${getStatusText(eq.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="eq-actions">
|
||||
<button class="eq-btn-action eq-btn-edit" onclick="editEquipment(${eq.equipment_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="eq-btn-action eq-btn-delete" onclick="deleteEquipment(${eq.equipment_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
@@ -179,7 +231,32 @@ function getStatusText(status) {
|
||||
'maintenance': '정비중',
|
||||
'inactive': '비활성'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
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' });
|
||||
}
|
||||
|
||||
// 필터링
|
||||
@@ -189,38 +266,28 @@ function filterEquipments() {
|
||||
const statusFilter = document.getElementById('filterStatus').value;
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
// API에서 필터링된 데이터를 가져오는 것이 더 효율적이지만,
|
||||
// 클라이언트 측에서도 필터링을 적용합니다.
|
||||
let filtered = [...equipments];
|
||||
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;
|
||||
});
|
||||
|
||||
if (workplaceFilter) {
|
||||
filtered = filtered.filter(e => e.workplace_id == workplaceFilter);
|
||||
}
|
||||
|
||||
if (typeFilter) {
|
||||
filtered = filtered.filter(e => e.equipment_type === typeFilter);
|
||||
}
|
||||
|
||||
if (statusFilter) {
|
||||
filtered = filtered.filter(e => e.status === statusFilter);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(e =>
|
||||
e.equipment_name.toLowerCase().includes(searchTerm) ||
|
||||
e.equipment_code.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
// 임시로 equipments를 필터링된 것으로 교체하고 렌더링
|
||||
const originalEquipments = equipments;
|
||||
equipments = filtered;
|
||||
renderEquipmentList();
|
||||
equipments = originalEquipments;
|
||||
}
|
||||
|
||||
// 설비 추가 모달 열기
|
||||
function openEquipmentModal(equipmentId = null) {
|
||||
async function openEquipmentModal(equipmentId = null) {
|
||||
currentEquipment = equipmentId;
|
||||
const modal = document.getElementById('equipmentModal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
@@ -234,30 +301,51 @@ function openEquipmentModal(equipmentId = null) {
|
||||
loadEquipmentData(equipmentId);
|
||||
} else {
|
||||
modalTitle.textContent = '설비 추가';
|
||||
// 새 설비일 경우 다음 관리번호 자동 생성
|
||||
await loadNextEquipmentCode();
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 다음 관리번호 로드
|
||||
async function loadNextEquipmentCode() {
|
||||
try {
|
||||
console.log('📋 다음 관리번호 조회 중...');
|
||||
const response = await axios.get('/equipments/next-code');
|
||||
console.log('📋 다음 관리번호 응답:', response.data);
|
||||
if (response.data.success) {
|
||||
document.getElementById('equipmentCode').value = response.data.data.next_code;
|
||||
console.log('✅ 다음 관리번호 설정:', 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 equipment = response.data.data;
|
||||
const eq = response.data.data;
|
||||
|
||||
document.getElementById('equipmentId').value = equipment.equipment_id;
|
||||
document.getElementById('equipmentCode').value = equipment.equipment_code;
|
||||
document.getElementById('equipmentName').value = equipment.equipment_name;
|
||||
document.getElementById('equipmentType').value = equipment.equipment_type || '';
|
||||
document.getElementById('workplaceId').value = equipment.workplace_id || '';
|
||||
document.getElementById('manufacturer').value = equipment.manufacturer || '';
|
||||
document.getElementById('modelName').value = equipment.model_name || '';
|
||||
document.getElementById('serialNumber').value = equipment.serial_number || '';
|
||||
document.getElementById('installationDate').value = equipment.installation_date ? equipment.installation_date.split('T')[0] : '';
|
||||
document.getElementById('equipmentStatus').value = equipment.status || 'active';
|
||||
document.getElementById('specifications').value = equipment.specifications || '';
|
||||
document.getElementById('notes').value = equipment.notes || '';
|
||||
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);
|
||||
@@ -280,6 +368,8 @@ async function saveEquipment() {
|
||||
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,
|
||||
@@ -288,9 +378,8 @@ async function saveEquipment() {
|
||||
notes: document.getElementById('notes').value.trim() || null
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
if (!equipmentData.equipment_code) {
|
||||
alert('설비 코드를 입력해주세요.');
|
||||
alert('관리번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -302,10 +391,8 @@ async function saveEquipment() {
|
||||
try {
|
||||
let response;
|
||||
if (equipmentId) {
|
||||
// 수정
|
||||
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
|
||||
} else {
|
||||
// 추가
|
||||
response = await axios.post('/equipments', equipmentData);
|
||||
}
|
||||
|
||||
@@ -313,11 +400,11 @@ async function saveEquipment() {
|
||||
alert(equipmentId ? '설비가 수정되었습니다.' : '설비가 추가되었습니다.');
|
||||
closeEquipmentModal();
|
||||
await loadEquipments();
|
||||
await loadEquipmentTypes(); // 새로운 유형이 추가될 수 있으므로
|
||||
await loadEquipmentTypes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('설비 저장 실패:', error);
|
||||
if (error.response && error.response.data && error.response.data.message) {
|
||||
if (error.response?.data?.message) {
|
||||
alert(error.response.data.message);
|
||||
} else {
|
||||
alert('설비 저장 중 오류가 발생했습니다.');
|
||||
@@ -332,7 +419,7 @@ function editEquipment(equipmentId) {
|
||||
|
||||
// 설비 삭제
|
||||
async function deleteEquipment(equipmentId) {
|
||||
const equipment = equipments.find(e => e.equipment_id === equipmentId);
|
||||
const equipment = allEquipments.find(e => e.equipment_id === equipmentId);
|
||||
if (!equipment) return;
|
||||
|
||||
if (!confirm(`'${equipment.equipment_name}' 설비를 삭제하시겠습니까?`)) {
|
||||
@@ -359,7 +446,7 @@ document.addEventListener('keydown', (e) => {
|
||||
});
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('equipmentModal').addEventListener('click', (e) => {
|
||||
document.getElementById('equipmentModal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'equipmentModal') {
|
||||
closeEquipmentModal();
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { config } from './config.js';
|
||||
|
||||
// 역할 이름을 한글로 변환하는 맵
|
||||
const ROLE_NAMES = {
|
||||
admin: '관리자',
|
||||
system: '시스템 관리자',
|
||||
leader: '그룹장',
|
||||
user: '작업자',
|
||||
support: '지원팀',
|
||||
default: '사용자',
|
||||
'system admin': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자',
|
||||
'support': '지원팀',
|
||||
'default': '사용자',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,10 @@ async function processSidebarDom(doc) {
|
||||
if (!currentUser) return;
|
||||
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system';
|
||||
const accessLevel = (currentUser.access_level || '').toLowerCase();
|
||||
// role 또는 access_level로 관리자 확인
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system' ||
|
||||
accessLevel === 'admin' || accessLevel === 'system';
|
||||
|
||||
// 1. 관리자 전용 메뉴 표시/숨김
|
||||
if (isAdmin) {
|
||||
@@ -164,6 +167,21 @@ function setupSidebarEvents() {
|
||||
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
|
||||
});
|
||||
});
|
||||
|
||||
// 링크 프리페치 - 마우스 올리면 미리 로드
|
||||
const prefetchedUrls = new Set();
|
||||
sidebar.querySelectorAll('a.nav-item').forEach(link => {
|
||||
link.addEventListener('mouseenter', () => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && !prefetchedUrls.has(href) && !href.startsWith('#')) {
|
||||
prefetchedUrls.add(href);
|
||||
const prefetchLink = document.createElement('link');
|
||||
prefetchLink.rel = 'prefetch';
|
||||
prefetchLink.href = href;
|
||||
document.head.appendChild(prefetchLink);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,36 +108,12 @@ async function initializeDashboard() {
|
||||
}
|
||||
|
||||
// ========== 사용자 정보 설정 ========== //
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리하므로 여기서는 currentUser만 설정
|
||||
function setupUserInfo() {
|
||||
const authData = getAuthData();
|
||||
if (authData && authData.user) {
|
||||
currentUser = authData.user;
|
||||
|
||||
// Navbar 컴포넌트가 사용자 정보를 처리하므로 여기서는 currentUser만 설정
|
||||
// 사용자 이름 설정 (navbar 컴포넌트가 없는 경우에만)
|
||||
if (elements.userName) {
|
||||
elements.userName.textContent = currentUser.name || currentUser.username;
|
||||
}
|
||||
|
||||
// 사용자 역할 설정 (navbar 컴포넌트가 없는 경우에만)
|
||||
if (elements.userRole) {
|
||||
const roleMap = {
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자',
|
||||
'group_leader': '그룹장',
|
||||
'leader': '그룹장',
|
||||
'user': '작업자'
|
||||
};
|
||||
elements.userRole.textContent = roleMap[currentUser.role] || '작업자';
|
||||
}
|
||||
|
||||
// 아바타 초기값 설정 (navbar 컴포넌트가 없는 경우에만)
|
||||
if (elements.userInitial) {
|
||||
const initial = (currentUser.name || currentUser.username).charAt(0);
|
||||
elements.userInitial.textContent = initial;
|
||||
}
|
||||
|
||||
console.log('👤 사용자 정보 설정 완료:', currentUser.name);
|
||||
console.log('👤 사용자 정보 로드 완료:', currentUser.name, currentUser.role);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
web-ui/js/page-access-cache.js
Normal file
119
web-ui/js/page-access-cache.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// /js/page-access-cache.js
|
||||
// 페이지 권한 캐시 - 중복 API 호출 방지
|
||||
|
||||
const CACHE_KEY = 'userPageAccess';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10분
|
||||
|
||||
// 진행 중인 API 호출 Promise (중복 방지)
|
||||
let fetchPromise = null;
|
||||
|
||||
/**
|
||||
* 페이지 접근 권한 데이터 가져오기 (캐시 우선)
|
||||
* @param {object} currentUser - 현재 사용자 객체
|
||||
* @returns {Promise<Array>} 접근 가능한 페이지 목록
|
||||
*/
|
||||
export async function getPageAccess(currentUser) {
|
||||
if (!currentUser || !currentUser.user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 캐시 확인
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const cacheData = JSON.parse(cached);
|
||||
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
|
||||
return cacheData.pages;
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이미 API 호출 중이면 기존 Promise 반환
|
||||
if (fetchPromise) {
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
// 3. 새로운 API 호출
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('페이지 권한 조회 실패:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
// 캐시 저장
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
return accessiblePages;
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 조회 오류:', error);
|
||||
return null;
|
||||
} finally {
|
||||
fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 페이지에 대한 접근 권한 확인
|
||||
* @param {string} pageKey - 페이지 키
|
||||
* @param {object} currentUser - 현재 사용자 객체
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function hasPageAccess(pageKey, currentUser) {
|
||||
// Admin은 모든 페이지 접근 가능
|
||||
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 대시보드, 프로필은 모든 사용자 접근 가능
|
||||
if (pageKey === 'dashboard' || (pageKey && pageKey.startsWith('profile.'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pages = await getPageAccess(currentUser);
|
||||
if (!pages) return false;
|
||||
|
||||
const pageAccess = pages.find(p => p.page_key === pageKey);
|
||||
return pageAccess && pageAccess.can_access === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 가능한 페이지 키 목록 반환
|
||||
* @param {object} currentUser
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function getAccessiblePageKeys(currentUser) {
|
||||
const pages = await getPageAccess(currentUser);
|
||||
if (!pages) return [];
|
||||
|
||||
return pages
|
||||
.filter(p => p.can_access === 1)
|
||||
.map(p => p.page_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
*/
|
||||
export function clearPageAccessCache() {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
fetchPromise = null;
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
// page-access-management.js - 페이지 권한 관리
|
||||
|
||||
// 전역 변수
|
||||
let allUsers = [];
|
||||
let allPages = [];
|
||||
let currentUserId = null;
|
||||
let currentFilter = 'all';
|
||||
|
||||
// DOM이 로드되면 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('🚀 페이지 권한 관리 시스템 초기화');
|
||||
|
||||
// API 함수가 로드될 때까지 대기
|
||||
let retryCount = 0;
|
||||
while (!window.apiCall && retryCount < 50) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (!window.apiCall) {
|
||||
showToast('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 데이터 로드
|
||||
await loadInitialData();
|
||||
});
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
// 필터 버튼
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
currentFilter = e.target.dataset.filter;
|
||||
filterUsers();
|
||||
});
|
||||
});
|
||||
|
||||
// 저장 버튼
|
||||
const saveBtn = document.getElementById('savePageAccessBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', savePageAccess);
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
// 페이지 목록 로드
|
||||
const pagesResponse = await window.apiCall('/pages');
|
||||
if (pagesResponse && pagesResponse.success) {
|
||||
allPages = pagesResponse.data;
|
||||
console.log('✅ 페이지 목록 로드:', allPages.length + '개');
|
||||
}
|
||||
|
||||
// 사용자 목록 로드 - 계정이 있는 작업자만
|
||||
const workersResponse = await window.apiCall('/workers?limit=1000');
|
||||
if (workersResponse) {
|
||||
const workers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
|
||||
|
||||
// user_id가 있고 활성 상태인 작업자만 필터링
|
||||
const usersWithAccounts = workers.filter(w => w.user_id && w.is_active);
|
||||
|
||||
// 각 사용자의 페이지 권한 수 조회
|
||||
allUsers = await Promise.all(usersWithAccounts.map(async (worker) => {
|
||||
try {
|
||||
const accessResponse = await window.apiCall(`/users/${worker.user_id}/page-access`);
|
||||
const grantedPagesCount = accessResponse && accessResponse.success
|
||||
? accessResponse.data.pageAccess.filter(p => p.can_access).length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
user_id: worker.user_id,
|
||||
username: worker.username || 'N/A',
|
||||
name: worker.name || worker.worker_name,
|
||||
role_name: worker.role_name || 'User',
|
||||
worker_name: worker.worker_name,
|
||||
worker_id: worker.worker_id,
|
||||
granted_pages_count: grantedPagesCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`권한 조회 오류 (user_id: ${worker.user_id}):`, error);
|
||||
return {
|
||||
...worker,
|
||||
granted_pages_count: 0
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('✅ 사용자 목록 로드:', allUsers.length + '명');
|
||||
displayUsers();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 데이터 로드 오류:', error);
|
||||
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 목록 표시
|
||||
function displayUsers() {
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (allUsers.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const filteredUsers = filterUsersByStatus();
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<p>필터 조건에 맞는 사용자가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filteredUsers.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div style="width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem;">
|
||||
${(user.name || user.username).charAt(0)}
|
||||
</div>
|
||||
<span style="font-weight: 600;">${user.name || user.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${user.username}</td>
|
||||
<td>
|
||||
<span class="badge ${user.role_name === 'Admin' ? 'badge-warning' : 'badge-info'}">
|
||||
${user.role_name}
|
||||
</span>
|
||||
</td>
|
||||
<td>${user.worker_name || '-'}</td>
|
||||
<td>
|
||||
<span style="font-weight: 600; color: ${user.granted_pages_count > 0 ? '#16a34a' : '#6b7280'};">
|
||||
${user.granted_pages_count}개
|
||||
</span>
|
||||
<span style="color: #9ca3af;"> / ${allPages.length}개</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="openPageAccessModal(${user.user_id})">
|
||||
권한 설정
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 사용자 필터링
|
||||
function filterUsersByStatus() {
|
||||
if (currentFilter === 'all') {
|
||||
return allUsers;
|
||||
} else if (currentFilter === 'with-access') {
|
||||
return allUsers.filter(u => u.granted_pages_count > 0);
|
||||
} else if (currentFilter === 'no-access') {
|
||||
return allUsers.filter(u => u.granted_pages_count === 0);
|
||||
}
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
function filterUsers() {
|
||||
displayUsers();
|
||||
}
|
||||
|
||||
// 페이지 권한 설정 모달 열기
|
||||
async function openPageAccessModal(userId) {
|
||||
currentUserId = userId;
|
||||
const user = allUsers.find(u => u.user_id === userId);
|
||||
|
||||
if (!user) {
|
||||
showToast('사용자 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모달 열기
|
||||
document.getElementById('pageAccessModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 사용자 정보 표시
|
||||
document.getElementById('modalUserInitial').textContent = (user.name || user.username).charAt(0);
|
||||
document.getElementById('modalUserName').textContent = user.name || user.username;
|
||||
document.getElementById('modalUsername').textContent = user.username;
|
||||
document.getElementById('modalWorkerName').textContent = user.worker_name || '작업자 정보 없음';
|
||||
|
||||
// 페이지 목록 로드
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${userId}/page-access`);
|
||||
|
||||
if (response && response.success) {
|
||||
const pageAccess = response.data.pageAccess;
|
||||
renderPageList(pageAccess);
|
||||
} else {
|
||||
showToast('페이지 권한 정보를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 조회 오류:', error);
|
||||
showToast('페이지 권한 정보를 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 목록 렌더링
|
||||
function renderPageList(pageAccess) {
|
||||
const container = document.getElementById('pageListContainer');
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = {};
|
||||
pageAccess.forEach(page => {
|
||||
const category = page.category || 'common';
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = [];
|
||||
}
|
||||
grouped[category].push(page);
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
'dashboard': '대시보드',
|
||||
'management': '관리',
|
||||
'common': '공통',
|
||||
'admin': '관리자',
|
||||
'work': '작업',
|
||||
'guest': '게스트'
|
||||
};
|
||||
|
||||
container.innerHTML = Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #6b7280; padding: 0.5rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${grouped[category].map(page => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6; display: flex; align-items: center; justify-content: space-between;">
|
||||
<label style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer; flex: 1;">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="page-checkbox"
|
||||
data-page-id="${page.page_id}"
|
||||
${page.can_access || page.is_default ? 'checked' : ''}
|
||||
${page.is_default ? 'disabled' : ''}
|
||||
style="width: 18px; height: 18px; cursor: pointer;"
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">${page.page_name}</div>
|
||||
<div style="font-size: 0.75rem; color: #9ca3af;">${page.page_path}</div>
|
||||
</div>
|
||||
</label>
|
||||
${page.is_default ? '<span style="font-size: 0.75rem; color: #16a34a; font-weight: 600;">기본 권한</span>' : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 페이지 권한 저장
|
||||
async function savePageAccess() {
|
||||
if (!currentUserId) return;
|
||||
|
||||
const checkboxes = document.querySelectorAll('.page-checkbox:not([disabled]):checked');
|
||||
const pageIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.pageId));
|
||||
|
||||
try {
|
||||
document.getElementById('savePageAccessBtn').disabled = true;
|
||||
document.getElementById('savePageAccessBtn').textContent = '저장 중...';
|
||||
|
||||
const response = await window.apiCall(
|
||||
`/users/${currentUserId}/page-access`,
|
||||
'POST',
|
||||
{ pageIds, canAccess: true }
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||||
closePageAccessModal();
|
||||
await loadInitialData(); // 목록 새로고침
|
||||
} else {
|
||||
throw new Error(response.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 저장 오류:', error);
|
||||
showToast('페이지 권한 저장 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
document.getElementById('savePageAccessBtn').disabled = false;
|
||||
document.getElementById('savePageAccessBtn').textContent = '저장';
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closePageAccessModal() {
|
||||
document.getElementById('pageAccessModal').style.display = 'none';
|
||||
document.body.style.overflow = 'auto';
|
||||
currentUserId = null;
|
||||
}
|
||||
|
||||
// 토스트 알림
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${iconMap[type] || 'ℹ️'}</div>
|
||||
<div class="toast-message">${message}</div>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) {
|
||||
toast.remove();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// 전역 함수로 export
|
||||
window.openPageAccessModal = openPageAccessModal;
|
||||
window.closePageAccessModal = closePageAccessModal;
|
||||
@@ -49,39 +49,9 @@ function updateCurrentTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
// app-init.js가 navbar 사용자 정보를 처리
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
|
||||
156
web-ui/js/tbm.js
156
web-ui/js/tbm.js
@@ -118,8 +118,8 @@ async function loadInitialData() {
|
||||
currentUser = userInfo;
|
||||
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
|
||||
|
||||
// 작업자 목록 로드
|
||||
const workersResponse = await window.apiCall('/workers?limit=1000');
|
||||
// 작업자 목록 로드 (생산팀 소속만)
|
||||
const workersResponse = await window.apiCall('/workers?limit=1000&department_id=1');
|
||||
if (workersResponse) {
|
||||
allWorkers = Array.isArray(workersResponse) ? workersResponse : (workersResponse.data || []);
|
||||
// 활성 상태인 작업자만 필터링
|
||||
@@ -185,7 +185,7 @@ function switchTbmTab(tabName) {
|
||||
currentTab = tabName;
|
||||
|
||||
// 탭 버튼 활성화 상태 변경
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
@@ -194,7 +194,7 @@ function switchTbmTab(tabName) {
|
||||
});
|
||||
|
||||
// 탭 컨텐츠 표시 변경
|
||||
document.querySelectorAll('.code-tab-content').forEach(content => {
|
||||
document.querySelectorAll('.tbm-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||||
@@ -409,20 +409,33 @@ function displayTbmGroupedByDate() {
|
||||
const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`;
|
||||
|
||||
return `
|
||||
<div class="date-group">
|
||||
<div class="date-group-header ${isToday ? 'today' : ''}">
|
||||
<span class="date-group-date">${displayDate}</span>
|
||||
<span class="date-group-day">${dayName}요일${isToday ? ' (오늘)' : ''}</span>
|
||||
<span class="date-group-count">${sessions.length}건</span>
|
||||
<div class="tbm-date-group" data-date="${date}">
|
||||
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
|
||||
<span class="tbm-date-toggle">▼</span>
|
||||
<span class="tbm-date-title">${displayDate}</span>
|
||||
<span class="tbm-date-day">${dayName}요일</span>
|
||||
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
|
||||
<span class="tbm-date-count">${sessions.length}건</span>
|
||||
</div>
|
||||
<div class="date-group-grid">
|
||||
${sessions.map(session => createSessionCard(session)).join('')}
|
||||
<div class="tbm-date-content">
|
||||
<div class="tbm-date-grid">
|
||||
${sessions.map(session => createSessionCard(session)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 날짜 그룹 토글
|
||||
function toggleDateGroup(date) {
|
||||
const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
|
||||
if (group) {
|
||||
group.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
window.toggleDateGroup = toggleDateGroup;
|
||||
|
||||
/**
|
||||
* 더 많은 날짜 로드
|
||||
*/
|
||||
@@ -474,73 +487,66 @@ function displayTbmSessions() {
|
||||
// TBM 세션 카드 생성 (공통)
|
||||
function createSessionCard(session) {
|
||||
const statusBadge = {
|
||||
'draft': '<span class="badge" style="background: #fef3c7; color: #92400e;">진행중</span>',
|
||||
'completed': '<span class="badge" style="background: #dcfce7; color: #166534;">완료</span>',
|
||||
'cancelled': '<span class="badge" style="background: #fee2e2; color: #991b1b;">취소</span>'
|
||||
'draft': '<span class="tbm-card-status draft">진행중</span>',
|
||||
'completed': '<span class="tbm-card-status completed">완료</span>',
|
||||
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
|
||||
}[session.status] || '';
|
||||
|
||||
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
|
||||
const leaderDisplay = session.leader_name
|
||||
? `${session.leader_name} (${session.leader_job_type || '작업자'})`
|
||||
: `${session.created_by_name || '작업 책임자'} (관리자)`;
|
||||
const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
|
||||
const leaderRole = session.leader_name
|
||||
? (session.leader_job_type || '작업자')
|
||||
: '관리자';
|
||||
|
||||
return `
|
||||
<div class="project-card" style="cursor: pointer;" onclick="viewTbmSession(${session.session_id})">
|
||||
<div class="project-header">
|
||||
<div>
|
||||
<h3 class="project-name" style="font-size: 1rem; margin-bottom: 0.25rem;">
|
||||
${leaderDisplay}
|
||||
</h3>
|
||||
<p style="font-size: 0.75rem; color: #6b7280; margin: 0;">
|
||||
${formatDate(session.session_date)}
|
||||
</p>
|
||||
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
|
||||
<div class="tbm-card-header">
|
||||
<div class="tbm-card-header-top">
|
||||
<div>
|
||||
<h3 class="tbm-card-leader">
|
||||
${leaderName}
|
||||
<span class="tbm-card-leader-role">${leaderRole}</span>
|
||||
</h3>
|
||||
</div>
|
||||
${statusBadge}
|
||||
</div>
|
||||
${statusBadge}
|
||||
</div>
|
||||
|
||||
<div class="project-info" style="margin-top: 1rem;">
|
||||
<div class="info-item">
|
||||
<span class="info-label">프로젝트</span>
|
||||
<span class="info-value">${session.project_name || '-'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">공정</span>
|
||||
<span class="info-value">${session.work_type_name || '-'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">작업</span>
|
||||
<span class="info-value">${session.task_name || '-'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">작업 장소</span>
|
||||
<span class="info-value">${session.work_location || '-'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">팀원 수</span>
|
||||
<span class="info-value">${session.team_member_count || 0}명</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">시작 시간</span>
|
||||
<span class="info-value">${session.start_time || '-'}</span>
|
||||
<div class="tbm-card-date">
|
||||
<span>📅</span>
|
||||
${formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${session.work_description ? `
|
||||
<div style="margin-top: 0.75rem; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; font-size: 0.875rem; color: #374151;">
|
||||
${session.work_description}
|
||||
<div class="tbm-card-body">
|
||||
<div class="tbm-card-info-grid">
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">프로젝트</span>
|
||||
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">공정</span>
|
||||
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">작업장</span>
|
||||
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<span class="tbm-card-info-label">팀원</span>
|
||||
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${session.status === 'draft' ? `
|
||||
<div class="tbm-card-footer">
|
||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
|
||||
👥 팀 구성
|
||||
</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
|
||||
✓ 안전 체크
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div style="margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
${session.status === 'draft' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||||
👥 팀 구성
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})" style="flex: 1; min-width: 100px;">
|
||||
✅ 안전 체크
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -550,23 +556,33 @@ function openNewTbmModal() {
|
||||
currentSessionId = null;
|
||||
workerTaskList = []; // 작업자 목록 초기화
|
||||
|
||||
document.getElementById('modalTitle').textContent = '새 TBM 시작';
|
||||
document.getElementById('modalTitle').innerHTML = '<span>📝</span> 새 TBM 시작';
|
||||
document.getElementById('sessionId').value = '';
|
||||
document.getElementById('tbmForm').reset();
|
||||
|
||||
const today = getTodayKST();
|
||||
document.getElementById('sessionDate').value = today;
|
||||
|
||||
// 날짜 표시 업데이트
|
||||
const [year, month, day] = today.split('-');
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const dateObj = new Date(today);
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const sessionDateDisplay = document.getElementById('sessionDateDisplay');
|
||||
if (sessionDateDisplay) {
|
||||
sessionDateDisplay.textContent = `${year}년 ${parseInt(month)}월 ${parseInt(day)}일 (${dayName})`;
|
||||
}
|
||||
|
||||
// 입력자 자동 설정 (readonly)
|
||||
if (currentUser && currentUser.worker_id) {
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
document.getElementById('leaderName').value = worker.worker_name;
|
||||
document.getElementById('leaderName').textContent = worker.worker_name;
|
||||
document.getElementById('leaderId').value = worker.worker_id;
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
// 관리자: 이름만 표시
|
||||
document.getElementById('leaderName').value = currentUser.name;
|
||||
document.getElementById('leaderName').textContent = currentUser.name;
|
||||
document.getElementById('leaderId').value = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -54,39 +54,9 @@ function updateCurrentTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
// 사용자 정보 업데이트 - navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
// app-init.js가 navbar 사용자 정보를 처리하므로 여기서는 아무것도 하지 않음
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
|
||||
@@ -48,39 +48,9 @@ function updateCurrentTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function updateUserInfo() {
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name || '사용자';
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자',
|
||||
'system': '시스템 관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자';
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
const name = finalUserInfo.worker_name || '사용자';
|
||||
userInitialElement.textContent = name.charAt(0);
|
||||
}
|
||||
// app-init.js가 navbar 사용자 정보를 처리
|
||||
}
|
||||
|
||||
// 프로필 메뉴 설정
|
||||
|
||||
@@ -788,57 +788,9 @@ function updateCurrentTime() {
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트 함수
|
||||
// navbar/sidebar는 app-init.js에서 공통 처리
|
||||
function updateUserInfo() {
|
||||
// auth-check.js에서 사용하는 'user' 키와 기존 'userInfo' 키 모두 확인
|
||||
let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
let authUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
console.log('👤 localStorage userInfo:', userInfo);
|
||||
console.log('👤 localStorage user (auth):', authUser);
|
||||
|
||||
// 두 소스에서 사용자 정보 통합
|
||||
const finalUserInfo = {
|
||||
worker_name: userInfo.worker_name || authUser.username || authUser.worker_name,
|
||||
job_type: userInfo.job_type || authUser.role || authUser.job_type,
|
||||
username: authUser.username || userInfo.username
|
||||
};
|
||||
|
||||
console.log('👤 최종 사용자 정보:', finalUserInfo);
|
||||
|
||||
const userNameElement = document.getElementById('userName');
|
||||
const userRoleElement = document.getElementById('userRole');
|
||||
const userInitialElement = document.getElementById('userInitial');
|
||||
|
||||
if (userNameElement) {
|
||||
if (finalUserInfo.worker_name) {
|
||||
userNameElement.textContent = finalUserInfo.worker_name;
|
||||
} else {
|
||||
userNameElement.textContent = '사용자';
|
||||
}
|
||||
}
|
||||
|
||||
if (userRoleElement) {
|
||||
if (finalUserInfo.job_type) {
|
||||
// role을 한글로 변환
|
||||
const roleMap = {
|
||||
'leader': '그룹장',
|
||||
'worker': '작업자',
|
||||
'admin': '관리자'
|
||||
};
|
||||
userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type;
|
||||
} else {
|
||||
userRoleElement.textContent = '작업자';
|
||||
}
|
||||
}
|
||||
|
||||
if (userInitialElement) {
|
||||
if (finalUserInfo.worker_name) {
|
||||
userInitialElement.textContent = finalUserInfo.worker_name.charAt(0);
|
||||
} else {
|
||||
userInitialElement.textContent = '사';
|
||||
}
|
||||
}
|
||||
// app-init.js가 navbar 사용자 정보를 처리
|
||||
}
|
||||
|
||||
// 페이지 초기화 개선
|
||||
|
||||
@@ -179,7 +179,7 @@ async function loadInitialData() {
|
||||
|
||||
async function loadWorkerInfo() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/workers/${currentWorkerId}`);
|
||||
const response = await window.apiCall(`/workers/${currentWorkerId}`);
|
||||
const worker = response.data || response;
|
||||
document.getElementById('workerJob').textContent = worker.job_type || '작업자';
|
||||
} catch (error) {
|
||||
@@ -189,7 +189,7 @@ async function loadWorkerInfo() {
|
||||
|
||||
async function loadExistingWork() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports?date=${selectedDate}&worker_id=${currentWorkerId}`);
|
||||
const response = await window.apiCall(`/daily-work-reports?date=${selectedDate}&worker_id=${currentWorkerId}`);
|
||||
existingWork = Array.isArray(response) ? response : (response.data || []);
|
||||
console.log(`✅ 기존 작업 ${existingWork.length}건 로드 완료`);
|
||||
} catch (error) {
|
||||
@@ -200,7 +200,7 @@ async function loadExistingWork() {
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/projects/active/list`);
|
||||
const response = await window.apiCall(`/projects/active/list`);
|
||||
projects = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 오류:', error);
|
||||
@@ -210,7 +210,7 @@ async function loadProjects() {
|
||||
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/work-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-types`);
|
||||
workTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('작업 유형 로드 오류:', error);
|
||||
@@ -220,7 +220,7 @@ async function loadWorkTypes() {
|
||||
|
||||
async function loadWorkStatusTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/work-status-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/work-status-types`);
|
||||
workStatusTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('작업 상태 유형 로드 오류:', error);
|
||||
@@ -230,7 +230,7 @@ async function loadWorkStatusTypes() {
|
||||
|
||||
async function loadErrorTypes() {
|
||||
try {
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports/error-types`);
|
||||
const response = await window.apiCall(`/daily-work-reports/error-types`);
|
||||
errorTypes = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('에러 유형 로드 오류:', error);
|
||||
@@ -407,7 +407,7 @@ async function saveNewWork() {
|
||||
created_by: currentUser?.user_id || 1
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports`, 'POST', workData);
|
||||
const response = await window.apiCall(`/daily-work-reports`, 'POST', workData);
|
||||
|
||||
showMessage('작업이 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
@@ -437,7 +437,7 @@ async function deleteWork(workId) {
|
||||
try {
|
||||
showMessage('작업을 삭제하는 중...', 'loading');
|
||||
|
||||
await window.apiCall(`${window.API}/daily-work-reports/${workId}`, {
|
||||
await window.apiCall(`/daily-work-reports/${workId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -486,7 +486,7 @@ async function handleVacationProcess(vacationType) {
|
||||
created_by: currentUser?.user_id || 1
|
||||
};
|
||||
|
||||
const response = await window.apiCall(`${window.API}/daily-work-reports`, {
|
||||
const response = await window.apiCall(`/daily-work-reports`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(vacationWork)
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
@@ -120,13 +119,18 @@
|
||||
<input type="tel" id="userPhone" class="form-control">
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 (사용자 편집 시에만 표시) -->
|
||||
<div class="form-group" id="pageAccessGroup" style="display: none;">
|
||||
<label class="form-label">페이지 접근 권한</label>
|
||||
<small class="form-help">관리자는 모든 페이지에 자동으로 접근 가능합니다</small>
|
||||
<div id="pageAccessList" class="page-access-list">
|
||||
<!-- 페이지 체크박스 목록이 동적으로 생성됩니다 -->
|
||||
<!-- 작업자 연결 (수정 시에만 표시) -->
|
||||
<div class="form-group" id="workerLinkGroup" style="display: none;">
|
||||
<label class="form-label">작업자 연결</label>
|
||||
<div class="worker-link-container">
|
||||
<div class="linked-worker-info" id="linkedWorkerInfo">
|
||||
<span class="no-worker">연결된 작업자 없음</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="openWorkerSelectModal()">
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-help">계정과 작업자를 연결하면 출퇴근, 작업보고서 등의 기록이 연동됩니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -194,13 +198,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 모달 -->
|
||||
<div id="workerSelectModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2>작업자 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="worker-select-layout">
|
||||
<!-- 부서 목록 -->
|
||||
<div class="department-list-panel">
|
||||
<h3 class="panel-title">부서</h3>
|
||||
<div class="department-list" id="departmentList">
|
||||
<!-- 부서 목록이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 -->
|
||||
<div class="worker-list-panel">
|
||||
<h3 class="panel-title">작업자</h3>
|
||||
<div class="worker-list" id="workerListForSelect">
|
||||
<div class="empty-message">부서를 선택하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" onclick="unlinkWorker()">연결 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="/js/admin-settings.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>출퇴근-작업보고서 대조 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
@@ -177,8 +178,6 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>코드 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -134,8 +135,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/code-management.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
316
web-ui/pages/admin/departments.html
Normal file
316
web-ui/pages/admin/departments.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.department-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.department-list-panel {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.department-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.department-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.department-item.active {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.department-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.department-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.department-count {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.department-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.btn-icon.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
.worker-list-panel {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.worker-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.worker-list-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.worker-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.worker-card:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.worker-card.selected {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.worker-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.worker-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
.worker-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.worker-name {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.worker-job {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.bulk-actions {
|
||||
display: none;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bulk-actions.visible {
|
||||
display: flex;
|
||||
}
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-backdrop.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.department-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">부서 관리</h1>
|
||||
<p class="page-description">부서를 등록하고 작업자를 부서별로 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openDepartmentModal()">새 부서 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="department-grid">
|
||||
<!-- 부서 목록 패널 -->
|
||||
<div class="department-list-panel">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1rem; color: #374151;">부서 목록</h3>
|
||||
<div id="departmentList">
|
||||
<!-- 부서 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 패널 -->
|
||||
<div class="worker-list-panel">
|
||||
<div class="worker-list-header">
|
||||
<span class="worker-list-title" id="workerListTitle">부서를 선택하세요</span>
|
||||
<button class="btn btn-secondary btn-sm" id="addWorkerBtn" style="display: none;" onclick="openAddWorkerModal()">
|
||||
작업자 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 작업 영역 -->
|
||||
<div class="bulk-actions" id="bulkActions">
|
||||
<span style="font-size: 0.875rem; color: #374151;"><strong id="selectedCount">0</strong>명 선택됨</span>
|
||||
<select class="form-select" id="moveToDepartment" style="width: 150px; margin-left: auto;">
|
||||
<option value="">부서 이동...</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" onclick="moveSelectedWorkers()">이동</button>
|
||||
</div>
|
||||
|
||||
<div id="workerList">
|
||||
<div style="text-align: center; padding: 2rem; color: #9ca3af;">
|
||||
왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 부서 등록/수정 모달 -->
|
||||
<div class="modal-backdrop" id="departmentModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="departmentModalTitle">새 부서 등록</h2>
|
||||
<button class="btn-icon" onclick="closeDepartmentModal()">×</button>
|
||||
</div>
|
||||
<form id="departmentForm" onsubmit="saveDepartment(event)">
|
||||
<input type="hidden" id="departmentId">
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서명 *</label>
|
||||
<input type="text" class="form-input" id="departmentName" required placeholder="예: 생산팀, 품질관리팀">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">상위 부서</label>
|
||||
<select class="form-select" id="parentDepartment">
|
||||
<option value="">없음 (최상위 부서)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea class="form-textarea" id="departmentDescription" placeholder="부서 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" class="form-input" id="displayOrder" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="isActive" checked>
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDepartmentModal()">취소</button>
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/department-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>설비 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -32,21 +33,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 요약 -->
|
||||
<div id="statsSection" class="eq-stats-section">
|
||||
<!-- JS에서 동적으로 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<div class="eq-filter-section">
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterWorkplace">작업장</label>
|
||||
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterType">설비 유형</label>
|
||||
<select id="filterType" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="eq-filter-group">
|
||||
<label for="filterStatus">상태</label>
|
||||
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
@@ -55,17 +61,15 @@
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="eq-filter-group eq-search-group">
|
||||
<label for="searchInput">검색</label>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="설비명, 코드, 제조사 검색..." oninput="filterEquipments()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 목록 -->
|
||||
<div class="content-section">
|
||||
<div id="equipmentList" class="data-table-container">
|
||||
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
<div id="equipmentList" class="eq-table-container">
|
||||
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -73,79 +77,99 @@
|
||||
|
||||
<!-- 설비 추가/수정 모달 -->
|
||||
<div id="equipmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-container" style="max-width: 720px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">설비 추가</h2>
|
||||
<button class="btn-close" onclick="closeEquipmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body eq-modal-body">
|
||||
<form id="equipmentForm">
|
||||
<input type="hidden" id="equipmentId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentCode">설비 코드 *</label>
|
||||
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
|
||||
<!-- 기본 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">기본 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentCode">관리번호 *</label>
|
||||
<input type="text" id="equipmentCode" class="form-control" placeholder="예: TKP-001" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentName">설비명 *</label>
|
||||
<input type="text" id="equipmentName" class="form-control" placeholder="예: TIG용접기" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentName">설비명 *</label>
|
||||
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="modelName">모델명</label>
|
||||
<input type="text" id="modelName" class="form-control" placeholder="예: Perfect-500PT">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="specifications">규격</label>
|
||||
<input type="text" id="specifications" class="form-control" placeholder="예: 500A/DC">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentType">설비 유형</label>
|
||||
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
|
||||
<!-- 제조사 및 구입 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">제조사 및 구입 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="manufacturer">제조사 (메이커)</label>
|
||||
<input type="text" id="manufacturer" class="form-control" placeholder="예: 퍼펙트대대">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="supplier">구입처</label>
|
||||
<input type="text" id="supplier" class="form-control" placeholder="예: 현대용접기">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="workplaceId">작업장</label>
|
||||
<select id="workplaceId" class="form-control">
|
||||
<option value="">선택 안함</option>
|
||||
</select>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="purchasePrice">구입가격 (원)</label>
|
||||
<input type="number" id="purchasePrice" class="form-control" placeholder="예: 1600000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="installationDate">구입일자</label>
|
||||
<input type="date" id="installationDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="manufacturer">제조사</label>
|
||||
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
|
||||
<!-- 상세 정보 -->
|
||||
<div class="eq-form-section">
|
||||
<div class="eq-form-section-title">상세 정보</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialNumber">시리얼 번호 (S/N)</label>
|
||||
<input type="text" id="serialNumber" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentStatus">상태</label>
|
||||
<select id="equipmentStatus" class="form-control">
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="eq-form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentType">설비 유형</label>
|
||||
<input type="text" id="equipmentType" class="form-control" placeholder="예: 용접기, 크레인 등">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="workplaceId">작업장</label>
|
||||
<select id="workplaceId" class="form-control">
|
||||
<option value="">선택 안함</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modelName">모델명</label>
|
||||
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
|
||||
<label for="notes">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialNumber">시리얼 번호</label>
|
||||
<input type="text" id="serialNumber" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="installationDate">설치일</label>
|
||||
<input type="date" id="installationDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="equipmentStatus">상태</label>
|
||||
<select id="equipmentStatus" class="form-control">
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="specifications">사양 정보</label>
|
||||
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -156,16 +180,11 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
// API 설정 먼저 로드
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(function() {
|
||||
// api-config.js가 로드될 때까지 대기
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
@@ -174,30 +193,17 @@
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// axios 요청 인터셉터 추가 (모든 요청에 토큰 자동 추가)
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
console.log('[Request]', config.method.toUpperCase(), config.url);
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
|
||||
// axios 응답 인터셉터 추가 (에러 처리)
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
console.log('[Response]', response.status, response.config.url);
|
||||
return response;
|
||||
},
|
||||
response => response,
|
||||
error => {
|
||||
console.error('[Error]', error.response?.status, error.config?.url, error.message);
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
@@ -205,12 +211,10 @@
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Axios Ready]', axios.defaults.baseURL);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/equipment-management.js?v=4"></script>
|
||||
<script src="/js/equipment-management.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>신고 카테고리 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
@@ -315,8 +316,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/issue-category-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">페이지 접근 권한 관리</h1>
|
||||
<p class="page-description">작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">사용자 목록</h2>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="with-access">권한 있음</button>
|
||||
<button class="filter-btn" data-filter="no-access">권한 없음</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>작업자</th>
|
||||
<th>접근 가능 페이지</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner"></div>
|
||||
<p>사용자 목록을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>권한을 부여할 사용자 계정이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">페이지 권한 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<div style="width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600;">
|
||||
<span id="modalUserInitial">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1rem; color: #111827;" id="modalUserName">사용자명</div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span id="modalUsername">username</span>
|
||||
<span style="margin: 0 0.5rem;">•</span>
|
||||
<span id="modalWorkerName">작업자</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-access-list">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; color: #374151; margin-bottom: 1rem;">
|
||||
접근 가능 페이지 선택
|
||||
</h3>
|
||||
|
||||
<div id="pageListContainer" style="max-height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div class="spinner" style="margin: 0 auto 0.5rem;"></div>
|
||||
페이지 목록을 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fde047; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #92400e;">
|
||||
<strong>💡 참고:</strong> Admin 및 System Admin은 모든 페이지에 자동으로 접근할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/page-access-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,9 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=6">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -169,9 +168,9 @@
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/project-management.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -144,8 +145,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/task-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,118 +5,415 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업자 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=6">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.department-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
/* 부서 패널 */
|
||||
.department-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.department-panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.department-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.department-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.department-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.department-item.active {
|
||||
background: #dbeafe;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.department-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.department-name {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.department-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.department-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.department-item:hover .department-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 작업자 패널 */
|
||||
.worker-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.worker-panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.worker-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.worker-toolbar {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.worker-toolbar .search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.worker-toolbar .filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.worker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 작업자 테이블 스타일 */
|
||||
.workers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.workers-table th,
|
||||
.workers-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.workers-table th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.workers-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.worker-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.worker-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-badge.resigned {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.account-badge.has-account {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.account-badge.no-account {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.department-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.department-panel {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 부서 모달 스타일 */
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-check input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">작업자 관리</h1>
|
||||
<p class="page-description">작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다</p>
|
||||
</div>
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">작업자 관리</h1>
|
||||
<p class="page-description">부서별 작업자를 관리합니다. 부서를 선택하면 해당 부서의 작업자를 확인하고 관리할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">새 작업자 등록</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkerList()">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 부서 기반 레이아웃 -->
|
||||
<div class="department-layout">
|
||||
<!-- 왼쪽: 부서 목록 -->
|
||||
<div class="department-panel">
|
||||
<div class="department-panel-header">
|
||||
<h3>부서 목록</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="openDepartmentModal()">+ 부서 추가</button>
|
||||
</div>
|
||||
<div class="department-list" id="departmentList">
|
||||
<!-- 부서 목록이 여기에 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="작업자명, 직책, 전화번호로 검색...">
|
||||
<button class="search-btn" onclick="searchWorkers()">검색</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-options">
|
||||
<select id="jobTypeFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 직책</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
|
||||
<select id="statusFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
|
||||
<select id="sortBy" class="filter-select" onchange="sortWorkers()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="worker_name">이름순</option>
|
||||
<option value="job_type">직책순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">등록된 작업자</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 작업자만 보기">활성 <span id="activeWorkers">0</span>명</span>
|
||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 작업자만 보기">비활성 <span id="inactiveWorkers">0</span>명</span>
|
||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 작업자 보기">총 <span id="totalWorkers">0</span>명</span>
|
||||
<!-- 오른쪽: 작업자 목록 -->
|
||||
<div class="worker-panel">
|
||||
<div class="worker-panel-header">
|
||||
<h3 id="workerListTitle">부서를 선택하세요</h3>
|
||||
<button class="btn btn-sm btn-primary" id="addWorkerBtn" onclick="openWorkerModal()" style="display: none;">+ 작업자 추가</button>
|
||||
</div>
|
||||
<div class="worker-toolbar" id="workerToolbar" style="display: none;">
|
||||
<input type="text" class="search-input" id="workerSearch" placeholder="작업자 검색..." oninput="filterWorkers()">
|
||||
<select class="filter-select" id="statusFilter" onchange="filterWorkers()">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
<option value="resigned">퇴사</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="worker-list" id="workerList">
|
||||
<div class="empty-state">
|
||||
<h4>부서를 선택해주세요</h4>
|
||||
<p>왼쪽에서 부서를 선택하면 해당 부서의 작업자가 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 테이블 -->
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="workersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">상태</th>
|
||||
<th style="width: 100px;">이름</th>
|
||||
<th style="width: 100px;">직책</th>
|
||||
<th style="width: 130px;">전화번호</th>
|
||||
<th style="width: 180px;">이메일</th>
|
||||
<th style="width: 100px;">입사일</th>
|
||||
<th style="width: 100px;">부서</th>
|
||||
<th style="width: 80px;">계정</th>
|
||||
<th style="width: 80px;">현장직</th>
|
||||
<th style="width: 120px;">등록일</th>
|
||||
<th style="width: 100px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workersGrid">
|
||||
<!-- 작업자 행들이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 부서 추가/수정 모달 -->
|
||||
<div id="departmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="departmentModalTitle">새 부서 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeDepartmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="departmentForm" onsubmit="event.preventDefault(); saveDepartment();">
|
||||
<input type="hidden" id="departmentId">
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<h3>등록된 작업자가 없습니다.</h3>
|
||||
<p>"새 작업자 등록" 버튼을 눌러 작업자를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">첫 작업자 등록하기</button>
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서명 *</label>
|
||||
<input type="text" id="departmentName" class="form-control" placeholder="예: 생산팀, 품질관리팀" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">상위 부서</label>
|
||||
<select id="parentDepartment" class="form-control">
|
||||
<option value="">없음 (최상위 부서)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="departmentDescription" class="form-control" rows="2" placeholder="부서에 대한 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-control" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label"> </label>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isActiveDept" checked>
|
||||
<label for="isActiveDept">활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDepartmentModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteDeptBtn" onclick="deleteDepartment()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDepartment()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 추가/수정 모달 -->
|
||||
<div id="workerModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 작업자 등록</h2>
|
||||
<h2 id="workerModalTitle">새 작업자 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerModal()">×</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workerForm" onsubmit="event.preventDefault(); saveWorker();">
|
||||
<input type="hidden" id="workerId">
|
||||
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자명 *</label>
|
||||
@@ -131,34 +428,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">전화번호</label>
|
||||
<input type="tel" id="phoneNumber" class="form-control" placeholder="010-0000-0000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="email" class="form-control" placeholder="example@company.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" id="hireDate" class="form-control">
|
||||
<input type="date" id="joinDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서</label>
|
||||
<input type="text" id="department" class="form-control" placeholder="소속 부서">
|
||||
<label class="form-label">급여</label>
|
||||
<input type="number" id="salary" class="form-control" placeholder="월급여">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="3" placeholder="추가 정보나 특이사항을 입력하세요"></textarea>
|
||||
<label class="form-label">연차</label>
|
||||
<input type="number" id="annualLeave" class="form-control" placeholder="연차 일수" value="0">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 상태 관리 섹션 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="font-weight: 600; margin-bottom: 0.75rem; display: block;">상태 관리</label>
|
||||
@@ -166,20 +452,20 @@
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<!-- 계정 생성/연동 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="hasAccount" style="margin: 0; cursor: pointer;">
|
||||
<input type="checkbox" id="createAccount" style="margin: 0; cursor: pointer;">
|
||||
<span>계정 생성/연동</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크 시 로그인 계정이 자동 생성됩니다 (나의 대시보드, 연차/출퇴근 관리 가능)
|
||||
체크 시 로그인 계정이 자동 생성됩니다 (ID: 이름 로마자 변환, 초기 비밀번호: 1234)
|
||||
</small>
|
||||
|
||||
<!-- 현장직/사무직 구분 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isActive" checked style="margin: 0; cursor: pointer;">
|
||||
<input type="checkbox" id="isActiveWorker" checked style="margin: 0; cursor: pointer;">
|
||||
<span>현장직 (활성화)</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크: 현장직 (출퇴근 관리 필요) / 체크 해제: 사무직 (출퇴근 관리 불필요)
|
||||
체크: 현장직 (TBM, 작업보고서에 표시) / 체크 해제: 사무직
|
||||
</small>
|
||||
|
||||
<!-- 퇴사 처리 -->
|
||||
@@ -188,13 +474,13 @@
|
||||
<span style="color: #ef4444;">퇴사 처리</span>
|
||||
</label>
|
||||
<small style="color: #ef4444; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
퇴사한 작업자로 표시됩니다. 작업 보고서에서 제외됩니다
|
||||
퇴사한 작업자로 표시됩니다. TBM/작업 보고서에서 제외됩니다
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkerBtn" onclick="deleteWorker()" style="display: none;">삭제</button>
|
||||
@@ -202,10 +488,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/worker-management.js?v=7"></script>
|
||||
<!-- worker-management.js만 로드 (navbar/sidebar는 app-init.js에서 처리) -->
|
||||
<script type="module" src="/js/worker-management.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,307 +5,419 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업장 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<!-- 메인 레이아웃 (기존 admin 레이아웃과 호환) -->
|
||||
<div class="page-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">작업장 관리</h1>
|
||||
<p class="page-description">공장 및 작업장을 등록하고 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="openCategoryModal()">공장 추가</button>
|
||||
<button class="btn btn-primary" onclick="openWorkplaceModal()">작업장 추가</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkplaces()">새로고침</button>
|
||||
<div class="wp-content">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="wp-page-header">
|
||||
<div class="wp-header-content">
|
||||
<h1 class="wp-page-title">
|
||||
<span class="wp-page-title-icon">🏭</span>
|
||||
작업장 관리
|
||||
</h1>
|
||||
<p class="wp-page-description">공장 및 작업장을 등록하고 설비 위치를 지도에서 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 탭 -->
|
||||
<div class="code-tabs" id="categoryTabs">
|
||||
<button class="tab-btn active" data-category="" onclick="switchCategory('')">전체</button>
|
||||
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
|
||||
<div class="code-section" id="layoutMapSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title"><span id="selectedCategoryName"></span> 레이아웃 지도</h2>
|
||||
<button class="btn btn-secondary" onclick="openLayoutMapModal()">지도 설정</button>
|
||||
</div>
|
||||
<div id="layoutMapPreview" style="padding: 20px; background: #f9fafb; border-radius: 8px; text-align: center;">
|
||||
<!-- 레이아웃 이미지 미리보기가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 목록 -->
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">작업장 목록</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-stats" id="workplaceStats">
|
||||
<span class="stat-item">전체 <span id="totalCount">0</span>개</span>
|
||||
<span class="stat-item">활성 <span id="activeCount">0</span>개</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workplaceGrid">
|
||||
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="wp-header-actions">
|
||||
<button class="wp-btn wp-btn-primary" onclick="openCategoryModal()">
|
||||
<span class="wp-btn-icon">🏢</span>
|
||||
공장 추가
|
||||
</button>
|
||||
<button class="wp-btn wp-btn-primary" onclick="openWorkplaceModal()">
|
||||
<span class="wp-btn-icon">📍</span>
|
||||
작업장 추가
|
||||
</button>
|
||||
<button class="wp-btn wp-btn-secondary" onclick="refreshWorkplaces()">
|
||||
<span class="wp-btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="wp-stats-row" id="statsRow">
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon factory">🏢</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="factoryCount">0</h3>
|
||||
<p>공장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon workplace">📍</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="totalCount">0</h3>
|
||||
<p>전체 작업장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon active">✅</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="activeCount">0</h3>
|
||||
<p>활성 작업장</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-stat-card">
|
||||
<div class="wp-stat-icon equipment">⚙️</div>
|
||||
<div class="wp-stat-content">
|
||||
<h3 id="equipmentCount">0</h3>
|
||||
<p>등록된 설비</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 탭 -->
|
||||
<div class="wp-factory-tabs" id="categoryTabs">
|
||||
<button class="wp-tab-btn active" data-category="" onclick="switchCategory('')">
|
||||
<span class="wp-tab-icon">🏗️</span>
|
||||
전체
|
||||
<span class="wp-tab-count" id="tabAllCount">0</span>
|
||||
</button>
|
||||
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
|
||||
<div class="wp-layout-section" id="layoutMapSection" style="display: none;">
|
||||
<div class="wp-layout-header">
|
||||
<h2 class="wp-layout-title">
|
||||
<span class="wp-layout-title-icon">🗺️</span>
|
||||
<span id="selectedCategoryName"></span> 레이아웃 지도
|
||||
</h2>
|
||||
<button class="wp-btn wp-btn-primary" onclick="openLayoutMapModal()">
|
||||
<span class="wp-btn-icon">⚙️</span>
|
||||
지도 설정
|
||||
</button>
|
||||
</div>
|
||||
<div class="wp-layout-body">
|
||||
<div id="layoutMapPreview" class="wp-layout-preview">
|
||||
<div class="wp-layout-empty">
|
||||
<div class="wp-layout-empty-icon">🗺️</div>
|
||||
<p>레이아웃 이미지가 아직 등록되지 않았습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 목록 -->
|
||||
<div class="wp-workplace-section">
|
||||
<div class="wp-section-header">
|
||||
<h2 class="wp-section-title">
|
||||
<span>📋</span>
|
||||
작업장 목록
|
||||
</h2>
|
||||
<div class="wp-section-stats">
|
||||
<span class="wp-section-stat">전체 <strong id="sectionTotalCount">0</strong>개</span>
|
||||
<span class="wp-section-stat">활성 <strong id="sectionActiveCount">0</strong>개</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-section-body">
|
||||
<div class="wp-grid" id="workplaceGrid">
|
||||
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="categoryModalTitle">공장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공장명 *</label>
|
||||
<input type="text" id="categoryName" class="form-control" placeholder="예: 제 1공장" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="categoryOrder" class="form-control" value="0" min="0">
|
||||
<small class="form-help">작은 숫자가 먼저 표시됩니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCategory()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 추가/수정 모달 -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceModalTitle">작업장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
|
||||
<input type="hidden" id="workplaceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공장</label>
|
||||
<select id="workplaceCategoryId" class="form-control">
|
||||
<option value="">공장 선택</option>
|
||||
<!-- 공장 목록이 동적으로 생성됩니다 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장명 *</label>
|
||||
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 용도</label>
|
||||
<select id="workplacePurpose" class="form-control">
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="작업구역">작업구역</option>
|
||||
<option value="설비">설비</option>
|
||||
<option value="휴게시설">휴게시설</option>
|
||||
<option value="회의실">회의실</option>
|
||||
<option value="창고">창고</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<small class="form-help">작업장의 주요 용도를 선택하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayPriority" class="form-control" value="0" min="0">
|
||||
<small class="form-help">숫자가 작을수록 먼저 표시됩니다</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplace()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 관리 모달 -->
|
||||
<div id="workplaceMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceMapModalTitle">작업장 지도 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 작업장 레이아웃 이미지 업로드</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장의 상세 레이아웃 이미지를 업로드하세요
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="workplaceLayoutPreview" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 200px;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="workplaceLayoutFile" accept="image/*" class="form-control" onchange="previewWorkplaceLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="uploadWorkplaceLayout()">이미지 업로드</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 설비/영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 설비 위치 정의 (선택사항)</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장 이미지 위에 마우스로 드래그하여 각 설비의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="workplaceCanvasContainer">
|
||||
<canvas id="workplaceRegionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 설비 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">설비 이름 입력</label>
|
||||
<input type="text" id="equipmentNameInput" class="form-control" placeholder="예: CNC-01, 선반기-A" style="margin-bottom: 12px;">
|
||||
<small class="form-help">드래그로 영역을 선택한 후 설비 이름을 입력하고 저장하세요</small>
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearWorkplaceCurrentRegion()">현재 영역 지우기</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplaceEquipmentRegion()">설비 위치 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 설비 목록</label>
|
||||
<div id="workplaceEquipmentList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 레이아웃 지도 설정 모달 -->
|
||||
<div id="layoutMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>공장 레이아웃 지도 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeLayoutMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="layoutImageFile" accept="image/*" class="form-control" onchange="previewLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="uploadLayoutImage()">이미지 업로드</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
|
||||
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 선택</label>
|
||||
<select id="regionWorkplaceSelect" class="form-control" style="margin-bottom: 12px;">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearCurrentRegion()">현재 영역 지우기</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveRegion()">선택 영역 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 영역 목록</label>
|
||||
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<!-- 영역 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeLayoutMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 공장(카테고리) 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🏢</span>
|
||||
<span id="categoryModalTitle">공장 추가</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label required">공장명</label>
|
||||
<input type="text" id="categoryName" class="wp-form-control" placeholder="예: 제 1공장" required>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="wp-form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">표시 순서</label>
|
||||
<input type="number" id="categoryOrder" class="wp-form-control" value="0" min="0">
|
||||
<span class="wp-form-help">숫자가 작을수록 먼저 표시됩니다</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="wp-btn wp-btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">삭제</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveCategory()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/workplace-management.js?v=3"></script>
|
||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||
<!-- 작업장 추가/수정 모달 -->
|
||||
<div id="workplaceModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>📍</span>
|
||||
<span id="workplaceModalTitle">작업장 추가</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body">
|
||||
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
|
||||
<input type="hidden" id="workplaceId">
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">소속 공장</label>
|
||||
<select id="workplaceCategoryId" class="wp-form-control">
|
||||
<option value="">공장 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label required">작업장명</label>
|
||||
<input type="text" id="workplaceName" class="wp-form-control" placeholder="예: 서스작업장, 조립구역" required>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">작업장 용도</label>
|
||||
<select id="workplacePurpose" class="wp-form-control">
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="작업구역">작업구역</option>
|
||||
<option value="설비">설비</option>
|
||||
<option value="휴게시설">휴게시설</option>
|
||||
<option value="회의실">회의실</option>
|
||||
<option value="창고">창고</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<span class="wp-form-help">작업장의 주요 용도를 선택하세요</span>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">표시 순서</label>
|
||||
<input type="number" id="displayPriority" class="wp-form-control" value="0" min="0">
|
||||
<span class="wp-form-help">숫자가 작을수록 먼저 표시됩니다</span>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">설명</label>
|
||||
<textarea id="workplaceDescription" class="wp-form-control" rows="3" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="wp-btn wp-btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">삭제</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveWorkplace()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 관리 모달 (간단 모드) -->
|
||||
<div id="workplaceMapModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal" style="max-width: 600px;">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🗺️</span>
|
||||
<span id="workplaceMapModalTitle">작업장 지도 관리</span>
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeWorkplaceMapModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body" style="padding: 24px;">
|
||||
<!-- 이미지 업로드 섹션 -->
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #334155;">📷 작업장 레이아웃 이미지</h3>
|
||||
<div class="wp-form-group">
|
||||
<div id="workplaceLayoutPreview" style="background: white; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 120px;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center; margin-top: 12px;">
|
||||
<input type="file" id="workplaceLayoutFile" accept="image/*" class="wp-form-control" style="flex: 1;" onchange="previewWorkplaceLayoutImage(event)">
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="uploadWorkplaceLayout()">업로드</button>
|
||||
</div>
|
||||
<span class="wp-form-help" style="margin-top: 8px; display: block;">JPG, PNG, GIF 형식 지원 (최대 5MB)</span>
|
||||
</div>
|
||||
|
||||
<!-- 설비 배치 버튼 -->
|
||||
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: 12px; padding: 24px; text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 12px;">⚙️</div>
|
||||
<h3 style="color: white; font-size: 18px; font-weight: 600; margin-bottom: 8px;">설비 위치 편집</h3>
|
||||
<p style="color: rgba(255,255,255,0.8); font-size: 14px; margin-bottom: 16px;">
|
||||
전체 화면에서 설비 위치를 쉽게 지정할 수 있습니다
|
||||
</p>
|
||||
<button type="button" class="wp-btn" style="background: white; color: #1d4ed8; font-weight: 600; padding: 12px 32px; font-size: 15px;" onclick="openFullscreenEquipmentEditor()">
|
||||
🖥️ 전체화면 편집 열기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 등록된 설비 요약 -->
|
||||
<div style="margin-top: 20px; background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<h4 style="font-size: 14px; font-weight: 600; color: #334155; margin: 0;">📋 등록된 설비</h4>
|
||||
<span id="workplaceEquipmentCount" style="font-size: 12px; color: #64748b;">0개</span>
|
||||
</div>
|
||||
<div id="workplaceEquipmentList" style="max-height: 150px; overflow-y: auto;">
|
||||
<p style="color: #94a3b8; text-align: center; padding: 12px; font-size: 13px;">아직 정의된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeWorkplaceMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체화면 설비 배치 편집기 -->
|
||||
<div id="fullscreenEquipmentEditor" class="fullscreen-editor" style="display: none;">
|
||||
<div class="fullscreen-editor-header">
|
||||
<div class="fullscreen-editor-title">
|
||||
<span>⚙️</span>
|
||||
<span id="fullscreenEditorTitle">설비 위치 편집</span>
|
||||
</div>
|
||||
<div class="fullscreen-editor-actions">
|
||||
<button type="button" class="editor-btn editor-btn-secondary" onclick="toggleEditorSidebar()">
|
||||
<span id="sidebarToggleIcon">◀</span> 패널
|
||||
</button>
|
||||
<button type="button" class="editor-btn editor-btn-primary" onclick="closeFullscreenEditor()">
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fullscreen-editor-body">
|
||||
<!-- 메인 캔버스 영역 -->
|
||||
<div class="fullscreen-canvas-area" id="fullscreenCanvasArea">
|
||||
<div class="canvas-toolbar">
|
||||
<span class="toolbar-info">🖱️ 드래그로 영역 선택</span>
|
||||
<span class="toolbar-zoom" id="canvasZoomInfo">100%</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper" id="fullscreenCanvasWrapper">
|
||||
<canvas id="fullscreenRegionCanvas"></canvas>
|
||||
</div>
|
||||
<div class="canvas-help">
|
||||
<span>💡 마우스로 드래그하여 설비 영역을 지정한 후, 오른쪽 패널에서 설비를 선택하고 저장하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사이드바 패널 -->
|
||||
<div class="fullscreen-sidebar" id="fullscreenSidebar">
|
||||
<!-- 설비 선택 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header">
|
||||
<h4>🔧 설비 선택</h4>
|
||||
<span id="fsAvailableEquipmentCount" class="badge badge-success">0개</span>
|
||||
</div>
|
||||
<div class="sidebar-section-body">
|
||||
<select id="fsEquipmentSelect" class="wp-form-control" onchange="fsToggleNewEquipmentFields()">
|
||||
<option value="">-- 기존 설비 선택 --</option>
|
||||
</select>
|
||||
<p class="form-help">이미 배치된 설비는 목록에 표시되지 않습니다</p>
|
||||
|
||||
<div id="fsNewEquipmentFields" class="new-equipment-box">
|
||||
<label>또는 새 설비 등록</label>
|
||||
<input type="text" id="fsEquipmentCode" class="wp-form-control" placeholder="설비 코드">
|
||||
<input type="text" id="fsEquipmentName" class="wp-form-control" placeholder="설비명">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="fsClearCurrentRegion()">영역 지우기</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="fsSaveEquipmentRegion()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 등록된 설비 목록 -->
|
||||
<div class="sidebar-section sidebar-section-flex">
|
||||
<div class="sidebar-section-header">
|
||||
<h4>📋 등록된 설비</h4>
|
||||
<span id="fsRegisteredCount" class="badge">0개</span>
|
||||
</div>
|
||||
<div class="sidebar-section-body sidebar-list" id="fsEquipmentList">
|
||||
<p class="empty-message">등록된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 레이아웃 지도 설정 모달 -->
|
||||
<div id="layoutMapModal" class="wp-modal-overlay" style="display: none;">
|
||||
<div class="wp-modal" style="max-width: 90vw; max-height: 90vh; width: 1000px;">
|
||||
<div class="wp-modal-header">
|
||||
<h2 class="wp-modal-title">
|
||||
<span>🗺️</span>
|
||||
공장 레이아웃 지도 설정
|
||||
</h2>
|
||||
<button class="wp-modal-close" onclick="closeLayoutMapModal()">×</button>
|
||||
</div>
|
||||
<div class="wp-modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">현재 이미지</label>
|
||||
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="layoutImageFile" accept="image/*" class="wp-form-control" onchange="previewLayoutImage(event)">
|
||||
<span class="wp-form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</span>
|
||||
</div>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="uploadLayoutImage()">이미지 업로드</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 영역 정의 -->
|
||||
<div>
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
|
||||
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 및 영역 목록 -->
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">작업장 선택</label>
|
||||
<select id="regionWorkplaceSelect" class="wp-form-control" style="margin-bottom: 12px;">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="clearCurrentRegion()">현재 영역 지우기</button>
|
||||
<button type="button" class="wp-btn wp-btn-primary" onclick="saveRegion()">선택 영역 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="wp-form-group">
|
||||
<label class="wp-form-label">정의된 영역 목록</label>
|
||||
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<!-- 영역 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wp-modal-footer">
|
||||
<button type="button" class="wp-btn wp-btn-outline" onclick="closeLayoutMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/workplace-management.js?v=8"></script>
|
||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
|
||||
</head>
|
||||
|
||||
394
web-ui/pages/attendance/checkin.html
Normal file
394
web-ui/pages/attendance/checkin.html
Normal file
@@ -0,0 +1,394 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.page-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid #d1d5db; }
|
||||
.btn-success { background: #10b981; color: white; }
|
||||
|
||||
/* 요약 바 */
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.summary-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot-present { background: #10b981; }
|
||||
.dot-absent { background: #ef4444; }
|
||||
.dot-vacation { background: #3b82f6; }
|
||||
.summary-count { font-weight: 700; }
|
||||
.summary-label { color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
/* 작업자 목록 */
|
||||
.worker-list {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.worker-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin: 0.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 2rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.worker-chip:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.worker-chip.present {
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.worker-chip.absent {
|
||||
border-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.worker-chip.vacation {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
cursor: default;
|
||||
}
|
||||
.chip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.worker-chip.present .chip-dot { background: #10b981; }
|
||||
.worker-chip.absent .chip-dot { background: #ef4444; }
|
||||
.worker-chip.vacation .chip-dot { background: #3b82f6; }
|
||||
|
||||
.save-section {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-save {
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-save:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-save.saved {
|
||||
background: #10b981;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge.saved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-badge.unsaved {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<h1 class="page-title">출근 체크</h1>
|
||||
<p class="page-desc">클릭하여 출근/결근 상태를 변경하세요</p>
|
||||
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadCheckinData()">새로고침</button>
|
||||
<button class="btn btn-outline" onclick="setAllPresent()">전체 출근</button>
|
||||
<button class="btn btn-outline" onclick="setAllAbsent()">전체 결근</button>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-present"></span>
|
||||
<span class="summary-count" id="presentCount">0</span>
|
||||
<span class="summary-label">출근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-absent"></span>
|
||||
<span class="summary-count" id="absentCount">0</span>
|
||||
<span class="summary-label">결근</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-dot dot-vacation"></span>
|
||||
<span class="summary-count" id="vacationCount">0</span>
|
||||
<span class="summary-label">연차</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="worker-list" id="workerList">
|
||||
<!-- 작업자 칩이 여기에 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="save-section">
|
||||
<div id="saveStatus" style="margin-bottom: 1rem;"></div>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveCheckin()">출근 체크 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
let workers = [];
|
||||
let checkinStatus = {};
|
||||
let isAlreadySaved = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadCheckinData();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCheckinData() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, checkinRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers'),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { data: [] } })),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => w.employment_status === 'employed');
|
||||
const checkinList = checkinRes.data.data || [];
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
// 이미 저장된 기록이 있는지 확인
|
||||
isAlreadySaved = records.length > 0;
|
||||
|
||||
checkinStatus = {};
|
||||
workers.forEach(w => {
|
||||
const checkin = checkinList.find(c => c.worker_id === w.worker_id);
|
||||
const record = records.find(r => r.worker_id === w.worker_id);
|
||||
|
||||
if (checkin?.vacation_status === 'approved' || record?.vacation_type_id) {
|
||||
checkinStatus[w.worker_id] = { status: 'vacation', vacationType: checkin?.vacation_type_name || record?.vacation_type_name || '연차' };
|
||||
} else if (record && record.is_present === 0) {
|
||||
checkinStatus[w.worker_id] = { status: 'absent' };
|
||||
} else if (record && record.is_present === 1) {
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
} else {
|
||||
// 기록이 없으면 기본 출근
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('데이터 로드 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const container = document.getElementById('workerList');
|
||||
if (workers.length === 0) {
|
||||
container.innerHTML = '<p style="color:#6b7280;text-align:center;padding:2rem;">작업자가 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = workers.map(w => {
|
||||
const s = checkinStatus[w.worker_id] || { status: 'present' };
|
||||
const label = s.status === 'present' ? '출근' : s.status === 'absent' ? '결근' : (s.vacationType || '연차');
|
||||
return `<span class="worker-chip ${s.status}" onclick="toggle(${w.worker_id})"><span class="chip-dot"></span>${w.worker_name} <small style="color:#6b7280">${label}</small></span>`;
|
||||
}).join('');
|
||||
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const s = checkinStatus[id];
|
||||
if (s.status === 'vacation') return;
|
||||
s.status = s.status === 'present' ? 'absent' : 'present';
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllPresent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.worker_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.worker_id] = { status: 'present' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function setAllAbsent() {
|
||||
workers.forEach(w => {
|
||||
if (checkinStatus[w.worker_id]?.status !== 'vacation') {
|
||||
checkinStatus[w.worker_id] = { status: 'absent' };
|
||||
}
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let p = 0, a = 0, v = 0;
|
||||
Object.values(checkinStatus).forEach(s => {
|
||||
if (s.status === 'present') p++;
|
||||
else if (s.status === 'absent') a++;
|
||||
else v++;
|
||||
});
|
||||
document.getElementById('presentCount').textContent = p;
|
||||
document.getElementById('absentCount').textContent = a;
|
||||
document.getElementById('vacationCount').textContent = v;
|
||||
}
|
||||
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '<span class="status-badge saved">이 날짜는 이미 출근 체크가 완료되었습니다</span>';
|
||||
saveBtn.textContent = '수정하여 다시 저장';
|
||||
saveBtn.classList.add('saved');
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="status-badge unsaved">아직 저장되지 않았습니다</span>';
|
||||
saveBtn.textContent = '출근 체크 저장';
|
||||
saveBtn.classList.remove('saved');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCheckin() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return alert('날짜를 선택해주세요.');
|
||||
|
||||
// 이미 저장된 경우 확인
|
||||
if (isAlreadySaved) {
|
||||
if (!confirm('이미 저장된 데이터가 있습니다.\n수정하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 연차가 아닌 작업자들만 체크인 데이터로 전송
|
||||
const checkins = workers
|
||||
.filter(w => checkinStatus[w.worker_id]?.status !== 'vacation')
|
||||
.map(w => ({
|
||||
worker_id: w.worker_id,
|
||||
is_present: checkinStatus[w.worker_id]?.status === 'present'
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await axios.post('/attendance/checkins', { date, checkins });
|
||||
if (res.data.success) {
|
||||
alert(`${res.data.data.saved_count}명 출근 체크 저장 완료`);
|
||||
isAlreadySaved = true;
|
||||
updateSaveStatus();
|
||||
} else {
|
||||
alert('저장 실패: ' + (res.data.message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('저장 실패: ' + (e.response?.data?.message || e.message));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -55,8 +56,6 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.calendar-container {
|
||||
margin-top: 2rem;
|
||||
@@ -186,8 +187,6 @@
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -109,8 +110,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -109,8 +110,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -191,8 +192,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
@@ -103,8 +104,6 @@
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/vacation-common.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
|
||||
657
web-ui/pages/attendance/work-status.html
Normal file
657
web-ui/pages/attendance/work-status.html
Normal file
@@ -0,0 +1,657 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.summary-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-card.normal { border-left: 4px solid #10b981; }
|
||||
.summary-card.annual { border-left: 4px solid #3b82f6; }
|
||||
.summary-card.half { border-left: 4px solid #22c55e; }
|
||||
.summary-card.quarter { border-left: 4px solid #eab308; }
|
||||
.summary-card.early { border-left: 4px solid #ef4444; }
|
||||
.summary-card.overtime { border-left: 4px solid #f97316; }
|
||||
.summary-value { font-size: 1.5rem; font-weight: 700; }
|
||||
.summary-label { font-size: 0.75rem; color: #6b7280; }
|
||||
|
||||
.status-table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-table th {
|
||||
background: #f8fafc;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.status-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-table tr:hover { background: #f8fafc; }
|
||||
.status-table tr.absent { background: #fef2f2; }
|
||||
|
||||
.worker-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.worker-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.worker-dot.present { background: #10b981; }
|
||||
.worker-dot.absent { background: #ef4444; }
|
||||
|
||||
.type-select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
min-width: 110px;
|
||||
}
|
||||
.overtime-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.overtime-input {
|
||||
width: 60px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.controls input[type="date"] {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-save {
|
||||
display: block;
|
||||
margin: 1.5rem auto 0;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-save:hover { background: #2563eb; }
|
||||
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
.btn-save.saved { background: #10b981; }
|
||||
.btn-save.saving { background: #6b7280; }
|
||||
.no-checkin-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #92400e;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 저장 상태 섹션 */
|
||||
.save-section {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-badge.saved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-badge.unsaved {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 토스트 알림 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.toast.success { background: #10b981; }
|
||||
.toast.error { background: #ef4444; }
|
||||
.toast.info { background: #3b82f6; }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 저장 성공 오버레이 */
|
||||
.save-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9998;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.save-overlay .checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
animation: scaleIn 0.4s ease;
|
||||
}
|
||||
.save-overlay .checkmark svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
stroke: #10b981;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.save-overlay .message {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.save-overlay .sub-message {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-wrapper">
|
||||
<h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">근무 현황</h1>
|
||||
<p style="color: #64748b; margin-bottom: 1.5rem;">휴가/조퇴 및 연장근무를 입력합니다</p>
|
||||
|
||||
<div class="controls">
|
||||
<input type="date" id="selectedDate">
|
||||
<button class="btn btn-primary" onclick="loadWorkStatus()">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div id="noCheckinWarning" class="no-checkin-warning" style="display:none;">
|
||||
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
|
||||
</div>
|
||||
|
||||
<div class="summary-cards">
|
||||
<div class="summary-card normal">
|
||||
<div class="summary-value" id="normalCount">0</div>
|
||||
<div class="summary-label">정시근무</div>
|
||||
</div>
|
||||
<div class="summary-card annual">
|
||||
<div class="summary-value" id="annualCount">0</div>
|
||||
<div class="summary-label">연차</div>
|
||||
</div>
|
||||
<div class="summary-card half">
|
||||
<div class="summary-value" id="halfCount">0</div>
|
||||
<div class="summary-label">반차</div>
|
||||
</div>
|
||||
<div class="summary-card quarter">
|
||||
<div class="summary-value" id="quarterCount">0</div>
|
||||
<div class="summary-label">반반차</div>
|
||||
</div>
|
||||
<div class="summary-card early">
|
||||
<div class="summary-value" id="earlyCount">0</div>
|
||||
<div class="summary-label">조퇴</div>
|
||||
</div>
|
||||
<div class="summary-card overtime">
|
||||
<div class="summary-value" id="overtimeCount">0</div>
|
||||
<div class="summary-label">연장근로</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 130px;">작업자</th>
|
||||
<th style="width: 80px;">출근</th>
|
||||
<th style="width: 130px;">근태 구분</th>
|
||||
<th style="width: 100px;">근무시간</th>
|
||||
<th style="width: 150px;">연장근로</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statusTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="save-section">
|
||||
<div id="saveStatus"></div>
|
||||
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">근무 현황 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 토스트 컨테이너 -->
|
||||
<div id="toastContainer"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
let workers = [];
|
||||
let workStatus = {};
|
||||
let hasCheckinData = false;
|
||||
let isAlreadySaved = false;
|
||||
let isSaving = false;
|
||||
|
||||
// 근태 구분 옵션
|
||||
const attendanceTypes = [
|
||||
{ value: 'normal', label: '정시근무', hours: 8 },
|
||||
{ value: 'annual', label: '연차', hours: 0 },
|
||||
{ value: 'half', label: '반차', hours: 4 },
|
||||
{ value: 'quarter', label: '반반차', hours: 6 },
|
||||
{ value: 'early', label: '조퇴', hours: 2 },
|
||||
{ value: 'overtime', label: '연장근로', hours: 8 }
|
||||
];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
|
||||
loadWorkStatus();
|
||||
});
|
||||
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkStatus() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||
|
||||
try {
|
||||
const [workersRes, recordsRes] = await Promise.all([
|
||||
axios.get('/workers'),
|
||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||
]);
|
||||
|
||||
workers = (workersRes.data.data || []).filter(w => w.employment_status === 'employed');
|
||||
const records = recordsRes.data.data || [];
|
||||
|
||||
// 출근 체크 데이터가 있는지 확인
|
||||
hasCheckinData = records.length > 0;
|
||||
// 이미 저장된 근무 현황이 있는지 확인 (attendance_type_id가 설정된 경우)
|
||||
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
|
||||
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
|
||||
|
||||
workStatus = {};
|
||||
workers.forEach(w => {
|
||||
const record = records.find(r => r.worker_id === w.worker_id);
|
||||
|
||||
if (record) {
|
||||
// 기존 데이터 기반으로 설정
|
||||
let type = 'normal';
|
||||
let overtimeHours = 0;
|
||||
|
||||
// is_present가 0이면 결근 → 연차로 기본 설정
|
||||
if (record.is_present === 0) {
|
||||
type = 'annual';
|
||||
} else {
|
||||
// 기존 저장된 타입이 있으면 사용
|
||||
if (record.attendance_type_code) {
|
||||
const codeMap = {
|
||||
'NORMAL': 'normal',
|
||||
'VACATION': 'annual',
|
||||
'HALF_LEAVE': 'half',
|
||||
'QUARTER_LEAVE': 'quarter',
|
||||
'EARLY_LEAVE': 'early'
|
||||
};
|
||||
type = codeMap[record.attendance_type_code] || 'normal';
|
||||
}
|
||||
// 연장근로 시간이 있으면 연장근로 타입으로
|
||||
if (record.total_work_hours > 8) {
|
||||
type = 'overtime';
|
||||
overtimeHours = record.total_work_hours - 8;
|
||||
}
|
||||
}
|
||||
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: record.is_present === 1,
|
||||
type: type,
|
||||
hours: attendanceTypes.find(t => t.value === type)?.hours || 8,
|
||||
overtimeHours: overtimeHours
|
||||
};
|
||||
} else {
|
||||
// 데이터 없으면 기본값 (출근, 정시근무)
|
||||
workStatus[w.worker_id] = {
|
||||
isPresent: true,
|
||||
type: 'normal',
|
||||
hours: 8,
|
||||
overtimeHours: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
updateSaveStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('데이터 로드 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = document.getElementById('statusTableBody');
|
||||
|
||||
if (workers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:#6b7280;">작업자가 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = workers.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const isAbsent = !s.isPresent;
|
||||
const showOvertimeInput = s.type === 'overtime';
|
||||
|
||||
return `
|
||||
<tr class="${isAbsent ? 'absent' : ''}">
|
||||
<td>
|
||||
<div class="worker-cell">
|
||||
<span class="worker-dot ${s.isPresent ? 'present' : 'absent'}"></span>
|
||||
<span>${w.worker_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${s.isPresent ? '<span style="color:#10b981">출근</span>' : '<span style="color:#ef4444">결근</span>'}</td>
|
||||
<td>
|
||||
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
|
||||
${attendanceTypes.map(t => `
|
||||
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>${s.type === 'overtime' ? (s.hours + s.overtimeHours) : s.hours}시간</td>
|
||||
<td>
|
||||
${showOvertimeInput ? `
|
||||
<div class="overtime-group">
|
||||
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
|
||||
onchange="updateOvertime(${w.worker_id}, this.value)">
|
||||
<span style="color:#6b7280;font-size:0.875rem;">시간</span>
|
||||
</div>
|
||||
` : '<span style="color:#9ca3af;">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateType(workerId, value) {
|
||||
const type = attendanceTypes.find(t => t.value === value);
|
||||
workStatus[workerId].type = value;
|
||||
workStatus[workerId].hours = type ? type.hours : 8;
|
||||
|
||||
// 연장근로 선택 시 기본 2시간
|
||||
if (value === 'overtime') {
|
||||
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
|
||||
} else {
|
||||
workStatus[workerId].overtimeHours = 0;
|
||||
}
|
||||
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateOvertime(workerId, value) {
|
||||
workStatus[workerId].overtimeHours = parseFloat(value) || 0;
|
||||
render();
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
|
||||
|
||||
Object.values(workStatus).forEach(s => {
|
||||
switch (s.type) {
|
||||
case 'normal': normal++; break;
|
||||
case 'annual': annual++; break;
|
||||
case 'half': half++; break;
|
||||
case 'quarter': quarter++; break;
|
||||
case 'early': early++; break;
|
||||
case 'overtime': overtime++; break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('normalCount').textContent = normal;
|
||||
document.getElementById('annualCount').textContent = annual;
|
||||
document.getElementById('halfCount').textContent = half;
|
||||
document.getElementById('quarterCount').textContent = quarter;
|
||||
document.getElementById('earlyCount').textContent = early;
|
||||
document.getElementById('overtimeCount').textContent = overtime;
|
||||
}
|
||||
|
||||
// 토스트 알림 표시
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// 저장 성공 오버레이 표시
|
||||
function showSaveOverlay(count) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'save-overlay';
|
||||
overlay.id = 'saveOverlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="checkmark">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="message">저장 완료!</div>
|
||||
<div class="sub-message">${count}명의 근무 현황이 저장되었습니다</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
setTimeout(() => {
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.3s ease';
|
||||
setTimeout(() => overlay.remove(), 300);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// 저장 상태 업데이트
|
||||
function updateSaveStatus() {
|
||||
const statusEl = document.getElementById('saveStatus');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
if (isAlreadySaved) {
|
||||
statusEl.innerHTML = '<span class="status-badge saved">✓ 이 날짜의 근무 현황이 저장되어 있습니다</span>';
|
||||
saveBtn.textContent = '수정하여 다시 저장';
|
||||
saveBtn.classList.add('saved');
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="status-badge unsaved">⚠ 아직 저장되지 않았습니다</span>';
|
||||
saveBtn.textContent = '근무 현황 저장';
|
||||
saveBtn.classList.remove('saved');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkStatus() {
|
||||
const date = document.getElementById('selectedDate').value;
|
||||
if (!date) return showToast('날짜를 선택해주세요.', 'error');
|
||||
|
||||
if (isSaving) return;
|
||||
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
|
||||
// attendance_type_id 매핑 (DB의 work_attendance_types 테이블)
|
||||
const typeIdMap = {
|
||||
'normal': 1, // NORMAL
|
||||
'annual': 5, // VACATION
|
||||
'half': 5, // VACATION (반차)
|
||||
'quarter': 5, // VACATION (반반차)
|
||||
'early': 3, // EARLY_LEAVE
|
||||
'overtime': 1 // NORMAL (연장근로는 정상출근 + 추가시간)
|
||||
};
|
||||
|
||||
// vacation_type_id 매핑 (필요한 경우)
|
||||
const vacationTypeIdMap = {
|
||||
'annual': 1, // ANNUAL
|
||||
'half': 2, // HALF_ANNUAL
|
||||
'quarter': null, // 반반차는 별도 처리 필요할 수 있음
|
||||
};
|
||||
|
||||
const recordsToSave = workers.map(w => {
|
||||
const s = workStatus[w.worker_id];
|
||||
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
|
||||
|
||||
return {
|
||||
record_date: date,
|
||||
worker_id: w.worker_id,
|
||||
attendance_type_id: typeIdMap[s.type] || 1,
|
||||
vacation_type_id: vacationTypeIdMap[s.type] || null,
|
||||
total_work_hours: totalHours,
|
||||
overtime_approved: s.type === 'overtime',
|
||||
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
|
||||
};
|
||||
});
|
||||
|
||||
// 저장 시작 - 버튼 상태 변경
|
||||
isSaving = true;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.classList.add('saving');
|
||||
saveBtn.textContent = '저장 중...';
|
||||
|
||||
try {
|
||||
let ok = 0, fail = 0;
|
||||
for (const r of recordsToSave) {
|
||||
try {
|
||||
await axios.post('/attendance/records', r);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.error('저장 실패:', e);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fail === 0) {
|
||||
// 성공 - 오버레이 표시
|
||||
showSaveOverlay(ok);
|
||||
isAlreadySaved = true;
|
||||
updateSaveStatus();
|
||||
} else if (ok > 0) {
|
||||
showToast(`${ok}명 성공, ${fail}명 실패`, 'error');
|
||||
} else {
|
||||
showToast('저장에 실패했습니다', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('저장 중 오류가 발생했습니다', 'error');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.classList.remove('saving');
|
||||
updateSaveStatus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,19 +6,25 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황판 | 테크니컬코리아</title>
|
||||
|
||||
<!-- 리소스 프리로딩 -->
|
||||
<link rel="preconnect" href="http://localhost:20005" crossorigin>
|
||||
<link rel="preload" href="/css/design-system.css" as="style">
|
||||
<link rel="preload" href="/js/api-base.js" as="script">
|
||||
<link rel="preload" href="/js/app-init.js?v=2" as="script">
|
||||
|
||||
<!-- 모던 디자인 시스템 적용 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 (순서 중요: api-config.js가 먼저 로드되어야 함) -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
|
||||
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
|
||||
<script src="/js/workplace-status.js" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
210
web-ui/pages/inspection/daily-patrol.html
Normal file
210
web-ui/pages/inspection/daily-patrol.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">일일순회점검</h1>
|
||||
<p class="page-description">작업장을 순회하며 안전 및 정리정돈 상태를 점검합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 세션 선택 영역 -->
|
||||
<div class="patrol-session-selector">
|
||||
<div class="patrol-date-time">
|
||||
<div class="form-group">
|
||||
<label for="patrolDate">점검 일자</label>
|
||||
<input type="date" id="patrolDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>점검 시간대</label>
|
||||
<div class="patrol-time-buttons">
|
||||
<button type="button" class="patrol-time-btn active" data-time="morning">오전</button>
|
||||
<button type="button" class="patrol-time-btn" data-time="afternoon">오후</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="categorySelect">공장 선택</label>
|
||||
<select id="categorySelect" class="form-control">
|
||||
<option value="">공장 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="startPatrolBtn" onclick="startPatrol()">
|
||||
순회점검 시작
|
||||
</button>
|
||||
</div>
|
||||
<!-- 오늘 점검 현황 요약 -->
|
||||
<div id="todayStatusSummary" class="today-status-summary">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
|
||||
<div id="patrolArea" class="patrol-area" style="display: none;">
|
||||
<!-- 세션 정보 -->
|
||||
<div id="sessionInfo" class="session-info-bar">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 지도 및 체크리스트 영역 -->
|
||||
<div class="patrol-content">
|
||||
<!-- 작업장 지도 (좌측) -->
|
||||
<div class="patrol-map-section">
|
||||
<div class="map-header">
|
||||
<h3>작업장 지도</h3>
|
||||
<div class="map-legend">
|
||||
<span class="legend-item completed"><span class="dot"></span> 점검완료</span>
|
||||
<span class="legend-item in-progress"><span class="dot"></span> 점검중</span>
|
||||
<span class="legend-item pending"><span class="dot"></span> 미점검</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="patrolMapContainer" class="patrol-map-container">
|
||||
<!-- 지도 이미지 및 작업장 마커 -->
|
||||
</div>
|
||||
<!-- 작업장 목록 (지도 대신 사용 가능) -->
|
||||
<div id="workplaceListContainer" class="workplace-list-container">
|
||||
<!-- 작업장 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 영역 (우측) -->
|
||||
<div class="patrol-checklist-section">
|
||||
<div id="checklistHeader" class="checklist-header">
|
||||
<h3>체크리스트</h3>
|
||||
<p class="checklist-subtitle">작업장을 선택하면 체크리스트가 표시됩니다</p>
|
||||
</div>
|
||||
<div id="checklistContent" class="checklist-content">
|
||||
<!-- 체크리스트 항목들 -->
|
||||
<div class="checklist-placeholder">
|
||||
<p>좌측 지도에서 점검할 작업장을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="checklistActions" class="checklist-actions" style="display: none;">
|
||||
<button type="button" class="btn btn-secondary" onclick="saveChecklistDraft()">임시저장</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveChecklist()">저장 후 다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 물품 현황 영역 -->
|
||||
<div id="itemsSection" class="items-section" style="display: none;">
|
||||
<div class="items-header">
|
||||
<h3><span id="selectedWorkplaceName">작업장</span> 물품 현황</h3>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="toggleItemEditMode()">
|
||||
<span id="itemEditModeText">편집모드</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="itemsMapContainer" class="items-map-container">
|
||||
<!-- 작업장 상세 지도 및 물품 마커 -->
|
||||
</div>
|
||||
<div id="itemsLegend" class="items-legend">
|
||||
<!-- 물품 유형 범례 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 순회점검 완료 버튼 -->
|
||||
<div class="patrol-complete-section">
|
||||
<div class="form-group">
|
||||
<label for="patrolNotes">특이사항</label>
|
||||
<textarea id="patrolNotes" class="form-control" rows="2" placeholder="순회 중 발견한 특이사항을 기록하세요..."></textarea>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success btn-lg" onclick="completePatrol()">
|
||||
순회점검 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 물품 추가/수정 모달 -->
|
||||
<div id="itemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemModalTitle">물품 추가</h2>
|
||||
<button class="btn-close" onclick="closeItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="itemForm">
|
||||
<input type="hidden" id="itemId">
|
||||
<div class="form-group">
|
||||
<label for="itemType">물품 유형 *</label>
|
||||
<select id="itemType" class="form-control" required>
|
||||
<!-- JS에서 옵션 추가 -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemName">물품명/설명</label>
|
||||
<input type="text" id="itemName" class="form-control" placeholder="예: A사 용기 10개">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemQuantity">수량</label>
|
||||
<input type="number" id="itemQuantity" class="form-control" value="1" min="1">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteItemBtn" onclick="deleteItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/daily-patrol.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 프로필 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
@@ -299,7 +298,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/my-profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>비밀번호 변경 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
/* 페이지 전용 스타일 */
|
||||
@@ -369,7 +368,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -588,10 +588,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
@@ -447,8 +448,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/issue-detail.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.issue-form-container {
|
||||
max-width: 900px;
|
||||
@@ -612,8 +613,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/work-issue-report.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
@@ -285,8 +286,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/safety-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
@@ -300,8 +301,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/safety-report-list.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 스텝 인디케이터 */
|
||||
.step-indicator {
|
||||
@@ -772,8 +773,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/issue-report.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.training-container {
|
||||
max-width: 1000px;
|
||||
@@ -321,8 +322,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/safety-training-conduct.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.visit-form-container {
|
||||
max-width: 800px;
|
||||
@@ -365,8 +366,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/visit-request.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -2853,6 +2852,5 @@
|
||||
// 초기 모드 설정
|
||||
window.currentAnalysisMode = 'period';
|
||||
</script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
210
web-ui/pages/work/daily-patrol.html
Normal file
210
web-ui/pages/work/daily-patrol.html
Normal file
@@ -0,0 +1,210 @@
|
||||
<!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="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃 -->
|
||||
<div class="page-container">
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">일일순회점검</h1>
|
||||
<p class="page-description">작업장을 순회하며 안전 및 정리정돈 상태를 점검합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 세션 선택 영역 -->
|
||||
<div class="patrol-session-selector">
|
||||
<div class="patrol-date-time">
|
||||
<div class="form-group">
|
||||
<label for="patrolDate">점검 일자</label>
|
||||
<input type="date" id="patrolDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>점검 시간대</label>
|
||||
<div class="patrol-time-buttons">
|
||||
<button type="button" class="patrol-time-btn active" data-time="morning">오전</button>
|
||||
<button type="button" class="patrol-time-btn" data-time="afternoon">오후</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="categorySelect">공장 선택</label>
|
||||
<select id="categorySelect" class="form-control">
|
||||
<option value="">공장 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="startPatrolBtn" onclick="startPatrol()">
|
||||
순회점검 시작
|
||||
</button>
|
||||
</div>
|
||||
<!-- 오늘 점검 현황 요약 -->
|
||||
<div id="todayStatusSummary" class="today-status-summary">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
|
||||
<div id="patrolArea" class="patrol-area" style="display: none;">
|
||||
<!-- 세션 정보 -->
|
||||
<div id="sessionInfo" class="session-info-bar">
|
||||
<!-- JS에서 렌더링 -->
|
||||
</div>
|
||||
|
||||
<!-- 지도 및 체크리스트 영역 -->
|
||||
<div class="patrol-content">
|
||||
<!-- 작업장 지도 (좌측) -->
|
||||
<div class="patrol-map-section">
|
||||
<div class="map-header">
|
||||
<h3>작업장 지도</h3>
|
||||
<div class="map-legend">
|
||||
<span class="legend-item completed"><span class="dot"></span> 점검완료</span>
|
||||
<span class="legend-item in-progress"><span class="dot"></span> 점검중</span>
|
||||
<span class="legend-item pending"><span class="dot"></span> 미점검</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="patrolMapContainer" class="patrol-map-container">
|
||||
<!-- 지도 이미지 및 작업장 마커 -->
|
||||
</div>
|
||||
<!-- 작업장 목록 (지도 대신 사용 가능) -->
|
||||
<div id="workplaceListContainer" class="workplace-list-container">
|
||||
<!-- 작업장 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 영역 (우측) -->
|
||||
<div class="patrol-checklist-section">
|
||||
<div id="checklistHeader" class="checklist-header">
|
||||
<h3>체크리스트</h3>
|
||||
<p class="checklist-subtitle">작업장을 선택하면 체크리스트가 표시됩니다</p>
|
||||
</div>
|
||||
<div id="checklistContent" class="checklist-content">
|
||||
<!-- 체크리스트 항목들 -->
|
||||
<div class="checklist-placeholder">
|
||||
<p>좌측 지도에서 점검할 작업장을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="checklistActions" class="checklist-actions" style="display: none;">
|
||||
<button type="button" class="btn btn-secondary" onclick="saveChecklistDraft()">임시저장</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveChecklist()">저장 후 다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 물품 현황 영역 -->
|
||||
<div id="itemsSection" class="items-section" style="display: none;">
|
||||
<div class="items-header">
|
||||
<h3><span id="selectedWorkplaceName">작업장</span> 물품 현황</h3>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="toggleItemEditMode()">
|
||||
<span id="itemEditModeText">편집모드</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="itemsMapContainer" class="items-map-container">
|
||||
<!-- 작업장 상세 지도 및 물품 마커 -->
|
||||
</div>
|
||||
<div id="itemsLegend" class="items-legend">
|
||||
<!-- 물품 유형 범례 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 순회점검 완료 버튼 -->
|
||||
<div class="patrol-complete-section">
|
||||
<div class="form-group">
|
||||
<label for="patrolNotes">특이사항</label>
|
||||
<textarea id="patrolNotes" class="form-control" rows="2" placeholder="순회 중 발견한 특이사항을 기록하세요..."></textarea>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success btn-lg" onclick="completePatrol()">
|
||||
순회점검 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 물품 추가/수정 모달 -->
|
||||
<div id="itemModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemModalTitle">물품 추가</h2>
|
||||
<button class="btn-close" onclick="closeItemModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="itemForm">
|
||||
<input type="hidden" id="itemId">
|
||||
<div class="form-group">
|
||||
<label for="itemType">물품 유형 *</label>
|
||||
<select id="itemType" class="form-control" required>
|
||||
<!-- JS에서 옵션 추가 -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemName">물품명/설명</label>
|
||||
<input type="text" id="itemName" class="form-control" placeholder="예: A사 용기 10개">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="itemQuantity">수량</label>
|
||||
<input type="number" id="itemQuantity" class="form-control" value="1" min="1">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteItemBtn" onclick="deleteItem()" style="display: none;">삭제</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveItem()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
const checkApiConfig = setInterval(() => {
|
||||
if (window.API_BASE_URL) {
|
||||
clearInterval(checkApiConfig);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
);
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/daily-patrol.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,8 +8,9 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
@@ -300,8 +301,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/nonconformity-list.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css?v=12">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<!-- 최적화된 로딩 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
@@ -167,8 +167,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script type="module" src="/js/daily-work-report.js?v=28"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,10 +8,10 @@
|
||||
<link rel="stylesheet" href="/css/common.css?v=13">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=14">
|
||||
<link rel="stylesheet" href="/css/work-report-calendar.css?v=29">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<!-- 최적화된 로딩 -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 헤더 -->
|
||||
@@ -283,9 +283,9 @@
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/auth-check.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
|
||||
|
||||
@@ -6,58 +6,13 @@
|
||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="stylesheet" href="/css/tbm.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<style>
|
||||
.date-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.date-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
.date-group-header.today {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
.date-group-date {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.date-group-day {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.date-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.date-group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.date-group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=2" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
@@ -66,60 +21,75 @@
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">TBM (Tool Box Meeting)</h1>
|
||||
<p class="page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions" id="headerActions">
|
||||
<!-- 탭에 따라 동적으로 변경됩니다 -->
|
||||
<div class="tbm-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="tbm-page-header">
|
||||
<div class="tbm-title-section">
|
||||
<h1 class="tbm-page-title">
|
||||
<span class="tbm-page-title-icon">🛠</span>
|
||||
TBM (Tool Box Meeting)
|
||||
</h1>
|
||||
<p class="tbm-page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div class="code-tabs">
|
||||
<button class="tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
|
||||
<!-- TBM 탭 메뉴 -->
|
||||
<div class="tbm-tab-menu">
|
||||
<button class="tbm-tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
|
||||
<span class="tbm-tab-icon">📝</span>
|
||||
TBM 입력
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
|
||||
<button class="tbm-tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
|
||||
<span class="tbm-tab-icon">📊</span>
|
||||
TBM 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TBM 입력 탭 -->
|
||||
<div id="tbm-input-tab" class="code-tab-content active">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">오늘의 TBM</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">새 TBM 시작</button>
|
||||
<div id="tbm-input-tab" class="tbm-tab-content active">
|
||||
<div class="tbm-section">
|
||||
<div class="tbm-section-header">
|
||||
<h2 class="tbm-section-title">
|
||||
<span>📅</span>
|
||||
오늘의 TBM
|
||||
</h2>
|
||||
<div class="tbm-section-actions">
|
||||
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
새 TBM 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
오늘 등록 <span id="todayTotalSessions">0</span>개
|
||||
<div class="tbm-stats-bar">
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">오늘 등록</span>
|
||||
<span class="tbm-stat-value highlight" id="todayTotalSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
완료 <span id="todayCompletedSessions">0</span>개
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">완료</span>
|
||||
<span class="tbm-stat-value success" id="todayCompletedSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
진행중 <span id="todayActiveSessions">0</span>개
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">진행중</span>
|
||||
<span class="tbm-stat-value warning" id="todayActiveSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="todayTbmGrid">
|
||||
<div class="tbm-card-grid" id="todayTbmGrid">
|
||||
<!-- 오늘의 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="todayEmptyState" style="display: none;">
|
||||
<div class="empty-icon"></div>
|
||||
<h3>오늘 등록된 TBM이 없습니다</h3>
|
||||
<p>"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
<div class="tbm-empty-state" id="todayEmptyState" style="display: none;">
|
||||
<div class="tbm-empty-icon">📋</div>
|
||||
<h3 class="tbm-empty-title">오늘 등록된 TBM이 없습니다</h3>
|
||||
<p class="tbm-empty-description">"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
첫 TBM 시작하기
|
||||
</button>
|
||||
</div>
|
||||
@@ -127,37 +97,46 @@
|
||||
</div>
|
||||
|
||||
<!-- TBM 관리 탭 -->
|
||||
<div id="tbm-manage-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">TBM 기록</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">더 보기</button>
|
||||
<div id="tbm-manage-tab" class="tbm-tab-content">
|
||||
<div class="tbm-section">
|
||||
<div class="tbm-section-header">
|
||||
<h2 class="tbm-section-title">
|
||||
<span>📚</span>
|
||||
TBM 기록
|
||||
</h2>
|
||||
<div class="tbm-section-actions">
|
||||
<button class="tbm-btn tbm-btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
|
||||
더 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
총 <span id="totalSessions">0</span>개
|
||||
<div class="tbm-stats-bar">
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">총</span>
|
||||
<span class="tbm-stat-value" id="totalSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
완료 <span id="completedSessions">0</span>개
|
||||
<span class="tbm-stat-item">
|
||||
<span class="tbm-stat-label">완료</span>
|
||||
<span class="tbm-stat-value success" id="completedSessions">0</span>
|
||||
<span class="tbm-stat-label">개</span>
|
||||
</span>
|
||||
<span class="stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span id="viewModeText">내 TBM만</span>
|
||||
<span class="tbm-stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span class="tbm-stat-value" id="viewModeText">내 TBM만</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 날짜별 그룹 컨테이너 -->
|
||||
<div id="tbmDateGroupsContainer">
|
||||
<div class="tbm-section-body" id="tbmDateGroupsContainer">
|
||||
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon"></div>
|
||||
<h3>등록된 TBM 세션이 없습니다</h3>
|
||||
<p>TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
|
||||
<div class="tbm-empty-state" id="emptyState" style="display: none;">
|
||||
<div class="tbm-empty-icon">📚</div>
|
||||
<h3 class="tbm-empty-title">등록된 TBM 세션이 없습니다</h3>
|
||||
<p class="tbm-empty-description">TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,131 +144,153 @@
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 1000px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 TBM 시작</h2>
|
||||
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
|
||||
<div id="tbmModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 1000px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title" id="modalTitle">
|
||||
<span>📝</span>
|
||||
새 TBM 시작
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeTbmModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="tbm-modal-body">
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<!-- 고정 정보 섹션 -->
|
||||
<div class="form-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">TBM 날짜 *</label>
|
||||
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>📅</span>
|
||||
기본 정보
|
||||
</h3>
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">TBM 날짜<span class="tbm-form-required">*</span></label>
|
||||
<div class="tbm-form-input-readonly" id="sessionDateDisplay">-</div>
|
||||
<input type="hidden" id="sessionDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">입력자 *</label>
|
||||
<input type="text" id="leaderName" class="form-control" readonly style="background: #e5e7eb;">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">입력자<span class="tbm-form-required">*</span></label>
|
||||
<div class="tbm-form-input-readonly" id="leaderName">-</div>
|
||||
<input type="hidden" id="leaderId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 및 작업 정보 섹션 -->
|
||||
<div class="form-section">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.1rem; font-weight: 600; color: #1f2937;">
|
||||
<div class="tbm-form-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">
|
||||
<span>👥</span>
|
||||
작업자 및 작업 정보
|
||||
</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="openBulkSettingModal()" style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="openBulkSettingModal()">
|
||||
일괄 설정
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="openWorkerSelectionModal()">
|
||||
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="openWorkerSelectionModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 카드 리스트 -->
|
||||
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 0.75rem; min-height: 100px;">
|
||||
<div id="workerTaskList" class="tbm-worker-list">
|
||||
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
|
||||
<div class="empty-state-small" id="workerListEmpty" style="display: flex; align-items: center; justify-content: center; padding: 2rem; border: 2px dashed #d1d5db; border-radius: 0.5rem; color: #6b7280;">
|
||||
<div style="text-align: center;">
|
||||
<p>작업자를 선택해주세요</p>
|
||||
</div>
|
||||
<div class="tbm-empty-state" id="workerListEmpty" style="padding: 2rem; border: 2px dashed #d1d5db; border-radius: 10px;">
|
||||
<div class="tbm-empty-icon">👥</div>
|
||||
<p class="tbm-empty-description" style="margin: 0;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">저장하기</button>
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTbmSession()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
저장하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일괄 설정 모달 -->
|
||||
<div id="bulkSettingModal" class="modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>일괄 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeBulkSettingModal()">×</button>
|
||||
<div id="bulkSettingModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="tbm-modal" style="max-width: 700px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>⚙</span>
|
||||
일괄 설정
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeBulkSettingModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div style="background: #dbeafe; border: 1px solid #3b82f6; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 600; color: #1e40af; margin-bottom: 0.25rem;">일괄 설정</div>
|
||||
<div style="color: #1e40af; font-size: 0.9rem;">
|
||||
선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.
|
||||
<div class="tbm-modal-body">
|
||||
<div class="tbm-alert tbm-alert-info">
|
||||
<span class="tbm-alert-icon">💡</span>
|
||||
<div class="tbm-alert-content">
|
||||
<div class="tbm-alert-title">일괄 설정</div>
|
||||
<div class="tbm-alert-text">선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 -->
|
||||
<div class="form-group">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||
<label class="form-label" style="margin-bottom: 0;">적용할 작업자 선택 *</label>
|
||||
<div class="tbm-form-section">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
|
||||
<label class="tbm-form-label">적용할 작업자 선택<span class="tbm-form-required">*</span></label>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">전체</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllForBulk()" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;">해제</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllForBulk()">전체</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllForBulk()">해제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulkWorkerSelection" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; background: #f9fafb;">
|
||||
<div id="bulkWorkerSelection" class="tbm-worker-select-grid" style="max-height: 180px;">
|
||||
<!-- 작업자 체크박스들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #e5e7eb; margin: 1.5rem 0; padding-top: 1.5rem;">
|
||||
<h4 style="font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">적용할 작업 정보</h4>
|
||||
<div class="tbm-form-section" style="border-top: 1px solid #e2e8f0; padding-top: 1.5rem;">
|
||||
<h3 class="tbm-form-section-title" style="border: 0; padding: 0; margin-bottom: 1rem;">
|
||||
<span>🛠</span>
|
||||
적용할 작업 정보
|
||||
</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">프로젝트</label>
|
||||
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="tbm-select-btn">
|
||||
프로젝트 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkProjectId">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정 *</label>
|
||||
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">공정<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="tbm-select-btn">
|
||||
공정 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkTypeId">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 *</label>
|
||||
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;" disabled>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">작업<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="tbm-select-btn" disabled>
|
||||
작업 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkTaskId">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 *</label>
|
||||
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">작업장<span class="tbm-form-required">*</span></label>
|
||||
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="tbm-select-btn">
|
||||
작업장 선택
|
||||
<span class="tbm-select-arrow">▼</span>
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkplaceCategoryId">
|
||||
<input type="hidden" id="bulkWorkplaceId">
|
||||
@@ -297,9 +298,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeBulkSettingModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="applyBulkSettings()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeBulkSettingModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="applyBulkSettings()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택한 작업자에 적용
|
||||
</button>
|
||||
</div>
|
||||
@@ -307,27 +309,31 @@
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 모달 -->
|
||||
<div id="workerSelectionModal" class="modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="modal-container" style="max-width: 800px;">
|
||||
<div class="modal-header">
|
||||
<h2>작업자 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerSelectionModal()">×</button>
|
||||
<div id="workerSelectionModal" class="tbm-modal-overlay" style="display: none; z-index: 1001;">
|
||||
<div class="tbm-modal" style="max-width: 800px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👥</span>
|
||||
작업자 선택
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeWorkerSelectionModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="tbm-modal-body">
|
||||
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="selectAllWorkersInModal()">전체 선택</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="deselectAllWorkersInModal()">전체 해제</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkersInModal()">전체 선택</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkersInModal()">전체 해제</button>
|
||||
</div>
|
||||
|
||||
<div id="workerCardGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 500px; overflow-y: auto; padding: 0.5rem;">
|
||||
<div id="workerCardGrid" class="tbm-worker-select-grid">
|
||||
<!-- 작업자 카드들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirmWorkerSelection()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkerSelection()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택 완료
|
||||
</button>
|
||||
</div>
|
||||
@@ -335,81 +341,89 @@
|
||||
</div>
|
||||
|
||||
<!-- 항목 선택 모달 (프로젝트/공정/작업 선택용) -->
|
||||
<div id="itemSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="itemSelectModalTitle">항목 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeItemSelectModal()">×</button>
|
||||
<div id="itemSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="tbm-modal" style="max-width: 600px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
|
||||
<button class="tbm-modal-close" onclick="closeItemSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="itemSelectList" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; padding: 0.5rem;">
|
||||
<div class="tbm-modal-body">
|
||||
<div id="itemSelectList" class="tbm-item-list">
|
||||
<!-- 선택 항목들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeItemSelectModal()">취소</button>
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeItemSelectModal()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 모달 (2단계: 공장 → 작업장) -->
|
||||
<div id="workplaceSelectModal" class="modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="modal-container" style="max-width: 1000px; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>작업장 선택</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceSelectModal()">×</button>
|
||||
<div id="workplaceSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1002;">
|
||||
<div class="tbm-modal" style="max-width: 1000px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>🏭</span>
|
||||
작업장 선택
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeWorkplaceSelectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<div class="tbm-modal-body">
|
||||
<!-- 1단계: 공장 선택 -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
1. 공장 선택
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">1</span>
|
||||
공장 선택
|
||||
</h3>
|
||||
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: #f9fafb;">
|
||||
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<!-- 공장 카테고리 버튼들이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
2. 작업장 선택
|
||||
</h3>
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">2</span>
|
||||
작업장 선택
|
||||
</h3>
|
||||
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1rem; padding: 1rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px;">
|
||||
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem;">
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</div>
|
||||
<div class="tbm-workplace-map-container">
|
||||
<canvas id="workplaceMapCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
|
||||
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 기반 선택 (오류 대비용) -->
|
||||
<div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>리스트에서 선택 (지도 오류 시)</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="toggleWorkplaceList()" id="toggleListBtn">
|
||||
<span id="toggleListIcon">▼</span>
|
||||
리스트 보기
|
||||
</button>
|
||||
</div>
|
||||
<div id="workplaceList" style="display: none; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
|
||||
<div style="color: #9ca3af; text-align: center; padding: 2rem;">
|
||||
공장을 먼저 선택해주세요
|
||||
<!-- 리스트 기반 선택 (오류 대비용) -->
|
||||
<div>
|
||||
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>리스트에서 선택 (지도 오류 시)</span>
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="toggleWorkplaceList()" id="toggleListBtn">
|
||||
<span id="toggleListIcon">▼</span>
|
||||
리스트 보기
|
||||
</button>
|
||||
</div>
|
||||
<div id="workplaceList" class="tbm-item-list" style="display: none; max-height: 250px;">
|
||||
<div style="color: #94a3b8; text-align: center; padding: 2rem;">
|
||||
공장을 먼저 선택해주세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled>
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
선택 완료
|
||||
</button>
|
||||
</div>
|
||||
@@ -417,37 +431,43 @@
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 모달 -->
|
||||
<div id="teamModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>팀 구성</h2>
|
||||
<button class="modal-close-btn" onclick="closeTeamModal()">×</button>
|
||||
<div id="teamModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 900px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👥</span>
|
||||
팀 구성
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeTeamModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600;">작업자 선택</h3>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-secondary" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
<div class="tbm-modal-body">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">작업자 선택</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerSelectionGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 400px; overflow-y: auto; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<div id="workerSelectionGrid" class="tbm-worker-select-grid">
|
||||
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem;">선택된 팀원 <span id="selectedCount">0</span>명</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
<h3 class="tbm-form-section-title">
|
||||
선택된 팀원 <span id="selectedCount" style="color: #3b82f6;">0</span>명
|
||||
</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f8fafc; border-radius: 10px; border: 1px solid #e2e8f0;">
|
||||
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTeamComposition()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTeamComposition()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
팀 구성 완료
|
||||
</button>
|
||||
</div>
|
||||
@@ -455,22 +475,26 @@
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크리스트 모달 -->
|
||||
<div id="safetyModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>안전 체크리스트</h2>
|
||||
<button class="modal-close-btn" onclick="closeSafetyModal()">×</button>
|
||||
<div id="safetyModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 700px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>🛡</span>
|
||||
안전 체크리스트
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeSafetyModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="safetyChecklistContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<div class="tbm-modal-body">
|
||||
<div id="safetyChecklistContainer" class="tbm-safety-list">
|
||||
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSafetyChecklist()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-success" onclick="saveSafetyChecklist()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
안전 체크 완료
|
||||
</button>
|
||||
</div>
|
||||
@@ -478,26 +502,35 @@
|
||||
</div>
|
||||
|
||||
<!-- TBM 완료 모달 -->
|
||||
<div id="completeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 완료</h2>
|
||||
<button class="modal-close-btn" onclick="closeCompleteModal()">×</button>
|
||||
<div id="completeModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 500px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>✓</span>
|
||||
TBM 완료
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeCompleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 1rem;">이 TBM 세션을 완료 처리하시겠습니까?</p>
|
||||
<p style="color: #6b7280; font-size: 0.875rem;">완료 후에는 수정할 수 없습니다.</p>
|
||||
<div class="tbm-modal-body">
|
||||
<div class="tbm-alert tbm-alert-warning">
|
||||
<span class="tbm-alert-icon">⚠</span>
|
||||
<div class="tbm-alert-content">
|
||||
<div class="tbm-alert-title">TBM 완료 확인</div>
|
||||
<div class="tbm-alert-text">완료 후에는 수정할 수 없습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="form-control">
|
||||
<div class="tbm-form-group" style="margin-top: 1.5rem;">
|
||||
<label class="tbm-form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="tbm-form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTbmSession()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-success" onclick="completeTbmSession()">
|
||||
<span class="tbm-btn-icon">✓</span>
|
||||
완료
|
||||
</button>
|
||||
</div>
|
||||
@@ -505,20 +538,23 @@
|
||||
</div>
|
||||
|
||||
<!-- 작업 인계 모달 -->
|
||||
<div id="handoverModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2>작업 인계</h2>
|
||||
<button class="modal-close-btn" onclick="closeHandoverModal()">×</button>
|
||||
<div id="handoverModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 600px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>👉</span>
|
||||
작업 인계
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeHandoverModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="tbm-modal-body">
|
||||
<form id="handoverForm">
|
||||
<input type="hidden" id="handoverSessionId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 사유 *</label>
|
||||
<select id="handoverReason" class="form-control" required>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 사유<span class="tbm-form-required">*</span></label>
|
||||
<select id="handoverReason" class="tbm-form-input" required>
|
||||
<option value="">사유 선택...</option>
|
||||
<option value="half_day">반차</option>
|
||||
<option value="early_leave">조퇴</option>
|
||||
@@ -527,41 +563,42 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인수자 (다음 팀장) *</label>
|
||||
<select id="toLeaderId" class="form-control" required>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인수자 (다음 팀장)<span class="tbm-form-required">*</span></label>
|
||||
<select id="toLeaderId" class="tbm-form-input" required>
|
||||
<option value="">인수자 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 날짜 *</label>
|
||||
<input type="date" id="handoverDate" class="form-control" required>
|
||||
<div class="tbm-form-row">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 날짜<span class="tbm-form-required">*</span></label>
|
||||
<input type="date" id="handoverDate" class="tbm-form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 시간</label>
|
||||
<input type="time" id="handoverTime" class="form-control">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 시간</label>
|
||||
<input type="time" id="handoverTime" class="tbm-form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 내용</label>
|
||||
<textarea id="handoverNotes" class="form-control" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요"></textarea>
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label">인계 내용</label>
|
||||
<textarea id="handoverNotes" class="tbm-form-input" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요" style="resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="margin-bottom: 0.75rem; display: block;">인계할 팀원 선택</label>
|
||||
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<div class="tbm-form-group">
|
||||
<label class="tbm-form-label" style="margin-bottom: 0.75rem;">인계할 팀원 선택</label>
|
||||
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.75rem; background: #f8fafc;">
|
||||
<!-- 팀원 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeHandoverModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveHandover()">
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeHandoverModal()">취소</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveHandover()">
|
||||
<span class="tbm-btn-icon">👉</span>
|
||||
인계 요청
|
||||
</button>
|
||||
</div>
|
||||
@@ -569,41 +606,53 @@
|
||||
</div>
|
||||
|
||||
<!-- TBM 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
<div id="detailModal" class="tbm-modal-overlay" style="display: none;">
|
||||
<div class="tbm-modal" style="max-width: 900px;">
|
||||
<div class="tbm-modal-header">
|
||||
<h2 class="tbm-modal-title">
|
||||
<span>📋</span>
|
||||
TBM 상세 정보
|
||||
</h2>
|
||||
<button class="tbm-modal-close" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||
<div class="tbm-modal-body">
|
||||
<!-- 세션 기본 정보 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">기본 정보</h3>
|
||||
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem;">
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>📅</span>
|
||||
기본 정보
|
||||
</h3>
|
||||
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">팀 구성</h3>
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>👥</span>
|
||||
팀 구성
|
||||
</h3>
|
||||
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크 -->
|
||||
<div class="section">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">안전 체크리스트</h3>
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>🛡</span>
|
||||
안전 체크리스트
|
||||
</h3>
|
||||
<div id="detailSafetyChecks">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
<div class="tbm-modal-footer">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,7 +661,6 @@
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/tbm.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user