feat: 대시보드 작업장 현황 지도 구현
- 실시간 작업장 현황을 지도로 시각화 - 작업장 관리 페이지에서 정의한 구역 정보 활용 - TBM 작업자 및 방문자 현황 표시 주요 변경사항: - dashboard.html: 작업장 현황 섹션 추가 (기존 작업 현황 테이블 제거) - workplace-status.js: 지도 렌더링 및 데이터 통합 로직 구현 - modern-dashboard.js: 삭제된 DOM 요소 조건부 체크 추가 시각화 방식: - 인원 없음: 회색 테두리 + 작업장 이름 - 내부 작업자: 파란색 영역 + 인원 수 - 외부 방문자: 보라색 영역 + 인원 수 - 둘 다: 초록색 영역 + 총 인원 수 기술 구현: - Canvas API 기반 사각형 영역 렌더링 - map-regions API를 통한 데이터 일관성 보장 - 클릭 이벤트로 상세 정보 모달 표시 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -193,6 +193,67 @@ cp web-ui/templates/work-layout.html web-ui/pages/work/new-page.html
|
||||
|
||||
---
|
||||
|
||||
## 🔐 페이지 접근 권한 관리
|
||||
|
||||
### 권한 체크 방식
|
||||
1. **관리자 전용 페이지**: `admin-only` 클래스 사용
|
||||
2. **페이지별 권한 체크**: `pages` 테이블 기반 권한 확인
|
||||
3. **클라이언트 측**: `auth-check.js`에서 자동 권한 검증
|
||||
|
||||
### 페이지 등록 (pages 테이블)
|
||||
새 페이지 생성 시 반드시 `pages` 테이블에 등록:
|
||||
|
||||
```sql
|
||||
-- 마이그레이션 예시
|
||||
INSERT INTO pages (page_name, page_url, page_category, description, display_order, is_active)
|
||||
VALUES
|
||||
('출입 신청', '/pages/work/visit-request.html', 'work', '작업장 출입 및 안전교육 신청', 150, 1),
|
||||
('안전관리', '/pages/admin/safety-management.html', 'admin', '출입 신청 승인 및 안전교육 관리', 210, 1);
|
||||
```
|
||||
|
||||
### 페이지 권한 할당
|
||||
- **Admin**: 모든 페이지 자동 접근 가능
|
||||
- **일반 사용자**: `page_access` 테이블에 명시적 권한 부여 필요
|
||||
|
||||
```sql
|
||||
-- 특정 사용자에게 페이지 권한 부여
|
||||
INSERT INTO page_access (user_id, page_id, granted_by, granted_at)
|
||||
VALUES (123, 45, 1, NOW());
|
||||
```
|
||||
|
||||
### HTML 페이지 설정
|
||||
```html
|
||||
<!-- 관리자 전용 페이지 -->
|
||||
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>안전관리</h3>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 모든 사용자 접근 가능 -->
|
||||
<a href="/pages/work/visit-request.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>출입 신청</h3>
|
||||
</div>
|
||||
</a>
|
||||
```
|
||||
|
||||
### API 라우트 보호
|
||||
```javascript
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
|
||||
// 모든 라우트에 인증 필요
|
||||
router.use(verifyToken);
|
||||
|
||||
// 관리자 전용 라우트
|
||||
router.put('/requests/:id/approve', (req, res) => {
|
||||
// verifyToken에서 req.user 제공
|
||||
// 필요시 추가 권한 체크
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API 개발 가이드
|
||||
- **RESTful**: 명사형 리소스 사용 (`POST /users` O, `/createUser` X).
|
||||
- **응답 포맷**:
|
||||
|
||||
138
DEV_LOG.md
138
DEV_LOG.md
@@ -1,6 +1,142 @@
|
||||
# 개발 진행 로그
|
||||
|
||||
## 📅 Recent Updates (2025-12-19)
|
||||
## 📅 Recent Updates (2026-01-29)
|
||||
|
||||
### 🗺️ 대시보드 작업장 현황 지도 구현 (2026-01-29)
|
||||
**개요**: 대시보드에 실시간 작업장 현황을 지도로 시각화하여 작업자 및 방문자 현황을 한눈에 파악
|
||||
|
||||
**배경**:
|
||||
- 기존 "오늘의 작업 현황" 테이블 방식은 직관성 부족
|
||||
- 작업장별 위치와 인원 현황을 시각적으로 표시할 필요
|
||||
- 작업장 관리 페이지에서 설정한 구역 정보 활용
|
||||
|
||||
**구현 내용**:
|
||||
1. **대시보드 UI 개편**
|
||||
- **제거**: "오늘의 작업 현황" 테이블 섹션
|
||||
- **추가**: "작업장 현황" 지도 섹션
|
||||
- 공장 선택 드롭다운 (제1공장 기본 선택)
|
||||
- 실시간 새로고침 버튼
|
||||
|
||||
2. **작업장 현황 지도 기능** (`web-ui/js/workplace-status.js`)
|
||||
- **데이터 소스**:
|
||||
- `map-regions` API: 작업장 관리 페이지에서 정의한 구역 정보
|
||||
- `tbm/sessions` API: 금일 TBM 작업 정보
|
||||
- `workplace-visits/requests` API: 금일 방문자 신청 정보
|
||||
|
||||
- **시각화 방식**:
|
||||
- 모든 작업장 구역을 사각형으로 표시
|
||||
- 인원 없음: 회색 테두리 + 작업장 이름
|
||||
- 내부 작업자만: 파란색 영역 + 인원 수 배지
|
||||
- 외부 방문자만: 보라색 영역 + 인원 수 배지
|
||||
- 작업자+방문자: 초록색 영역 + 총 인원 수 배지
|
||||
|
||||
- **상호작용**:
|
||||
- 작업장 구역 클릭 → 상세 정보 모달 표시
|
||||
- 내부 작업자: 작업명 + 인원 수 + 작업 위치 + 프로젝트명
|
||||
- 외부 방문자: 업체명 + 인원 수 + 방문 시간 + 목적
|
||||
|
||||
3. **TBM 데이터 통합**
|
||||
- 세션별 작업 정보 집계 (`task_name`, `team_member_count`)
|
||||
- 조장 포함 총 인원 계산
|
||||
- Draft 상태 세션도 예정 작업으로 표시
|
||||
|
||||
4. **기술적 구현**:
|
||||
- Canvas API 기반 지도 렌더링
|
||||
- 사각형 좌표 변환 (퍼센트 → 픽셀)
|
||||
- 마우스 클릭 위치를 영역 좌표로 매핑
|
||||
- 작업장 관리 페이지와 동일한 `map-regions` API 사용으로 데이터 일관성 보장
|
||||
|
||||
**수정 파일**:
|
||||
- `web-ui/pages/dashboard.html` - 작업장 현황 섹션 추가
|
||||
- `web-ui/js/workplace-status.js` - 신규 생성 (지도 렌더링 및 데이터 로직)
|
||||
- `web-ui/js/modern-dashboard.js` - 삭제된 DOM 요소 조건부 체크 추가
|
||||
|
||||
**효과**:
|
||||
- 작업장별 실시간 인원 현황을 시각적으로 파악 가능
|
||||
- 작업 배치 및 안전 관리 의사결정 지원
|
||||
- 외부 방문자 위치 추적으로 안전 관리 강화
|
||||
|
||||
---
|
||||
|
||||
### 🏖️ 휴가 관리 시스템 리팩토링 및 페이지 분리 (2026-01-29)
|
||||
**개요**: 코딩 가이드 준수를 위해 536줄의 단일 파일을 2개 페이지로 분리하고 공통 함수 라이브러리 생성
|
||||
|
||||
**배경**:
|
||||
- 기존 `vacation-management.html` (536줄) - 코딩 가이드 위반 (파일 길이 초과)
|
||||
- 단일 파일에 작업자/관리자 기능이 혼재
|
||||
- 코드 중복 발생
|
||||
|
||||
**구조 개선**:
|
||||
1. **공통 함수 라이브러리 생성**
|
||||
- **파일**: `web-ui/js/vacation-common.js`
|
||||
- **역할**: 모든 휴가 페이지에서 사용하는 공통 함수 모음
|
||||
- **주요 함수**:
|
||||
- `loadWorkers()`: 작업자 목록 로드
|
||||
- `loadVacationTypes()`: 휴가 유형 로드
|
||||
- `getCurrentUser()`: 현재 사용자 정보 조회
|
||||
- `renderVacationRequests()`: 휴가 신청 목록 렌더링
|
||||
- `approveVacationRequest()`: 휴가 승인
|
||||
- `rejectVacationRequest()`: 휴가 거부
|
||||
- `deleteVacationRequest()`: 휴가 신청 삭제
|
||||
|
||||
2. **2개 페이지로 분리**
|
||||
- **`vacation-request.html`** (작업자 휴가 신청)
|
||||
- 역할: 휴가 신청 및 본인 신청 내역 확인
|
||||
- 권한: 모든 작업자 (자동으로 본인 선택됨)
|
||||
- 기능:
|
||||
- 휴가 잔여 현황 표시
|
||||
- 휴가 신청 폼
|
||||
- 내 신청 내역 (삭제 가능 - pending만)
|
||||
|
||||
- **`vacation-management.html`** (관리자 휴가 관리)
|
||||
- 역할: 휴가 승인/직접입력/전체내역 관리 (3개 탭)
|
||||
- 권한: 관리자 전용 (system/admin)
|
||||
- 기능:
|
||||
- **탭 1: 승인 대기 목록** - 승인/거부 버튼
|
||||
- **탭 2: 직접 입력** - 승인 절차 없이 휴가 정보 직접 입력
|
||||
- 작업자 선택 시 휴가 잔여 표시
|
||||
- 입력 즉시 승인 상태로 저장
|
||||
- 최근 입력 내역 표시
|
||||
- **탭 3: 전체 신청 내역** - 날짜 필터링 지원
|
||||
|
||||
3. **데이터베이스 페이지 등록**
|
||||
- **마이그레이션**: `20260129000003_update_vacation_pages.js`
|
||||
- **변경사항**:
|
||||
- 기존 `vacation-management` 페이지 삭제
|
||||
- 신규 2개 페이지 등록 (vacation-request, vacation-management)
|
||||
- `is_admin_only` 플래그로 권한 구분 (vacation-request: 0, vacation-management: 1)
|
||||
- `display_order`로 표시 순서 관리
|
||||
|
||||
4. **파일 정리**
|
||||
- 기존: `vacation-management.html` → `.old` 확장자로 이름 변경
|
||||
- 향후: 충분한 테스트 후 삭제 예정
|
||||
|
||||
**기술적 개선사항**:
|
||||
- 코드 중복 제거: 공통 함수를 vacation-common.js로 추출
|
||||
- 권한 체크 강화: 페이지 로드 시 access_level 확인 및 리다이렉트
|
||||
- 이벤트 기반 UI 업데이트: 'vacation-updated' 이벤트로 페이지 간 동기화
|
||||
- 역할 기반 접근 제어: 작업자는 본인 정보만, 관리자는 전체 관리
|
||||
- 탭 기반 UI: 관리자 페이지는 3개 탭으로 기능 구분
|
||||
|
||||
**파일 변경사항**:
|
||||
```
|
||||
[NEW] web-ui/js/vacation-common.js (공통 함수 라이브러리)
|
||||
[NEW] web-ui/pages/common/vacation-request.html (작업자 휴가 신청)
|
||||
[NEW] web-ui/pages/common/vacation-management.html (관리자 3-탭 관리)
|
||||
[NEW] api.hyungi.net/db/migrations/20260129000003_update_vacation_pages.js
|
||||
[UPDATED] web-ui/pages/dashboard.html (휴가 관련 링크 업데이트)
|
||||
[RENAMED] web-ui/pages/common/vacation-management.html → vacation-management.html.old
|
||||
```
|
||||
|
||||
**코딩 가이드 준수**:
|
||||
- ✅ 파일 길이 제한 준수 (기존 536줄 → 각 350줄 이하)
|
||||
- ✅ 공통 로직 분리 (DRY 원칙)
|
||||
- ✅ 단일 책임 원칙 (각 페이지가 명확한 역할)
|
||||
- ✅ 역할 기반 접근 제어 명확화
|
||||
|
||||
---
|
||||
|
||||
## 📅 Previous Updates (2025-12-19)
|
||||
|
||||
### WorkAnalysis 리팩토링 완료
|
||||
**내용**: 복잡한 통계 로직을 포함하던 `workAnalysisController.js`를 리팩토링함.
|
||||
|
||||
@@ -44,6 +44,10 @@ function setupRoutes(app) {
|
||||
const equipmentRoutes = require('../routes/equipmentRoutes');
|
||||
const taskRoutes = require('../routes/taskRoutes');
|
||||
const tbmRoutes = require('../routes/tbmRoutes');
|
||||
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
|
||||
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
||||
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
||||
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
||||
|
||||
// Rate Limiters 설정
|
||||
const rateLimit = require('express-rate-limit');
|
||||
@@ -141,6 +145,10 @@ function setupRoutes(app) {
|
||||
app.use('/api/workplaces', workplaceRoutes);
|
||||
app.use('/api/equipments', equipmentRoutes);
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
|
||||
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||
app.use('/api', uploadBgRoutes);
|
||||
|
||||
@@ -86,6 +86,15 @@ const helmetOptions = {
|
||||
*/
|
||||
permittedCrossDomainPolicies: {
|
||||
permittedPolicies: 'none'
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-Origin-Resource-Policy
|
||||
* 크로스 오리진 리소스 공유 설정
|
||||
* 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용
|
||||
*/
|
||||
crossOriginResourcePolicy: {
|
||||
policy: 'cross-origin'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -154,6 +154,34 @@ const getMonthlyAttendanceStats = asyncHandler(async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
|
||||
*/
|
||||
const getCheckinList = asyncHandler(async (req, res) => {
|
||||
const { date } = req.query;
|
||||
const data = await attendanceService.getCheckinListService(date);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
message: '출근 체크 목록을 성공적으로 조회했습니다'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출근 체크 저장 (일괄 처리)
|
||||
*/
|
||||
const saveCheckins = asyncHandler(async (req, res) => {
|
||||
const { date, checkins } = req.body; // checkins: [{worker_id, is_present}, ...]
|
||||
const result = await attendanceService.saveCheckinsService(date, checkins);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '출근 체크가 성공적으로 저장되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getDailyAttendanceStatus,
|
||||
getDailyAttendanceRecords,
|
||||
@@ -163,5 +191,7 @@ module.exports = {
|
||||
getAttendanceTypes,
|
||||
getVacationTypes,
|
||||
getWorkerVacationBalance,
|
||||
getMonthlyAttendanceStats
|
||||
getMonthlyAttendanceStats,
|
||||
getCheckinList,
|
||||
saveCheckins
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ const TbmController = {
|
||||
createSession: (req, res) => {
|
||||
const sessionData = {
|
||||
session_date: req.body.session_date,
|
||||
leader_id: req.body.leader_id,
|
||||
leader_id: req.body.leader_id || null,
|
||||
project_id: req.body.project_id || null,
|
||||
work_location: req.body.work_location || null,
|
||||
work_description: req.body.work_description || null,
|
||||
@@ -19,11 +19,11 @@ const TbmController = {
|
||||
created_by: req.user.user_id
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!sessionData.session_date || !sessionData.leader_id) {
|
||||
// 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용)
|
||||
if (!sessionData.session_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'TBM 날짜와 팀장 정보는 필수입니다.'
|
||||
message: 'TBM 날짜는 필수입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ const TbmController = {
|
||||
// ==================== 팀 구성 관련 ====================
|
||||
|
||||
/**
|
||||
* 팀원 추가
|
||||
* 팀원 추가 (작업자별 상세 정보 포함)
|
||||
*/
|
||||
addTeamMember: (req, res) => {
|
||||
const assignmentData = {
|
||||
@@ -188,7 +188,12 @@ const TbmController = {
|
||||
assigned_role: req.body.assigned_role || null,
|
||||
work_detail: req.body.work_detail || null,
|
||||
is_present: req.body.is_present,
|
||||
absence_reason: req.body.absence_reason || null
|
||||
absence_reason: req.body.absence_reason || null,
|
||||
project_id: req.body.project_id || null,
|
||||
work_type_id: req.body.work_type_id || null,
|
||||
task_id: req.body.task_id || null,
|
||||
workplace_category_id: req.body.workplace_category_id || null,
|
||||
workplace_id: req.body.workplace_id || null
|
||||
};
|
||||
|
||||
if (!assignmentData.worker_id) {
|
||||
@@ -300,6 +305,30 @@ const TbmController = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 세션의 모든 팀원 삭제 (수정 시 사용)
|
||||
*/
|
||||
clearAllTeamMembers: (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
TbmModel.clearAllTeamMembers(sessionId, (err, result) => {
|
||||
if (err) {
|
||||
console.error('팀원 전체 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '팀원 전체 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '모든 팀원이 삭제되었습니다.',
|
||||
data: { deletedCount: result.affectedRows }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// ==================== 안전 체크리스트 관련 ====================
|
||||
|
||||
/**
|
||||
@@ -564,6 +593,33 @@ const TbmController = {
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업보고서가 작성되지 않은 TBM 팀 배정 조회
|
||||
*/
|
||||
getIncompleteWorkReports: (req, res) => {
|
||||
const userId = req.user.user_id;
|
||||
const accessLevel = req.user.access_level;
|
||||
|
||||
// 관리자는 모든 TBM 조회, 일반 사용자는 본인이 작성한 것만 조회
|
||||
const filterUserId = (accessLevel === 'system' || accessLevel === 'admin') ? null : userId;
|
||||
|
||||
TbmModel.getIncompleteWorkReports(filterUserId, (err, results) => {
|
||||
if (err) {
|
||||
console.error('미완료 작업보고서 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '미완료 작업보고서 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
|
||||
@@ -218,16 +218,16 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
checkAdminPermission(req.user);
|
||||
|
||||
const { id } = req.params;
|
||||
const { username, name, email, phone, role, password } = req.body;
|
||||
const { username, name, email, phone, role, role_id, password } = req.body;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('사용자 수정 요청', { userId: id });
|
||||
logger.info('사용자 수정 요청', { userId: id, body: req.body });
|
||||
|
||||
// 최소 하나의 수정 필드가 필요
|
||||
if (!username && !name && email === undefined && phone === undefined && !role && !password) {
|
||||
if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) {
|
||||
throw new ValidationError('수정할 필드가 없습니다');
|
||||
}
|
||||
|
||||
@@ -283,13 +283,35 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
values.push(phone || null);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
const validRoles = ['admin', 'group_leader', 'worker'];
|
||||
if (!validRoles.includes(role)) {
|
||||
throw new ValidationError('유효하지 않은 권한입니다');
|
||||
// role_id 또는 role 문자열 처리
|
||||
if (role_id) {
|
||||
// role_id가 유효한지 확인
|
||||
const [roleCheck] = await db.execute('SELECT id, name FROM roles WHERE id = ?', [role_id]);
|
||||
if (roleCheck.length === 0) {
|
||||
throw new ValidationError('유효하지 않은 역할 ID입니다');
|
||||
}
|
||||
updates.push('role = ?, access_level = ?');
|
||||
values.push(role, role);
|
||||
updates.push('role_id = ?');
|
||||
values.push(role_id);
|
||||
logger.info('role_id로 역할 변경', { userId: id, role_id, role_name: roleCheck[0].name });
|
||||
} else if (role) {
|
||||
// role 문자열을 role_id로 변환 (하위 호환성)
|
||||
const roleNameMap = {
|
||||
'admin': 'Admin',
|
||||
'system': 'System Admin',
|
||||
'user': 'User',
|
||||
'guest': 'Guest',
|
||||
'group_leader': 'User', // 임시 매핑
|
||||
'worker': 'User' // 임시 매핑
|
||||
};
|
||||
const roleName = roleNameMap[role.toLowerCase()] || role;
|
||||
const [roleCheck] = await db.execute('SELECT id FROM roles WHERE name = ?', [roleName]);
|
||||
|
||||
if (roleCheck.length === 0) {
|
||||
throw new ValidationError(`유효하지 않은 권한입니다: ${role}`);
|
||||
}
|
||||
updates.push('role_id = ?');
|
||||
values.push(roleCheck[0].id);
|
||||
logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
@@ -297,7 +319,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
updates.push('password_hash = ?');
|
||||
updates.push('password = ?');
|
||||
values.push(hashedPassword);
|
||||
}
|
||||
|
||||
@@ -306,6 +328,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
|
||||
const updateQuery = `UPDATE users SET ${updates.join(', ')} WHERE user_id = ?`;
|
||||
|
||||
logger.info('실행할 UPDATE 쿼리', { query: updateQuery, values });
|
||||
await db.execute(updateQuery, values);
|
||||
|
||||
logger.info('사용자 수정 성공', {
|
||||
@@ -324,7 +347,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('사용자 수정 실패', { userId: id, error: error.message });
|
||||
logger.error('사용자 수정 실패', { userId: id, error: error.message, stack: error.stack });
|
||||
throw new DatabaseError('사용자 정보를 수정하는데 실패했습니다');
|
||||
}
|
||||
});
|
||||
@@ -458,11 +481,127 @@ const deleteUser = asyncHandler(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한 조회
|
||||
*/
|
||||
const getUserPageAccess = asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('사용자 페이지 권한 조회 요청', { userId: id });
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
p.id as page_id,
|
||||
p.page_key,
|
||||
p.page_name,
|
||||
p.page_path,
|
||||
p.category,
|
||||
COALESCE(upa.can_access, 0) as can_access
|
||||
FROM pages p
|
||||
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
|
||||
WHERE p.is_active = 1
|
||||
ORDER BY p.category, p.display_order
|
||||
`;
|
||||
|
||||
const [pageAccess] = await db.execute(query, [id]);
|
||||
|
||||
logger.info('사용자 페이지 권한 조회 성공', { userId: id, pageCount: pageAccess.length });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
pageAccess
|
||||
},
|
||||
message: '페이지 권한 조회 성공'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('사용자 페이지 권한 조회 실패', { userId: id, error: error.message });
|
||||
throw new DatabaseError('페이지 권한을 조회하는데 실패했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한 업데이트
|
||||
*/
|
||||
const updateUserPageAccess = asyncHandler(async (req, res) => {
|
||||
checkAdminPermission(req.user);
|
||||
|
||||
const { id } = req.params;
|
||||
const { pageAccess } = req.body;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
}
|
||||
|
||||
if (!Array.isArray(pageAccess)) {
|
||||
throw new ValidationError('pageAccess는 배열이어야 합니다');
|
||||
}
|
||||
|
||||
logger.info('사용자 페이지 권한 업데이트 요청', {
|
||||
userId: id,
|
||||
pageCount: pageAccess.length,
|
||||
updatedBy: req.user.username
|
||||
});
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
// 트랜잭션 시작
|
||||
await db.query('START TRANSACTION');
|
||||
|
||||
// 기존 권한 삭제
|
||||
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
|
||||
|
||||
// 새 권한 삽입
|
||||
if (pageAccess.length > 0) {
|
||||
const values = pageAccess.map(p => [id, p.page_id, p.can_access]);
|
||||
const placeholders = values.map(() => '(?, ?, ?)').join(', ');
|
||||
const flatValues = values.flat();
|
||||
|
||||
await db.execute(
|
||||
`INSERT INTO user_page_access (user_id, page_id, can_access) VALUES ${placeholders}`,
|
||||
flatValues
|
||||
);
|
||||
}
|
||||
|
||||
// 커밋
|
||||
await db.query('COMMIT');
|
||||
|
||||
logger.info('사용자 페이지 권한 업데이트 성공', {
|
||||
userId: id,
|
||||
pageCount: pageAccess.length,
|
||||
updatedBy: req.user.username
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { user_id: id },
|
||||
message: '페이지 권한이 성공적으로 업데이트되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
// 롤백
|
||||
await db.query('ROLLBACK');
|
||||
logger.error('사용자 페이지 권한 업데이트 실패', { userId: id, error: error.message });
|
||||
throw new DatabaseError('페이지 권한을 업데이트하는데 실패했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
deleteUser,
|
||||
getUserPageAccess,
|
||||
updateUserPageAccess
|
||||
};
|
||||
|
||||
357
api.hyungi.net/controllers/vacationBalanceController.js
Normal file
357
api.hyungi.net/controllers/vacationBalanceController.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* vacationBalanceController.js
|
||||
* 휴가 잔액 관련 컨트롤러
|
||||
*/
|
||||
|
||||
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||
const vacationTypeModel = require('../models/vacationTypeModel');
|
||||
|
||||
const vacationBalanceController = {
|
||||
/**
|
||||
* 특정 작업자의 휴가 잔액 조회 (특정 연도)
|
||||
* GET /api/vacation-balances/worker/:workerId/year/:year
|
||||
*/
|
||||
async getByWorkerAndYear(req, res) {
|
||||
try {
|
||||
const { workerId, year } = req.params;
|
||||
|
||||
vacationBalanceModel.getByWorkerAndYear(workerId, year, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 잔액 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getByWorkerAndYear 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
|
||||
* GET /api/vacation-balances/year/:year
|
||||
*/
|
||||
async getAllByYear(req, res) {
|
||||
try {
|
||||
const { year } = req.params;
|
||||
|
||||
vacationBalanceModel.getAllByYear(year, (err, results) => {
|
||||
if (err) {
|
||||
console.error('전체 휴가 잔액 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '전체 휴가 잔액을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getAllByYear 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 생성
|
||||
* POST /api/vacation-balances
|
||||
*/
|
||||
async createBalance(req, res) {
|
||||
try {
|
||||
const {
|
||||
worker_id,
|
||||
vacation_type_id,
|
||||
year,
|
||||
total_days,
|
||||
used_days,
|
||||
notes
|
||||
} = req.body;
|
||||
const created_by = req.user.user_id;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다 (worker_id, vacation_type_id, year, total_days)'
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
vacationBalanceModel.getByWorkerTypeYear(worker_id, vacation_type_id, year, (err, existing) => {
|
||||
if (err) {
|
||||
console.error('중복 체크 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '중복 체크 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 해당 작업자의 해당 연도 휴가 잔액이 존재합니다'
|
||||
});
|
||||
}
|
||||
|
||||
const balanceData = {
|
||||
worker_id,
|
||||
vacation_type_id,
|
||||
year,
|
||||
total_days,
|
||||
used_days: used_days || 0,
|
||||
notes: notes || null,
|
||||
created_by
|
||||
};
|
||||
|
||||
vacationBalanceModel.create(balanceData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 잔액 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '휴가 잔액이 생성되었습니다',
|
||||
data: { id: result.insertId }
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createBalance 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 수정
|
||||
* PUT /api/vacation-balances/:id
|
||||
*/
|
||||
async updateBalance(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { total_days, used_days, notes } = req.body;
|
||||
|
||||
const updateData = {};
|
||||
if (total_days !== undefined) updateData.total_days = total_days;
|
||||
if (used_days !== undefined) updateData.used_days = used_days;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
if (Object.keys(updateData).length === 1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '수정할 데이터가 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
vacationBalanceModel.update(id, updateData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 잔액 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 수정하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 잔액이 수정되었습니다'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('updateBalance 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 삭제
|
||||
* DELETE /api/vacation-balances/:id
|
||||
*/
|
||||
async deleteBalance(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
vacationBalanceModel.delete(id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 잔액 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 삭제하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 잔액이 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('deleteBalance 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 근속년수 기반 연차 자동 계산 및 생성
|
||||
* POST /api/vacation-balances/auto-calculate
|
||||
*/
|
||||
async autoCalculateAndCreate(req, res) {
|
||||
try {
|
||||
const { worker_id, hire_date, year } = req.body;
|
||||
const created_by = req.user.user_id;
|
||||
|
||||
if (!worker_id || !hire_date || !year) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다 (worker_id, hire_date, year)'
|
||||
});
|
||||
}
|
||||
|
||||
// 연차 일수 계산
|
||||
const annualDays = vacationBalanceModel.calculateAnnualLeaveDays(hire_date, year);
|
||||
|
||||
// ANNUAL 휴가 유형 ID 조회
|
||||
vacationTypeModel.getByCode('ANNUAL', (err, types) => {
|
||||
if (err || !types || types.length === 0) {
|
||||
console.error('ANNUAL 휴가 유형 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'ANNUAL 휴가 유형을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const annualTypeId = types[0].id;
|
||||
|
||||
// 중복 체크
|
||||
vacationBalanceModel.getByWorkerTypeYear(worker_id, annualTypeId, year, (err, existing) => {
|
||||
if (err) {
|
||||
console.error('중복 체크 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '중복 체크 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 해당 작업자의 해당 연도 연차가 존재합니다'
|
||||
});
|
||||
}
|
||||
|
||||
const balanceData = {
|
||||
worker_id,
|
||||
vacation_type_id: annualTypeId,
|
||||
year,
|
||||
total_days: annualDays,
|
||||
used_days: 0,
|
||||
notes: `근속년수 기반 자동 계산 (입사일: ${hire_date})`,
|
||||
created_by
|
||||
};
|
||||
|
||||
vacationBalanceModel.create(balanceData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 잔액 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${annualDays}일의 연차가 자동으로 생성되었습니다`,
|
||||
data: {
|
||||
id: result.insertId,
|
||||
calculated_days: annualDays
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('autoCalculateAndCreate 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업자의 사용 가능한 휴가 일수 조회
|
||||
* GET /api/vacation-balances/worker/:workerId/year/:year/available
|
||||
*/
|
||||
async getAvailableDays(req, res) {
|
||||
try {
|
||||
const { workerId, year } = req.params;
|
||||
|
||||
vacationBalanceModel.getAvailableVacationDays(workerId, year, (err, results) => {
|
||||
if (err) {
|
||||
console.error('사용 가능 휴가 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '사용 가능 휴가를 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getAvailableDays 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationBalanceController;
|
||||
565
api.hyungi.net/controllers/vacationRequestController.js
Normal file
565
api.hyungi.net/controllers/vacationRequestController.js
Normal file
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* vacationRequestController.js
|
||||
* 휴가 신청 관련 컨트롤러
|
||||
*/
|
||||
|
||||
const vacationRequestModel = require('../models/vacationRequestModel');
|
||||
// TODO: workerVacationBalanceModel 구현 필요
|
||||
// const workerVacationBalanceModel = require('../models/workerVacationBalanceModel');
|
||||
|
||||
const vacationRequestController = {
|
||||
/**
|
||||
* 휴가 신청 생성
|
||||
*/
|
||||
async createRequest(req, res) {
|
||||
try {
|
||||
const { worker_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body;
|
||||
const requested_by = req.user.user_id;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!worker_id || !vacation_type_id || !start_date || !end_date || !days_used) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 유효성 검증
|
||||
const startDate = new Date(start_date);
|
||||
const endDate = new Date(end_date);
|
||||
|
||||
if (endDate < startDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '종료일은 시작일보다 이후여야 합니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 기간 중복 체크
|
||||
vacationRequestModel.checkOverlap(worker_id, start_date, end_date, null, (err, results) => {
|
||||
if (err) {
|
||||
console.error('기간 중복 체크 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '기간 중복 체크 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results[0].count > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '해당 기간에 이미 신청된 휴가가 있습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: 잔여 연차 확인 로직 구현 필요
|
||||
// 현재는 잔여 연차 확인 없이 신청 가능
|
||||
|
||||
// 휴가 신청 생성
|
||||
const requestData = {
|
||||
worker_id,
|
||||
vacation_type_id,
|
||||
start_date,
|
||||
end_date,
|
||||
days_used,
|
||||
reason: reason || null,
|
||||
status: 'pending',
|
||||
requested_by
|
||||
};
|
||||
|
||||
vacationRequestModel.create(requestData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 생성 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '휴가 신청이 완료되었습니다',
|
||||
data: {
|
||||
request_id: result.insertId
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 생성 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 목록 조회
|
||||
*/
|
||||
async getAllRequests(req, res) {
|
||||
try {
|
||||
const filters = {
|
||||
worker_id: req.query.worker_id,
|
||||
status: req.query.status,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
vacation_type_id: req.query.vacation_type_id
|
||||
};
|
||||
|
||||
// 일반 사용자는 자신의 신청만 조회 가능
|
||||
if (req.user.access_level !== 'system') {
|
||||
if (req.user.worker_id) {
|
||||
filters.worker_id = req.user.worker_id;
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '권한이 없습니다'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
vacationRequestModel.getAll(filters, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 목록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 목록 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 목록 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 휴가 신청 조회
|
||||
*/
|
||||
async getRequestById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
vacationRequestModel.getById(id, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const request = results[0];
|
||||
|
||||
// 권한 검증: 관리자 또는 본인만 조회 가능
|
||||
if (req.user.access_level !== 'system' && req.user.worker_id !== request.worker_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '권한이 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: request
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 수정 (대기 중인 신청만)
|
||||
*/
|
||||
async updateRequest(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { start_date, end_date, days_used, reason } = req.body;
|
||||
|
||||
// 기존 신청 조회
|
||||
vacationRequestModel.getById(id, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const existingRequest = results[0];
|
||||
|
||||
// 권한 검증
|
||||
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '권한이 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 대기 중인 신청만 수정 가능
|
||||
if (existingRequest.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '승인/거부된 신청은 수정할 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (start_date) updateData.start_date = start_date;
|
||||
if (end_date) updateData.end_date = end_date;
|
||||
if (days_used) updateData.days_used = days_used;
|
||||
if (reason !== undefined) updateData.reason = reason;
|
||||
|
||||
// 날짜가 변경된 경우 중복 체크
|
||||
if (start_date || end_date) {
|
||||
const newStartDate = start_date || existingRequest.start_date;
|
||||
const newEndDate = end_date || existingRequest.end_date;
|
||||
|
||||
vacationRequestModel.checkOverlap(
|
||||
existingRequest.worker_id,
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
id,
|
||||
(err, overlapResults) => {
|
||||
if (err) {
|
||||
console.error('기간 중복 체크 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '기간 중복 체크 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (overlapResults[0].count > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '해당 기간에 이미 신청된 휴가가 있습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 실행
|
||||
vacationRequestModel.update(id, updateData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 수정 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 신청이 수정되었습니다'
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// 날짜 변경 없이 바로 수정
|
||||
vacationRequestModel.update(id, updateData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 수정 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 신청이 수정되었습니다'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 수정 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 삭제 (대기 중인 신청만)
|
||||
*/
|
||||
async deleteRequest(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 기존 신청 조회
|
||||
vacationRequestModel.getById(id, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const existingRequest = results[0];
|
||||
|
||||
// 권한 검증
|
||||
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '권한이 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 대기 중인 신청만 삭제 가능
|
||||
if (existingRequest.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '승인/거부된 신청은 삭제할 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
vacationRequestModel.delete(id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 삭제 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 신청이 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 삭제 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 승인 (관리자만)
|
||||
*/
|
||||
async approveRequest(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { review_note } = req.body;
|
||||
const reviewed_by = req.user.user_id;
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (req.user.access_level !== 'system') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '관리자만 승인할 수 있습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 신청 조회
|
||||
vacationRequestModel.getById(id, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const request = results[0];
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 처리된 신청입니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
const statusData = {
|
||||
status: 'approved',
|
||||
reviewed_by,
|
||||
review_note
|
||||
};
|
||||
|
||||
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 승인 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 승인 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: 잔여 연차에서 차감 로직 구현 필요
|
||||
// 현재는 연차 차감 없이 승인만 처리
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 신청이 승인되었습니다'
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 승인 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 거부 (관리자만)
|
||||
*/
|
||||
async rejectRequest(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { review_note } = req.body;
|
||||
const reviewed_by = req.user.user_id;
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (req.user.access_level !== 'system') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '관리자만 거부할 수 있습니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 신청 조회
|
||||
vacationRequestModel.getById(id, (err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '해당 휴가 신청을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const request = results[0];
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 처리된 신청입니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 업데이트
|
||||
const statusData = {
|
||||
status: 'rejected',
|
||||
reviewed_by,
|
||||
review_note
|
||||
};
|
||||
|
||||
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 거부 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 거부 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 신청이 거부되었습니다'
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('휴가 거부 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 대기 중인 휴가 신청 목록 (관리자용)
|
||||
*/
|
||||
async getPendingRequests(req, res) {
|
||||
try {
|
||||
// 관리자 권한 확인
|
||||
if (req.user.access_level !== 'system') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '관리자만 조회할 수 있습니다'
|
||||
});
|
||||
}
|
||||
|
||||
vacationRequestModel.getAllPending((err, results) => {
|
||||
if (err) {
|
||||
console.error('대기 중인 휴가 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '대기 중인 휴가 신청 조회 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('대기 중인 휴가 신청 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationRequestController;
|
||||
333
api.hyungi.net/controllers/vacationTypeController.js
Normal file
333
api.hyungi.net/controllers/vacationTypeController.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* vacationTypeController.js
|
||||
* 휴가 유형 관련 컨트롤러
|
||||
*/
|
||||
|
||||
const vacationTypeModel = require('../models/vacationTypeModel');
|
||||
|
||||
const vacationTypeController = {
|
||||
/**
|
||||
* 모든 활성 휴가 유형 조회
|
||||
* GET /api/vacation-types
|
||||
*/
|
||||
async getAllTypes(req, res) {
|
||||
try {
|
||||
vacationTypeModel.getAll((err, results) => {
|
||||
if (err) {
|
||||
console.error('휴가 유형 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getAllTypes 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 시스템 기본 휴가 유형 조회
|
||||
* GET /api/vacation-types/system
|
||||
*/
|
||||
async getSystemTypes(req, res) {
|
||||
try {
|
||||
vacationTypeModel.getSystemTypes((err, results) => {
|
||||
if (err) {
|
||||
console.error('시스템 휴가 유형 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '시스템 휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getSystemTypes 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특별 휴가 유형 조회
|
||||
* GET /api/vacation-types/special
|
||||
*/
|
||||
async getSpecialTypes(req, res) {
|
||||
try {
|
||||
vacationTypeModel.getSpecialTypes((err, results) => {
|
||||
if (err) {
|
||||
console.error('특별 휴가 유형 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '특별 휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('getSpecialTypes 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특별 휴가 유형 생성 (관리자만)
|
||||
* POST /api/vacation-types
|
||||
*/
|
||||
async createType(req, res) {
|
||||
try {
|
||||
const {
|
||||
type_code,
|
||||
type_name,
|
||||
deduct_days,
|
||||
priority,
|
||||
description
|
||||
} = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!type_code || !type_name || !deduct_days) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다 (type_code, type_name, deduct_days)'
|
||||
});
|
||||
}
|
||||
|
||||
// type_code 중복 체크
|
||||
vacationTypeModel.getByCode(type_code, (err, existingTypes) => {
|
||||
if (err) {
|
||||
console.error('type_code 중복 체크 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'type_code 중복 체크 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (existingTypes && existingTypes.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 존재하는 type_code입니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 특별 휴가 유형으로 생성
|
||||
const typeData = {
|
||||
type_code,
|
||||
type_name,
|
||||
deduct_days,
|
||||
priority: priority || 50,
|
||||
description: description || null,
|
||||
is_special: true,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
};
|
||||
|
||||
vacationTypeModel.create(typeData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 유형 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 생성하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '특별 휴가 유형이 생성되었습니다',
|
||||
data: { id: result.insertId }
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('createType 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 수정 (관리자만)
|
||||
* PUT /api/vacation-types/:id
|
||||
*/
|
||||
async updateType(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
type_name,
|
||||
deduct_days,
|
||||
priority,
|
||||
description,
|
||||
is_active
|
||||
} = req.body;
|
||||
|
||||
// 먼저 해당 유형 조회
|
||||
vacationTypeModel.getById(id, (err, types) => {
|
||||
if (err) {
|
||||
console.error('휴가 유형 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (!types || types.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 찾을 수 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
const type = types[0];
|
||||
|
||||
// 시스템 기본 휴가의 경우 제한적으로만 수정 가능
|
||||
const updateData = {};
|
||||
if (type.is_system) {
|
||||
// 시스템 휴가는 priority와 description만 수정 가능
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
} else {
|
||||
// 특별 휴가는 모든 필드 수정 가능
|
||||
if (type_name) updateData.type_name = type_name;
|
||||
if (deduct_days !== undefined) updateData.deduct_days = deduct_days;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (is_active !== undefined) updateData.is_active = is_active;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '수정할 데이터가 없습니다'
|
||||
});
|
||||
}
|
||||
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
vacationTypeModel.update(id, updateData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 유형 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 수정하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 유형이 수정되었습니다'
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('updateType 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
|
||||
* DELETE /api/vacation-types/:id
|
||||
*/
|
||||
async deleteType(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
vacationTypeModel.delete(id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('휴가 유형 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '휴가 유형을 삭제하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '삭제할 수 없습니다. 시스템 기본 휴가이거나 존재하지 않는 휴가 유형입니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '휴가 유형이 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('deleteType 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 우선순위 일괄 업데이트 (관리자만)
|
||||
* PUT /api/vacation-types/priorities
|
||||
*/
|
||||
async updatePriorities(req, res) {
|
||||
try {
|
||||
const { priorities } = req.body;
|
||||
|
||||
// priorities = [{ id: 1, priority: 10 }, { id: 2, priority: 20 }, ...]
|
||||
if (!priorities || !Array.isArray(priorities)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'priorities 배열이 필요합니다'
|
||||
});
|
||||
}
|
||||
|
||||
vacationTypeModel.updatePriorities(priorities, (err, result) => {
|
||||
if (err) {
|
||||
console.error('우선순위 업데이트 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '우선순위를 업데이트하는 중 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '우선순위가 업데이트되었습니다',
|
||||
data: { updated: result.affectedRows }
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('updatePriorities 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationTypeController;
|
||||
555
api.hyungi.net/controllers/visitRequestController.js
Normal file
555
api.hyungi.net/controllers/visitRequestController.js
Normal file
@@ -0,0 +1,555 @@
|
||||
const visitRequestModel = require('../models/visitRequestModel');
|
||||
|
||||
// ==================== 출입 신청 관리 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 생성
|
||||
*/
|
||||
exports.createVisitRequest = (req, res) => {
|
||||
const requester_id = req.user.user_id;
|
||||
const requestData = {
|
||||
requester_id,
|
||||
...req.body
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
|
||||
for (const field of requiredFields) {
|
||||
if (!requestData[field]) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `${field}는 필수 입력 항목입니다.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
visitRequestModel.createVisitRequest(requestData, (err, requestId) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 생성 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '출입 신청이 성공적으로 생성되었습니다.',
|
||||
data: { request_id: requestId }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 목록 조회
|
||||
*/
|
||||
exports.getAllVisitRequests = (req, res) => {
|
||||
const filters = {
|
||||
status: req.query.status,
|
||||
visit_date: req.query.visit_date,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
requester_id: req.query.requester_id,
|
||||
category_id: req.query.category_id
|
||||
};
|
||||
|
||||
visitRequestModel.getAllVisitRequests(filters, (err, requests) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 목록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 목록 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: requests
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 상세 조회
|
||||
*/
|
||||
exports.getVisitRequestById = (req, res) => {
|
||||
const requestId = req.params.id;
|
||||
|
||||
visitRequestModel.getVisitRequestById(requestId, (err, request) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '출입 신청을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: request
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 수정
|
||||
*/
|
||||
exports.updateVisitRequest = (req, res) => {
|
||||
const requestId = req.params.id;
|
||||
const requestData = req.body;
|
||||
|
||||
visitRequestModel.updateVisitRequest(requestId, requestData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 수정 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '출입 신청을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '출입 신청이 수정되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 삭제
|
||||
*/
|
||||
exports.deleteVisitRequest = (req, res) => {
|
||||
const requestId = req.params.id;
|
||||
|
||||
visitRequestModel.deleteVisitRequest(requestId, (err, result) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '출입 신청을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '출입 신청이 삭제되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 승인
|
||||
*/
|
||||
exports.approveVisitRequest = (req, res) => {
|
||||
const requestId = req.params.id;
|
||||
const approvedBy = req.user.user_id;
|
||||
|
||||
visitRequestModel.approveVisitRequest(requestId, approvedBy, (err, result) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 승인 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 승인 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '출입 신청을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '출입 신청이 승인되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 반려
|
||||
*/
|
||||
exports.rejectVisitRequest = (req, res) => {
|
||||
const requestId = req.params.id;
|
||||
const approvedBy = req.user.user_id;
|
||||
const rejectionReason = req.body.rejection_reason || '사유 없음';
|
||||
|
||||
const rejectionData = {
|
||||
approved_by: approvedBy,
|
||||
rejection_reason: rejectionReason
|
||||
};
|
||||
|
||||
visitRequestModel.rejectVisitRequest(requestId, rejectionData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 반려 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '출입 신청 반려 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '출입 신청을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '출입 신청이 반려되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 방문 목적 관리 ====================
|
||||
|
||||
/**
|
||||
* 모든 방문 목적 조회
|
||||
*/
|
||||
exports.getAllVisitPurposes = (req, res) => {
|
||||
visitRequestModel.getAllVisitPurposes((err, purposes) => {
|
||||
if (err) {
|
||||
console.error('방문 목적 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '방문 목적 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: purposes
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 방문 목적만 조회
|
||||
*/
|
||||
exports.getActiveVisitPurposes = (req, res) => {
|
||||
visitRequestModel.getActiveVisitPurposes((err, purposes) => {
|
||||
if (err) {
|
||||
console.error('활성 방문 목적 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '활성 방문 목적 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: purposes
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 추가
|
||||
*/
|
||||
exports.createVisitPurpose = (req, res) => {
|
||||
const purposeData = req.body;
|
||||
|
||||
if (!purposeData.purpose_name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'purpose_name은 필수 입력 항목입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
visitRequestModel.createVisitPurpose(purposeData, (err, purposeId) => {
|
||||
if (err) {
|
||||
console.error('방문 목적 추가 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '방문 목적 추가 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '방문 목적이 추가되었습니다.',
|
||||
data: { purpose_id: purposeId }
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 수정
|
||||
*/
|
||||
exports.updateVisitPurpose = (req, res) => {
|
||||
const purposeId = req.params.id;
|
||||
const purposeData = req.body;
|
||||
|
||||
visitRequestModel.updateVisitPurpose(purposeId, purposeData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('방문 목적 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '방문 목적 수정 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '방문 목적을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '방문 목적이 수정되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 삭제
|
||||
*/
|
||||
exports.deleteVisitPurpose = (req, res) => {
|
||||
const purposeId = req.params.id;
|
||||
|
||||
visitRequestModel.deleteVisitPurpose(purposeId, (err, result) => {
|
||||
if (err) {
|
||||
console.error('방문 목적 삭제 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '방문 목적 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '방문 목적을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '방문 목적이 삭제되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 안전교육 기록 관리 ====================
|
||||
|
||||
/**
|
||||
* 안전교육 기록 생성
|
||||
*/
|
||||
exports.createTrainingRecord = (req, res) => {
|
||||
const trainerId = req.user.user_id;
|
||||
const trainingData = {
|
||||
trainer_id: trainerId,
|
||||
...req.body
|
||||
};
|
||||
|
||||
// 필수 필드 검증
|
||||
const requiredFields = ['request_id', 'training_date', 'training_start_time'];
|
||||
for (const field of requiredFields) {
|
||||
if (!trainingData[field]) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `${field}는 필수 입력 항목입니다.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
visitRequestModel.createTrainingRecord(trainingData, (err, trainingId) => {
|
||||
if (err) {
|
||||
console.error('안전교육 기록 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전교육 기록 생성 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
// 안전교육 기록이 생성되면 출입 신청 상태를 training_completed로 변경
|
||||
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태를 training_completed로 변경 중...`);
|
||||
visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed', (statusErr) => {
|
||||
if (statusErr) {
|
||||
console.error('출입 신청 상태 업데이트 오류:', statusErr);
|
||||
// 에러가 발생해도 교육 기록은 생성되었으므로 성공 응답
|
||||
} else {
|
||||
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태 변경 성공`);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '안전교육 기록이 생성되었습니다.',
|
||||
data: { training_id: trainingId }
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 출입 신청의 안전교육 기록 조회
|
||||
*/
|
||||
exports.getTrainingRecordByRequestId = (req, res) => {
|
||||
const requestId = req.params.requestId;
|
||||
|
||||
visitRequestModel.getTrainingRecordByRequestId(requestId, (err, record) => {
|
||||
if (err) {
|
||||
console.error('안전교육 기록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전교육 기록 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record || null
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 기록 수정
|
||||
*/
|
||||
exports.updateTrainingRecord = (req, res) => {
|
||||
const trainingId = req.params.id;
|
||||
const trainingData = req.body;
|
||||
|
||||
visitRequestModel.updateTrainingRecord(trainingId, trainingData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('안전교육 기록 수정 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전교육 기록 수정 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '안전교육 기록을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '안전교육 기록이 수정되었습니다.'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 완료 (서명 포함)
|
||||
*/
|
||||
exports.completeTraining = (req, res) => {
|
||||
const trainingId = req.params.id;
|
||||
const signatureData = req.body.signature_data;
|
||||
|
||||
if (!signatureData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '서명 데이터가 필요합니다.'
|
||||
});
|
||||
}
|
||||
|
||||
visitRequestModel.completeTraining(trainingId, signatureData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('안전교육 완료 처리 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전교육 완료 처리 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '안전교육 기록을 찾을 수 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
// 교육 완료 후 출입 신청 상태를 'training_completed'로 변경
|
||||
visitRequestModel.getTrainingRecordByRequestId(trainingId, (err, record) => {
|
||||
if (err || !record) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '안전교육이 완료되었습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed', (err) => {
|
||||
if (err) {
|
||||
console.error('출입 신청 상태 업데이트 오류:', err);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '안전교육이 완료되었습니다.'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 기록 목록 조회
|
||||
*/
|
||||
exports.getTrainingRecords = (req, res) => {
|
||||
const filters = {
|
||||
training_date: req.query.training_date,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
trainer_id: req.query.trainer_id
|
||||
};
|
||||
|
||||
visitRequestModel.getTrainingRecords(filters, (err, records) => {
|
||||
if (err) {
|
||||
console.error('안전교육 기록 목록 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -178,36 +178,68 @@ exports.updateWorker = asyncHandler(async (req, res) => {
|
||||
// 계정 생성/해제 처리
|
||||
const db = await getDb();
|
||||
const hasAccount = currentWorker.user_id !== null && currentWorker.user_id !== undefined;
|
||||
let accountAction = null;
|
||||
let accountUsername = null;
|
||||
|
||||
console.log('🔍 계정 생성 체크:', {
|
||||
createAccount,
|
||||
hasAccount,
|
||||
currentWorker_user_id: currentWorker.user_id,
|
||||
worker_name: workerData.worker_name
|
||||
});
|
||||
|
||||
if (createAccount && !hasAccount && workerData.worker_name) {
|
||||
// 계정 생성
|
||||
console.log('✅ 계정 생성 로직 시작');
|
||||
try {
|
||||
console.log('🔑 사용자명 생성 중...');
|
||||
const username = await generateUniqueUsername(workerData.worker_name, db);
|
||||
console.log('🔑 생성된 사용자명:', username);
|
||||
|
||||
const hashedPassword = await bcrypt.hash('1234', 10);
|
||||
console.log('🔒 비밀번호 해싱 완료');
|
||||
|
||||
// User 역할 조회
|
||||
console.log('👤 User 역할 조회 중...');
|
||||
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
|
||||
console.log('👤 User 역할 조회 결과:', userRole);
|
||||
|
||||
if (userRole && userRole.length > 0) {
|
||||
console.log('💾 계정 DB 삽입 시작...');
|
||||
await db.query(
|
||||
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
|
||||
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
|
||||
);
|
||||
console.log('✅ 계정 DB 삽입 완료');
|
||||
|
||||
accountAction = 'created';
|
||||
accountUsername = username;
|
||||
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
|
||||
} else {
|
||||
console.log('❌ User 역할을 찾을 수 없음');
|
||||
}
|
||||
} catch (accountError) {
|
||||
console.error('❌ 계정 생성 오류:', accountError);
|
||||
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
|
||||
accountAction = 'failed';
|
||||
}
|
||||
} else if (!createAccount && hasAccount) {
|
||||
} else {
|
||||
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
|
||||
}
|
||||
|
||||
if (!createAccount && hasAccount) {
|
||||
// 계정 연동 해제 (users.worker_id = NULL)
|
||||
try {
|
||||
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
|
||||
accountAction = 'unlinked';
|
||||
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
|
||||
} catch (unlinkError) {
|
||||
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
|
||||
accountAction = 'unlink_failed';
|
||||
}
|
||||
} else if (createAccount && hasAccount) {
|
||||
accountAction = 'already_exists';
|
||||
}
|
||||
|
||||
// 작업자 관련 캐시 무효화
|
||||
@@ -216,10 +248,26 @@ exports.updateWorker = asyncHandler(async (req, res) => {
|
||||
|
||||
logger.info('작업자 수정 성공', { worker_id: id });
|
||||
|
||||
// 응답 메시지 구성
|
||||
let message = '작업자 정보가 성공적으로 수정되었습니다';
|
||||
if (accountAction === 'created') {
|
||||
message += ` (계정 생성 완료: ${accountUsername}, 초기 비밀번호: 1234)`;
|
||||
} else if (accountAction === 'unlinked') {
|
||||
message += ' (계정 연동 해제 완료)';
|
||||
} else if (accountAction === 'already_exists') {
|
||||
message += ' (이미 계정이 존재합니다)';
|
||||
} else if (accountAction === 'failed') {
|
||||
message += ' (계정 생성 실패)';
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { changes },
|
||||
message: '작업자 정보가 성공적으로 수정되었습니다'
|
||||
data: {
|
||||
changes,
|
||||
account_action: accountAction,
|
||||
account_username: accountUsername
|
||||
},
|
||||
message
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 마이그레이션: tbm_team_assignments 테이블 확장
|
||||
* 작업자별 프로젝트/공정/작업/작업장 정보 저장 가능하도록 컬럼 추가 및 외래키 설정
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. workplace_category_id와 workplace_id를 UNSIGNED로 변경
|
||||
await knex.raw(`
|
||||
ALTER TABLE tbm_team_assignments
|
||||
MODIFY COLUMN workplace_category_id INT UNSIGNED NULL COMMENT '작업자별 작업장 대분류 (공장)',
|
||||
MODIFY COLUMN workplace_id INT UNSIGNED NULL COMMENT '작업자별 작업장 ID'
|
||||
`);
|
||||
|
||||
// 2. 외래키 제약조건 추가
|
||||
return knex.schema.alterTable('tbm_team_assignments', function(table) {
|
||||
// 외래키 제약조건 추가
|
||||
table.foreign('workplace_category_id')
|
||||
.references('category_id')
|
||||
.inTable('workplace_categories')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('workplace_id')
|
||||
.references('workplace_id')
|
||||
.inTable('workplaces')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.alterTable('tbm_team_assignments', function(table) {
|
||||
// 외래키 제약조건 제거
|
||||
table.dropForeign('workplace_category_id');
|
||||
table.dropForeign('workplace_id');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 마이그레이션: tbm_sessions 테이블에서 불필요한 컬럼 제거
|
||||
* work_description, safety_notes, start_time 컬럼 제거
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.alterTable('tbm_sessions', function(table) {
|
||||
table.dropColumn('work_description');
|
||||
table.dropColumn('safety_notes');
|
||||
table.dropColumn('start_time');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.alterTable('tbm_sessions', function(table) {
|
||||
table.text('work_description').nullable().comment('작업 내용');
|
||||
table.text('safety_notes').nullable().comment('안전 관련 특이사항');
|
||||
table.time('start_time').nullable().comment('시작 시간');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 마이그레이션: 작업장 지도 이미지 기능 추가
|
||||
* - workplace_categories에 layout_image 필드 추가
|
||||
* - workplace_map_regions 테이블 생성 (클릭 가능한 영역 정의)
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. workplace_categories 테이블에 layout_image 필드 추가
|
||||
await knex.schema.alterTable('workplace_categories', function(table) {
|
||||
table.string('layout_image', 500).nullable().comment('공장 배치도 이미지 경로');
|
||||
});
|
||||
|
||||
// 2. 작업장 지도 클릭 영역 정의 테이블 생성
|
||||
await knex.schema.createTable('workplace_map_regions', function(table) {
|
||||
table.increments('region_id').primary().comment('영역 ID');
|
||||
table.integer('workplace_id').unsigned().notNullable().comment('작업장 ID');
|
||||
table.integer('category_id').unsigned().notNullable().comment('공장 카테고리 ID');
|
||||
|
||||
// 좌표 정보 (비율 기반: 0~100%)
|
||||
table.decimal('x_start', 5, 2).notNullable().comment('시작 X 좌표 (%)');
|
||||
table.decimal('y_start', 5, 2).notNullable().comment('시작 Y 좌표 (%)');
|
||||
table.decimal('x_end', 5, 2).notNullable().comment('끝 X 좌표 (%)');
|
||||
table.decimal('y_end', 5, 2).notNullable().comment('끝 Y 좌표 (%)');
|
||||
|
||||
table.string('shape', 20).defaultTo('rect').comment('영역 모양 (rect, circle, polygon)');
|
||||
table.text('polygon_points').nullable().comment('다각형인 경우 좌표 배열 (JSON)');
|
||||
|
||||
table.timestamps(true, true);
|
||||
|
||||
// 외래키
|
||||
table.foreign('workplace_id')
|
||||
.references('workplace_id')
|
||||
.inTable('workplaces')
|
||||
.onDelete('CASCADE')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('category_id')
|
||||
.references('category_id')
|
||||
.inTable('workplace_categories')
|
||||
.onDelete('CASCADE')
|
||||
.onUpdate('CASCADE');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 테이블 삭제
|
||||
await knex.schema.dropTableIfExists('workplace_map_regions');
|
||||
|
||||
// 필드 제거
|
||||
await knex.schema.alterTable('workplace_categories', function(table) {
|
||||
table.dropColumn('layout_image');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 마이그레이션: 작업장 용도 및 표시 순서 필드 추가
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.alterTable('workplaces', function(table) {
|
||||
table.string('workplace_purpose', 50).nullable().comment('작업장 용도 (작업구역, 설비, 휴게시설, 회의실 등)');
|
||||
table.integer('display_priority').defaultTo(0).comment('표시 우선순위 (숫자가 작을수록 먼저 표시)');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.alterTable('workplaces', function(table) {
|
||||
table.dropColumn('workplace_purpose');
|
||||
table.dropColumn('display_priority');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* leader_id를 nullable로 변경
|
||||
* 관리자가 TBM을 입력할 때 leader_id를 NULL로 설정하고 created_by를 사용
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. 외래 키 제약조건 삭제 (존재하는 경우에만)
|
||||
try {
|
||||
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
|
||||
} catch (err) {
|
||||
console.log('외래 키가 이미 존재하지 않음 (정상)');
|
||||
}
|
||||
|
||||
// 2. leader_id를 nullable로 변경 (UNSIGNED 제거하여 workers.worker_id와 타입 일치)
|
||||
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NULL');
|
||||
|
||||
// 3. 외래 키 제약조건 다시 추가 (nullable 허용)
|
||||
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE SET NULL');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 1. 외래 키 제약조건 삭제
|
||||
await knex.raw('ALTER TABLE tbm_sessions DROP FOREIGN KEY tbm_sessions_leader_id_foreign');
|
||||
|
||||
// 2. leader_id를 NOT NULL로 되돌림
|
||||
await knex.raw('ALTER TABLE tbm_sessions MODIFY leader_id INT(11) NOT NULL');
|
||||
|
||||
// 3. 외래 키 제약조건 다시 추가
|
||||
await knex.raw('ALTER TABLE tbm_sessions ADD CONSTRAINT tbm_sessions_leader_id_foreign FOREIGN KEY (leader_id) REFERENCES workers(worker_id) ON DELETE CASCADE');
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* daily_work_reports 테이블에 TBM 연동 필드 추가
|
||||
* - TBM 세션 및 팀 배정과 연결
|
||||
* - 작업 시간 및 오류 시간 추적
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await knex.schema.table('daily_work_reports', (table) => {
|
||||
// TBM 연동 필드
|
||||
table.integer('tbm_session_id').unsigned().nullable()
|
||||
.comment('연결된 TBM 세션 ID');
|
||||
table.integer('tbm_assignment_id').unsigned().nullable()
|
||||
.comment('연결된 TBM 팀 배정 ID');
|
||||
|
||||
// 작업 시간 추적
|
||||
table.time('start_time').nullable()
|
||||
.comment('작업 시작 시간');
|
||||
table.time('end_time').nullable()
|
||||
.comment('작업 종료 시간');
|
||||
table.decimal('total_hours', 5, 2).nullable()
|
||||
.comment('총 작업 시간');
|
||||
table.decimal('regular_hours', 5, 2).nullable()
|
||||
.comment('정규 작업 시간 (총 시간 - 오류 시간)');
|
||||
table.decimal('error_hours', 5, 2).nullable()
|
||||
.comment('부적합 사항 처리 시간');
|
||||
|
||||
// 외래 키 제약조건
|
||||
table.foreign('tbm_session_id')
|
||||
.references('session_id')
|
||||
.inTable('tbm_sessions')
|
||||
.onDelete('SET NULL');
|
||||
|
||||
table.foreign('tbm_assignment_id')
|
||||
.references('assignment_id')
|
||||
.inTable('tbm_team_assignments')
|
||||
.onDelete('SET NULL');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.table('daily_work_reports', (table) => {
|
||||
// 외래 키 제약조건 삭제
|
||||
table.dropForeign('tbm_session_id');
|
||||
table.dropForeign('tbm_assignment_id');
|
||||
|
||||
// 컬럼 삭제
|
||||
table.dropColumn('tbm_session_id');
|
||||
table.dropColumn('tbm_assignment_id');
|
||||
table.dropColumn('start_time');
|
||||
table.dropColumn('end_time');
|
||||
table.dropColumn('total_hours');
|
||||
table.dropColumn('regular_hours');
|
||||
table.dropColumn('error_hours');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 현재 사용 중인 페이지를 pages 테이블에 업데이트
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 기존 페이지 모두 삭제
|
||||
await knex('pages').del();
|
||||
|
||||
// 현재 사용 중인 페이지들을 등록
|
||||
await knex('pages').insert([
|
||||
// 공통 페이지
|
||||
{
|
||||
page_key: 'dashboard',
|
||||
page_name: '대시보드',
|
||||
page_path: '/pages/dashboard.html',
|
||||
category: 'common',
|
||||
description: '전체 현황 대시보드',
|
||||
is_admin_only: 0,
|
||||
display_order: 1
|
||||
},
|
||||
|
||||
// 작업 관련 페이지
|
||||
{
|
||||
page_key: 'work.tbm',
|
||||
page_name: 'TBM',
|
||||
page_path: '/pages/work/tbm.html',
|
||||
category: 'work',
|
||||
description: 'TBM (Tool Box Meeting) 관리',
|
||||
is_admin_only: 0,
|
||||
display_order: 10
|
||||
},
|
||||
{
|
||||
page_key: 'work.report_create',
|
||||
page_name: '작업보고서 작성',
|
||||
page_path: '/pages/work/report-create.html',
|
||||
category: 'work',
|
||||
description: '일일 작업보고서 작성',
|
||||
is_admin_only: 0,
|
||||
display_order: 11
|
||||
},
|
||||
{
|
||||
page_key: 'work.report_view',
|
||||
page_name: '작업보고서 조회',
|
||||
page_path: '/pages/work/report-view.html',
|
||||
category: 'work',
|
||||
description: '작업보고서 조회 및 검색',
|
||||
is_admin_only: 0,
|
||||
display_order: 12
|
||||
},
|
||||
{
|
||||
page_key: 'work.analysis',
|
||||
page_name: '작업 분석',
|
||||
page_path: '/pages/work/analysis.html',
|
||||
category: 'work',
|
||||
description: '작업 통계 및 분석',
|
||||
is_admin_only: 0,
|
||||
display_order: 13
|
||||
},
|
||||
|
||||
// Admin 페이지
|
||||
{
|
||||
page_key: 'admin.accounts',
|
||||
page_name: '계정 관리',
|
||||
page_path: '/pages/admin/accounts.html',
|
||||
category: 'admin',
|
||||
description: '사용자 계정 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 20
|
||||
},
|
||||
{
|
||||
page_key: 'admin.page_access',
|
||||
page_name: '페이지 권한 관리',
|
||||
page_path: '/pages/admin/page-access.html',
|
||||
category: 'admin',
|
||||
description: '사용자별 페이지 접근 권한 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 21
|
||||
},
|
||||
{
|
||||
page_key: 'admin.workers',
|
||||
page_name: '작업자 관리',
|
||||
page_path: '/pages/admin/workers.html',
|
||||
category: 'admin',
|
||||
description: '작업자 정보 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 22
|
||||
},
|
||||
{
|
||||
page_key: 'admin.projects',
|
||||
page_name: '프로젝트 관리',
|
||||
page_path: '/pages/admin/projects.html',
|
||||
category: 'admin',
|
||||
description: '프로젝트 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 23
|
||||
},
|
||||
{
|
||||
page_key: 'admin.workplaces',
|
||||
page_name: '작업장 관리',
|
||||
page_path: '/pages/admin/workplaces.html',
|
||||
category: 'admin',
|
||||
description: '작업장소 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 24
|
||||
},
|
||||
{
|
||||
page_key: 'admin.codes',
|
||||
page_name: '코드 관리',
|
||||
page_path: '/pages/admin/codes.html',
|
||||
category: 'admin',
|
||||
description: '시스템 코드 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 25
|
||||
},
|
||||
{
|
||||
page_key: 'admin.tasks',
|
||||
page_name: '작업 관리',
|
||||
page_path: '/pages/admin/tasks.html',
|
||||
category: 'admin',
|
||||
description: '작업 유형 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 26
|
||||
},
|
||||
|
||||
// 프로필 페이지
|
||||
{
|
||||
page_key: 'profile.info',
|
||||
page_name: '내 정보',
|
||||
page_path: '/pages/profile/info.html',
|
||||
category: 'profile',
|
||||
description: '내 프로필 정보',
|
||||
is_admin_only: 0,
|
||||
display_order: 30
|
||||
},
|
||||
{
|
||||
page_key: 'profile.password',
|
||||
page_name: '비밀번호 변경',
|
||||
page_path: '/pages/profile/password.html',
|
||||
category: 'profile',
|
||||
description: '비밀번호 변경',
|
||||
is_admin_only: 0,
|
||||
display_order: 31
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('✅ 현재 사용 중인 페이지 목록 업데이트 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('pages').del();
|
||||
console.log('✅ 페이지 목록 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Migration: Create vacation_requests table
|
||||
* Purpose: Track vacation request workflow (request, approval/rejection)
|
||||
* Date: 2026-01-29
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Create vacation_requests table
|
||||
await knex.schema.createTable('vacation_requests', (table) => {
|
||||
table.increments('request_id').primary().comment('휴가 신청 ID');
|
||||
|
||||
// 작업자 정보
|
||||
table.integer('worker_id').notNullable().comment('작업자 ID');
|
||||
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
|
||||
|
||||
// 휴가 정보
|
||||
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
|
||||
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
|
||||
|
||||
table.date('start_date').notNullable().comment('휴가 시작일');
|
||||
table.date('end_date').notNullable().comment('휴가 종료일');
|
||||
table.decimal('days_used', 4, 1).notNullable().comment('사용 일수 (0.5일 단위)');
|
||||
|
||||
table.text('reason').nullable().comment('휴가 사유');
|
||||
|
||||
// 신청 및 승인 정보
|
||||
table.enum('status', ['pending', 'approved', 'rejected'])
|
||||
.notNullable()
|
||||
.defaultTo('pending')
|
||||
.comment('승인 상태: pending(대기), approved(승인), rejected(거부)');
|
||||
|
||||
table.integer('requested_by').notNullable().comment('신청자 user_id');
|
||||
table.foreign('requested_by').references('user_id').inTable('users').onDelete('RESTRICT');
|
||||
|
||||
table.integer('reviewed_by').nullable().comment('승인/거부자 user_id');
|
||||
table.foreign('reviewed_by').references('user_id').inTable('users').onDelete('SET NULL');
|
||||
|
||||
table.timestamp('reviewed_at').nullable().comment('승인/거부 일시');
|
||||
table.text('review_note').nullable().comment('승인/거부 메모');
|
||||
|
||||
// 타임스탬프
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now()).comment('신청 일시');
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now()).comment('수정 일시');
|
||||
|
||||
// 인덱스
|
||||
table.index('worker_id', 'idx_vacation_requests_worker');
|
||||
table.index('status', 'idx_vacation_requests_status');
|
||||
table.index(['start_date', 'end_date'], 'idx_vacation_requests_dates');
|
||||
});
|
||||
|
||||
console.log('✅ vacation_requests 테이블 생성 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('vacation_requests');
|
||||
console.log('✅ vacation_requests 테이블 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Migration: Register attendance management pages
|
||||
* Purpose: Add 4 new pages to pages table for attendance management system
|
||||
* Date: 2026-01-29
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 페이지 등록 (실제 pages 테이블 컬럼에 맞춤)
|
||||
await knex('pages').insert([
|
||||
{
|
||||
page_key: 'daily-attendance',
|
||||
page_name: '일일 출퇴근 입력',
|
||||
page_path: '/pages/common/daily-attendance.html',
|
||||
description: '일일 출퇴근 기록 입력 페이지 (관리자/조장)',
|
||||
category: 'common',
|
||||
is_admin_only: false,
|
||||
display_order: 50
|
||||
},
|
||||
{
|
||||
page_key: 'monthly-attendance',
|
||||
page_name: '월별 출퇴근 현황',
|
||||
page_path: '/pages/common/monthly-attendance.html',
|
||||
description: '월별 출퇴근 현황 조회 페이지',
|
||||
category: 'common',
|
||||
is_admin_only: false,
|
||||
display_order: 51
|
||||
},
|
||||
{
|
||||
page_key: 'vacation-management',
|
||||
page_name: '휴가 관리',
|
||||
page_path: '/pages/common/vacation-management.html',
|
||||
description: '휴가 신청 및 승인 관리 페이지',
|
||||
category: 'common',
|
||||
is_admin_only: false,
|
||||
display_order: 52
|
||||
},
|
||||
{
|
||||
page_key: 'attendance-report-comparison',
|
||||
page_name: '출퇴근-작업보고서 대조',
|
||||
page_path: '/pages/admin/attendance-report-comparison.html',
|
||||
description: '출퇴근 기록과 작업보고서 대조 페이지 (관리자)',
|
||||
category: 'admin',
|
||||
is_admin_only: true,
|
||||
display_order: 120
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('✅ 출퇴근 관리 페이지 4개 등록 완료');
|
||||
|
||||
// Admin 사용자(user_id=1)에게 페이지 접근 권한 부여
|
||||
const adminUserId = 1;
|
||||
const pages = await knex('pages')
|
||||
.whereIn('page_key', [
|
||||
'daily-attendance',
|
||||
'monthly-attendance',
|
||||
'vacation-management',
|
||||
'attendance-report-comparison'
|
||||
])
|
||||
.select('id');
|
||||
|
||||
const accessRecords = pages.map(page => ({
|
||||
user_id: adminUserId,
|
||||
page_id: page.id,
|
||||
can_access: true,
|
||||
granted_by: adminUserId
|
||||
}));
|
||||
|
||||
await knex('user_page_access').insert(accessRecords);
|
||||
console.log('✅ Admin 사용자에게 출퇴근 관리 페이지 접근 권한 부여 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 페이지 삭제 (user_page_access는 FK CASCADE로 자동 삭제됨)
|
||||
await knex('pages')
|
||||
.whereIn('page_key', [
|
||||
'daily-attendance',
|
||||
'monthly-attendance',
|
||||
'vacation-management',
|
||||
'attendance-report-comparison'
|
||||
])
|
||||
.delete();
|
||||
|
||||
console.log('✅ 출퇴근 관리 페이지 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 출퇴근 출근 여부 필드 추가
|
||||
* 아침 출근 확인용 간단한 필드
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 컬럼 존재 여부 확인
|
||||
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
|
||||
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table('daily_attendance_records', (table) => {
|
||||
// 출근 여부 (아침에 체크)
|
||||
table.boolean('is_present').defaultTo(true).comment('출근 여부');
|
||||
});
|
||||
|
||||
// 기존 데이터는 모두 출근으로 처리
|
||||
await knex('daily_attendance_records')
|
||||
.whereNotNull('id')
|
||||
.update({ is_present: true });
|
||||
|
||||
console.log('✅ is_present 컬럼 추가 완료');
|
||||
} else {
|
||||
console.log('⏭️ is_present 컬럼이 이미 존재합니다');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('daily_attendance_records', 'is_present');
|
||||
|
||||
if (hasColumn) {
|
||||
await knex.schema.table('daily_attendance_records', (table) => {
|
||||
table.dropColumn('is_present');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 휴가 관리 페이지 분리 및 등록
|
||||
* - 기존 vacation-management.html을 2개 페이지로 분리
|
||||
* - vacation-request.html: 작업자 휴가 신청 및 본인 내역 확인
|
||||
* - vacation-management.html: 관리자 휴가 승인/직접입력/전체내역 (3개 탭)
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 기존 vacation-management 페이지 삭제
|
||||
await knex('pages')
|
||||
.where('page_key', 'vacation-management')
|
||||
.del();
|
||||
|
||||
// 새로운 휴가 관리 페이지 2개 등록
|
||||
await knex('pages').insert([
|
||||
{
|
||||
page_key: 'vacation-request',
|
||||
page_name: '휴가 신청',
|
||||
page_path: '/pages/common/vacation-request.html',
|
||||
category: 'common',
|
||||
description: '작업자가 휴가를 신청하고 본인의 신청 내역을 확인하는 페이지',
|
||||
is_admin_only: 0,
|
||||
display_order: 51
|
||||
},
|
||||
{
|
||||
page_key: 'vacation-management',
|
||||
page_name: '휴가 관리',
|
||||
page_path: '/pages/common/vacation-management.html',
|
||||
category: 'common',
|
||||
description: '관리자가 휴가 승인, 직접 입력, 전체 내역을 관리하는 페이지',
|
||||
is_admin_only: 1,
|
||||
display_order: 52
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('✅ 휴가 관리 페이지 분리 완료 (기존 1개 → 신규 2개)');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 새로운 페이지 삭제
|
||||
await knex('pages')
|
||||
.whereIn('page_key', ['vacation-request', 'vacation-management'])
|
||||
.del();
|
||||
|
||||
// 기존 vacation-management 페이지 복원
|
||||
await knex('pages').insert({
|
||||
page_key: 'vacation-management',
|
||||
page_name: '휴가 관리',
|
||||
page_path: '/pages/common/vacation-management.html.old',
|
||||
category: 'common',
|
||||
description: '휴가 신청 및 승인 관리',
|
||||
is_admin_only: 0,
|
||||
display_order: 50
|
||||
});
|
||||
|
||||
console.log('✅ 휴가 관리 페이지 롤백 완료');
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* vacation_types 테이블 확장
|
||||
* - 특별 휴가 유형 추가 기능
|
||||
* - 차감 우선순위 관리
|
||||
* - 시스템 기본 휴가 보호
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// vacation_types 테이블 확장
|
||||
await knex.schema.table('vacation_types', (table) => {
|
||||
table.boolean('is_special').defaultTo(false).comment('특별 휴가 여부 (장기근속, 출산 등)');
|
||||
table.integer('priority').defaultTo(99).comment('차감 우선순위 (낮을수록 먼저 차감)');
|
||||
table.text('description').nullable().comment('휴가 설명');
|
||||
table.boolean('is_system').defaultTo(true).comment('시스템 기본 휴가 (삭제 불가)');
|
||||
});
|
||||
|
||||
// 기존 휴가 유형에 우선순위 설정
|
||||
await knex('vacation_types').where('type_code', 'ANNUAL').update({
|
||||
priority: 10,
|
||||
is_system: true,
|
||||
description: '근로기준법에 따른 연차 유급휴가'
|
||||
});
|
||||
|
||||
await knex('vacation_types').where('type_code', 'HALF_ANNUAL').update({
|
||||
priority: 10,
|
||||
is_system: true,
|
||||
description: '반일 연차 (0.5일)'
|
||||
});
|
||||
|
||||
await knex('vacation_types').where('type_code', 'SICK').update({
|
||||
priority: 20,
|
||||
is_system: true,
|
||||
description: '병가'
|
||||
});
|
||||
|
||||
await knex('vacation_types').where('type_code', 'SPECIAL').update({
|
||||
priority: 0,
|
||||
is_system: true,
|
||||
description: '경조사 휴가 (무급)'
|
||||
});
|
||||
|
||||
console.log('✅ vacation_types 테이블 확장 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 컬럼 삭제
|
||||
await knex.schema.table('vacation_types', (table) => {
|
||||
table.dropColumn('is_special');
|
||||
table.dropColumn('priority');
|
||||
table.dropColumn('description');
|
||||
table.dropColumn('is_system');
|
||||
});
|
||||
|
||||
console.log('✅ vacation_types 테이블 롤백 완료');
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* vacation_balance_details 테이블 생성 및 데이터 마이그레이션
|
||||
* - 작업자별, 휴가 유형별, 연도별 휴가 잔액 관리
|
||||
* - 기존 worker_vacation_balance 데이터 이관
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// vacation_balance_details 테이블 생성
|
||||
await knex.schema.createTable('vacation_balance_details', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('worker_id').notNullable().comment('작업자 ID');
|
||||
table.integer('vacation_type_id').unsigned().notNullable().comment('휴가 유형 ID');
|
||||
table.integer('year').notNullable().comment('연도');
|
||||
table.decimal('total_days', 4, 1).defaultTo(0).comment('총 발생 일수');
|
||||
table.decimal('used_days', 4, 1).defaultTo(0).comment('사용 일수');
|
||||
table.text('notes').nullable().comment('비고');
|
||||
table.integer('created_by').notNullable().comment('생성자 ID');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 인덱스
|
||||
table.unique(['worker_id', 'vacation_type_id', 'year'], 'unique_worker_vacation_year');
|
||||
table.index(['worker_id', 'year'], 'idx_worker_year');
|
||||
table.index('vacation_type_id', 'idx_vacation_type');
|
||||
|
||||
// 외래키
|
||||
table.foreign('worker_id').references('worker_id').inTable('workers').onDelete('CASCADE');
|
||||
table.foreign('vacation_type_id').references('id').inTable('vacation_types').onDelete('RESTRICT');
|
||||
table.foreign('created_by').references('user_id').inTable('users');
|
||||
});
|
||||
|
||||
// remaining_days를 generated column으로 추가 (Raw SQL)
|
||||
await knex.raw(`
|
||||
ALTER TABLE vacation_balance_details
|
||||
ADD COLUMN remaining_days DECIMAL(4,1)
|
||||
GENERATED ALWAYS AS (total_days - used_days) STORED
|
||||
COMMENT '잔여 일수'
|
||||
`);
|
||||
|
||||
console.log('✅ vacation_balance_details 테이블 생성 완료');
|
||||
|
||||
// 기존 worker_vacation_balance 데이터를 vacation_balance_details로 마이그레이션
|
||||
const existingBalances = await knex('worker_vacation_balance').select('*');
|
||||
|
||||
if (existingBalances.length > 0) {
|
||||
// ANNUAL 휴가 유형 ID 조회
|
||||
const annualType = await knex('vacation_types')
|
||||
.where('type_code', 'ANNUAL')
|
||||
.first();
|
||||
|
||||
if (!annualType) {
|
||||
throw new Error('ANNUAL 휴가 유형을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 관리자 사용자 ID 조회 (created_by 용)
|
||||
// role_id 1 = System Admin, 2 = Admin
|
||||
const adminUser = await knex('users')
|
||||
.whereIn('role_id', [1, 2])
|
||||
.first();
|
||||
|
||||
const createdById = adminUser ? adminUser.user_id : 1;
|
||||
|
||||
// 데이터 변환 및 삽입
|
||||
const balanceDetails = existingBalances.map(balance => ({
|
||||
worker_id: balance.worker_id,
|
||||
vacation_type_id: annualType.id,
|
||||
year: balance.year,
|
||||
total_days: balance.total_annual_leave || 0,
|
||||
used_days: balance.used_annual_leave || 0,
|
||||
notes: 'Migrated from worker_vacation_balance',
|
||||
created_by: createdById,
|
||||
created_at: balance.created_at,
|
||||
updated_at: balance.updated_at
|
||||
}));
|
||||
|
||||
await knex('vacation_balance_details').insert(balanceDetails);
|
||||
|
||||
console.log(`✅ ${balanceDetails.length}건의 기존 휴가 데이터 마이그레이션 완료`);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// vacation_balance_details 테이블 삭제
|
||||
await knex.schema.dropTableIfExists('vacation_balance_details');
|
||||
|
||||
console.log('✅ vacation_balance_details 테이블 롤백 완료');
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 새로운 휴가 관리 페이지 등록
|
||||
* - annual-vacation-overview: 연간 연차 현황 (차트)
|
||||
* - vacation-allocation: 휴가 발생 입력 및 관리
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
await knex('pages').insert([
|
||||
{
|
||||
page_key: 'annual-vacation-overview',
|
||||
page_name: '연간 연차 현황',
|
||||
page_path: '/pages/common/annual-vacation-overview.html',
|
||||
category: 'common',
|
||||
description: '모든 작업자의 연간 연차 현황을 차트로 시각화',
|
||||
is_admin_only: 1,
|
||||
display_order: 54
|
||||
},
|
||||
{
|
||||
page_key: 'vacation-allocation',
|
||||
page_name: '휴가 발생 입력',
|
||||
page_path: '/pages/common/vacation-allocation.html',
|
||||
category: 'common',
|
||||
description: '작업자별 휴가 발생 입력 및 특별 휴가 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 55
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('✅ 휴가 관리 신규 페이지 2개 등록 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('pages')
|
||||
.whereIn('page_key', ['annual-vacation-overview', 'vacation-allocation'])
|
||||
.del();
|
||||
|
||||
console.log('✅ 휴가 관리 페이지 롤백 완료');
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 마이그레이션: 출입 신청 및 안전교육 시스템
|
||||
* - 방문 목적 타입 테이블
|
||||
* - 출입 신청 테이블
|
||||
* - 안전교육 기록 테이블
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. 방문 목적 타입 테이블 생성
|
||||
await knex.schema.createTable('visit_purpose_types', function(table) {
|
||||
table.increments('purpose_id').primary().comment('방문 목적 ID');
|
||||
table.string('purpose_name', 100).notNullable().comment('방문 목적명');
|
||||
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// 초기 데이터 삽입
|
||||
await knex('visit_purpose_types').insert([
|
||||
{ purpose_name: '외주작업', display_order: 1, is_active: true },
|
||||
{ purpose_name: '검사', display_order: 2, is_active: true },
|
||||
{ purpose_name: '견학', display_order: 3, is_active: true },
|
||||
{ purpose_name: '기타', display_order: 99, is_active: true }
|
||||
]);
|
||||
|
||||
// 2. 출입 신청 테이블 생성
|
||||
await knex.schema.createTable('workplace_visit_requests', function(table) {
|
||||
table.increments('request_id').primary().comment('신청 ID');
|
||||
|
||||
// 신청자 정보
|
||||
table.integer('requester_id').notNullable().comment('신청자 user_id');
|
||||
|
||||
// 방문자 정보
|
||||
table.string('visitor_company', 200).notNullable().comment('방문자 소속 (회사명 또는 "일용직")');
|
||||
table.integer('visitor_count').defaultTo(1).comment('방문 인원');
|
||||
|
||||
// 방문 장소
|
||||
table.integer('category_id').unsigned().notNullable().comment('방문 구역 (공장)');
|
||||
table.integer('workplace_id').unsigned().notNullable().comment('방문 작업장');
|
||||
|
||||
// 방문 일시
|
||||
table.date('visit_date').notNullable().comment('방문 날짜');
|
||||
table.time('visit_time').notNullable().comment('방문 시간');
|
||||
|
||||
// 방문 목적
|
||||
table.integer('purpose_id').unsigned().notNullable().comment('방문 목적 ID');
|
||||
table.text('notes').nullable().comment('비고');
|
||||
|
||||
// 상태 관리
|
||||
table.enum('status', ['pending', 'approved', 'rejected', 'training_completed'])
|
||||
.defaultTo('pending')
|
||||
.comment('신청 상태');
|
||||
|
||||
// 승인 정보
|
||||
table.integer('approved_by').nullable().comment('승인자 user_id');
|
||||
table.timestamp('approved_at').nullable().comment('승인 시간');
|
||||
table.text('rejection_reason').nullable().comment('반려 사유');
|
||||
|
||||
// 타임스탬프
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 외래키
|
||||
table.foreign('requester_id')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('category_id')
|
||||
.references('category_id')
|
||||
.inTable('workplace_categories')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('workplace_id')
|
||||
.references('workplace_id')
|
||||
.inTable('workplaces')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('purpose_id')
|
||||
.references('purpose_id')
|
||||
.inTable('visit_purpose_types')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('approved_by')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
// 인덱스
|
||||
table.index('visit_date', 'idx_visit_date');
|
||||
table.index('status', 'idx_status');
|
||||
table.index(['visit_date', 'status'], 'idx_visit_date_status');
|
||||
});
|
||||
|
||||
// 3. 안전교육 기록 테이블 생성
|
||||
await knex.schema.createTable('safety_training_records', function(table) {
|
||||
table.increments('training_id').primary().comment('교육 기록 ID');
|
||||
|
||||
table.integer('request_id').unsigned().notNullable().comment('출입 신청 ID');
|
||||
|
||||
// 교육 진행 정보
|
||||
table.integer('trainer_id').notNullable().comment('교육 진행자 user_id');
|
||||
table.date('training_date').notNullable().comment('교육 날짜');
|
||||
table.time('training_start_time').notNullable().comment('교육 시작 시간');
|
||||
table.time('training_end_time').nullable().comment('교육 종료 시간');
|
||||
|
||||
// 교육 내용
|
||||
table.text('training_topics').nullable().comment('교육 내용 (JSON 배열)');
|
||||
|
||||
// 서명 데이터 (Base64 이미지)
|
||||
table.text('signature_data', 'longtext').nullable().comment('교육 이수자 서명 (Base64 PNG)');
|
||||
|
||||
// 완료 정보
|
||||
table.timestamp('completed_at').nullable().comment('교육 완료 시간');
|
||||
|
||||
// 타임스탬프
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 외래키
|
||||
table.foreign('request_id')
|
||||
.references('request_id')
|
||||
.inTable('workplace_visit_requests')
|
||||
.onDelete('CASCADE')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('trainer_id')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
// 인덱스
|
||||
table.index('training_date', 'idx_training_date');
|
||||
table.index('request_id', 'idx_request_id');
|
||||
});
|
||||
|
||||
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 생성 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 역순으로 테이블 삭제
|
||||
await knex.schema.dropTableIfExists('safety_training_records');
|
||||
await knex.schema.dropTableIfExists('workplace_visit_requests');
|
||||
await knex.schema.dropTableIfExists('visit_purpose_types');
|
||||
|
||||
console.log('✅ 출입 신청 및 안전교육 시스템 테이블 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 마이그레이션: 출입 신청 및 안전관리 페이지 등록
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. 출입 신청 페이지 등록
|
||||
await knex('pages').insert({
|
||||
page_key: 'visit-request',
|
||||
page_name: '출입 신청',
|
||||
page_path: '/pages/work/visit-request.html',
|
||||
category: 'work',
|
||||
description: '작업장 출입 신청 및 안전교육 신청',
|
||||
is_admin_only: 0,
|
||||
display_order: 15
|
||||
});
|
||||
|
||||
// 2. 안전관리 대시보드 페이지 등록
|
||||
await knex('pages').insert({
|
||||
page_key: 'safety-management',
|
||||
page_name: '안전관리',
|
||||
page_path: '/pages/admin/safety-management.html',
|
||||
category: 'admin',
|
||||
description: '출입 신청 승인 및 안전교육 관리',
|
||||
is_admin_only: 0,
|
||||
display_order: 60
|
||||
});
|
||||
|
||||
// 3. 안전교육 진행 페이지 등록
|
||||
await knex('pages').insert({
|
||||
page_key: 'safety-training-conduct',
|
||||
page_name: '안전교육 진행',
|
||||
page_path: '/pages/admin/safety-training-conduct.html',
|
||||
category: 'admin',
|
||||
description: '안전교육 실시 및 서명 관리',
|
||||
is_admin_only: 0,
|
||||
display_order: 61
|
||||
});
|
||||
|
||||
console.log('✅ 출입 신청 및 안전관리 페이지 등록 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('pages').whereIn('page_key', [
|
||||
'visit-request',
|
||||
'safety-management',
|
||||
'safety-training-conduct'
|
||||
]).delete();
|
||||
|
||||
console.log('✅ 출입 신청 및 안전관리 페이지 삭제 완료');
|
||||
};
|
||||
@@ -297,7 +297,7 @@ class AttendanceModel {
|
||||
|
||||
// 휴가 유형 정보 조회
|
||||
const [vacationTypes] = await db.execute(
|
||||
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
|
||||
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
|
||||
[vacationType]
|
||||
);
|
||||
|
||||
@@ -391,7 +391,7 @@ class AttendanceModel {
|
||||
static async getVacationTypes() {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY hours_deduction DESC'
|
||||
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY deduct_days DESC'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -458,6 +458,68 @@ class AttendanceModel {
|
||||
const [rows] = await db.execute(query, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 출근 체크 기록 생성 또는 업데이트
|
||||
static async upsertCheckin(checkinData) {
|
||||
const db = await getDb();
|
||||
const { worker_id, record_date, is_present } = checkinData;
|
||||
|
||||
// 해당 날짜에 기록이 있는지 확인
|
||||
const [existing] = await db.execute(
|
||||
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
|
||||
[worker_id, record_date]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 업데이트
|
||||
await db.execute(
|
||||
'UPDATE daily_attendance_records SET is_present = ? WHERE id = ?',
|
||||
[is_present, existing[0].id]
|
||||
);
|
||||
return existing[0].id;
|
||||
} else {
|
||||
// 새로 생성 (기본값으로)
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO daily_attendance_records
|
||||
(worker_id, record_date, is_present, attendance_type_id, created_by)
|
||||
VALUES (?, ?, ?, 1, 1)`,
|
||||
[worker_id, record_date, is_present]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 날짜의 출근 체크 목록 조회 (휴가 정보 포함)
|
||||
static async getCheckinList(date) {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
w.worker_id,
|
||||
w.worker_name,
|
||||
w.job_type,
|
||||
w.employment_status,
|
||||
COALESCE(dar.is_present, TRUE) as is_present,
|
||||
dar.id as record_id,
|
||||
vr.request_id as vacation_request_id,
|
||||
vr.status as vacation_status,
|
||||
vt.type_name as vacation_type_name,
|
||||
vt.type_code as vacation_type_code,
|
||||
vr.days_used as vacation_days
|
||||
FROM workers w
|
||||
LEFT JOIN daily_attendance_records dar
|
||||
ON w.worker_id = dar.worker_id AND dar.record_date = ?
|
||||
LEFT JOIN vacation_requests vr
|
||||
ON w.worker_id = vr.worker_id
|
||||
AND ? BETWEEN vr.start_date AND vr.end_date
|
||||
AND vr.status = 'approved'
|
||||
LEFT JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||
WHERE w.employment_status = 'employed'
|
||||
ORDER BY w.worker_name
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, [date, date]);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AttendanceModel;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// models/tbmModel.js - TBM 시스템 모델
|
||||
const db = require('../db/connection');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const TbmModel = {
|
||||
// ==================== TBM 세션 관련 ====================
|
||||
@@ -7,65 +7,90 @@ const TbmModel = {
|
||||
/**
|
||||
* TBM 세션 생성
|
||||
*/
|
||||
createSession: (sessionData, callback) => {
|
||||
createSession: async (sessionData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO tbm_sessions
|
||||
(session_date, leader_id, project_id, work_type_id, task_id, work_location,
|
||||
work_description, safety_notes, start_time, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(session_date, leader_id, project_id, work_type_id, task_id, work_location, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const values = [
|
||||
sessionData.session_date,
|
||||
sessionData.leader_id,
|
||||
sessionData.project_id,
|
||||
sessionData.work_type_id,
|
||||
sessionData.task_id,
|
||||
sessionData.work_location,
|
||||
sessionData.work_description,
|
||||
sessionData.safety_notes,
|
||||
sessionData.start_time,
|
||||
sessionData.project_id || null,
|
||||
sessionData.work_type_id || null,
|
||||
sessionData.task_id || null,
|
||||
sessionData.work_location || null,
|
||||
sessionData.created_by
|
||||
];
|
||||
|
||||
db.query(sql, values, callback);
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 날짜의 TBM 세션 조회
|
||||
*/
|
||||
getSessionsByDate: (date, callback) => {
|
||||
getSessionsByDate: async (date, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
s.*,
|
||||
w.worker_name as leader_name,
|
||||
w.job_type as leader_job_type,
|
||||
p.project_name,
|
||||
p.job_no,
|
||||
wt.name as work_type_name,
|
||||
wt.category as work_type_category,
|
||||
t.task_name,
|
||||
u.username as created_by_username,
|
||||
COUNT(DISTINCT ta.worker_id) as team_member_count
|
||||
u.name as created_by_name,
|
||||
COUNT(DISTINCT ta.worker_id) as team_member_count,
|
||||
-- 첫 번째 팀원의 작업 정보 가져오기
|
||||
first_ta.project_id,
|
||||
first_ta.work_type_id,
|
||||
first_ta.task_id,
|
||||
first_ta.workplace_id,
|
||||
first_p.project_name,
|
||||
first_wt.name as work_type_name,
|
||||
first_t.task_name,
|
||||
first_wp.workplace_name as work_location
|
||||
FROM tbm_sessions s
|
||||
LEFT JOIN workers w ON s.leader_id = w.worker_id
|
||||
LEFT JOIN projects p ON s.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON s.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON s.task_id = t.task_id
|
||||
LEFT JOIN users u ON s.created_by = u.user_id
|
||||
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
|
||||
-- 첫 번째 팀원 정보 (가장 먼저 등록된 작업)
|
||||
LEFT JOIN (
|
||||
SELECT * FROM tbm_team_assignments
|
||||
WHERE (session_id, assignment_id) IN (
|
||||
SELECT session_id, MIN(assignment_id)
|
||||
FROM tbm_team_assignments
|
||||
GROUP BY session_id
|
||||
)
|
||||
) first_ta ON s.session_id = first_ta.session_id
|
||||
LEFT JOIN projects first_p ON first_ta.project_id = first_p.project_id
|
||||
LEFT JOIN work_types first_wt ON first_ta.work_type_id = first_wt.id
|
||||
LEFT JOIN tasks first_t ON first_ta.task_id = first_t.task_id
|
||||
LEFT JOIN workplaces first_wp ON first_ta.workplace_id = first_wp.workplace_id
|
||||
WHERE s.session_date = ?
|
||||
GROUP BY s.session_id
|
||||
ORDER BY s.start_time DESC
|
||||
ORDER BY s.session_id DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [date], callback);
|
||||
const [rows] = await db.query(sql, [date]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션 상세 조회
|
||||
*/
|
||||
getSessionById: (sessionId, callback) => {
|
||||
getSessionById: async (sessionId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
s.*,
|
||||
@@ -90,20 +115,24 @@ const TbmModel = {
|
||||
WHERE s.session_id = ?
|
||||
`;
|
||||
|
||||
db.query(sql, [sessionId], callback);
|
||||
const [rows] = await db.query(sql, [sessionId]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션 수정
|
||||
*/
|
||||
updateSession: (sessionId, sessionData, callback) => {
|
||||
updateSession: async (sessionId, sessionData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
UPDATE tbm_sessions
|
||||
SET
|
||||
project_id = ?,
|
||||
work_location = ?,
|
||||
work_description = ?,
|
||||
safety_notes = ?,
|
||||
status = ?,
|
||||
updated_at = NOW()
|
||||
WHERE session_id = ?
|
||||
@@ -112,19 +141,23 @@ const TbmModel = {
|
||||
const values = [
|
||||
sessionData.project_id,
|
||||
sessionData.work_location,
|
||||
sessionData.work_description,
|
||||
sessionData.safety_notes,
|
||||
sessionData.status,
|
||||
sessionId
|
||||
];
|
||||
|
||||
db.query(sql, values, callback);
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션 완료 처리
|
||||
*/
|
||||
completeSession: (sessionId, endTime, callback) => {
|
||||
completeSession: async (sessionId, endTime, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
UPDATE tbm_sessions
|
||||
SET
|
||||
@@ -134,24 +167,36 @@ const TbmModel = {
|
||||
WHERE session_id = ?
|
||||
`;
|
||||
|
||||
db.query(sql, [endTime, sessionId], callback);
|
||||
const [result] = await db.query(sql, [endTime, sessionId]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 팀 구성 관련 ====================
|
||||
|
||||
/**
|
||||
* 팀원 추가
|
||||
* 팀원 추가 (작업자별 상세 정보 포함)
|
||||
*/
|
||||
addTeamMember: (assignmentData, callback) => {
|
||||
addTeamMember: async (assignmentData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO tbm_team_assignments
|
||||
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
|
||||
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
assigned_role = VALUES(assigned_role),
|
||||
work_detail = VALUES(work_detail),
|
||||
is_present = VALUES(is_present),
|
||||
absence_reason = VALUES(absence_reason)
|
||||
absence_reason = VALUES(absence_reason),
|
||||
project_id = VALUES(project_id),
|
||||
work_type_id = VALUES(work_type_id),
|
||||
task_id = VALUES(task_id),
|
||||
workplace_category_id = VALUES(workplace_category_id),
|
||||
workplace_id = VALUES(workplace_id)
|
||||
`;
|
||||
|
||||
const values = [
|
||||
@@ -160,68 +205,129 @@ const TbmModel = {
|
||||
assignmentData.assigned_role,
|
||||
assignmentData.work_detail,
|
||||
assignmentData.is_present !== undefined ? assignmentData.is_present : true,
|
||||
assignmentData.absence_reason
|
||||
assignmentData.absence_reason,
|
||||
assignmentData.project_id || null,
|
||||
assignmentData.work_type_id || null,
|
||||
assignmentData.task_id || null,
|
||||
assignmentData.workplace_category_id || null,
|
||||
assignmentData.workplace_id || null
|
||||
];
|
||||
|
||||
db.query(sql, values, callback);
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 팀 구성 일괄 추가
|
||||
* 팀 구성 일괄 추가 (작업자별 상세 정보 포함)
|
||||
*/
|
||||
addTeamMembers: (sessionId, members, callback) => {
|
||||
addTeamMembers: async (sessionId, members, callback) => {
|
||||
try {
|
||||
if (!members || members.length === 0) {
|
||||
return callback(null, { affectedRows: 0 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const values = members.map(m => [
|
||||
sessionId,
|
||||
m.worker_id,
|
||||
m.assigned_role || null,
|
||||
m.work_detail || null,
|
||||
m.is_present !== undefined ? m.is_present : true,
|
||||
m.absence_reason || null
|
||||
m.absence_reason || null,
|
||||
m.project_id || null,
|
||||
m.work_type_id || null,
|
||||
m.task_id || null,
|
||||
m.workplace_category_id || null,
|
||||
m.workplace_id || null
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO tbm_team_assignments
|
||||
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason)
|
||||
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
|
||||
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
|
||||
VALUES ?
|
||||
`;
|
||||
|
||||
db.query(sql, [values], callback);
|
||||
const [result] = await db.query(sql, [values]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션의 팀 구성 조회
|
||||
* TBM 세션의 팀 구성 조회 (작업자별 상세 정보 포함)
|
||||
*/
|
||||
getTeamMembers: (sessionId, callback) => {
|
||||
getTeamMembers: async (sessionId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
ta.*,
|
||||
w.worker_name,
|
||||
w.job_type,
|
||||
w.phone_number,
|
||||
w.department
|
||||
w.department,
|
||||
p.project_name,
|
||||
wt.name as work_type_name,
|
||||
t.task_name,
|
||||
wc.category_name AS workplace_category_name,
|
||||
wp.workplace_name
|
||||
FROM tbm_team_assignments ta
|
||||
INNER JOIN workers w ON ta.worker_id = w.worker_id
|
||||
LEFT JOIN projects p ON ta.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON ta.task_id = t.task_id
|
||||
LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id
|
||||
LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id
|
||||
WHERE ta.session_id = ?
|
||||
ORDER BY ta.assigned_at DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [sessionId], callback);
|
||||
const [rows] = await db.query(sql, [sessionId]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 팀원 제거
|
||||
*/
|
||||
removeTeamMember: (sessionId, workerId, callback) => {
|
||||
removeTeamMember: async (sessionId, workerId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
DELETE FROM tbm_team_assignments
|
||||
WHERE session_id = ? AND worker_id = ?
|
||||
`;
|
||||
|
||||
db.query(sql, [sessionId, workerId], callback);
|
||||
const [result] = await db.query(sql, [sessionId, workerId]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 세션의 모든 팀원 삭제
|
||||
*/
|
||||
clearAllTeamMembers: async (sessionId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
DELETE FROM tbm_team_assignments
|
||||
WHERE session_id = ?
|
||||
`;
|
||||
|
||||
const [result] = await db.query(sql, [sessionId]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 안전 체크리스트 관련 ====================
|
||||
@@ -229,7 +335,9 @@ const TbmModel = {
|
||||
/**
|
||||
* 모든 안전 체크 항목 조회
|
||||
*/
|
||||
getAllSafetyChecks: (callback) => {
|
||||
getAllSafetyChecks: async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM tbm_safety_checks
|
||||
@@ -237,13 +345,19 @@ const TbmModel = {
|
||||
ORDER BY check_category, display_order
|
||||
`;
|
||||
|
||||
db.query(sql, callback);
|
||||
const [rows] = await db.query(sql);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 카테고리별 안전 체크 항목 조회
|
||||
*/
|
||||
getSafetyChecksByCategory: (category, callback) => {
|
||||
getSafetyChecksByCategory: async (category, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM tbm_safety_checks
|
||||
@@ -251,13 +365,19 @@ const TbmModel = {
|
||||
ORDER BY display_order
|
||||
`;
|
||||
|
||||
db.query(sql, [category], callback);
|
||||
const [rows] = await db.query(sql, [category]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션의 안전 체크 기록 조회
|
||||
*/
|
||||
getSafetyRecords: (sessionId, callback) => {
|
||||
getSafetyRecords: async (sessionId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
sr.*,
|
||||
@@ -274,13 +394,19 @@ const TbmModel = {
|
||||
ORDER BY sc.check_category, sc.display_order
|
||||
`;
|
||||
|
||||
db.query(sql, [sessionId], callback);
|
||||
const [rows] = await db.query(sql, [sessionId]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 기록 저장/업데이트
|
||||
*/
|
||||
saveSafetyRecord: (recordData, callback) => {
|
||||
saveSafetyRecord: async (recordData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO tbm_safety_records
|
||||
(session_id, check_id, is_checked, notes, checked_by, checked_at)
|
||||
@@ -300,17 +426,23 @@ const TbmModel = {
|
||||
recordData.checked_by
|
||||
];
|
||||
|
||||
db.query(sql, values, callback);
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 일괄 저장
|
||||
*/
|
||||
saveSafetyRecords: (sessionId, records, checkedBy, callback) => {
|
||||
saveSafetyRecords: async (sessionId, records, checkedBy, callback) => {
|
||||
try {
|
||||
if (!records || records.length === 0) {
|
||||
return callback(null, { affectedRows: 0 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const values = records.map(r => [
|
||||
sessionId,
|
||||
r.check_id,
|
||||
@@ -330,7 +462,11 @@ const TbmModel = {
|
||||
checked_at = NOW()
|
||||
`;
|
||||
|
||||
db.query(sql, [values], callback);
|
||||
const [result] = await db.query(sql, [values]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
@@ -338,7 +474,9 @@ const TbmModel = {
|
||||
/**
|
||||
* 작업 인계 생성
|
||||
*/
|
||||
createHandover: (handoverData, callback) => {
|
||||
createHandover: async (handoverData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO team_handovers
|
||||
(session_id, from_leader_id, to_leader_id, handover_date, handover_time,
|
||||
@@ -357,13 +495,19 @@ const TbmModel = {
|
||||
JSON.stringify(handoverData.worker_ids || [])
|
||||
];
|
||||
|
||||
db.query(sql, values, callback);
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업 인계 확인
|
||||
*/
|
||||
confirmHandover: (handoverId, confirmedBy, callback) => {
|
||||
confirmHandover: async (handoverId, confirmedBy, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
UPDATE team_handovers
|
||||
SET
|
||||
@@ -373,13 +517,19 @@ const TbmModel = {
|
||||
WHERE handover_id = ?
|
||||
`;
|
||||
|
||||
db.query(sql, [confirmedBy, handoverId], callback);
|
||||
const [result] = await db.query(sql, [confirmedBy, handoverId]);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 날짜의 작업 인계 목록 조회
|
||||
*/
|
||||
getHandoversByDate: (date, callback) => {
|
||||
getHandoversByDate: async (date, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
h.*,
|
||||
@@ -395,20 +545,25 @@ const TbmModel = {
|
||||
ORDER BY h.handover_time DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [date], callback);
|
||||
const [rows] = await db.query(sql, [date]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 인수자가 받은 미확인 인계 건 조회
|
||||
*/
|
||||
getPendingHandovers: (toLeaderId, callback) => {
|
||||
getPendingHandovers: async (toLeaderId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
h.*,
|
||||
w1.worker_name as from_leader_name,
|
||||
w1.phone_number as from_leader_phone,
|
||||
s.work_location,
|
||||
s.work_description
|
||||
s.work_location
|
||||
FROM team_handovers h
|
||||
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
|
||||
LEFT JOIN tbm_sessions s ON h.session_id = s.session_id
|
||||
@@ -416,7 +571,11 @@ const TbmModel = {
|
||||
ORDER BY h.handover_date DESC, h.handover_time DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [toLeaderId], callback);
|
||||
const [rows] = await db.query(sql, [toLeaderId]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 통계 및 리포트 ====================
|
||||
@@ -424,7 +583,9 @@ const TbmModel = {
|
||||
/**
|
||||
* 특정 기간의 TBM 통계
|
||||
*/
|
||||
getTbmStatistics: (startDate, endDate, callback) => {
|
||||
getTbmStatistics: async (startDate, endDate, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
DATE(session_date) as date,
|
||||
@@ -437,13 +598,19 @@ const TbmModel = {
|
||||
ORDER BY date DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [startDate, endDate], callback);
|
||||
const [rows] = await db.query(sql, [startDate, endDate]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 리더별 TBM 진행 현황
|
||||
*/
|
||||
getLeaderStatistics: (startDate, endDate, callback) => {
|
||||
getLeaderStatistics: async (startDate, endDate, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
SELECT
|
||||
s.leader_id,
|
||||
@@ -459,7 +626,78 @@ const TbmModel = {
|
||||
ORDER BY total_sessions DESC
|
||||
`;
|
||||
|
||||
db.query(sql, [startDate, endDate], callback);
|
||||
const [rows] = await db.query(sql, [startDate, endDate]);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업보고서가 작성되지 않은 TBM 세션의 팀 배정 조회
|
||||
* @param {number|null} userId - 조회할 사용자 ID (null이면 모든 TBM 조회 - 관리자용)
|
||||
*/
|
||||
getIncompleteWorkReports: async (userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// WHERE 조건 동적 생성
|
||||
let whereClause = `
|
||||
WHERE dwr.id IS NULL
|
||||
AND s.status = 'draft'
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// userId가 있으면 created_by 조건 추가 (일반 사용자)
|
||||
if (userId !== null && userId !== undefined) {
|
||||
whereClause = `
|
||||
WHERE s.created_by = ?
|
||||
AND dwr.id IS NULL
|
||||
AND s.status = 'draft'
|
||||
`;
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
ta.assignment_id,
|
||||
ta.session_id,
|
||||
ta.worker_id,
|
||||
ta.project_id,
|
||||
ta.work_type_id,
|
||||
ta.task_id,
|
||||
ta.workplace_category_id,
|
||||
ta.workplace_id,
|
||||
s.session_date,
|
||||
s.status as session_status,
|
||||
s.created_by,
|
||||
w.worker_name,
|
||||
w.job_type,
|
||||
p.project_name,
|
||||
wt.name as work_type_name,
|
||||
t.task_name,
|
||||
wp.workplace_name,
|
||||
wc.category_name,
|
||||
creator.name as created_by_name
|
||||
FROM tbm_team_assignments ta
|
||||
INNER JOIN tbm_sessions s ON ta.session_id = s.session_id
|
||||
INNER JOIN workers w ON ta.worker_id = w.worker_id
|
||||
LEFT JOIN users creator ON s.created_by = creator.user_id
|
||||
LEFT JOIN projects p ON ta.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
|
||||
LEFT JOIN tasks t ON ta.task_id = t.task_id
|
||||
LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id
|
||||
LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id
|
||||
LEFT JOIN daily_work_reports dwr ON ta.assignment_id = dwr.tbm_assignment_id
|
||||
${whereClause}
|
||||
ORDER BY s.session_date DESC, ta.assignment_id ASC
|
||||
`;
|
||||
|
||||
const [rows] = await db.query(sql, params);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
288
api.hyungi.net/models/vacationBalanceModel.js
Normal file
288
api.hyungi.net/models/vacationBalanceModel.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* vacationBalanceModel.js
|
||||
* 휴가 잔액 관련 데이터베이스 쿼리 모델
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const vacationBalanceModel = {
|
||||
/**
|
||||
* 특정 작업자의 모든 휴가 잔액 조회 (특정 연도)
|
||||
*/
|
||||
async getByWorkerAndYear(workerId, year, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vbd.*,
|
||||
vt.type_name,
|
||||
vt.type_code,
|
||||
vt.priority,
|
||||
vt.is_special
|
||||
FROM vacation_balance_details vbd
|
||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.worker_id = ? AND vbd.year = ?
|
||||
ORDER BY vt.priority ASC, vt.type_name ASC
|
||||
`;
|
||||
const [rows] = await db.query(query, [workerId, year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 작업자의 특정 휴가 유형 잔액 조회
|
||||
*/
|
||||
async getByWorkerTypeYear(workerId, vacationTypeId, year, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vbd.*,
|
||||
vt.type_name,
|
||||
vt.type_code
|
||||
FROM vacation_balance_details vbd
|
||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.worker_id = ?
|
||||
AND vbd.vacation_type_id = ?
|
||||
AND vbd.year = ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [workerId, vacationTypeId, year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
|
||||
* - 연간 연차 현황 차트용
|
||||
*/
|
||||
async getAllByYear(year, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vbd.*,
|
||||
w.worker_name,
|
||||
w.employment_status,
|
||||
vt.type_name,
|
||||
vt.type_code,
|
||||
vt.priority
|
||||
FROM vacation_balance_details vbd
|
||||
INNER JOIN workers w ON vbd.worker_id = w.worker_id
|
||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.year = ?
|
||||
AND w.employment_status = 'employed'
|
||||
ORDER BY w.worker_name ASC, vt.priority ASC
|
||||
`;
|
||||
const [rows] = await db.query(query, [year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 생성
|
||||
*/
|
||||
async create(balanceData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `INSERT INTO vacation_balance_details SET ?`;
|
||||
const [rows] = await db.query(query, balanceData);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 수정
|
||||
*/
|
||||
async update(id, updateData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `UPDATE vacation_balance_details SET ? WHERE id = ?`;
|
||||
const [rows] = await db.query(query, [updateData, id]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 잔액 삭제
|
||||
*/
|
||||
async delete(id, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `DELETE FROM vacation_balance_details WHERE id = ?`;
|
||||
const [rows] = await db.query(query, [id]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업자의 휴가 사용 일수 업데이트 (차감)
|
||||
* - 휴가 신청 승인 시 호출
|
||||
*/
|
||||
async deductDays(workerId, vacationTypeId, year, daysToDeduct, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
UPDATE vacation_balance_details
|
||||
SET used_days = used_days + ?,
|
||||
updated_at = NOW()
|
||||
WHERE worker_id = ?
|
||||
AND vacation_type_id = ?
|
||||
AND year = ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [daysToDeduct, workerId, vacationTypeId, year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업자의 휴가 사용 일수 복구 (취소)
|
||||
* - 휴가 신청 취소/거부 시 호출
|
||||
*/
|
||||
async restoreDays(workerId, vacationTypeId, year, daysToRestore, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
UPDATE vacation_balance_details
|
||||
SET used_days = GREATEST(0, used_days - ?),
|
||||
updated_at = NOW()
|
||||
WHERE worker_id = ?
|
||||
AND vacation_type_id = ?
|
||||
AND year = ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [daysToRestore, workerId, vacationTypeId, year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 작업자의 사용 가능한 휴가 일수 확인
|
||||
* - 우선순위가 높은 순서대로 차감 가능 여부 확인
|
||||
*/
|
||||
async getAvailableVacationDays(workerId, year, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vbd.id,
|
||||
vbd.vacation_type_id,
|
||||
vt.type_name,
|
||||
vt.type_code,
|
||||
vt.priority,
|
||||
vbd.total_days,
|
||||
vbd.used_days,
|
||||
vbd.remaining_days
|
||||
FROM vacation_balance_details vbd
|
||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.worker_id = ?
|
||||
AND vbd.year = ?
|
||||
AND vbd.remaining_days > 0
|
||||
ORDER BY vt.priority ASC
|
||||
`;
|
||||
const [rows] = await db.query(query, [workerId, year]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업자별 휴가 잔액 일괄 생성 (연도별)
|
||||
* - 매년 초 또는 입사 시 사용
|
||||
*/
|
||||
async bulkCreate(balances, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
if (!balances || balances.length === 0) {
|
||||
return callback(new Error('생성할 휴가 잔액 데이터가 없습니다'));
|
||||
}
|
||||
|
||||
const query = `INSERT INTO vacation_balance_details
|
||||
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
|
||||
VALUES ?`;
|
||||
|
||||
const values = balances.map(b => [
|
||||
b.worker_id,
|
||||
b.vacation_type_id,
|
||||
b.year,
|
||||
b.total_days || 0,
|
||||
b.used_days || 0,
|
||||
b.notes || null,
|
||||
b.created_by
|
||||
]);
|
||||
|
||||
const [rows] = await db.query(query, [values]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 근속년수 기반 연차 일수 계산 (한국 근로기준법)
|
||||
* @param {Date} hireDate - 입사일
|
||||
* @param {number} targetYear - 대상 연도
|
||||
* @returns {number} - 부여받을 연차 일수
|
||||
*/
|
||||
calculateAnnualLeaveDays(hireDate, targetYear) {
|
||||
const hire = new Date(hireDate);
|
||||
const targetDate = new Date(targetYear, 0, 1);
|
||||
|
||||
// 근속 월수 계산
|
||||
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
|
||||
+ (targetDate.getMonth() - hire.getMonth());
|
||||
|
||||
// 1년 미만: 월 1일
|
||||
if (monthsDiff < 12) {
|
||||
return Math.floor(monthsDiff);
|
||||
}
|
||||
|
||||
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
|
||||
const yearsWorked = Math.floor(monthsDiff / 12);
|
||||
const additionalDays = Math.floor((yearsWorked - 1) / 2);
|
||||
|
||||
return Math.min(15 + additionalDays, 25);
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 ID로 휴가 잔액 조회
|
||||
*/
|
||||
async getById(id, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vbd.*,
|
||||
w.worker_name,
|
||||
vt.type_name,
|
||||
vt.type_code
|
||||
FROM vacation_balance_details vbd
|
||||
INNER JOIN workers w ON vbd.worker_id = w.worker_id
|
||||
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
|
||||
WHERE vbd.id = ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [id]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationBalanceModel;
|
||||
271
api.hyungi.net/models/vacationRequestModel.js
Normal file
271
api.hyungi.net/models/vacationRequestModel.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* vacationRequestModel.js
|
||||
* 휴가 신청 관련 데이터베이스 쿼리 모델
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const vacationRequestModel = {
|
||||
/**
|
||||
* 휴가 신청 생성
|
||||
*/
|
||||
async create(requestData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `INSERT INTO vacation_requests SET ?`;
|
||||
const [result] = await db.query(query, requestData);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 목록 조회 (필터링 지원)
|
||||
*/
|
||||
async getAll(filters = {}, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
vr.*,
|
||||
w.worker_name,
|
||||
vt.type_name as vacation_type_name,
|
||||
vt.deduct_days as vacation_deduct_days,
|
||||
requester.name as requester_name,
|
||||
reviewer.name as reviewer_name
|
||||
FROM vacation_requests vr
|
||||
INNER JOIN workers w ON vr.worker_id = w.worker_id
|
||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||
LEFT JOIN users requester ON vr.requested_by = requester.user_id
|
||||
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
// 작업자 필터
|
||||
if (filters.worker_id) {
|
||||
query += ` AND vr.worker_id = ?`;
|
||||
params.push(filters.worker_id);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filters.status) {
|
||||
query += ` AND vr.status = ?`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
// 기간 필터
|
||||
if (filters.start_date) {
|
||||
query += ` AND vr.start_date >= ?`;
|
||||
params.push(filters.start_date);
|
||||
}
|
||||
|
||||
if (filters.end_date) {
|
||||
query += ` AND vr.end_date <= ?`;
|
||||
params.push(filters.end_date);
|
||||
}
|
||||
|
||||
// 휴가 유형 필터
|
||||
if (filters.vacation_type_id) {
|
||||
query += ` AND vr.vacation_type_id = ?`;
|
||||
params.push(filters.vacation_type_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY vr.created_at DESC`;
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 휴가 신청 조회
|
||||
*/
|
||||
async getById(requestId, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vr.*,
|
||||
w.worker_name,
|
||||
w.phone_number as worker_phone,
|
||||
w.email as worker_email,
|
||||
vt.type_name as vacation_type_name,
|
||||
vt.type_code as vacation_type_code,
|
||||
vt.deduct_days as vacation_deduct_days,
|
||||
requester.name as requester_name,
|
||||
requester.username as requester_username,
|
||||
reviewer.name as reviewer_name,
|
||||
reviewer.username as reviewer_username
|
||||
FROM vacation_requests vr
|
||||
INNER JOIN workers w ON vr.worker_id = w.worker_id
|
||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||
LEFT JOIN users requester ON vr.requested_by = requester.user_id
|
||||
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
|
||||
WHERE vr.request_id = ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [requestId]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 수정
|
||||
*/
|
||||
async update(requestId, updateData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `UPDATE vacation_requests SET ? WHERE request_id = ?`;
|
||||
const [result] = await db.query(query, [updateData, requestId]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 삭제
|
||||
*/
|
||||
async delete(requestId, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `DELETE FROM vacation_requests WHERE request_id = ?`;
|
||||
const [result] = await db.query(query, [requestId]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 신청 승인/거부
|
||||
*/
|
||||
async updateStatus(requestId, statusData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
UPDATE vacation_requests
|
||||
SET
|
||||
status = ?,
|
||||
reviewed_by = ?,
|
||||
reviewed_at = NOW(),
|
||||
review_note = ?
|
||||
WHERE request_id = ?
|
||||
`;
|
||||
const [result] = await db.query(query, [
|
||||
statusData.status,
|
||||
statusData.reviewed_by,
|
||||
statusData.review_note || null,
|
||||
requestId
|
||||
]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 작업자의 대기 중인 휴가 신청 수
|
||||
*/
|
||||
async getPendingCount(workerId, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM vacation_requests
|
||||
WHERE worker_id = ? AND status = 'pending'
|
||||
`;
|
||||
const [rows] = await db.query(query, [workerId]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 작업자의 승인된 휴가 일수 합계 (특정 기간)
|
||||
*/
|
||||
async getApprovedDaysInPeriod(workerId, startDate, endDate, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT COALESCE(SUM(days_used), 0) as total_days
|
||||
FROM vacation_requests
|
||||
WHERE worker_id = ?
|
||||
AND status = 'approved'
|
||||
AND start_date >= ?
|
||||
AND end_date <= ?
|
||||
`;
|
||||
const [rows] = await db.query(query, [workerId, startDate, endDate]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 기간 중복 체크
|
||||
*/
|
||||
async checkOverlap(workerId, startDate, endDate, excludeRequestId = null, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM vacation_requests
|
||||
WHERE worker_id = ?
|
||||
AND status IN ('pending', 'approved')
|
||||
AND (
|
||||
(start_date <= ? AND end_date >= ?) OR
|
||||
(start_date <= ? AND end_date >= ?) OR
|
||||
(start_date >= ? AND end_date <= ?)
|
||||
)
|
||||
`;
|
||||
const params = [workerId, startDate, startDate, endDate, endDate, startDate, endDate];
|
||||
|
||||
if (excludeRequestId) {
|
||||
query += ` AND request_id != ?`;
|
||||
params.push(excludeRequestId);
|
||||
}
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 모든 대기 중인 휴가 신청 (관리자용)
|
||||
*/
|
||||
async getAllPending(callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT
|
||||
vr.*,
|
||||
w.worker_name,
|
||||
vt.type_name as vacation_type_name,
|
||||
requester.name as requester_name
|
||||
FROM vacation_requests vr
|
||||
INNER JOIN workers w ON vr.worker_id = w.worker_id
|
||||
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
|
||||
LEFT JOIN users requester ON vr.requested_by = requester.user_id
|
||||
WHERE vr.status = 'pending'
|
||||
ORDER BY vr.created_at ASC
|
||||
`;
|
||||
const [rows] = await db.query(query);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationRequestModel;
|
||||
132
api.hyungi.net/models/vacationTypeModel.js
Normal file
132
api.hyungi.net/models/vacationTypeModel.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* vacationTypeModel.js
|
||||
* 휴가 유형 관련 데이터베이스 쿼리 모델
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
const vacationTypeModel = {
|
||||
/**
|
||||
* 모든 활성 휴가 유형 조회 (우선순위 순서대로)
|
||||
*/
|
||||
async getAll(callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM vacation_types
|
||||
WHERE is_active = 1
|
||||
ORDER BY priority ASC, id ASC
|
||||
`;
|
||||
const [rows] = await db.query(query);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 시스템 기본 휴가 유형만 조회
|
||||
*/
|
||||
async getSystemTypes(callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM vacation_types
|
||||
WHERE is_system = 1 AND is_active = 1
|
||||
ORDER BY priority ASC
|
||||
`;
|
||||
const [rows] = await db.query(query);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 ID로 휴가 유형 조회
|
||||
*/
|
||||
async getById(id, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `SELECT * FROM vacation_types WHERE id = ?`;
|
||||
const [rows] = await db.query(query, [id]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 코드로 조회
|
||||
*/
|
||||
async getByCode(code, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `SELECT * FROM vacation_types WHERE type_code = ?`;
|
||||
const [rows] = await db.query(query, [code]);
|
||||
callback(null, rows);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 생성
|
||||
*/
|
||||
async create(typeData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `INSERT INTO vacation_types SET ?`;
|
||||
const [result] = await db.query(query, typeData);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 수정
|
||||
*/
|
||||
async update(id, updateData, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `UPDATE vacation_types SET ? WHERE id = ?`;
|
||||
const [result] = await db.query(query, [updateData, id]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 휴가 유형 삭제 (논리적 삭제 - is_active = 0)
|
||||
*/
|
||||
async delete(id, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `UPDATE vacation_types SET is_active = 0, updated_at = NOW() WHERE id = ?`;
|
||||
const [result] = await db.query(query, [id]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 우선순위 업데이트
|
||||
*/
|
||||
async updatePriority(id, priority, callback) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const query = `UPDATE vacation_types SET priority = ?, updated_at = NOW() WHERE id = ?`;
|
||||
const [result] = await db.query(query, [priority, id]);
|
||||
callback(null, result);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vacationTypeModel;
|
||||
505
api.hyungi.net/models/visitRequestModel.js
Normal file
505
api.hyungi.net/models/visitRequestModel.js
Normal file
@@ -0,0 +1,505 @@
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// ==================== 출입 신청 관리 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 생성
|
||||
*/
|
||||
const createVisitRequest = async (requestData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
requester_id,
|
||||
visitor_company,
|
||||
visitor_count = 1,
|
||||
category_id,
|
||||
workplace_id,
|
||||
visit_date,
|
||||
visit_time,
|
||||
purpose_id,
|
||||
notes = null
|
||||
} = requestData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workplace_visit_requests
|
||||
(requester_id, visitor_company, visitor_count, category_id, workplace_id,
|
||||
visit_date, visit_time, purpose_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[requester_id, visitor_company, visitor_count, category_id, workplace_id,
|
||||
visit_date, visit_time, purpose_id, notes]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 목록 조회 (필터 옵션 포함)
|
||||
*/
|
||||
const getAllVisitRequests = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT
|
||||
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
|
||||
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
|
||||
vr.purpose_id, vr.notes, vr.status,
|
||||
vr.approved_by, vr.approved_at, vr.rejection_reason,
|
||||
vr.created_at, vr.updated_at,
|
||||
u.username as requester_name, u.name as requester_full_name,
|
||||
wc.category_name, w.workplace_name,
|
||||
vpt.purpose_name,
|
||||
approver.username as approver_name
|
||||
FROM workplace_visit_requests vr
|
||||
INNER JOIN users u ON vr.requester_id = u.user_id
|
||||
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
|
||||
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
|
||||
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
|
||||
LEFT JOIN users approver ON vr.approved_by = approver.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
// 필터 적용
|
||||
if (filters.status) {
|
||||
query += ` AND vr.status = ?`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.visit_date) {
|
||||
query += ` AND vr.visit_date = ?`;
|
||||
params.push(filters.visit_date);
|
||||
}
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
query += ` AND vr.visit_date BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.requester_id) {
|
||||
query += ` AND vr.requester_id = ?`;
|
||||
params.push(filters.requester_id);
|
||||
}
|
||||
|
||||
if (filters.category_id) {
|
||||
query += ` AND vr.category_id = ?`;
|
||||
params.push(filters.category_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`;
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 상세 조회
|
||||
*/
|
||||
const getVisitRequestById = async (requestId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
|
||||
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
|
||||
vr.purpose_id, vr.notes, vr.status,
|
||||
vr.approved_by, vr.approved_at, vr.rejection_reason,
|
||||
vr.created_at, vr.updated_at,
|
||||
u.username as requester_name, u.name as requester_full_name,
|
||||
wc.category_name, w.workplace_name,
|
||||
vpt.purpose_name,
|
||||
approver.username as approver_name
|
||||
FROM workplace_visit_requests vr
|
||||
INNER JOIN users u ON vr.requester_id = u.user_id
|
||||
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
|
||||
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
|
||||
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
|
||||
LEFT JOIN users approver ON vr.approved_by = approver.user_id
|
||||
WHERE vr.request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 수정
|
||||
*/
|
||||
const updateVisitRequest = async (requestId, requestData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
visitor_company,
|
||||
visitor_count,
|
||||
category_id,
|
||||
workplace_id,
|
||||
visit_date,
|
||||
visit_time,
|
||||
purpose_id,
|
||||
notes
|
||||
} = requestData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET visitor_company = ?, visitor_count = ?, category_id = ?, workplace_id = ?,
|
||||
visit_date = ?, visit_time = ?, purpose_id = ?, notes = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[visitor_company, visitor_count, category_id, workplace_id,
|
||||
visit_date, visit_time, purpose_id, notes, requestId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 삭제
|
||||
*/
|
||||
const deleteVisitRequest = async (requestId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM workplace_visit_requests WHERE request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 승인
|
||||
*/
|
||||
const approveVisitRequest = async (requestId, approvedBy, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[approvedBy, requestId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 반려
|
||||
*/
|
||||
const rejectVisitRequest = async (requestId, rejectionData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { approved_by, rejection_reason } = rejectionData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = 'rejected', approved_by = ?, approved_at = NOW(),
|
||||
rejection_reason = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[approved_by, rejection_reason, requestId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출입 신청 상태 변경
|
||||
*/
|
||||
const updateVisitRequestStatus = async (requestId, status, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[status, requestId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 방문 목적 관리 ====================
|
||||
|
||||
/**
|
||||
* 모든 방문 목적 조회
|
||||
*/
|
||||
const getAllVisitPurposes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
|
||||
FROM visit_purpose_types
|
||||
ORDER BY display_order ASC, purpose_id ASC`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 활성 방문 목적만 조회
|
||||
*/
|
||||
const getActiveVisitPurposes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
|
||||
FROM visit_purpose_types
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order ASC, purpose_id ASC`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 추가
|
||||
*/
|
||||
const createVisitPurpose = async (purposeData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { purpose_name, display_order = 0, is_active = true } = purposeData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO visit_purpose_types (purpose_name, display_order, is_active)
|
||||
VALUES (?, ?, ?)`,
|
||||
[purpose_name, display_order, is_active]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 수정
|
||||
*/
|
||||
const updateVisitPurpose = async (purposeId, purposeData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { purpose_name, display_order, is_active } = purposeData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE visit_purpose_types
|
||||
SET purpose_name = ?, display_order = ?, is_active = ?
|
||||
WHERE purpose_id = ?`,
|
||||
[purpose_name, display_order, is_active, purposeId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 방문 목적 삭제
|
||||
*/
|
||||
const deleteVisitPurpose = async (purposeId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM visit_purpose_types WHERE purpose_id = ?`,
|
||||
[purposeId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 안전교육 기록 관리 ====================
|
||||
|
||||
/**
|
||||
* 안전교육 기록 생성
|
||||
*/
|
||||
const createTrainingRecord = async (trainingData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
request_id,
|
||||
trainer_id,
|
||||
training_date,
|
||||
training_start_time,
|
||||
training_end_time = null,
|
||||
training_topics = null
|
||||
} = trainingData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO safety_training_records
|
||||
(request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 출입 신청의 안전교육 기록 조회
|
||||
*/
|
||||
const getTrainingRecordByRequestId = async (requestId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
str.training_id, str.request_id, str.trainer_id, str.training_date,
|
||||
str.training_start_time, str.training_end_time, str.training_topics,
|
||||
str.signature_data, str.completed_at, str.created_at, str.updated_at,
|
||||
u.username as trainer_name, u.name as trainer_full_name
|
||||
FROM safety_training_records str
|
||||
INNER JOIN users u ON str.trainer_id = u.user_id
|
||||
WHERE str.request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 기록 수정
|
||||
*/
|
||||
const updateTrainingRecord = async (trainingId, trainingData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
training_date,
|
||||
training_start_time,
|
||||
training_end_time,
|
||||
training_topics
|
||||
} = trainingData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE safety_training_records
|
||||
SET training_date = ?, training_start_time = ?, training_end_time = ?,
|
||||
training_topics = ?, updated_at = NOW()
|
||||
WHERE training_id = ?`,
|
||||
[training_date, training_start_time, training_end_time, training_topics, trainingId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 완료 (서명 포함)
|
||||
*/
|
||||
const completeTraining = async (trainingId, signatureData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`UPDATE safety_training_records
|
||||
SET signature_data = ?, completed_at = NOW(), updated_at = NOW()
|
||||
WHERE training_id = ?`,
|
||||
[signatureData, trainingId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전교육 목록 조회 (날짜별 필터)
|
||||
*/
|
||||
const getTrainingRecords = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT
|
||||
str.training_id, str.request_id, str.trainer_id, str.training_date,
|
||||
str.training_start_time, str.training_end_time, str.training_topics,
|
||||
str.completed_at, str.created_at, str.updated_at,
|
||||
u.username as trainer_name, u.name as trainer_full_name,
|
||||
vr.visitor_company, vr.visitor_count, vr.visit_date
|
||||
FROM safety_training_records str
|
||||
INNER JOIN users u ON str.trainer_id = u.user_id
|
||||
INNER JOIN workplace_visit_requests vr ON str.request_id = vr.request_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
if (filters.training_date) {
|
||||
query += ` AND str.training_date = ?`;
|
||||
params.push(filters.training_date);
|
||||
}
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
query += ` AND str.training_date BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.trainer_id) {
|
||||
query += ` AND str.trainer_id = ?`;
|
||||
params.push(filters.trainer_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY str.training_date DESC, str.training_start_time DESC`;
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 출입 신청
|
||||
createVisitRequest,
|
||||
getAllVisitRequests,
|
||||
getVisitRequestById,
|
||||
updateVisitRequest,
|
||||
deleteVisitRequest,
|
||||
approveVisitRequest,
|
||||
rejectVisitRequest,
|
||||
updateVisitRequestStatus,
|
||||
|
||||
// 방문 목적
|
||||
getAllVisitPurposes,
|
||||
getActiveVisitPurposes,
|
||||
createVisitPurpose,
|
||||
updateVisitPurpose,
|
||||
deleteVisitPurpose,
|
||||
|
||||
// 안전교육
|
||||
createTrainingRecord,
|
||||
getTrainingRecordByRequestId,
|
||||
updateTrainingRecord,
|
||||
completeTraining,
|
||||
getTrainingRecords
|
||||
};
|
||||
@@ -35,7 +35,7 @@ const getAllCategories = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
|
||||
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
|
||||
FROM workplace_categories
|
||||
ORDER BY display_order ASC, category_id ASC`
|
||||
);
|
||||
@@ -52,7 +52,7 @@ const getActiveCategories = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
|
||||
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
|
||||
FROM workplace_categories
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order ASC, category_id ASC`
|
||||
@@ -70,7 +70,7 @@ const getCategoryById = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_name, description, display_order, is_active, created_at, updated_at
|
||||
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
|
||||
FROM workplace_categories
|
||||
WHERE category_id = ?`,
|
||||
[categoryId]
|
||||
@@ -91,14 +91,15 @@ const updateCategory = async (categoryId, category, callback) => {
|
||||
category_name,
|
||||
description,
|
||||
display_order,
|
||||
is_active
|
||||
is_active,
|
||||
layout_image
|
||||
} = category;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_categories
|
||||
SET category_name = ?, description = ?, display_order = ?, is_active = ?, updated_at = NOW()
|
||||
SET category_name = ?, description = ?, display_order = ?, is_active = ?, layout_image = ?, updated_at = NOW()
|
||||
WHERE category_id = ?`,
|
||||
[category_name, description, display_order, is_active, categoryId]
|
||||
[category_name, description, display_order, is_active, layout_image, categoryId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
@@ -135,14 +136,16 @@ const createWorkplace = async (workplace, callback) => {
|
||||
category_id = null,
|
||||
workplace_name,
|
||||
description = null,
|
||||
is_active = true
|
||||
is_active = true,
|
||||
workplace_purpose = null,
|
||||
display_priority = 0
|
||||
} = workplace;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workplaces
|
||||
(category_id, workplace_name, description, is_active)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[category_id, workplace_name, description, is_active]
|
||||
(category_id, workplace_name, description, is_active, workplace_purpose, display_priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
@@ -158,12 +161,12 @@ const getAllWorkplaces = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
|
||||
w.created_at, w.updated_at,
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
|
||||
w.workplace_purpose, w.display_priority, w.created_at, w.updated_at,
|
||||
wc.category_name
|
||||
FROM workplaces w
|
||||
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
|
||||
ORDER BY wc.display_order ASC, w.workplace_id DESC`
|
||||
ORDER BY wc.display_order ASC, w.display_priority ASC, w.workplace_id DESC`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
@@ -178,7 +181,7 @@ const getActiveWorkplaces = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
|
||||
w.created_at, w.updated_at,
|
||||
wc.category_name
|
||||
FROM workplaces w
|
||||
@@ -199,7 +202,7 @@ const getWorkplacesByCategory = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
|
||||
w.created_at, w.updated_at,
|
||||
wc.category_name
|
||||
FROM workplaces w
|
||||
@@ -221,7 +224,7 @@ const getWorkplaceById = async (workplaceId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active,
|
||||
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
|
||||
w.created_at, w.updated_at,
|
||||
wc.category_name
|
||||
FROM workplaces w
|
||||
@@ -245,14 +248,17 @@ const updateWorkplace = async (workplaceId, workplace, callback) => {
|
||||
category_id,
|
||||
workplace_name,
|
||||
description,
|
||||
is_active
|
||||
is_active,
|
||||
workplace_purpose,
|
||||
display_priority
|
||||
} = workplace;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplaces
|
||||
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?, updated_at = NOW()
|
||||
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?,
|
||||
workplace_purpose = ?, display_priority = ?, updated_at = NOW()
|
||||
WHERE workplace_id = ?`,
|
||||
[category_id, workplace_name, description, is_active, workplaceId]
|
||||
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority, workplaceId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
@@ -277,6 +283,134 @@ const deleteWorkplace = async (workplaceId, callback) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 작업장 지도 영역 관련 ====================
|
||||
|
||||
/**
|
||||
* 작업장 지도 영역 생성
|
||||
*/
|
||||
const createMapRegion = async (region, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
workplace_id,
|
||||
category_id,
|
||||
x_start,
|
||||
y_start,
|
||||
x_end,
|
||||
y_end,
|
||||
shape = 'rect',
|
||||
polygon_points = null
|
||||
} = region;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO workplace_map_regions
|
||||
(workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리(공장)별 지도 영역 조회
|
||||
*/
|
||||
const getMapRegionsByCategory = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT mr.*, w.workplace_name, w.description
|
||||
FROM workplace_map_regions mr
|
||||
INNER JOIN workplaces w ON mr.workplace_id = w.workplace_id
|
||||
WHERE mr.category_id = ? AND w.is_active = TRUE
|
||||
ORDER BY mr.region_id ASC`,
|
||||
[categoryId]
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업장별 지도 영역 조회
|
||||
*/
|
||||
const getMapRegionByWorkplace = async (workplaceId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT * FROM workplace_map_regions WHERE workplace_id = ?`,
|
||||
[workplaceId]
|
||||
);
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 지도 영역 수정
|
||||
*/
|
||||
const updateMapRegion = async (regionId, region, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
x_start,
|
||||
y_start,
|
||||
x_end,
|
||||
y_end,
|
||||
shape,
|
||||
polygon_points
|
||||
} = region;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_map_regions
|
||||
SET x_start = ?, y_start = ?, x_end = ?, y_end = ?, shape = ?, polygon_points = ?, updated_at = NOW()
|
||||
WHERE region_id = ?`,
|
||||
[x_start, y_start, x_end, y_end, shape, polygon_points, regionId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 지도 영역 삭제
|
||||
*/
|
||||
const deleteMapRegion = async (regionId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM workplace_map_regions WHERE region_id = ?`,
|
||||
[regionId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업장 영역 일괄 삭제 (카테고리별)
|
||||
*/
|
||||
const deleteMapRegionsByCategory = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM workplace_map_regions WHERE category_id = ?`,
|
||||
[categoryId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 카테고리
|
||||
createCategory,
|
||||
@@ -293,5 +427,13 @@ module.exports = {
|
||||
getWorkplacesByCategory,
|
||||
getWorkplaceById,
|
||||
updateWorkplace,
|
||||
deleteWorkplace
|
||||
deleteWorkplace,
|
||||
|
||||
// 지도 영역
|
||||
createMapRegion,
|
||||
getMapRegionsByCategory,
|
||||
getMapRegionByWorkplace,
|
||||
updateMapRegion,
|
||||
deleteMapRegion,
|
||||
deleteMapRegionsByCategory
|
||||
};
|
||||
|
||||
@@ -34,4 +34,10 @@ router.get('/vacation-balance/:worker_id', AttendanceController.getWorkerVacatio
|
||||
// 월별 근태 통계
|
||||
router.get('/monthly-stats', AttendanceController.getMonthlyAttendanceStats);
|
||||
|
||||
// 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
|
||||
router.get('/checkin-list', AttendanceController.getCheckinList);
|
||||
|
||||
// 출근 체크 일괄 저장
|
||||
router.post('/checkins', AttendanceController.saveCheckins);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -70,6 +70,9 @@ router.get('/stats', dailyWorkReportController.getWorkReportStats);
|
||||
// 📝 일일 작업보고서 생성 (누적 방식 - 덮어쓰기 없음!)
|
||||
router.post('/', dailyWorkReportController.createDailyWorkReport);
|
||||
|
||||
// 📝 TBM 기반 작업보고서 생성
|
||||
router.post('/from-tbm', dailyWorkReportController.createFromTbm);
|
||||
|
||||
// 📊 일일 작업보고서 조회 (날짜별 - 경로 파라미터)
|
||||
router.get('/date/:date', dailyWorkReportController.getDailyWorkReportsByDate);
|
||||
|
||||
|
||||
@@ -2,70 +2,76 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const TbmController = require('../controllers/tbmController');
|
||||
const { authenticateToken } = require('../middlewares/auth');
|
||||
const { requireAuth } = require('../middlewares/auth');
|
||||
|
||||
// ==================== TBM 세션 관련 ====================
|
||||
|
||||
// TBM 세션 생성
|
||||
router.post('/sessions', authenticateToken, TbmController.createSession);
|
||||
router.post('/sessions', requireAuth, TbmController.createSession);
|
||||
|
||||
// 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의)
|
||||
router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports);
|
||||
|
||||
// 특정 날짜의 TBM 세션 목록 조회
|
||||
router.get('/sessions/date/:date', authenticateToken, TbmController.getSessionsByDate);
|
||||
router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate);
|
||||
|
||||
// TBM 세션 상세 조회
|
||||
router.get('/sessions/:sessionId', authenticateToken, TbmController.getSessionById);
|
||||
router.get('/sessions/:sessionId', requireAuth, TbmController.getSessionById);
|
||||
|
||||
// TBM 세션 수정
|
||||
router.put('/sessions/:sessionId', authenticateToken, TbmController.updateSession);
|
||||
router.put('/sessions/:sessionId', requireAuth, TbmController.updateSession);
|
||||
|
||||
// TBM 세션 완료 처리
|
||||
router.post('/sessions/:sessionId/complete', authenticateToken, TbmController.completeSession);
|
||||
router.post('/sessions/:sessionId/complete', requireAuth, TbmController.completeSession);
|
||||
|
||||
// ==================== 팀 구성 관련 ====================
|
||||
|
||||
// 팀원 추가 (단일)
|
||||
router.post('/sessions/:sessionId/team', authenticateToken, TbmController.addTeamMember);
|
||||
router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember);
|
||||
|
||||
// 팀 구성 일괄 추가
|
||||
router.post('/sessions/:sessionId/team/batch', authenticateToken, TbmController.addTeamMembers);
|
||||
router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers);
|
||||
|
||||
// TBM 세션의 팀 구성 조회
|
||||
router.get('/sessions/:sessionId/team', authenticateToken, TbmController.getTeamMembers);
|
||||
router.get('/sessions/:sessionId/team', requireAuth, TbmController.getTeamMembers);
|
||||
|
||||
// 팀원 전체 삭제 (수정 시 사용) - 더 구체적인 경로이므로 먼저 정의
|
||||
router.delete('/sessions/:sessionId/team/clear', requireAuth, TbmController.clearAllTeamMembers);
|
||||
|
||||
// 팀원 제거
|
||||
router.delete('/sessions/:sessionId/team/:workerId', authenticateToken, TbmController.removeTeamMember);
|
||||
router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.removeTeamMember);
|
||||
|
||||
// ==================== 안전 체크리스트 관련 ====================
|
||||
|
||||
// 모든 안전 체크 항목 조회
|
||||
router.get('/safety-checks', authenticateToken, TbmController.getAllSafetyChecks);
|
||||
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
||||
|
||||
// TBM 세션의 안전 체크 기록 조회
|
||||
router.get('/sessions/:sessionId/safety', authenticateToken, TbmController.getSafetyRecords);
|
||||
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
||||
|
||||
// 안전 체크 일괄 저장
|
||||
router.post('/sessions/:sessionId/safety', authenticateToken, TbmController.saveSafetyRecords);
|
||||
router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords);
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
// 작업 인계 생성
|
||||
router.post('/handovers', authenticateToken, TbmController.createHandover);
|
||||
router.post('/handovers', requireAuth, TbmController.createHandover);
|
||||
|
||||
// 작업 인계 확인
|
||||
router.post('/handovers/:handoverId/confirm', authenticateToken, TbmController.confirmHandover);
|
||||
router.post('/handovers/:handoverId/confirm', requireAuth, TbmController.confirmHandover);
|
||||
|
||||
// 특정 날짜의 작업 인계 목록 조회
|
||||
router.get('/handovers/date/:date', authenticateToken, TbmController.getHandoversByDate);
|
||||
router.get('/handovers/date/:date', requireAuth, TbmController.getHandoversByDate);
|
||||
|
||||
// 나에게 온 미확인 인계 건 조회
|
||||
router.get('/handovers/pending', authenticateToken, TbmController.getMyPendingHandovers);
|
||||
router.get('/handovers/pending', requireAuth, TbmController.getMyPendingHandovers);
|
||||
|
||||
// ==================== 통계 및 리포트 ====================
|
||||
|
||||
// TBM 통계 조회
|
||||
router.get('/statistics/tbm', authenticateToken, TbmController.getTbmStatistics);
|
||||
router.get('/statistics/tbm', requireAuth, TbmController.getTbmStatistics);
|
||||
|
||||
// 리더별 TBM 진행 현황 조회
|
||||
router.get('/statistics/leaders', authenticateToken, TbmController.getLeaderStatistics);
|
||||
router.get('/statistics/leaders', requireAuth, TbmController.getLeaderStatistics);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -128,4 +128,10 @@ router.put('/:id/status', userController.updateUserStatus);
|
||||
// 🗑️ 사용자 삭제
|
||||
router.delete('/:id', userController.deleteUser);
|
||||
|
||||
// 📄 사용자 페이지 접근 권한 조회
|
||||
router.get('/:id/page-access', userController.getUserPageAccess);
|
||||
|
||||
// 🔐 사용자 페이지 접근 권한 업데이트
|
||||
router.put('/:id/page-access', userController.updateUserPageAccess);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
31
api.hyungi.net/routes/vacationBalanceRoutes.js
Normal file
31
api.hyungi.net/routes/vacationBalanceRoutes.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* vacationBalanceRoutes.js
|
||||
* 휴가 잔액 관련 라우트
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const vacationBalanceController = require('../controllers/vacationBalanceController');
|
||||
|
||||
// 모든 작업자의 휴가 잔액 조회 (특정 연도)
|
||||
router.get('/year/:year', vacationBalanceController.getAllByYear);
|
||||
|
||||
// 특정 작업자의 휴가 잔액 조회 (특정 연도)
|
||||
router.get('/worker/:workerId/year/:year', vacationBalanceController.getByWorkerAndYear);
|
||||
|
||||
// 작업자의 사용 가능한 휴가 일수 조회
|
||||
router.get('/worker/:workerId/year/:year/available', vacationBalanceController.getAvailableDays);
|
||||
|
||||
// 근속년수 기반 연차 자동 계산 및 생성 (관리자만)
|
||||
router.post('/auto-calculate', vacationBalanceController.autoCalculateAndCreate);
|
||||
|
||||
// 휴가 잔액 생성 (관리자만)
|
||||
router.post('/', vacationBalanceController.createBalance);
|
||||
|
||||
// 휴가 잔액 수정 (관리자만)
|
||||
router.put('/:id', vacationBalanceController.updateBalance);
|
||||
|
||||
// 휴가 잔액 삭제 (관리자만)
|
||||
router.delete('/:id', vacationBalanceController.deleteBalance);
|
||||
|
||||
module.exports = router;
|
||||
34
api.hyungi.net/routes/vacationRequestRoutes.js
Normal file
34
api.hyungi.net/routes/vacationRequestRoutes.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* vacationRequestRoutes.js
|
||||
* 휴가 신청 관련 라우트
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const vacationRequestController = require('../controllers/vacationRequestController');
|
||||
|
||||
// 휴가 신청 생성
|
||||
router.post('/', vacationRequestController.createRequest);
|
||||
|
||||
// 휴가 신청 목록 조회
|
||||
router.get('/', vacationRequestController.getAllRequests);
|
||||
|
||||
// 대기 중인 휴가 신청 목록 (관리자용)
|
||||
router.get('/pending', vacationRequestController.getPendingRequests);
|
||||
|
||||
// 특정 휴가 신청 조회
|
||||
router.get('/:id', vacationRequestController.getRequestById);
|
||||
|
||||
// 휴가 신청 수정
|
||||
router.put('/:id', vacationRequestController.updateRequest);
|
||||
|
||||
// 휴가 신청 삭제
|
||||
router.delete('/:id', vacationRequestController.deleteRequest);
|
||||
|
||||
// 휴가 신청 승인
|
||||
router.patch('/:id/approve', vacationRequestController.approveRequest);
|
||||
|
||||
// 휴가 신청 거부
|
||||
router.patch('/:id/reject', vacationRequestController.rejectRequest);
|
||||
|
||||
module.exports = router;
|
||||
31
api.hyungi.net/routes/vacationTypeRoutes.js
Normal file
31
api.hyungi.net/routes/vacationTypeRoutes.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* vacationTypeRoutes.js
|
||||
* 휴가 유형 관련 라우트
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const vacationTypeController = require('../controllers/vacationTypeController');
|
||||
|
||||
// 모든 활성 휴가 유형 조회
|
||||
router.get('/', vacationTypeController.getAllTypes);
|
||||
|
||||
// 시스템 기본 휴가 유형 조회
|
||||
router.get('/system', vacationTypeController.getSystemTypes);
|
||||
|
||||
// 특별 휴가 유형 조회
|
||||
router.get('/special', vacationTypeController.getSpecialTypes);
|
||||
|
||||
// 휴가 유형 우선순위 일괄 업데이트 (관리자만)
|
||||
router.put('/priorities', vacationTypeController.updatePriorities);
|
||||
|
||||
// 특별 휴가 유형 생성 (관리자만)
|
||||
router.post('/', vacationTypeController.createType);
|
||||
|
||||
// 휴가 유형 수정 (관리자만)
|
||||
router.put('/:id', vacationTypeController.updateType);
|
||||
|
||||
// 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
|
||||
router.delete('/:id', vacationTypeController.deleteType);
|
||||
|
||||
module.exports = router;
|
||||
66
api.hyungi.net/routes/visitRequestRoutes.js
Normal file
66
api.hyungi.net/routes/visitRequestRoutes.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const visitRequestController = require('../controllers/visitRequestController');
|
||||
const { verifyToken } = require('../middlewares/authMiddleware');
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(verifyToken);
|
||||
|
||||
// ==================== 출입 신청 관리 ====================
|
||||
|
||||
// 출입 신청 생성
|
||||
router.post('/requests', visitRequestController.createVisitRequest);
|
||||
|
||||
// 출입 신청 목록 조회 (필터: status, visit_date, start_date, end_date, requester_id, category_id)
|
||||
router.get('/requests', visitRequestController.getAllVisitRequests);
|
||||
|
||||
// 출입 신청 상세 조회
|
||||
router.get('/requests/:id', visitRequestController.getVisitRequestById);
|
||||
|
||||
// 출입 신청 수정
|
||||
router.put('/requests/:id', visitRequestController.updateVisitRequest);
|
||||
|
||||
// 출입 신청 삭제
|
||||
router.delete('/requests/:id', visitRequestController.deleteVisitRequest);
|
||||
|
||||
// 출입 신청 승인
|
||||
router.put('/requests/:id/approve', visitRequestController.approveVisitRequest);
|
||||
|
||||
// 출입 신청 반려
|
||||
router.put('/requests/:id/reject', visitRequestController.rejectVisitRequest);
|
||||
|
||||
// ==================== 방문 목적 관리 ====================
|
||||
|
||||
// 모든 방문 목적 조회
|
||||
router.get('/purposes', visitRequestController.getAllVisitPurposes);
|
||||
|
||||
// 활성 방문 목적만 조회
|
||||
router.get('/purposes/active', visitRequestController.getActiveVisitPurposes);
|
||||
|
||||
// 방문 목적 추가
|
||||
router.post('/purposes', visitRequestController.createVisitPurpose);
|
||||
|
||||
// 방문 목적 수정
|
||||
router.put('/purposes/:id', visitRequestController.updateVisitPurpose);
|
||||
|
||||
// 방문 목적 삭제
|
||||
router.delete('/purposes/:id', visitRequestController.deleteVisitPurpose);
|
||||
|
||||
// ==================== 안전교육 기록 관리 ====================
|
||||
|
||||
// 안전교육 기록 생성
|
||||
router.post('/training', visitRequestController.createTrainingRecord);
|
||||
|
||||
// 안전교육 기록 목록 조회 (필터: training_date, start_date, end_date, trainer_id)
|
||||
router.get('/training', visitRequestController.getTrainingRecords);
|
||||
|
||||
// 특정 출입 신청의 안전교육 기록 조회
|
||||
router.get('/training/request/:requestId', visitRequestController.getTrainingRecordByRequestId);
|
||||
|
||||
// 안전교육 기록 수정
|
||||
router.put('/training/:id', visitRequestController.updateTrainingRecord);
|
||||
|
||||
// 안전교육 완료 (서명 포함)
|
||||
router.post('/training/:id/complete', visitRequestController.completeTraining);
|
||||
|
||||
module.exports = router;
|
||||
@@ -248,6 +248,77 @@ const getMonthlyAttendanceStatsService = async (year, month, workerId = null) =>
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출근 체크 목록 조회 (휴가 정보 포함)
|
||||
*/
|
||||
const getCheckinListService = async (date) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 목록 조회 요청', { date });
|
||||
|
||||
try {
|
||||
const checkinList = await AttendanceModel.getCheckinList(date);
|
||||
logger.info('출근 체크 목록 조회 성공', { date, count: checkinList.length });
|
||||
return checkinList;
|
||||
} catch (error) {
|
||||
logger.error('출근 체크 목록 조회 실패', { date, error: error.message });
|
||||
throw new DatabaseError('출근 체크 목록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출근 체크 일괄 저장
|
||||
*/
|
||||
const saveCheckinsService = async (date, checkins) => {
|
||||
if (!date || !checkins || !Array.isArray(checkins)) {
|
||||
throw new ValidationError('날짜와 출근 체크 목록이 필요합니다', {
|
||||
required: ['date', 'checkins'],
|
||||
received: { date, checkins: checkins ? `Array[${checkins.length}]` : null }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 일괄 저장 요청', { date, count: checkins.length });
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
for (const checkin of checkins) {
|
||||
const { worker_id, is_present } = checkin;
|
||||
|
||||
if (!worker_id || is_present === undefined) {
|
||||
logger.warn('출근 체크 데이터 누락', { checkin });
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await AttendanceModel.upsertCheckin({
|
||||
worker_id,
|
||||
record_date: date,
|
||||
is_present
|
||||
});
|
||||
|
||||
results.push({
|
||||
worker_id,
|
||||
record_id: result,
|
||||
is_present
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 일괄 저장 성공', { date, saved: results.length });
|
||||
return {
|
||||
saved_count: results.length,
|
||||
results
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('출근 체크 일괄 저장 실패', { date, error: error.message });
|
||||
throw new DatabaseError('출근 체크 저장 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getDailyAttendanceStatusService,
|
||||
getDailyAttendanceRecordsService,
|
||||
@@ -257,5 +328,7 @@ module.exports = {
|
||||
getAttendanceTypesService,
|
||||
getVacationTypesService,
|
||||
getWorkerVacationBalanceService,
|
||||
getMonthlyAttendanceStatsService
|
||||
getMonthlyAttendanceStatsService,
|
||||
getCheckinListService,
|
||||
saveCheckinsService
|
||||
};
|
||||
|
||||
@@ -122,19 +122,29 @@ function convertToRoman(text) {
|
||||
/**
|
||||
* 사용자명 생성 (중복 확인 및 처리)
|
||||
* @param {string} koreanName - 한글 이름
|
||||
* @param {object} knex - Knex 인스턴스
|
||||
* @param {object} db - Database connection (mysql2 pool or knex)
|
||||
* @returns {Promise<string>} 고유한 username
|
||||
*/
|
||||
async function generateUniqueUsername(koreanName, knex) {
|
||||
async function generateUniqueUsername(koreanName, db) {
|
||||
const baseUsername = hangulToRoman(koreanName);
|
||||
let username = baseUsername;
|
||||
let counter = 1;
|
||||
|
||||
// 중복 확인
|
||||
while (true) {
|
||||
const existing = await knex('users')
|
||||
let existing;
|
||||
|
||||
// mysql2 pool 또는 knex 모두 지원
|
||||
if (typeof db === 'function') {
|
||||
// Knex
|
||||
existing = await db('users')
|
||||
.where('username', username)
|
||||
.first();
|
||||
} else {
|
||||
// mysql2 pool
|
||||
const [rows] = await db.query('SELECT username FROM users WHERE username = ?', [username]);
|
||||
existing = rows[0];
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
break; // 중복 없음
|
||||
|
||||
@@ -675,3 +675,100 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 페이지 권한 관리 스타일 */
|
||||
.page-access-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.page-access-category {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.page-access-category:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-access-category-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-access-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
|
||||
.page-access-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.page-access-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-access-item input[type="checkbox"] {
|
||||
margin-right: var(--space-2);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-access-item .page-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* 권한 관리 버튼 스타일 */
|
||||
.action-btn.permissions {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.permissions:hover {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 페이지 권한 모달 사용자 정보 */
|
||||
.page-access-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.page-access-user-info h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-access-user-info p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
348
web-ui/css/annual-vacation-overview.css
Normal file
348
web-ui/css/annual-vacation-overview.css
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* annual-vacation-overview.css
|
||||
* 연간 연차 현황 페이지 스타일
|
||||
*/
|
||||
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 필터 섹션 */
|
||||
.filter-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 탭 네비게이션 */
|
||||
.tabs-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 1rem 2rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 탭 컨텐츠 */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 월 선택 컨트롤 */
|
||||
.month-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.month-controls .form-select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 차트 섹션 */
|
||||
.chart-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 500px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* 테이블 섹션 */
|
||||
.table-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 3rem 1rem !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
margin: 0 auto 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 사용률 프로그레스 바 */
|
||||
.usage-rate-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-fill.low {
|
||||
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
.progress-fill.medium {
|
||||
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
.progress-fill.high {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%);
|
||||
}
|
||||
|
||||
.usage-rate-text {
|
||||
font-weight: 600;
|
||||
min-width: 45px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-controls button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -809,3 +809,202 @@
|
||||
.confirm-btn:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
저장 결과 모달 스타일
|
||||
================================================ */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container.result-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-title.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.result-title.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-title.warning {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.result-message {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-details h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.result-details ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.result-details li {
|
||||
margin-bottom: 0.25rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result-details p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
일괄제출 버튼 스타일
|
||||
================================================================= */
|
||||
|
||||
.batch-submit-container {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-batch-submit {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-batch-submit:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-batch-submit:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-batch-submit:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 수동입력 섹션 강조 */
|
||||
.manual-input-section {
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.manual-input-section .tbm-session-header {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
}
|
||||
|
||||
/* TBM 세션 그룹 간격 조정 */
|
||||
.tbm-session-group:not(.manual-input-section) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
472
web-ui/css/vacation-allocation.css
Normal file
472
web-ui/css/vacation-allocation.css
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* vacation-allocation.css
|
||||
* 휴가 발생 입력 페이지 스타일
|
||||
*/
|
||||
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 탭 네비게이션 */
|
||||
.tab-navigation {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 1rem 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* 탭 콘텐츠 */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 폼 섹션 */
|
||||
.form-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-input[type="number"] {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 자동 계산 섹션 */
|
||||
.auto-calculate-section {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #93c5fd;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
|
||||
/* 폼 액션 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* 기존 데이터 섹션 */
|
||||
.existing-data-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.existing-data-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 미리보기 섹션 */
|
||||
.preview-section {
|
||||
margin-top: 2rem;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 테이블 */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 3rem 1rem !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
margin: 0 auto 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 액션 버튼 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
min-width: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 배지 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
animation: modalSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-navigation {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
max-height: 95vh;
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,11 @@ function renderUsersTable() {
|
||||
<button class="action-btn edit" onclick="editUser(${user.user_id})">
|
||||
수정
|
||||
</button>
|
||||
${user.role !== 'Admin' && user.role !== 'admin' ? `
|
||||
<button class="action-btn permissions" onclick="managePageAccess(${user.user_id})">
|
||||
권한
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
@@ -351,10 +356,18 @@ function editUser(userId) {
|
||||
elements.modalTitle.textContent = '사용자 정보 수정';
|
||||
}
|
||||
|
||||
// 역할 이름을 HTML select option value로 변환
|
||||
const roleToValueMap = {
|
||||
'Admin': 'admin',
|
||||
'System Admin': 'admin',
|
||||
'User': 'user',
|
||||
'Guest': 'user'
|
||||
};
|
||||
|
||||
// 폼에 데이터 채우기
|
||||
if (elements.userNameInput) elements.userNameInput.value = user.name || '';
|
||||
if (elements.userIdInput) elements.userIdInput.value = user.username || '';
|
||||
if (elements.userRoleSelect) elements.userRoleSelect.value = user.role || '';
|
||||
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 || '';
|
||||
|
||||
@@ -403,11 +416,13 @@ async function saveUser() {
|
||||
const formData = {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value,
|
||||
role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user'
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
};
|
||||
|
||||
console.log('저장할 데이터:', formData);
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.name || !formData.username || !formData.role) {
|
||||
showToast('필수 항목을 모두 입력해주세요.', 'error');
|
||||
@@ -532,3 +547,325 @@ window.deleteUser = deleteUser;
|
||||
window.toggleUserStatus = toggleUserStatus;
|
||||
window.closeUserModal = closeUserModal;
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
|
||||
// ========== 페이지 권한 관리 ========== //
|
||||
let allPages = [];
|
||||
let userPageAccess = [];
|
||||
|
||||
// 모든 페이지 목록 로드
|
||||
async function loadAllPages() {
|
||||
try {
|
||||
const response = await apiCall('/pages');
|
||||
allPages = response.data || response || [];
|
||||
console.log('📄 페이지 목록 로드:', allPages.length, '개');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 목록 로드 오류:', error);
|
||||
allPages = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
async function loadUserPageAccess(userId) {
|
||||
try {
|
||||
const response = await apiCall(`/users/${userId}/page-access`);
|
||||
userPageAccess = response.data?.pageAccess || [];
|
||||
console.log(`👤 사용자 ${userId} 페이지 권한 로드:`, userPageAccess.length, '개');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 페이지 권한 로드 오류:', error);
|
||||
userPageAccess = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
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) {
|
||||
try {
|
||||
const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
const pageAccessData = [];
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
pageAccessData.push({
|
||||
page_id: parseInt(checkbox.dataset.pageId),
|
||||
can_access: checkbox.checked ? 1 : 0
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📤 페이지 권한 저장:', userId, pageAccessData);
|
||||
|
||||
await apiCall(`/users/${userId}/page-access`, 'PUT', {
|
||||
pageAccess: pageAccessData
|
||||
});
|
||||
|
||||
console.log('✅ 페이지 권한 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ========== 페이지 권한 관리 모달 ========== //
|
||||
let currentPageAccessUser = null;
|
||||
|
||||
// 페이지 권한 관리 모달 열기
|
||||
async function managePageAccess(userId) {
|
||||
try {
|
||||
// 페이지 목록이 없으면 로드
|
||||
if (allPages.length === 0) {
|
||||
await loadAllPages();
|
||||
}
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const user = users.find(u => u.user_id === userId);
|
||||
if (!user) {
|
||||
showToast('사용자를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPageAccessUser = user;
|
||||
|
||||
// 사용자의 페이지 권한 로드
|
||||
await loadUserPageAccess(userId);
|
||||
|
||||
// 모달 정보 업데이트
|
||||
const userName = user.name || user.username;
|
||||
document.getElementById('pageAccessModalTitle').textContent = userName + ' - 페이지 권한 관리';
|
||||
document.getElementById('pageAccessUserName').textContent = userName;
|
||||
document.getElementById('pageAccessUserRole').textContent = getRoleName(user.role);
|
||||
document.getElementById('pageAccessUserAvatar').textContent = userName.charAt(0);
|
||||
|
||||
// 페이지 권한 체크박스 렌더링
|
||||
renderPageAccessModalList();
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('pageAccessModal').style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 관리 모달 오류:', error);
|
||||
showToast('페이지 권한 관리를 열 수 없습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 권한 모달 닫기
|
||||
function closePageAccessModal() {
|
||||
document.getElementById('pageAccessModal').style.display = 'none';
|
||||
currentPageAccessUser = null;
|
||||
}
|
||||
|
||||
// 페이지 권한 체크박스 렌더링 (모달용)
|
||||
function renderPageAccessModalList() {
|
||||
const pageAccessList = document.getElementById('pageAccessModalList');
|
||||
if (!pageAccessList) return;
|
||||
|
||||
// 카테고리별로 페이지 그룹화
|
||||
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 savePageAccessFromModal() {
|
||||
if (!currentPageAccessUser) {
|
||||
showToast('사용자 정보가 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await savePageAccess(currentPageAccessUser.user_id);
|
||||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||||
|
||||
// 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요)
|
||||
localStorage.removeItem('userPageAccess');
|
||||
|
||||
closePageAccessModal();
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
showToast('페이지 권한 저장에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 등록
|
||||
window.managePageAccess = managePageAccess;
|
||||
window.closePageAccessModal = closePageAccessModal;
|
||||
|
||||
// 저장 버튼 이벤트 리스너
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const saveBtn = document.getElementById('savePageAccessBtn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', savePageAccessFromModal);
|
||||
}
|
||||
});
|
||||
|
||||
412
web-ui/js/annual-vacation-overview.js
Normal file
412
web-ui/js/annual-vacation-overview.js
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* annual-vacation-overview.js
|
||||
* 연간 연차 현황 페이지 로직 (2-탭 구조)
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let annualUsageChart = null;
|
||||
let currentYear = new Date().getFullYear();
|
||||
let vacationRequests = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
initializeYearSelector();
|
||||
initializeMonthSelector();
|
||||
initializeEventListeners();
|
||||
await loadAnnualUsageData();
|
||||
});
|
||||
|
||||
/**
|
||||
* 연도 선택 초기화
|
||||
*/
|
||||
function initializeYearSelector() {
|
||||
const yearSelect = document.getElementById('yearSelect');
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 최근 5년, 현재 연도, 다음 연도
|
||||
for (let year = currentYear - 5; year <= currentYear + 1; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
yearSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 선택 초기화
|
||||
*/
|
||||
function initializeMonthSelector() {
|
||||
const monthSelect = document.getElementById('monthSelect');
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
// 현재 월을 기본 선택
|
||||
monthSelect.value = currentMonth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// 탭 전환
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.target.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// 조회 버튼
|
||||
document.getElementById('refreshBtn').addEventListener('click', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 연도 변경 시 자동 조회
|
||||
document.getElementById('yearSelect').addEventListener('change', async () => {
|
||||
await loadAnnualUsageData();
|
||||
const activeTab = document.querySelector('.tab-btn.active').dataset.tab;
|
||||
if (activeTab === 'monthlyDetails') {
|
||||
await loadMonthlyDetails();
|
||||
}
|
||||
});
|
||||
|
||||
// 월 선택 변경 시
|
||||
document.getElementById('monthSelect').addEventListener('change', loadMonthlyDetails);
|
||||
|
||||
// 엑셀 다운로드
|
||||
document.getElementById('exportExcelBtn').addEventListener('click', exportToExcel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 활성화
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (tabName === 'annualUsage') {
|
||||
document.getElementById('annualUsageTab').classList.add('active');
|
||||
} else if (tabName === 'monthlyDetails') {
|
||||
document.getElementById('monthlyDetailsTab').classList.add('active');
|
||||
loadMonthlyDetails();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 데이터 로드 (탭 1)
|
||||
*/
|
||||
async function loadAnnualUsageData() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 연도의 모든 승인된 휴가 신청 조회
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${year}-01-01&end_date=${year}-12-31&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('휴가 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
vacationRequests = result.data || [];
|
||||
|
||||
// 월별로 집계
|
||||
const monthlyData = aggregateMonthlyUsage(vacationRequests);
|
||||
|
||||
// 잔여 일수 계산 (올해 총 부여 - 사용)
|
||||
const remainingDays = await calculateRemainingDays(year);
|
||||
|
||||
updateAnnualUsageChart(monthlyData, remainingDays);
|
||||
} catch (error) {
|
||||
console.error('연간 사용 데이터 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 일수 집계
|
||||
*/
|
||||
function aggregateMonthlyUsage(requests) {
|
||||
const monthlyUsage = Array(12).fill(0); // 1월~12월
|
||||
|
||||
requests.forEach(req => {
|
||||
const startDate = new Date(req.start_date);
|
||||
const endDate = new Date(req.end_date);
|
||||
const daysUsed = req.days_used || 0;
|
||||
|
||||
// 간단한 집계: 시작일의 월에 모든 일수를 할당
|
||||
// (더 정교한 계산이 필요하면 일자별로 쪼개야 함)
|
||||
const month = startDate.getMonth(); // 0-11
|
||||
monthlyUsage[month] += daysUsed;
|
||||
});
|
||||
|
||||
return monthlyUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여 일수 계산
|
||||
*/
|
||||
async function calculateRemainingDays(year) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 전체 작업자의 휴가 잔액 조회
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const balances = result.data || [];
|
||||
|
||||
// 전체 잔여 일수 합계
|
||||
const totalRemaining = balances.reduce((sum, item) => sum + (item.remaining_days || 0), 0);
|
||||
return totalRemaining;
|
||||
} catch (error) {
|
||||
console.error('잔여 일수 계산 오류:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 사용 차트 업데이트
|
||||
*/
|
||||
function updateAnnualUsageChart(monthlyData, remainingDays) {
|
||||
const ctx = document.getElementById('annualUsageChart');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (annualUsageChart) {
|
||||
annualUsageChart.destroy();
|
||||
}
|
||||
|
||||
const labels = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', '잔여'];
|
||||
const data = [...monthlyData, remainingDays];
|
||||
|
||||
annualUsageChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '일수',
|
||||
data: data,
|
||||
backgroundColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 0.8)' : 'rgba(59, 130, 246, 0.8)'
|
||||
),
|
||||
borderColor: data.map((_, idx) =>
|
||||
idx === 12 ? 'rgba(16, 185, 129, 1)' : 'rgba(59, 130, 246, 1)'
|
||||
),
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.parsed.y}일`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 상세 기록 로드 (탭 2)
|
||||
*/
|
||||
async function loadMonthlyDetails() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// 해당 월의 모든 휴가 신청 조회 (승인된 것만)
|
||||
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/vacation-requests?start_date=${startDate}&end_date=${endDate}&status=approved`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('월별 데이터를 불러오는데 실패했습니다');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const monthlyRequests = result.data || [];
|
||||
|
||||
updateMonthlyTable(monthlyRequests);
|
||||
} catch (error) {
|
||||
console.error('월별 상세 기록 로드 오류:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 테이블 업데이트
|
||||
*/
|
||||
function updateMonthlyTable(requests) {
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
if (requests.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<p>데이터가 없습니다</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = requests.map(req => {
|
||||
const statusText = req.status === 'approved' ? '승인' : req.status === 'pending' ? '대기' : '거부';
|
||||
const statusClass = req.status === 'approved' ? 'success' : req.status === 'pending' ? 'warning' : 'danger';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${req.worker_name}</td>
|
||||
<td>${req.vacation_type_name}</td>
|
||||
<td>${req.start_date}</td>
|
||||
<td>${req.end_date}</td>
|
||||
<td>${req.days_used}일</td>
|
||||
<td>${req.reason || '-'}</td>
|
||||
<td><span class="badge badge-${statusClass}">${statusText}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드
|
||||
*/
|
||||
function exportToExcel() {
|
||||
const year = document.getElementById('yearSelect').value;
|
||||
const month = document.getElementById('monthSelect').value;
|
||||
const tbody = document.getElementById('monthlyTableBody');
|
||||
|
||||
// 테이블에 데이터가 없으면 중단
|
||||
if (!tbody.querySelector('tr:not(.loading-state)')) {
|
||||
showToast('다운로드할 데이터가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV 형식으로 데이터 생성
|
||||
const headers = ['작업자명', '휴가유형', '시작일', '종료일', '사용일수', '사유', '상태'];
|
||||
const rows = Array.from(tbody.querySelectorAll('tr:not(.loading-state)')).map(tr => {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
return Array.from(cells).map(cell => {
|
||||
// badge 클래스가 있으면 텍스트만 추출
|
||||
const badge = cell.querySelector('.badge');
|
||||
return badge ? badge.textContent : cell.textContent;
|
||||
});
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// BOM 추가 (한글 깨짐 방지)
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `월별_연차_상세_${year}_${month}월.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('엑셀 파일이 다운로드되었습니다', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -244,3 +244,6 @@ setInterval(() => {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, config.app.tokenRefreshInterval); // 5분마다 확인
|
||||
|
||||
// ES6 모듈 export
|
||||
export { API_URL as API_BASE_URL };
|
||||
@@ -14,10 +14,105 @@ function getUser() {
|
||||
function clearAuthData() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('userPageAccess'); // 페이지 권한 캐시도 삭제
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지의 page_key를 URL 경로로부터 추출
|
||||
* 예: /pages/work/tbm.html -> work.tbm
|
||||
* /pages/admin/accounts.html -> admin.accounts
|
||||
* /pages/dashboard.html -> dashboard
|
||||
*/
|
||||
function getCurrentPageKey() {
|
||||
const path = window.location.pathname;
|
||||
|
||||
// /pages/로 시작하는지 확인
|
||||
if (!path.startsWith('/pages/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// /pages/ 이후 경로 추출
|
||||
const pagePath = path.substring(7); // '/pages/' 제거
|
||||
|
||||
// .html 제거
|
||||
const withoutExt = pagePath.replace('.html', '');
|
||||
|
||||
// 슬래시를 점으로 변환
|
||||
const pageKey = withoutExt.replace(/\//g, '.');
|
||||
|
||||
return pageKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한 확인 (캐시 활용)
|
||||
*/
|
||||
async function checkPageAccess(pageKey) {
|
||||
const currentUser = getUser();
|
||||
|
||||
// Admin은 모든 페이지 접근 가능
|
||||
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 프로필 페이지는 모든 사용자 접근 가능
|
||||
if (pageKey && pageKey.startsWith('profile.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 대시보드는 모든 사용자 접근 가능
|
||||
if (pageKey === 'dashboard') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 캐시된 권한 확인
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
// 캐시가 5분 이내인 경우 사용
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/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 false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
// 캐시 저장
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
// 해당 페이지에 대한 접근 권한 확인
|
||||
const pageAccess = accessiblePages.find(p => p.page_key === pageKey);
|
||||
return pageAccess && pageAccess.can_access === 1;
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 체크 오류:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 즉시 실행 함수로 스코프를 보호하고 로직을 실행
|
||||
(function() {
|
||||
(async function() {
|
||||
if (!isLoggedIn()) {
|
||||
console.log('🚨 인증되지 않은 사용자. 로그인 페이지로 이동합니다.');
|
||||
clearAuthData(); // 만약을 위해 한번 더 정리
|
||||
@@ -38,6 +133,25 @@ function clearAuthData() {
|
||||
const userRole = currentUser.role || currentUser.access_level || '사용자';
|
||||
console.log(`✅ ${currentUser.username}(${userRole})님 인증 성공.`);
|
||||
|
||||
// 페이지 접근 권한 체크 (Admin은 건너뛰기)
|
||||
if (currentUser.role !== 'Admin' && currentUser.role !== 'System Admin') {
|
||||
const pageKey = getCurrentPageKey();
|
||||
|
||||
if (pageKey) {
|
||||
console.log(`🔍 페이지 권한 체크: ${pageKey}`);
|
||||
const hasAccess = await checkPageAccess(pageKey);
|
||||
|
||||
if (!hasAccess) {
|
||||
console.error(`🚫 페이지 접근 권한이 없습니다: ${pageKey}`);
|
||||
alert('이 페이지에 접근할 권한이 없습니다.');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ 페이지 접근 권한 확인됨: ${pageKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함.
|
||||
// 전역 변수 할당(window.currentUser) 제거.
|
||||
})();
|
||||
@@ -80,7 +80,18 @@ async function loadIncompleteTbms() {
|
||||
throw new Error(response.message || '미완료 TBM 조회 실패');
|
||||
}
|
||||
|
||||
incompleteTbms = response.data || [];
|
||||
let data = response.data || [];
|
||||
|
||||
// 사용자 권한 확인 및 필터링
|
||||
const user = getUser();
|
||||
if (user && user.role !== 'Admin' && user.access_level !== 'system') {
|
||||
// 일반 사용자: 자신이 생성한 세션만 표시
|
||||
const userId = user.user_id;
|
||||
data = data.filter(tbm => tbm.created_by === userId);
|
||||
}
|
||||
// 관리자는 모든 데이터 표시
|
||||
|
||||
incompleteTbms = data;
|
||||
renderTbmWorkList();
|
||||
} catch (error) {
|
||||
console.error('미완료 TBM 로드 오류:', error);
|
||||
@@ -88,6 +99,14 @@ async function loadIncompleteTbms() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
|
||||
*/
|
||||
function getUser() {
|
||||
const user = localStorage.getItem('user');
|
||||
return user ? JSON.parse(user) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 작업 목록 렌더링 (세션별 그룹화)
|
||||
*/
|
||||
@@ -120,11 +139,42 @@ function renderTbmWorkList() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 수동 입력 섹션 먼저 추가 (맨 위)
|
||||
html += `
|
||||
<div class="tbm-session-group manual-input-section">
|
||||
<div class="tbm-session-header" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
||||
<span class="tbm-session-badge" style="background-color: #92400e; color: white;">수동 입력</span>
|
||||
<span class="tbm-session-info" style="color: white; font-weight: 500;">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualWorkTableBody">
|
||||
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 각 TBM 세션별로 테이블 생성
|
||||
Object.keys(groupedTbms).forEach(key => {
|
||||
const group = groupedTbms[key];
|
||||
html += `
|
||||
<div class="tbm-session-group">
|
||||
<div class="tbm-session-group" data-session-key="${key}">
|
||||
<div class="tbm-session-header">
|
||||
<span class="tbm-session-badge">TBM 세션</span>
|
||||
<span class="tbm-session-date">${formatDate(group.session_date)}</span>
|
||||
@@ -150,7 +200,7 @@ function renderTbmWorkList() {
|
||||
${group.items.map(tbm => {
|
||||
const index = tbm.originalIndex;
|
||||
return `
|
||||
<tr data-index="${index}" data-type="tbm">
|
||||
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
|
||||
<td>
|
||||
<div class="worker-cell">
|
||||
<strong>${tbm.worker_name || '작업자'}</strong>
|
||||
@@ -202,41 +252,17 @@ function renderTbmWorkList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="batch-submit-container">
|
||||
<button type="button"
|
||||
class="btn-batch-submit"
|
||||
onclick="batchSubmitTbmSession('${key}')">
|
||||
📤 이 세션 일괄제출 (${group.items.length}건)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// 수동 입력 섹션 추가
|
||||
html += `
|
||||
<div class="tbm-session-group" style="margin-top: 2rem;">
|
||||
<div class="tbm-session-header" style="background-color: #fef3c7;">
|
||||
<span class="tbm-session-badge" style="background-color: #f59e0b;">수동 입력</span>
|
||||
<span class="tbm-session-info">TBM에 없는 작업을 추가로 입력할 수 있습니다</span>
|
||||
</div>
|
||||
<div class="tbm-table-container">
|
||||
<table class="tbm-work-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="manualWorkTableBody">
|
||||
<!-- 수동 입력 행들이 여기에 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -286,13 +312,20 @@ window.submitTbmWorkReport = async function(index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환
|
||||
const reportDate = tbm.session_date instanceof Date
|
||||
? tbm.session_date.toISOString().split('T')[0]
|
||||
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
|
||||
? tbm.session_date.split('T')[0]
|
||||
: tbm.session_date);
|
||||
|
||||
const reportData = {
|
||||
tbm_assignment_id: tbm.assignment_id,
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
report_date: tbm.session_date,
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
@@ -301,6 +334,9 @@ window.submitTbmWorkReport = async function(index) {
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
};
|
||||
|
||||
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
|
||||
console.log('🔍 tbm 객체:', tbm);
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
|
||||
|
||||
@@ -325,6 +361,155 @@ window.submitTbmWorkReport = async function(index) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* TBM 세션 일괄제출
|
||||
*/
|
||||
window.batchSubmitTbmSession = async function(sessionKey) {
|
||||
// 해당 세션의 모든 항목 가져오기
|
||||
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
|
||||
|
||||
if (sessionRows.length === 0) {
|
||||
showMessage('제출할 항목이 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1단계: 모든 항목 검증
|
||||
const validationErrors = [];
|
||||
const itemsToSubmit = [];
|
||||
|
||||
sessionRows.forEach((row, rowIndex) => {
|
||||
const index = parseInt(row.getAttribute('data-index'));
|
||||
const tbm = incompleteTbms[index];
|
||||
|
||||
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`)?.value);
|
||||
const errorHours = parseFloat(document.getElementById(`errorHours_${index}`)?.value) || 0;
|
||||
const errorTypeId = document.getElementById(`errorType_${index}`)?.value;
|
||||
|
||||
// 검증
|
||||
if (!totalHours || totalHours <= 0) {
|
||||
validationErrors.push(`${tbm.worker_name}: 작업시간 미입력`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > totalHours) {
|
||||
validationErrors.push(`${tbm.worker_name}: 부적합 시간이 총 작업시간 초과`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > 0 && !errorTypeId) {
|
||||
validationErrors.push(`${tbm.worker_name}: 부적합 원인 미선택`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 검증 통과한 항목 저장
|
||||
const reportDate = tbm.session_date instanceof Date
|
||||
? tbm.session_date.toISOString().split('T')[0]
|
||||
: (typeof tbm.session_date === 'string' && tbm.session_date.includes('T')
|
||||
? tbm.session_date.split('T')[0]
|
||||
: tbm.session_date);
|
||||
|
||||
itemsToSubmit.push({
|
||||
index,
|
||||
tbm,
|
||||
data: {
|
||||
tbm_assignment_id: tbm.assignment_id,
|
||||
tbm_session_id: tbm.session_id,
|
||||
worker_id: tbm.worker_id,
|
||||
project_id: tbm.project_id,
|
||||
work_type_id: tbm.work_type_id,
|
||||
report_date: reportDate,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
error_hours: errorHours,
|
||||
error_type_id: errorTypeId || null,
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 검증 실패가 하나라도 있으면 전체 중단
|
||||
if (validationErrors.length > 0) {
|
||||
showSaveResultModal(
|
||||
'error',
|
||||
'일괄제출 검증 실패',
|
||||
'모든 항목이 유효해야 제출할 수 있습니다.',
|
||||
validationErrors
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2단계: 모든 항목 제출
|
||||
const submitBtn = event.target;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '제출 중...';
|
||||
|
||||
const results = {
|
||||
success: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
try {
|
||||
for (const item of itemsToSubmit) {
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data);
|
||||
|
||||
if (response.success) {
|
||||
results.success.push(item.tbm.worker_name);
|
||||
} else {
|
||||
results.failed.push(`${item.tbm.worker_name}: ${response.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed.push(`${item.tbm.worker_name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 표시
|
||||
const totalCount = itemsToSubmit.length;
|
||||
const successCount = results.success.length;
|
||||
const failedCount = results.failed.length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
// 모두 성공
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'일괄제출 완료',
|
||||
`${totalCount}건의 작업보고서가 모두 성공적으로 제출되었습니다.`,
|
||||
results.success.map(name => `✓ ${name}`)
|
||||
);
|
||||
} else if (successCount === 0) {
|
||||
// 모두 실패
|
||||
showSaveResultModal(
|
||||
'error',
|
||||
'일괄제출 실패',
|
||||
`${totalCount}건의 작업보고서가 모두 실패했습니다.`,
|
||||
results.failed.map(msg => `✗ ${msg}`)
|
||||
);
|
||||
} else {
|
||||
// 일부 성공, 일부 실패
|
||||
const details = [
|
||||
...results.success.map(name => `✓ ${name} - 성공`),
|
||||
...results.failed.map(msg => `✗ ${msg}`)
|
||||
];
|
||||
showSaveResultModal(
|
||||
'warning',
|
||||
'일괄제출 부분 완료',
|
||||
`성공: ${successCount}건 / 실패: ${failedCount}건`,
|
||||
details
|
||||
);
|
||||
}
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIncompleteTbms();
|
||||
} catch (error) {
|
||||
console.error('일괄제출 오류:', error);
|
||||
showSaveResultModal('error', '일괄제출 오류', error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 수동 작업 추가
|
||||
*/
|
||||
@@ -1075,7 +1260,8 @@ function showSaveResultModal(type, title, message, details = null) {
|
||||
`;
|
||||
|
||||
// 상세 정보가 있으면 추가
|
||||
if (details && details.length > 0) {
|
||||
if (details) {
|
||||
if (Array.isArray(details) && details.length > 0) {
|
||||
content += `
|
||||
<div class="result-details">
|
||||
<h4>상세 정보:</h4>
|
||||
@@ -1084,6 +1270,13 @@ function showSaveResultModal(type, title, message, details = null) {
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
} else if (typeof details === 'string') {
|
||||
content += `
|
||||
<div class="result-details">
|
||||
<p>${details}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
titleElement.textContent = '저장 결과';
|
||||
@@ -1114,6 +1307,9 @@ function closeSaveResultModal() {
|
||||
document.removeEventListener('keydown', closeSaveResultModal);
|
||||
}
|
||||
|
||||
// 전역에서 접근 가능하도록 window에 할당
|
||||
window.closeSaveResultModal = closeSaveResultModal;
|
||||
|
||||
// 단계 이동
|
||||
function goToStep(stepNumber) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
|
||||
@@ -8,9 +8,31 @@ let currentEquipment = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// axios 설정이 완료될 때까지 대기
|
||||
await waitForAxiosConfig();
|
||||
await loadInitialData();
|
||||
});
|
||||
|
||||
// axios 설정 대기 함수
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
// 최대 5초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
if (!axios.defaults.baseURL) {
|
||||
console.error('⚠️ Axios 설정 시간 초과');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
@@ -28,7 +50,7 @@ async function loadInitialData() {
|
||||
// 설비 목록 로드
|
||||
async function loadEquipments() {
|
||||
try {
|
||||
const response = await axios.get('/api/equipments');
|
||||
const response = await axios.get('/equipments');
|
||||
if (response.data.success) {
|
||||
equipments = response.data.data;
|
||||
renderEquipmentList();
|
||||
@@ -42,7 +64,7 @@ async function loadEquipments() {
|
||||
// 작업장 목록 로드
|
||||
async function loadWorkplaces() {
|
||||
try {
|
||||
const response = await axios.get('/api/workplaces');
|
||||
const response = await axios.get('/workplaces');
|
||||
if (response.data.success) {
|
||||
workplaces = response.data.data;
|
||||
populateWorkplaceFilters();
|
||||
@@ -56,7 +78,7 @@ async function loadWorkplaces() {
|
||||
// 설비 유형 목록 로드
|
||||
async function loadEquipmentTypes() {
|
||||
try {
|
||||
const response = await axios.get('/api/equipments/types');
|
||||
const response = await axios.get('/equipments/types');
|
||||
if (response.data.success) {
|
||||
equipmentTypes = response.data.data;
|
||||
populateTypeFilter();
|
||||
@@ -220,7 +242,7 @@ function openEquipmentModal(equipmentId = null) {
|
||||
// 설비 데이터 로드 (수정용)
|
||||
async function loadEquipmentData(equipmentId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/equipments/${equipmentId}`);
|
||||
const response = await axios.get(`/equipments/${equipmentId}`);
|
||||
if (response.data.success) {
|
||||
const equipment = response.data.data;
|
||||
|
||||
@@ -281,10 +303,10 @@ async function saveEquipment() {
|
||||
let response;
|
||||
if (equipmentId) {
|
||||
// 수정
|
||||
response = await axios.put(`/api/equipments/${equipmentId}`, equipmentData);
|
||||
response = await axios.put(`/equipments/${equipmentId}`, equipmentData);
|
||||
} else {
|
||||
// 추가
|
||||
response = await axios.post('/api/equipments', equipmentData);
|
||||
response = await axios.post('/equipments', equipmentData);
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
@@ -318,7 +340,7 @@ async function deleteEquipment(equipmentId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`/api/equipments/${equipmentId}`);
|
||||
const response = await axios.delete(`/equipments/${equipmentId}`);
|
||||
if (response.data.success) {
|
||||
alert('설비가 삭제되었습니다.');
|
||||
await loadEquipments();
|
||||
|
||||
@@ -17,37 +17,95 @@ const ROLE_NAMES = {
|
||||
* 네비게이션 바 DOM을 사용자 정보와 역할에 맞게 수정하는 프로세서입니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
*/
|
||||
function processNavbarDom(doc) {
|
||||
async function processNavbarDom(doc) {
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
// 1. 역할 기반 메뉴 필터링
|
||||
filterMenuByRole(doc, currentUser.role);
|
||||
// 1. 역할 및 페이지 권한 기반 메뉴 필터링
|
||||
await filterMenuByPageAccess(doc, currentUser);
|
||||
|
||||
// 2. 사용자 정보 채우기
|
||||
populateUserInfo(doc, currentUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 메뉴 항목을 필터링합니다.
|
||||
* 사용자의 페이지 접근 권한에 따라 메뉴 항목을 필터링합니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* @param {string} userRole - 현재 사용자의 역할
|
||||
* @param {object} currentUser - 현재 사용자 객체
|
||||
*/
|
||||
function filterMenuByRole(doc, userRole) {
|
||||
// 대소문자 구분 없이 처리
|
||||
const userRoleLower = (userRole || '').toLowerCase();
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
|
||||
const selectors = [
|
||||
{ role: 'admin', selector: '.admin-only' },
|
||||
{ role: 'system', selector: '.system-only' },
|
||||
{ role: 'leader', selector: '.leader-only' },
|
||||
];
|
||||
// Admin은 모든 메뉴 표시
|
||||
if (userRole === 'admin' || userRole === 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
selectors.forEach(({ role, selector }) => {
|
||||
if (userRoleLower !== role && userRoleLower !== 'system') {
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
try {
|
||||
// 사용자의 페이지 접근 권한 조회
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
// 캐시가 5분 이내인 경우 사용
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/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;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
accessiblePages = data.data.pageAccess || [];
|
||||
|
||||
// 캐시 저장
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
pages: accessiblePages,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
// 접근 가능한 페이지 키 목록
|
||||
const accessiblePageKeys = accessiblePages
|
||||
.filter(p => p.can_access === 1)
|
||||
.map(p => p.page_key);
|
||||
|
||||
// 메뉴 항목에 data-page-key 속성이 있으면 해당 권한 체크
|
||||
const menuItems = doc.querySelectorAll('[data-page-key]');
|
||||
menuItems.forEach(item => {
|
||||
const pageKey = item.getAttribute('data-page-key');
|
||||
|
||||
// 대시보드와 프로필 페이지는 모든 사용자 접근 가능
|
||||
if (pageKey === 'dashboard' || pageKey.startsWith('profile.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한이 없으면 메뉴 항목 제거
|
||||
if (!accessiblePageKeys.includes(pageKey)) {
|
||||
item.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Admin 전용 메뉴는 무조건 제거
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.remove());
|
||||
|
||||
} catch (error) {
|
||||
console.error('메뉴 필터링 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,8 +34,8 @@ const elements = {
|
||||
userName: document.getElementById('userName'),
|
||||
userRole: document.getElementById('userRole'),
|
||||
userInitial: document.getElementById('userInitial'),
|
||||
selectedDate: document.getElementById('selectedDate'),
|
||||
refreshBtn: document.getElementById('refreshBtn'),
|
||||
selectedDate: document.getElementById('selectedDate'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
refreshBtn: document.getElementById('refreshBtn'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
logoutBtn: document.getElementById('logoutBtn'),
|
||||
|
||||
// 요약 카드
|
||||
@@ -45,7 +45,7 @@ const elements = {
|
||||
errorCount: document.getElementById('errorCount'),
|
||||
|
||||
// 컨테이너
|
||||
workStatusContainer: document.getElementById('workStatusContainer'),
|
||||
workStatusContainer: document.getElementById('workStatusContainer'), // 작업장 현황으로 교체되어 없을 수 있음
|
||||
workersContainer: document.getElementById('workersContainer'),
|
||||
toastContainer: document.getElementById('toastContainer')
|
||||
};
|
||||
@@ -85,14 +85,18 @@ async function initializeDashboard() {
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
|
||||
// 날짜 설정
|
||||
// 날짜 설정 (요소가 있을 때만)
|
||||
if (elements.selectedDate) {
|
||||
elements.selectedDate.value = selectedDate;
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 (작업 현황 컨테이너가 있을 때만)
|
||||
if (elements.workStatusContainer) {
|
||||
await loadDashboardData();
|
||||
}
|
||||
|
||||
// 관리자 권한 확인
|
||||
checkAdminAccess();
|
||||
@@ -154,17 +158,21 @@ function updateCurrentTime() {
|
||||
|
||||
// ========== 이벤트 리스너 ========== //
|
||||
function setupEventListeners() {
|
||||
// 날짜 변경
|
||||
// 날짜 변경 (요소가 있을 때만)
|
||||
if (elements.selectedDate) {
|
||||
elements.selectedDate.addEventListener('change', (e) => {
|
||||
selectedDate = e.target.value;
|
||||
loadDashboardData();
|
||||
});
|
||||
}
|
||||
|
||||
// 새로고침 버튼
|
||||
// 새로고침 버튼 (요소가 있을 때만)
|
||||
if (elements.refreshBtn) {
|
||||
elements.refreshBtn.addEventListener('click', () => {
|
||||
loadDashboardData();
|
||||
showToast('데이터를 새로고침했습니다.', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// 로그아웃 버튼 (navbar 컴포넌트가 이미 처리하므로 버튼이 있을 때만)
|
||||
if (elements.logoutBtn) {
|
||||
@@ -747,19 +755,31 @@ async function checkTbmPageAccess() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🛠️ TBM 페이지 권한 확인 중...');
|
||||
const tbmQuickAction = document.getElementById('tbmQuickAction');
|
||||
if (!tbmQuickAction) {
|
||||
console.log('⚠️ TBM 빠른 작업 버튼 요소를 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자의 페이지 접근 권한 조회
|
||||
console.log('🛠️ TBM 페이지 권한 확인 중...', { role: currentUser.role, access_level: currentUser.access_level });
|
||||
|
||||
// Admin은 모든 페이지 접근 가능
|
||||
if (currentUser.role === 'Admin' || currentUser.role === 'System Admin' || currentUser.access_level === 'admin' || currentUser.access_level === 'system') {
|
||||
tbmQuickAction.style.display = 'block';
|
||||
console.log('✅ Admin 사용자 - TBM 빠른 작업 버튼 표시');
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 사용자는 페이지 접근 권한 조회
|
||||
const response = await window.apiCall(`/users/${currentUser.user_id}/page-access`);
|
||||
|
||||
if (response && response.success) {
|
||||
const pageAccess = response.data?.pageAccess || [];
|
||||
|
||||
// 'tbm' 페이지 접근 권한 확인
|
||||
const tbmPage = pageAccess.find(p => p.page_key === 'tbm');
|
||||
const tbmQuickAction = document.getElementById('tbmQuickAction');
|
||||
// 'work.tbm' 페이지 접근 권한 확인 (마이그레이션에서 work.tbm으로 등록함)
|
||||
const tbmPage = pageAccess.find(p => p.page_key === 'work.tbm');
|
||||
|
||||
if (tbmPage && tbmPage.can_access && tbmQuickAction) {
|
||||
if (tbmPage && tbmPage.can_access) {
|
||||
tbmQuickAction.style.display = 'block';
|
||||
console.log('✅ TBM 페이지 접근 권한 있음 - 빠른 작업 버튼 표시');
|
||||
} else {
|
||||
|
||||
447
web-ui/js/safety-management.js
Normal file
447
web-ui/js/safety-management.js
Normal file
@@ -0,0 +1,447 @@
|
||||
// 안전관리 대시보드 JavaScript
|
||||
|
||||
let currentStatus = 'pending';
|
||||
let requests = [];
|
||||
let currentRejectRequestId = null;
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// ==================== 데이터 로드 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 목록 로드
|
||||
*/
|
||||
async function loadRequests() {
|
||||
try {
|
||||
const filters = currentStatus === 'all' ? {} : { status: currentStatus };
|
||||
const queryString = new URLSearchParams(filters).toString();
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests?${queryString}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
requests = response.data || [];
|
||||
renderRequestTable();
|
||||
updateStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 목록 로드 오류:', error);
|
||||
showToast('출입 신청 목록을 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 업데이트
|
||||
*/
|
||||
async function updateStats() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/requests', 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const allRequests = response.data || [];
|
||||
|
||||
const stats = {
|
||||
pending: allRequests.filter(r => r.status === 'pending').length,
|
||||
approved: allRequests.filter(r => r.status === 'approved').length,
|
||||
training_completed: allRequests.filter(r => r.status === 'training_completed').length,
|
||||
rejected: allRequests.filter(r => r.status === 'rejected').length
|
||||
};
|
||||
|
||||
document.getElementById('statPending').textContent = stats.pending;
|
||||
document.getElementById('statApproved').textContent = stats.approved;
|
||||
document.getElementById('statTrainingCompleted').textContent = stats.training_completed;
|
||||
document.getElementById('statRejected').textContent = stats.rejected;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('통계 업데이트 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 렌더링
|
||||
*/
|
||||
function renderRequestTable() {
|
||||
const container = document.getElementById('requestTableContainer');
|
||||
|
||||
if (requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">📭</div>
|
||||
<h3>출입 신청이 없습니다</h3>
|
||||
<p>현재 ${getStatusText(currentStatus)} 상태의 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<table class="request-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일</th>
|
||||
<th>신청자</th>
|
||||
<th>방문자</th>
|
||||
<th>인원</th>
|
||||
<th>방문 작업장</th>
|
||||
<th>방문 일시</th>
|
||||
<th>목적</th>
|
||||
<th>상태</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
requests.forEach(req => {
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${new Date(req.created_at).toLocaleDateString()}</td>
|
||||
<td>${req.requester_full_name || req.requester_name}</td>
|
||||
<td>${req.visitor_company}</td>
|
||||
<td>${req.visitor_count}명</td>
|
||||
<td>${req.category_name} - ${req.workplace_name}</td>
|
||||
<td>${req.visit_date} ${req.visit_time}</td>
|
||||
<td>${req.purpose_name}</td>
|
||||
<td><span class="status-badge ${req.status}">${statusText}</span></td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary" onclick="viewDetail(${req.request_id})">상세</button>
|
||||
${req.status === 'pending' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="approveRequest(${req.request_id})">승인</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="openRejectModal(${req.request_id})">반려</button>
|
||||
` : ''}
|
||||
${req.status === 'approved' ? `
|
||||
<button class="btn btn-sm btn-primary" onclick="startTraining(${req.request_id})">교육 진행</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 텍스트 변환
|
||||
*/
|
||||
function getStatusText(status) {
|
||||
const map = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인 완료',
|
||||
'rejected': '반려',
|
||||
'training_completed': '교육 완료',
|
||||
'all': '전체'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
// ==================== 탭 전환 ====================
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
async function switchTab(status) {
|
||||
currentStatus = status;
|
||||
|
||||
// 탭 활성화 상태 변경
|
||||
document.querySelectorAll('.status-tab').forEach(tab => {
|
||||
if (tab.dataset.status === status) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
await loadRequests();
|
||||
}
|
||||
|
||||
// ==================== 상세보기 ====================
|
||||
|
||||
/**
|
||||
* 상세보기 모달 열기
|
||||
*/
|
||||
async function viewDetail(requestId) {
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const req = response.data;
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
let html = `
|
||||
<div class="detail-grid">
|
||||
<div class="detail-label">신청 번호</div>
|
||||
<div class="detail-value">#${req.request_id}</div>
|
||||
|
||||
<div class="detail-label">신청일</div>
|
||||
<div class="detail-value">${new Date(req.created_at).toLocaleString()}</div>
|
||||
|
||||
<div class="detail-label">신청자</div>
|
||||
<div class="detail-value">${req.requester_full_name || req.requester_name}</div>
|
||||
|
||||
<div class="detail-label">방문자 소속</div>
|
||||
<div class="detail-value">${req.visitor_company}</div>
|
||||
|
||||
<div class="detail-label">방문 인원</div>
|
||||
<div class="detail-value">${req.visitor_count}명</div>
|
||||
|
||||
<div class="detail-label">방문 구역</div>
|
||||
<div class="detail-value">${req.category_name}</div>
|
||||
|
||||
<div class="detail-label">방문 작업장</div>
|
||||
<div class="detail-value">${req.workplace_name}</div>
|
||||
|
||||
<div class="detail-label">방문 날짜</div>
|
||||
<div class="detail-value">${req.visit_date}</div>
|
||||
|
||||
<div class="detail-label">방문 시간</div>
|
||||
<div class="detail-value">${req.visit_time}</div>
|
||||
|
||||
<div class="detail-label">방문 목적</div>
|
||||
<div class="detail-value">${req.purpose_name}</div>
|
||||
|
||||
<div class="detail-label">상태</div>
|
||||
<div class="detail-value"><span class="status-badge ${req.status}">${statusText}</span></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (req.notes) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--gray-50); border-radius: var(--radius-md);">
|
||||
<strong>비고:</strong><br>
|
||||
${req.notes}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.rejection_reason) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--red-50); border-radius: var(--radius-md); color: var(--red-700);">
|
||||
<strong>반려 사유:</strong><br>
|
||||
${req.rejection_reason}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.approved_by) {
|
||||
html += `
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--blue-50); border-radius: var(--radius-md);">
|
||||
<strong>처리 정보:</strong><br>
|
||||
처리자: ${req.approver_name || 'Unknown'}<br>
|
||||
처리 시간: ${new Date(req.approved_at).toLocaleString()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
document.getElementById('detailModal').style.display = 'flex';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상세 정보 로드 오류:', error);
|
||||
showToast('상세 정보를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세보기 모달 닫기
|
||||
*/
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 승인/반려 ====================
|
||||
|
||||
/**
|
||||
* 승인 처리
|
||||
*/
|
||||
async function approveRequest(requestId) {
|
||||
if (!confirm('이 출입 신청을 승인하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}/approve`, 'PUT');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('출입 신청이 승인되었습니다.', 'success');
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
} else {
|
||||
throw new Error(response?.message || '승인 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 처리 오류:', error);
|
||||
showToast(error.message || '승인 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 모달 열기
|
||||
*/
|
||||
function openRejectModal(requestId) {
|
||||
currentRejectRequestId = requestId;
|
||||
document.getElementById('rejectionReason').value = '';
|
||||
document.getElementById('rejectModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 모달 닫기
|
||||
*/
|
||||
function closeRejectModal() {
|
||||
currentRejectRequestId = null;
|
||||
document.getElementById('rejectModal').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 확정
|
||||
*/
|
||||
async function confirmReject() {
|
||||
const reason = document.getElementById('rejectionReason').value.trim();
|
||||
|
||||
if (!reason) {
|
||||
showToast('반려 사유를 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(
|
||||
`/workplace-visits/requests/${currentRejectRequestId}/reject`,
|
||||
'PUT',
|
||||
{ rejection_reason: reason }
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('출입 신청이 반려되었습니다.', 'success');
|
||||
closeRejectModal();
|
||||
await loadRequests();
|
||||
updateStats();
|
||||
} else {
|
||||
throw new Error(response?.message || '반려 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('반려 처리 오류:', error);
|
||||
showToast(error.message || '반려 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 안전교육 진행 ====================
|
||||
|
||||
/**
|
||||
* 안전교육 진행 페이지로 이동
|
||||
*/
|
||||
function startTraining(requestId) {
|
||||
window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.switchTab = switchTab;
|
||||
window.viewDetail = viewDetail;
|
||||
window.closeDetailModal = closeDetailModal;
|
||||
window.approveRequest = approveRequest;
|
||||
window.openRejectModal = openRejectModal;
|
||||
window.closeRejectModal = closeRejectModal;
|
||||
window.confirmReject = confirmReject;
|
||||
window.startTraining = startTraining;
|
||||
553
web-ui/js/safety-training-conduct.js
Normal file
553
web-ui/js/safety-training-conduct.js
Normal file
@@ -0,0 +1,553 @@
|
||||
// 안전교육 진행 페이지 JavaScript
|
||||
|
||||
let requestId = null;
|
||||
let requestData = null;
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let isDrawing = false;
|
||||
let hasSignature = false;
|
||||
let savedSignatures = []; // 저장된 서명 목록
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// URL 파라미터에서 request_id 가져오기
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
requestId = urlParams.get('request_id');
|
||||
|
||||
if (!requestId) {
|
||||
showToast('출입 신청 ID가 없습니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서명 캔버스 초기화
|
||||
initSignatureCanvas();
|
||||
|
||||
// 현재 날짜 표시
|
||||
const today = new Date().toLocaleDateString('ko-KR');
|
||||
document.getElementById('signatureDate').textContent = today;
|
||||
|
||||
// 출입 신청 정보 로드
|
||||
await loadRequestInfo();
|
||||
});
|
||||
|
||||
// ==================== 출입 신청 정보 로드 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 정보 로드
|
||||
*/
|
||||
async function loadRequestInfo() {
|
||||
try {
|
||||
const response = await window.apiCall(`/workplace-visits/requests/${requestId}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
requestData = response.data;
|
||||
|
||||
// 상태 확인 - 승인됨 상태만 진행 가능
|
||||
if (requestData.status !== 'approved') {
|
||||
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
renderRequestInfo();
|
||||
} else {
|
||||
throw new Error(response?.message || '정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 정보 로드 오류:', error);
|
||||
showToast('출입 신청 정보를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 출입 신청 정보 렌더링
|
||||
*/
|
||||
function renderRequestInfo() {
|
||||
const container = document.getElementById('requestInfo');
|
||||
|
||||
// 날짜 포맷 변환
|
||||
const visitDate = new Date(requestData.visit_date);
|
||||
const formattedDate = visitDate.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
|
||||
const html = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신청 번호</div>
|
||||
<div class="info-value">#${requestData.request_id}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">신청자</div>
|
||||
<div class="info-value">${requestData.requester_full_name || requestData.requester_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문자 소속</div>
|
||||
<div class="info-value">${requestData.visitor_company}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 인원</div>
|
||||
<div class="info-value">${requestData.visitor_count}명</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 작업장</div>
|
||||
<div class="info-value">${requestData.category_name} - ${requestData.workplace_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 일시</div>
|
||||
<div class="info-value">${formattedDate} ${requestData.visit_time}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">방문 목적</div>
|
||||
<div class="info-value">${requestData.purpose_name}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ==================== 서명 캔버스 ====================
|
||||
|
||||
/**
|
||||
* 서명 캔버스 초기화
|
||||
*/
|
||||
function initSignatureCanvas() {
|
||||
canvas = document.getElementById('signatureCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 캔버스 크기 설정
|
||||
const container = canvas.parentElement;
|
||||
canvas.width = container.clientWidth - 4; // border 제외
|
||||
canvas.height = 300;
|
||||
|
||||
// 그리기 설정
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// 마우스 이벤트
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
canvas.addEventListener('mouseout', stopDrawing);
|
||||
|
||||
// 터치 이벤트 (모바일, Apple Pencil)
|
||||
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', stopDrawing);
|
||||
canvas.addEventListener('touchcancel', stopDrawing);
|
||||
|
||||
// Pointer Events (Apple Pencil 최적화)
|
||||
if (window.PointerEvent) {
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerup', stopDrawing);
|
||||
canvas.addEventListener('pointercancel', stopDrawing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 시작 (마우스)
|
||||
*/
|
||||
function startDrawing(e) {
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 (마우스)
|
||||
*/
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 중지
|
||||
*/
|
||||
function stopDrawing() {
|
||||
isDrawing = false;
|
||||
ctx.beginPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치 시작 처리
|
||||
*/
|
||||
function handleTouchStart(e) {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* 터치 이동 처리
|
||||
*/
|
||||
function handleTouchMove(e) {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer 시작 처리 (Apple Pencil)
|
||||
*/
|
||||
function handlePointerDown(e) {
|
||||
isDrawing = true;
|
||||
hasSignature = true;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'none';
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer 이동 처리 (Apple Pencil)
|
||||
*/
|
||||
function handlePointerMove(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 지우기
|
||||
*/
|
||||
function clearSignature() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hasSignature = false;
|
||||
document.getElementById('signaturePlaceholder').style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 Base64로 변환
|
||||
*/
|
||||
function getSignatureBase64() {
|
||||
if (!hasSignature) {
|
||||
return null;
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 서명 저장
|
||||
*/
|
||||
function saveSignature() {
|
||||
if (!hasSignature) {
|
||||
showToast('서명이 없습니다. 이름과 서명을 작성해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const signatureImage = getSignatureBase64();
|
||||
const now = new Date();
|
||||
|
||||
savedSignatures.push({
|
||||
id: Date.now(),
|
||||
image: signatureImage,
|
||||
timestamp: now.toLocaleString('ko-KR')
|
||||
});
|
||||
|
||||
// 서명 카운트 업데이트
|
||||
document.getElementById('signatureCount').textContent = savedSignatures.length;
|
||||
|
||||
// 캔버스 초기화
|
||||
clearSignature();
|
||||
|
||||
// 저장된 서명 목록 렌더링
|
||||
renderSavedSignatures();
|
||||
|
||||
// 교육 완료 버튼 활성화
|
||||
updateCompleteButton();
|
||||
|
||||
showToast('서명이 저장되었습니다.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 서명 목록 렌더링
|
||||
*/
|
||||
function renderSavedSignatures() {
|
||||
const container = document.getElementById('savedSignatures');
|
||||
|
||||
if (savedSignatures.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--gray-700);">저장된 서명 목록</h3>';
|
||||
|
||||
savedSignatures.forEach((sig, index) => {
|
||||
html += `
|
||||
<div class="saved-signature-card">
|
||||
<img src="${sig.image}" alt="서명 ${index + 1}">
|
||||
<div class="saved-signature-info">
|
||||
<div class="saved-signature-number">방문자 ${index + 1}</div>
|
||||
<div class="saved-signature-date">저장 시간: ${sig.timestamp}</div>
|
||||
</div>
|
||||
<div class="saved-signature-actions">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSignature(${sig.id})">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 삭제
|
||||
*/
|
||||
function deleteSignature(signatureId) {
|
||||
if (!confirm('이 서명을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
savedSignatures = savedSignatures.filter(sig => sig.id !== signatureId);
|
||||
|
||||
// 서명 카운트 업데이트
|
||||
document.getElementById('signatureCount').textContent = savedSignatures.length;
|
||||
|
||||
// 목록 다시 렌더링
|
||||
renderSavedSignatures();
|
||||
|
||||
// 교육 완료 버튼 상태 업데이트
|
||||
updateCompleteButton();
|
||||
|
||||
showToast('서명이 삭제되었습니다.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 교육 완료 버튼 활성화/비활성화
|
||||
*/
|
||||
function updateCompleteButton() {
|
||||
const completeBtn = document.getElementById('completeBtn');
|
||||
|
||||
// 체크리스트와 서명이 모두 있어야 활성화
|
||||
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
|
||||
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
|
||||
const allChecked = checkedItems.length === checkboxes.length;
|
||||
const hasSignatures = savedSignatures.length > 0;
|
||||
|
||||
completeBtn.disabled = !(allChecked && hasSignatures);
|
||||
}
|
||||
|
||||
// ==================== 교육 완료 처리 ====================
|
||||
|
||||
/**
|
||||
* 교육 완료 처리
|
||||
*/
|
||||
async function completeTraining() {
|
||||
// 체크리스트 검증
|
||||
const checkboxes = document.querySelectorAll('input[name="safety-check"]');
|
||||
const checkedItems = Array.from(checkboxes).filter(cb => cb.checked);
|
||||
|
||||
if (checkedItems.length !== checkboxes.length) {
|
||||
showToast('모든 안전교육 항목을 체크해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 서명 검증
|
||||
if (savedSignatures.length === 0) {
|
||||
showToast('최소 1명 이상의 서명이 필요합니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 확인
|
||||
if (!confirm(`${savedSignatures.length}명의 방문자 안전교육을 완료하시겠습니까?\n완료 후에는 수정할 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 교육 항목 수집
|
||||
const trainingItems = checkedItems.map(cb => cb.value).join(', ');
|
||||
|
||||
// API 호출
|
||||
const userData = localStorage.getItem('user');
|
||||
const currentUser = userData ? JSON.parse(userData) : null;
|
||||
|
||||
if (!currentUser) {
|
||||
showToast('로그인 정보를 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 시간
|
||||
const now = new Date();
|
||||
const currentTime = now.toTimeString().split(' ')[0]; // HH:MM:SS
|
||||
const trainingDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
// 각 서명에 대해 개별적으로 API 호출
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < savedSignatures.length; i++) {
|
||||
const sig = savedSignatures[i];
|
||||
|
||||
const payload = {
|
||||
request_id: requestId,
|
||||
conducted_by: currentUser.user_id,
|
||||
training_date: trainingDate,
|
||||
training_start_time: currentTime,
|
||||
training_end_time: currentTime,
|
||||
training_items: trainingItems,
|
||||
visitor_name: `방문자 ${i + 1}`, // 순번으로 구분
|
||||
signature_image: sig.image,
|
||||
notes: `교육 완료 - ${checkedItems.length}개 항목 (${i + 1}/${savedSignatures.length})`
|
||||
};
|
||||
|
||||
const response = await window.apiCall(
|
||||
'/workplace-visits/training',
|
||||
'POST',
|
||||
payload
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`서명 ${i + 1} 저장 실패:`, response);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount === savedSignatures.length) {
|
||||
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}, 1500);
|
||||
} else if (successCount > 0) {
|
||||
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
|
||||
} else {
|
||||
throw new Error('교육 완료 처리 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('교육 완료 처리 오류:', error);
|
||||
showToast(error.message || '교육 완료 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 뒤로 가기
|
||||
*/
|
||||
function goBack() {
|
||||
if (hasSignature || document.querySelector('input[name="safety-check"]:checked')) {
|
||||
if (!confirm('작성 중인 내용이 있습니다. 정말 나가시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.clearSignature = clearSignature;
|
||||
window.saveSignature = saveSignature;
|
||||
window.deleteSignature = deleteSignature;
|
||||
window.updateCompleteButton = updateCompleteButton;
|
||||
window.completeTraining = completeTraining;
|
||||
window.goBack = goBack;
|
||||
1377
web-ui/js/tbm.js
1377
web-ui/js/tbm.js
File diff suppressed because it is too large
Load Diff
864
web-ui/js/vacation-allocation.js
Normal file
864
web-ui/js/vacation-allocation.js
Normal file
@@ -0,0 +1,864 @@
|
||||
/**
|
||||
* vacation-allocation.js
|
||||
* 휴가 발생 입력 페이지 로직
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let vacationTypes = [];
|
||||
let currentWorkerBalances = [];
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 관리자 권한 체크
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
console.log('Current user:', user);
|
||||
console.log('Role ID:', user.role_id, 'Role:', user.role);
|
||||
|
||||
// role이 'Admin'이거나 role_id가 1 또는 2인 경우 허용
|
||||
const isAdmin = user.role === 'Admin' || [1, 2].includes(user.role_id);
|
||||
|
||||
if (!isAdmin) {
|
||||
console.error('Access denied. User:', user);
|
||||
alert('관리자만 접근할 수 있습니다');
|
||||
window.location.href = '/pages/dashboard.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadInitialData();
|
||||
initializeYearSelectors();
|
||||
initializeTabNavigation();
|
||||
initializeEventListeners();
|
||||
});
|
||||
|
||||
/**
|
||||
* 초기 데이터 로드
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
await Promise.all([
|
||||
loadWorkers(),
|
||||
loadVacationTypes()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자 목록 로드
|
||||
*/
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
console.log('Loading workers... Token:', token ? 'exists' : 'missing');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/workers`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Workers API Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Workers API Error:', errorData);
|
||||
throw new Error(errorData.message || '작업자 목록 로드 실패');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Workers data:', result);
|
||||
workers = result.data || [];
|
||||
|
||||
if (workers.length === 0) {
|
||||
console.warn('No workers found in database');
|
||||
showToast('등록된 작업자가 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 개별 입력 탭 - 작업자 셀렉트 박스
|
||||
const selectWorker = document.getElementById('individualWorker');
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = `${worker.worker_name} (${worker.employment_status === 'employed' ? '재직' : '퇴사'})`;
|
||||
selectWorker.appendChild(option);
|
||||
});
|
||||
console.log(`Loaded ${workers.length} workers successfully`);
|
||||
} catch (error) {
|
||||
console.error('작업자 로드 오류:', error);
|
||||
showToast(`작업자 목록을 불러오는데 실패했습니다: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 목록 로드
|
||||
*/
|
||||
async function loadVacationTypes() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-types`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('휴가 유형 로드 실패');
|
||||
|
||||
const result = await response.json();
|
||||
vacationTypes = result.data || [];
|
||||
|
||||
// 개별 입력 탭 - 휴가 유형 셀렉트 박스
|
||||
const selectType = document.getElementById('individualVacationType');
|
||||
vacationTypes.forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type.id;
|
||||
option.textContent = `${type.type_name} ${type.is_special ? '(특별)' : ''}`;
|
||||
selectType.appendChild(option);
|
||||
});
|
||||
|
||||
// 특별 휴가 관리 탭 테이블 로드
|
||||
loadSpecialTypesTable();
|
||||
} catch (error) {
|
||||
console.error('휴가 유형 로드 오류:', error);
|
||||
showToast('휴가 유형을 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 셀렉터 초기화
|
||||
*/
|
||||
function initializeYearSelectors() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearSelectors = ['individualYear', 'bulkYear'];
|
||||
|
||||
yearSelectors.forEach(selectorId => {
|
||||
const select = document.getElementById(selectorId);
|
||||
for (let year = currentYear - 1; year <= currentYear + 2; year++) {
|
||||
const option = document.createElement('option');
|
||||
option.value = year;
|
||||
option.textContent = `${year}년`;
|
||||
if (year === currentYear) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 네비게이션 초기화
|
||||
*/
|
||||
function initializeTabNavigation() {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const tabName = button.dataset.tab;
|
||||
switchTab(tabName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
// 탭 버튼 활성화
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
||||
|
||||
// 탭 콘텐츠 표시
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 초기화
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// === 탭 1: 개별 입력 ===
|
||||
document.getElementById('individualWorker').addEventListener('change', loadWorkerBalances);
|
||||
document.getElementById('autoCalculateBtn').addEventListener('click', autoCalculateAnnualLeave);
|
||||
document.getElementById('individualSubmitBtn').addEventListener('click', submitIndividualVacation);
|
||||
document.getElementById('individualResetBtn').addEventListener('click', resetIndividualForm);
|
||||
|
||||
// === 탭 2: 일괄 입력 ===
|
||||
document.getElementById('bulkPreviewBtn').addEventListener('click', previewBulkAllocation);
|
||||
document.getElementById('bulkSubmitBtn').addEventListener('click', submitBulkAllocation);
|
||||
|
||||
// === 탭 3: 특별 휴가 관리 ===
|
||||
document.getElementById('addSpecialTypeBtn').addEventListener('click', () => openVacationTypeModal());
|
||||
|
||||
// 모달 닫기
|
||||
document.querySelectorAll('.modal-close').forEach(btn => {
|
||||
btn.addEventListener('click', closeModals);
|
||||
});
|
||||
|
||||
// 모달 폼 제출
|
||||
document.getElementById('vacationTypeForm').addEventListener('submit', submitVacationType);
|
||||
document.getElementById('editBalanceForm').addEventListener('submit', submitEditBalance);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 1: 개별 입력
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 작업자의 기존 휴가 잔액 로드
|
||||
*/
|
||||
async function loadWorkerBalances() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
|
||||
if (!workerId) {
|
||||
document.getElementById('individualTableBody').innerHTML = `
|
||||
<tr><td colspan="8" class="loading-state"><p>작업자를 선택하세요</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/worker/${workerId}/year/${year}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('휴가 잔액 로드 실패');
|
||||
|
||||
const result = await response.json();
|
||||
currentWorkerBalances = result.data || [];
|
||||
|
||||
updateWorkerBalancesTable();
|
||||
} catch (error) {
|
||||
console.error('휴가 잔액 로드 오류:', error);
|
||||
showToast('휴가 잔액을 불러오는데 실패했습니다', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업자 휴가 잔액 테이블 업데이트
|
||||
*/
|
||||
function updateWorkerBalancesTable() {
|
||||
const tbody = document.getElementById('individualTableBody');
|
||||
|
||||
if (currentWorkerBalances.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="8" class="loading-state"><p>등록된 휴가가 없습니다</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = currentWorkerBalances.map(balance => `
|
||||
<tr>
|
||||
<td>${balance.worker_name || '-'}</td>
|
||||
<td>${balance.year}</td>
|
||||
<td>${balance.type_name} ${balance.is_special ? '<span class="badge badge-info">특별</span>' : ''}</td>
|
||||
<td>${balance.total_days}일</td>
|
||||
<td>${balance.used_days}일</td>
|
||||
<td>${balance.remaining_days}일</td>
|
||||
<td>${balance.notes || '-'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editBalance(${balance.id})">✏️</button>
|
||||
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteBalance(${balance.id})">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 계산 (연차만 해당)
|
||||
*/
|
||||
async function autoCalculateAnnualLeave() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
const typeId = document.getElementById('individualVacationType').value;
|
||||
|
||||
if (!workerId) {
|
||||
showToast('작업자를 선택하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택한 휴가 유형이 ANNUAL인지 확인
|
||||
const selectedType = vacationTypes.find(t => t.id == typeId);
|
||||
if (!selectedType || selectedType.type_code !== 'ANNUAL') {
|
||||
showToast('연차(ANNUAL) 유형만 자동 계산이 가능합니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업자의 입사일 조회
|
||||
const worker = workers.find(w => w.worker_id == workerId);
|
||||
if (!worker || !worker.hire_date) {
|
||||
showToast('작업자의 입사일 정보가 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: workerId,
|
||||
hire_date: worker.hire_date,
|
||||
year: year
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '자동 계산 실패');
|
||||
}
|
||||
|
||||
// 계산 결과 표시
|
||||
const resultDiv = document.getElementById('autoCalculateResult');
|
||||
resultDiv.innerHTML = `
|
||||
<strong>자동 계산 완료</strong><br>
|
||||
입사일: ${worker.hire_date}<br>
|
||||
계산된 연차: ${result.data.calculated_days}일<br>
|
||||
아래 "총 부여 일수"에 자동으로 입력됩니다.
|
||||
`;
|
||||
resultDiv.style.display = 'block';
|
||||
|
||||
// 폼에 자동 입력
|
||||
document.getElementById('individualTotalDays').value = result.data.calculated_days;
|
||||
document.getElementById('individualNotes').value = `근속년수 기반 자동 계산 (입사일: ${worker.hire_date})`;
|
||||
|
||||
showToast(result.message, 'success');
|
||||
|
||||
// 기존 데이터 새로고침
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('자동 계산 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 휴가 제출
|
||||
*/
|
||||
async function submitIndividualVacation() {
|
||||
const workerId = document.getElementById('individualWorker').value;
|
||||
const year = document.getElementById('individualYear').value;
|
||||
const typeId = document.getElementById('individualVacationType').value;
|
||||
const totalDays = document.getElementById('individualTotalDays').value;
|
||||
const usedDays = document.getElementById('individualUsedDays').value || 0;
|
||||
const notes = document.getElementById('individualNotes').value;
|
||||
|
||||
if (!workerId || !year || !typeId || !totalDays) {
|
||||
showToast('필수 항목을 모두 입력하세요', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: workerId,
|
||||
vacation_type_id: typeId,
|
||||
year: year,
|
||||
total_days: parseFloat(totalDays),
|
||||
used_days: parseFloat(usedDays),
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '저장 실패');
|
||||
}
|
||||
|
||||
showToast('휴가가 등록되었습니다', 'success');
|
||||
resetIndividualForm();
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('휴가 등록 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 입력 폼 초기화
|
||||
*/
|
||||
function resetIndividualForm() {
|
||||
document.getElementById('individualVacationType').value = '';
|
||||
document.getElementById('individualTotalDays').value = '';
|
||||
document.getElementById('individualUsedDays').value = '0';
|
||||
document.getElementById('individualNotes').value = '';
|
||||
document.getElementById('autoCalculateResult').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 수정 (전역 함수로 노출)
|
||||
*/
|
||||
window.editBalance = function(balanceId) {
|
||||
const balance = currentWorkerBalances.find(b => b.id === balanceId);
|
||||
if (!balance) return;
|
||||
|
||||
document.getElementById('editBalanceId').value = balance.id;
|
||||
document.getElementById('editTotalDays').value = balance.total_days;
|
||||
document.getElementById('editUsedDays').value = balance.used_days;
|
||||
document.getElementById('editNotes').value = balance.notes || '';
|
||||
|
||||
document.getElementById('editBalanceModal').classList.add('active');
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 삭제 (전역 함수로 노출)
|
||||
*/
|
||||
window.deleteBalance = async function(balanceId) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '삭제 실패');
|
||||
}
|
||||
|
||||
showToast('삭제되었습니다', 'success');
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 수정 제출
|
||||
*/
|
||||
async function submitEditBalance(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const balanceId = document.getElementById('editBalanceId').value;
|
||||
const totalDays = document.getElementById('editTotalDays').value;
|
||||
const usedDays = document.getElementById('editUsedDays').value;
|
||||
const notes = document.getElementById('editNotes').value;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/${balanceId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
total_days: parseFloat(totalDays),
|
||||
used_days: parseFloat(usedDays),
|
||||
notes: notes
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '수정 실패');
|
||||
}
|
||||
|
||||
showToast('수정되었습니다', 'success');
|
||||
closeModals();
|
||||
await loadWorkerBalances();
|
||||
} catch (error) {
|
||||
console.error('수정 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 2: 일괄 입력
|
||||
// =============================================================================
|
||||
|
||||
let bulkPreviewData = [];
|
||||
|
||||
/**
|
||||
* 일괄 할당 미리보기
|
||||
*/
|
||||
async function previewBulkAllocation() {
|
||||
const year = document.getElementById('bulkYear').value;
|
||||
const employmentStatus = document.getElementById('bulkEmploymentStatus').value;
|
||||
|
||||
// 필터링된 작업자 목록
|
||||
let targetWorkers = workers;
|
||||
if (employmentStatus === 'employed') {
|
||||
targetWorkers = workers.filter(w => w.employment_status === 'employed');
|
||||
}
|
||||
|
||||
// ANNUAL 유형 찾기
|
||||
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
|
||||
if (!annualType) {
|
||||
showToast('ANNUAL 휴가 유형이 없습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 미리보기 데이터 생성
|
||||
bulkPreviewData = targetWorkers.map(worker => {
|
||||
const hireDate = worker.hire_date;
|
||||
if (!hireDate) {
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
hire_date: '-',
|
||||
years_worked: '-',
|
||||
calculated_days: 0,
|
||||
reason: '입사일 정보 없음',
|
||||
status: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
const calculatedDays = calculateAnnualLeaveDays(hireDate, year);
|
||||
const yearsWorked = calculateYearsWorked(hireDate, year);
|
||||
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
hire_date: hireDate,
|
||||
years_worked: yearsWorked,
|
||||
calculated_days: calculatedDays,
|
||||
reason: getCalculationReason(yearsWorked, calculatedDays),
|
||||
status: 'ready'
|
||||
};
|
||||
});
|
||||
|
||||
updateBulkPreviewTable();
|
||||
document.getElementById('bulkPreviewSection').style.display = 'block';
|
||||
document.getElementById('bulkSubmitBtn').disabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연차 일수 계산 (한국 근로기준법)
|
||||
*/
|
||||
function calculateAnnualLeaveDays(hireDate, targetYear) {
|
||||
const hire = new Date(hireDate);
|
||||
const targetDate = new Date(targetYear, 0, 1);
|
||||
|
||||
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
|
||||
+ (targetDate.getMonth() - hire.getMonth());
|
||||
|
||||
// 1년 미만: 월 1일
|
||||
if (monthsDiff < 12) {
|
||||
return Math.floor(monthsDiff);
|
||||
}
|
||||
|
||||
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
|
||||
const yearsWorked = Math.floor(monthsDiff / 12);
|
||||
const additionalDays = Math.floor((yearsWorked - 1) / 2);
|
||||
|
||||
return Math.min(15 + additionalDays, 25);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근속년수 계산
|
||||
*/
|
||||
function calculateYearsWorked(hireDate, targetYear) {
|
||||
const hire = new Date(hireDate);
|
||||
const targetDate = new Date(targetYear, 0, 1);
|
||||
|
||||
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
|
||||
+ (targetDate.getMonth() - hire.getMonth());
|
||||
|
||||
return (monthsDiff / 12).toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 근거 생성
|
||||
*/
|
||||
function getCalculationReason(yearsWorked, days) {
|
||||
const years = parseFloat(yearsWorked);
|
||||
if (years < 1) {
|
||||
return `입사 ${Math.floor(years * 12)}개월 (월 1일)`;
|
||||
}
|
||||
if (days === 25) {
|
||||
return '최대 25일 (근속 3년 이상)';
|
||||
}
|
||||
return `근속 ${Math.floor(years)}년 (15일 + ${days - 15}일)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 테이블 업데이트
|
||||
*/
|
||||
function updateBulkPreviewTable() {
|
||||
const tbody = document.getElementById('bulkPreviewTableBody');
|
||||
|
||||
tbody.innerHTML = bulkPreviewData.map(item => {
|
||||
const statusBadge = item.status === 'error'
|
||||
? '<span class="badge badge-error">오류</span>'
|
||||
: '<span class="badge badge-success">준비</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.worker_name}</td>
|
||||
<td>${item.hire_date}</td>
|
||||
<td>${item.years_worked}년</td>
|
||||
<td>${item.calculated_days}일</td>
|
||||
<td>${item.reason}</td>
|
||||
<td>${statusBadge}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 할당 제출
|
||||
*/
|
||||
async function submitBulkAllocation() {
|
||||
const year = document.getElementById('bulkYear').value;
|
||||
|
||||
// 오류가 없는 항목만 필터링
|
||||
const validItems = bulkPreviewData.filter(item => item.status !== 'error' && item.calculated_days > 0);
|
||||
|
||||
if (validItems.length === 0) {
|
||||
showToast('생성할 항목이 없습니다', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`${validItems.length}명의 연차를 생성하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ANNUAL 유형 찾기
|
||||
const annualType = vacationTypes.find(t => t.type_code === 'ANNUAL');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const item of validItems) {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-balances/auto-calculate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
worker_id: item.worker_id,
|
||||
hire_date: item.hire_date,
|
||||
year: year
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`완료: ${successCount}건 성공, ${failCount}건 실패`, successCount > 0 ? 'success' : 'error');
|
||||
|
||||
// 미리보기 초기화
|
||||
document.getElementById('bulkPreviewSection').style.display = 'none';
|
||||
document.getElementById('bulkSubmitBtn').disabled = true;
|
||||
bulkPreviewData = [];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 탭 3: 특별 휴가 관리
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 특별 휴가 유형 테이블 로드
|
||||
*/
|
||||
function loadSpecialTypesTable() {
|
||||
const tbody = document.getElementById('specialTypesTableBody');
|
||||
|
||||
if (vacationTypes.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr><td colspan="7" class="loading-state"><p>등록된 휴가 유형이 없습니다</p></td></tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = vacationTypes.map(type => `
|
||||
<tr>
|
||||
<td>${type.type_name}</td>
|
||||
<td>${type.type_code}</td>
|
||||
<td>${type.priority}</td>
|
||||
<td>${type.is_special ? '<span class="badge badge-info">특별</span>' : '-'}</td>
|
||||
<td>${type.is_system ? '<span class="badge badge-warning">시스템</span>' : '-'}</td>
|
||||
<td>${type.description || '-'}</td>
|
||||
<td class="action-buttons">
|
||||
<button class="btn btn-sm btn-secondary btn-icon" onclick="window.editVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>✏️</button>
|
||||
<button class="btn btn-sm btn-danger btn-icon" onclick="window.deleteVacationType(${type.id})" ${type.is_system ? 'disabled' : ''}>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 모달 열기
|
||||
*/
|
||||
function openVacationTypeModal(typeId = null) {
|
||||
const modal = document.getElementById('vacationTypeModal');
|
||||
const form = document.getElementById('vacationTypeForm');
|
||||
form.reset();
|
||||
|
||||
if (typeId) {
|
||||
const type = vacationTypes.find(t => t.id === typeId);
|
||||
if (!type) return;
|
||||
|
||||
document.getElementById('modalTitle').textContent = '휴가 유형 수정';
|
||||
document.getElementById('modalTypeId').value = type.id;
|
||||
document.getElementById('modalTypeName').value = type.type_name;
|
||||
document.getElementById('modalTypeCode').value = type.type_code;
|
||||
document.getElementById('modalPriority').value = type.priority;
|
||||
document.getElementById('modalIsSpecial').checked = type.is_special === 1;
|
||||
document.getElementById('modalDescription').value = type.description || '';
|
||||
} else {
|
||||
document.getElementById('modalTitle').textContent = '휴가 유형 추가';
|
||||
document.getElementById('modalTypeId').value = '';
|
||||
}
|
||||
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 수정 (전역 함수)
|
||||
*/
|
||||
window.editVacationType = function(typeId) {
|
||||
openVacationTypeModal(typeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 유형 삭제 (전역 함수)
|
||||
*/
|
||||
window.deleteVacationType = async function(typeId) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/vacation-types/${typeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '삭제 실패');
|
||||
}
|
||||
|
||||
showToast('삭제되었습니다', 'success');
|
||||
await loadVacationTypes();
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 유형 제출
|
||||
*/
|
||||
async function submitVacationType(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const typeId = document.getElementById('modalTypeId').value;
|
||||
const typeName = document.getElementById('modalTypeName').value;
|
||||
const typeCode = document.getElementById('modalTypeCode').value;
|
||||
const priority = document.getElementById('modalPriority').value;
|
||||
const isSpecial = document.getElementById('modalIsSpecial').checked ? 1 : 0;
|
||||
const description = document.getElementById('modalDescription').value;
|
||||
|
||||
const data = {
|
||||
type_name: typeName,
|
||||
type_code: typeCode.toUpperCase(),
|
||||
priority: parseInt(priority),
|
||||
is_special: isSpecial,
|
||||
description: description
|
||||
};
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const url = typeId
|
||||
? `${API_BASE_URL}/api/vacation-types/${typeId}`
|
||||
: `${API_BASE_URL}/api/vacation-types`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: typeId ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '저장 실패');
|
||||
}
|
||||
|
||||
showToast(typeId ? '수정되었습니다' : '추가되었습니다', 'success');
|
||||
closeModals();
|
||||
await loadVacationTypes();
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 공통 함수
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
*/
|
||||
function closeModals() {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
container.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
234
web-ui/js/vacation-common.js
Normal file
234
web-ui/js/vacation-common.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 휴가 관리 공통 함수
|
||||
* 모든 휴가 관련 페이지에서 사용하는 공통 함수 모음
|
||||
*/
|
||||
|
||||
// 전역 변수
|
||||
window.VacationCommon = {
|
||||
workers: [],
|
||||
vacationTypes: [],
|
||||
currentUser: null
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업자 목록 로드
|
||||
*/
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
window.VacationCommon.workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
return window.VacationCommon.workers;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 유형 목록 로드
|
||||
*/
|
||||
async function loadVacationTypes() {
|
||||
try {
|
||||
const response = await axios.get('/attendance/vacation-types');
|
||||
if (response.data.success) {
|
||||
window.VacationCommon.vacationTypes = response.data.data;
|
||||
return window.VacationCommon.vacationTypes;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 유형 로드 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 가져오기
|
||||
*/
|
||||
function getCurrentUser() {
|
||||
if (!window.VacationCommon.currentUser) {
|
||||
window.VacationCommon.currentUser = JSON.parse(localStorage.getItem('user'));
|
||||
}
|
||||
return window.VacationCommon.currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 목록 렌더링
|
||||
*/
|
||||
function renderVacationRequests(requests, containerId, showActions = false, actionType = 'approval') {
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (!requests || requests.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>휴가 신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>시작일</th>
|
||||
<th>종료일</th>
|
||||
<th>일수</th>
|
||||
<th>상태</th>
|
||||
<th>사유</th>
|
||||
${showActions ? '<th>관리</th>' : ''}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${requests.map(request => {
|
||||
const statusClass = request.status === 'pending' ? 'status-pending' :
|
||||
request.status === 'approved' ? 'status-approved' : 'status-rejected';
|
||||
const statusText = request.status === 'pending' ? '대기' :
|
||||
request.status === 'approved' ? '승인' : '거부';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${request.worker_name || '알 수 없음'}</strong></td>
|
||||
<td>${request.vacation_type_name || request.type_name || '알 수 없음'}</td>
|
||||
<td>${request.start_date}</td>
|
||||
<td>${request.end_date}</td>
|
||||
<td>${request.days_used}일</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
${statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${request.reason || '-'}">
|
||||
${request.reason || '-'}
|
||||
</td>
|
||||
${showActions ? renderActionButtons(request, actionType) : ''}
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액션 버튼 렌더링
|
||||
*/
|
||||
function renderActionButtons(request, actionType) {
|
||||
if (actionType === 'approval' && request.status === 'pending') {
|
||||
return `
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn-small btn-success" onclick="approveVacationRequest(${request.request_id})" title="승인">
|
||||
✓
|
||||
</button>
|
||||
<button class="btn-small btn-danger" onclick="rejectVacationRequest(${request.request_id})" title="거부">
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
} else if (actionType === 'delete' && request.status === 'pending') {
|
||||
return `
|
||||
<td>
|
||||
<button class="btn-small btn-danger" onclick="deleteVacationRequest(${request.request_id})" title="삭제">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
return '<td>-</td>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 승인
|
||||
*/
|
||||
async function approveVacationRequest(requestId) {
|
||||
if (!confirm('이 휴가 신청을 승인하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 승인되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 오류:', error);
|
||||
alert(error.response?.data?.message || '승인 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 거부
|
||||
*/
|
||||
async function rejectVacationRequest(requestId) {
|
||||
const reason = prompt('거부 사유를 입력하세요:');
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.patch(`/vacation-requests/${requestId}/reject`, {
|
||||
review_note: reason
|
||||
});
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 거부되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('거부 오류:', error);
|
||||
alert(error.response?.data?.message || '거부 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 삭제
|
||||
*/
|
||||
async function deleteVacationRequest(requestId) {
|
||||
if (!confirm('이 휴가 신청을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`/vacation-requests/${requestId}`);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 삭제되었습니다.');
|
||||
// 페이지 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert(error.response?.data?.message || '삭제 중 오류가 발생했습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* axios 설정 대기
|
||||
*/
|
||||
function waitForAxiosConfig() {
|
||||
return new Promise((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (axios.defaults.baseURL) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
531
web-ui/js/visit-request.js
Normal file
531
web-ui/js/visit-request.js
Normal file
@@ -0,0 +1,531 @@
|
||||
// 출입 신청 페이지 JavaScript
|
||||
|
||||
let categories = [];
|
||||
let workplaces = [];
|
||||
let mapRegions = [];
|
||||
let visitPurposes = [];
|
||||
let selectedWorkplace = null;
|
||||
let selectedCategory = null;
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let layoutImage = null;
|
||||
|
||||
// ==================== Toast 알림 ====================
|
||||
|
||||
/**
|
||||
* Toast 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info', duration = 3000) {
|
||||
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const iconMap = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${iconMap[type] || 'ℹ️'}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// 자동 제거
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast 컨테이너 생성
|
||||
*/
|
||||
function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toastContainer';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Toast 스타일 추가
|
||||
if (!document.getElementById('toastStyles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'toastStyles';
|
||||
style.textContent = `
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0;
|
||||
transform: translateX(100px);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.toast-success { border-left: 4px solid #10b981; }
|
||||
.toast-error { border-left: 4px solid #ef4444; }
|
||||
.toast-warning { border-left: 4px solid #f59e0b; }
|
||||
.toast-info { border-left: 4px solid #3b82f6; }
|
||||
.toast-icon { font-size: 20px; }
|
||||
.toast-message { font-size: 14px; color: #374151; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 오늘 날짜 기본값 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('visitDate').value = today;
|
||||
document.getElementById('visitDate').min = today;
|
||||
|
||||
// 현재 시간 + 1시간 기본값 설정
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
document.getElementById('visitTime').value = timeString;
|
||||
|
||||
// 데이터 로드
|
||||
await loadCategories();
|
||||
await loadVisitPurposes();
|
||||
await loadMyRequests();
|
||||
|
||||
// 폼 제출 이벤트
|
||||
document.getElementById('visitRequestForm').addEventListener('submit', handleSubmit);
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('workplaceMapCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
});
|
||||
|
||||
// ==================== 데이터 로드 ====================
|
||||
|
||||
/**
|
||||
* 카테고리(공장) 목록 로드
|
||||
*/
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||
if (response && response.success) {
|
||||
categories = response.data || [];
|
||||
|
||||
const categorySelect = document.getElementById('categorySelect');
|
||||
categorySelect.innerHTML = '<option value="">구역을 선택하세요</option>';
|
||||
|
||||
categories.forEach(cat => {
|
||||
if (cat.is_active) {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.category_id;
|
||||
option.textContent = cat.category_name;
|
||||
categorySelect.appendChild(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
window.showToast('카테고리 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 방문 목적 목록 로드
|
||||
*/
|
||||
async function loadVisitPurposes() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/purposes/active', 'GET');
|
||||
if (response && response.success) {
|
||||
visitPurposes = response.data || [];
|
||||
|
||||
const purposeSelect = document.getElementById('visitPurpose');
|
||||
purposeSelect.innerHTML = '<option value="">선택하세요</option>';
|
||||
|
||||
visitPurposes.forEach(purpose => {
|
||||
const option = document.createElement('option');
|
||||
option.value = purpose.purpose_id;
|
||||
option.textContent = purpose.purpose_name;
|
||||
purposeSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('방문 목적 로드 오류:', error);
|
||||
window.showToast('방문 목적 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 출입 신청 목록 로드
|
||||
*/
|
||||
async function loadMyRequests() {
|
||||
try {
|
||||
// localStorage에서 사용자 정보 가져오기
|
||||
const userData = localStorage.getItem('user');
|
||||
const currentUser = userData ? JSON.parse(userData) : null;
|
||||
|
||||
if (!currentUser || !currentUser.user_id) {
|
||||
console.log('사용자 정보 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests?requester_id=${currentUser.user_id}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const requests = response.data || [];
|
||||
renderMyRequests(requests);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('내 신청 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 신청 목록 렌더링
|
||||
*/
|
||||
function renderMyRequests(requests) {
|
||||
const listDiv = document.getElementById('myRequestsList');
|
||||
|
||||
if (requests.length === 0) {
|
||||
listDiv.innerHTML = '<p style="text-align: center; color: var(--gray-500); padding: 32px;">신청 내역이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
requests.forEach(req => {
|
||||
const statusText = {
|
||||
'pending': '승인 대기',
|
||||
'approved': '승인됨',
|
||||
'rejected': '반려됨',
|
||||
'training_completed': '교육 완료'
|
||||
}[req.status] || req.status;
|
||||
|
||||
html += `
|
||||
<div class="request-card">
|
||||
<div class="request-card-header">
|
||||
<h3 style="margin: 0; font-size: var(--text-lg);">${req.visitor_company} (${req.visitor_count}명)</h3>
|
||||
<span class="request-status ${req.status}">${statusText}</span>
|
||||
</div>
|
||||
<div class="request-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 작업장</span>
|
||||
<span class="info-value">${req.category_name} - ${req.workplace_name}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 일시</span>
|
||||
<span class="info-value">${req.visit_date} ${req.visit_time}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">방문 목적</span>
|
||||
<span class="info-value">${req.purpose_name}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">신청일</span>
|
||||
<span class="info-value">${new Date(req.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
${req.rejection_reason ? `<p style="margin-top: 12px; padding: 12px; background: var(--red-50); color: var(--red-700); border-radius: var(--radius-md); font-size: var(--text-sm);"><strong>반려 사유:</strong> ${req.rejection_reason}</p>` : ''}
|
||||
${req.notes ? `<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);"><strong>비고:</strong> ${req.notes}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// ==================== 작업장 지도 모달 ====================
|
||||
|
||||
/**
|
||||
* 지도 모달 열기
|
||||
*/
|
||||
function openMapModal() {
|
||||
document.getElementById('mapModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 모달 닫기
|
||||
*/
|
||||
function closeMapModal() {
|
||||
document.getElementById('mapModal').style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 지도 로드
|
||||
*/
|
||||
async function loadWorkplaceMap() {
|
||||
const categoryId = document.getElementById('categorySelect').value;
|
||||
if (!categoryId) {
|
||||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCategory = categories.find(c => c.category_id == categoryId);
|
||||
|
||||
try {
|
||||
// 작업장 목록 로드
|
||||
const workplacesResponse = await window.apiCall(`/workplaces/categories/${categoryId}`, 'GET');
|
||||
if (workplacesResponse && workplacesResponse.success) {
|
||||
workplaces = workplacesResponse.data || [];
|
||||
}
|
||||
|
||||
// 지도 영역 로드
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${categoryId}/map-regions`, 'GET');
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
}
|
||||
|
||||
// 레이아웃 이미지가 있으면 표시
|
||||
if (selectedCategory && selectedCategory.layout_image) {
|
||||
// API_BASE_URL에서 /api 제거하고 이미지 경로 생성
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
console.log('이미지 URL:', fullImageUrl);
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
document.getElementById('mapCanvasContainer').style.display = 'block';
|
||||
} else {
|
||||
window.showToast('선택한 구역에 레이아웃 지도가 없습니다.', 'warning');
|
||||
document.getElementById('mapCanvasContainer').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업장 지도 로드 오류:', error);
|
||||
window.showToast('작업장 지도 로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 캔버스에 로드
|
||||
*/
|
||||
function loadImageToCanvas(imagePath) {
|
||||
const img = new Image();
|
||||
// crossOrigin 제거 - 같은 도메인이므로 불필요
|
||||
img.onload = function() {
|
||||
// 캔버스 크기 설정
|
||||
const maxWidth = 800;
|
||||
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
layoutImage = img;
|
||||
|
||||
// 영역 표시
|
||||
drawRegions();
|
||||
|
||||
// 클릭 이벤트 등록
|
||||
canvas.onclick = handleCanvasClick;
|
||||
};
|
||||
img.onerror = function() {
|
||||
window.showToast('지도 이미지를 불러올 수 없습니다.', 'error');
|
||||
};
|
||||
img.src = imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 영역 그리기
|
||||
*/
|
||||
function drawRegions() {
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
// 영역 박스
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.1)';
|
||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 작업장 이름
|
||||
ctx.fillStyle = '#10b981';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔버스 클릭 핸들러
|
||||
*/
|
||||
function handleCanvasClick(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
// 클릭한 위치의 영역 찾기
|
||||
for (const region of mapRegions) {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
|
||||
// 작업장 선택
|
||||
selectWorkplace(region);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.showToast('작업장 영역을 클릭해주세요.', 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택
|
||||
*/
|
||||
function selectWorkplace(region) {
|
||||
selectedWorkplace = {
|
||||
workplace_id: region.workplace_id,
|
||||
workplace_name: region.workplace_name,
|
||||
category_id: selectedCategory.category_id,
|
||||
category_name: selectedCategory.category_name
|
||||
};
|
||||
|
||||
// 선택 표시
|
||||
const selectionDiv = document.getElementById('workplaceSelection');
|
||||
selectionDiv.classList.add('selected');
|
||||
selectionDiv.innerHTML = `
|
||||
<div class="icon">✅</div>
|
||||
<div class="text">${selectedCategory.category_name} - ${region.workplace_name}</div>
|
||||
`;
|
||||
|
||||
// 상세 정보 카드 표시
|
||||
const infoDiv = document.getElementById('selectedWorkplaceInfo');
|
||||
infoDiv.style.display = 'block';
|
||||
infoDiv.innerHTML = `
|
||||
<div class="workplace-info-card">
|
||||
<div class="icon">📍</div>
|
||||
<div class="details">
|
||||
<div class="name">${region.workplace_name}</div>
|
||||
<div class="category">${selectedCategory.category_name}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkplaceSelection()">변경</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 모달 닫기
|
||||
closeMapModal();
|
||||
|
||||
window.showToast(`${region.workplace_name} 작업장이 선택되었습니다.`, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택 초기화
|
||||
*/
|
||||
function clearWorkplaceSelection() {
|
||||
selectedWorkplace = null;
|
||||
|
||||
const selectionDiv = document.getElementById('workplaceSelection');
|
||||
selectionDiv.classList.remove('selected');
|
||||
selectionDiv.innerHTML = `
|
||||
<div class="icon">📍</div>
|
||||
<div class="text">지도에서 작업장을 선택하세요</div>
|
||||
`;
|
||||
|
||||
document.getElementById('selectedWorkplaceInfo').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 폼 제출 ====================
|
||||
|
||||
/**
|
||||
* 출입 신청 제출
|
||||
*/
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedWorkplace) {
|
||||
window.showToast('작업장을 선택해주세요.', 'warning');
|
||||
openMapModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
visitor_company: document.getElementById('visitorCompany').value.trim(),
|
||||
visitor_count: parseInt(document.getElementById('visitorCount').value),
|
||||
category_id: selectedWorkplace.category_id,
|
||||
workplace_id: selectedWorkplace.workplace_id,
|
||||
visit_date: document.getElementById('visitDate').value,
|
||||
visit_time: document.getElementById('visitTime').value,
|
||||
purpose_id: parseInt(document.getElementById('visitPurpose').value),
|
||||
notes: document.getElementById('notes').value.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/workplace-visits/requests', 'POST', formData);
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('출입 신청 및 안전교육 신청이 완료되었습니다. 안전관리자의 승인을 기다려주세요.', 'success');
|
||||
|
||||
// 폼 초기화
|
||||
resetForm();
|
||||
|
||||
// 내 신청 목록 새로고침
|
||||
await loadMyRequests();
|
||||
} else {
|
||||
throw new Error(response?.message || '신청 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 오류:', error);
|
||||
window.showToast(error.message || '출입 신청 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 초기화
|
||||
*/
|
||||
function resetForm() {
|
||||
document.getElementById('visitRequestForm').reset();
|
||||
clearWorkplaceSelection();
|
||||
|
||||
// 오늘 날짜와 시간 다시 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('visitDate').value = today;
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + 1);
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
document.getElementById('visitTime').value = timeString;
|
||||
|
||||
document.getElementById('visitorCount').value = 1;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.showToast = showToast;
|
||||
window.openMapModal = openMapModal;
|
||||
window.closeMapModal = closeMapModal;
|
||||
window.loadWorkplaceMap = loadWorkplaceMap;
|
||||
window.clearWorkplaceSelection = clearWorkplaceSelection;
|
||||
window.resetForm = resetForm;
|
||||
494
web-ui/js/workplace-layout-map.js
Normal file
494
web-ui/js/workplace-layout-map.js
Normal file
@@ -0,0 +1,494 @@
|
||||
// 작업장 레이아웃 지도 관리
|
||||
|
||||
// 전역 변수
|
||||
let layoutMapImage = null;
|
||||
let mapRegions = [];
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let currentRect = null;
|
||||
|
||||
// ==================== 레이아웃 지도 모달 ====================
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 모달 열기
|
||||
*/
|
||||
async function openLayoutMapModal() {
|
||||
// window 객체에서 currentCategoryId 가져오기
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
if (!currentCategoryId) {
|
||||
window.window.showToast('공장을 먼저 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
if (!modal) return;
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('regionCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 현재 카테고리의 레이아웃 이미지 및 영역 로드
|
||||
await loadLayoutMapData();
|
||||
|
||||
// 작업장 선택 옵션 업데이트
|
||||
updateWorkplaceSelect();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 모달 닫기
|
||||
*/
|
||||
function closeLayoutMapModal() {
|
||||
const modal = document.getElementById('layoutMapModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// 캔버스 이벤트 리스너 제거
|
||||
if (canvas) {
|
||||
canvas.removeEventListener('mousedown', startDrawing);
|
||||
canvas.removeEventListener('mousemove', draw);
|
||||
canvas.removeEventListener('mouseup', stopDrawing);
|
||||
}
|
||||
|
||||
// 메인 페이지의 레이아웃 미리보기 업데이트
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
|
||||
if (currentCategoryId && categories) {
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (category && window.updateLayoutPreview) {
|
||||
window.updateLayoutPreview(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 지도 데이터 로드
|
||||
*/
|
||||
async function loadLayoutMapData() {
|
||||
try {
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
|
||||
// 현재 카테고리 정보 가져오기
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (!category) return;
|
||||
|
||||
// 레이아웃 이미지 표시
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
if (category.layout_image) {
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = category.layout_image.startsWith('http')
|
||||
? category.layout_image
|
||||
: `${window.API_BASE_URL || 'http://localhost:20005/api'}${category.layout_image}`.replace('/api/', '/');
|
||||
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
||||
`;
|
||||
|
||||
// 캔버스에도 이미지 로드
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
} else {
|
||||
currentImageDiv.innerHTML = '<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>';
|
||||
}
|
||||
|
||||
// 영역 데이터 로드
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${currentCategoryId}/map-regions`, 'GET');
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
} else {
|
||||
mapRegions = [];
|
||||
}
|
||||
|
||||
renderRegionList();
|
||||
} catch (error) {
|
||||
console.error('레이아웃 지도 데이터 로딩 오류:', error);
|
||||
window.window.showToast('레이아웃 지도 데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 캔버스에 로드
|
||||
*/
|
||||
function loadImageToCanvas(imagePath) {
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
// 캔버스 크기를 이미지 크기에 맞춤 (최대 800px)
|
||||
const maxWidth = 800;
|
||||
const scale = img.width > maxWidth ? maxWidth / img.width : 1;
|
||||
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
layoutMapImage = img;
|
||||
|
||||
// 기존 영역들 그리기
|
||||
drawExistingRegions();
|
||||
|
||||
// 캔버스 이벤트 리스너 등록
|
||||
setupCanvasEvents();
|
||||
};
|
||||
img.src = imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택 옵션 업데이트
|
||||
*/
|
||||
function updateWorkplaceSelect() {
|
||||
const select = document.getElementById('regionWorkplaceSelect');
|
||||
if (!select) return;
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const workplaces = window.workplaces;
|
||||
|
||||
// 현재 카테고리의 작업장만 필터링
|
||||
const categoryWorkplaces = workplaces.filter(w => w.category_id == currentCategoryId);
|
||||
|
||||
let options = '<option value="">작업장을 선택하세요</option>';
|
||||
categoryWorkplaces.forEach(wp => {
|
||||
// 이미 영역이 정의된 작업장은 표시
|
||||
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
|
||||
options += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
|
||||
});
|
||||
|
||||
select.innerHTML = options;
|
||||
}
|
||||
|
||||
// ==================== 이미지 업로드 ====================
|
||||
|
||||
/**
|
||||
* 이미지 미리보기
|
||||
*/
|
||||
function previewLayoutImage(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${e.target.result}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="미리보기">
|
||||
<p style="color: #64748b; font-size: 14px; margin-top: 8px;">미리보기 (저장하려면 "이미지 업로드" 버튼을 클릭하세요)</p>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 이미지 업로드
|
||||
*/
|
||||
async function uploadLayoutImage() {
|
||||
const fileInput = document.getElementById('layoutImageFile');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
window.showToast('이미지 파일을 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
if (!currentCategoryId) {
|
||||
window.showToast('공장을 먼저 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// FormData 생성
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
// 업로드 요청
|
||||
const response = await fetch(`${window.API_BASE_URL || 'http://localhost:20005/api'}/workplaces/categories/${currentCategoryId}/layout-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
window.showToast('이미지가 성공적으로 업로드되었습니다.', 'success');
|
||||
|
||||
// 이미지 경로를 전체 URL로 변환
|
||||
const fullImageUrl = `${window.API_BASE_URL || 'http://localhost:20005/api'}${result.data.image_path}`.replace('/api/', '/');
|
||||
|
||||
// 이미지를 캔버스에 로드
|
||||
loadImageToCanvas(fullImageUrl);
|
||||
|
||||
// 현재 이미지 미리보기도 업데이트
|
||||
const currentImageDiv = document.getElementById('currentLayoutImage');
|
||||
if (currentImageDiv) {
|
||||
currentImageDiv.innerHTML = `
|
||||
<img src="${fullImageUrl}" style="max-width: 100%; max-height: 300px; border-radius: 4px;" alt="현재 레이아웃 이미지">
|
||||
`;
|
||||
}
|
||||
|
||||
// 카테고리 데이터 새로고침 (workplace-management.js의 loadCategories 함수 호출)
|
||||
if (window.loadCategories) {
|
||||
await window.loadCategories();
|
||||
|
||||
// 메인 페이지 미리보기도 업데이트
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
const categories = window.categories;
|
||||
if (currentCategoryId && categories && window.updateLayoutPreview) {
|
||||
const category = categories.find(c => c.category_id == currentCategoryId);
|
||||
if (category) {
|
||||
window.updateLayoutPreview(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || '업로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
window.showToast(error.message || '이미지 업로드 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 영역 그리기 ====================
|
||||
|
||||
/**
|
||||
* 캔버스 이벤트 설정
|
||||
*/
|
||||
function setupCanvasEvents() {
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
canvas.addEventListener('mouseleave', stopDrawing);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 시작
|
||||
*/
|
||||
function startDrawing(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
isDrawing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기
|
||||
*/
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
// 캔버스 다시 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (layoutMapImage) {
|
||||
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 기존 영역들 그리기
|
||||
drawExistingRegions();
|
||||
|
||||
// 현재 그리는 사각형
|
||||
const width = currentX - startX;
|
||||
const height = currentY - startY;
|
||||
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(startX, startY, width, height);
|
||||
|
||||
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||
ctx.fillRect(startX, startY, width, height);
|
||||
|
||||
currentRect = { startX, startY, endX: currentX, endY: currentY };
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리기 종료
|
||||
*/
|
||||
function stopDrawing() {
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 영역들 그리기
|
||||
*/
|
||||
function drawExistingRegions() {
|
||||
mapRegions.forEach(region => {
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
ctx.strokeStyle = '#10b981';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
ctx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 작업장 이름 표시
|
||||
ctx.fillStyle = '#10b981';
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 영역 지우기
|
||||
*/
|
||||
function clearCurrentRegion() {
|
||||
currentRect = null;
|
||||
|
||||
// 캔버스 다시 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (layoutMapImage) {
|
||||
ctx.drawImage(layoutMapImage, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
drawExistingRegions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 저장
|
||||
*/
|
||||
async function saveRegion() {
|
||||
const workplaceId = document.getElementById('regionWorkplaceSelect').value;
|
||||
|
||||
if (!workplaceId) {
|
||||
window.showToast('작업장을 선택해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentRect) {
|
||||
window.showToast('영역을 그려주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCategoryId = window.currentCategoryId;
|
||||
|
||||
try {
|
||||
// 비율로 변환 (0~100%)
|
||||
const xStart = Math.min(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
||||
const yStart = Math.min(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
||||
const xEnd = Math.max(currentRect.startX, currentRect.endX) / canvas.width * 100;
|
||||
const yEnd = Math.max(currentRect.startY, currentRect.endY) / canvas.height * 100;
|
||||
|
||||
// 기존 영역이 있는지 확인
|
||||
const existingRegion = mapRegions.find(r => r.workplace_id == workplaceId);
|
||||
|
||||
const regionData = {
|
||||
workplace_id: parseInt(workplaceId),
|
||||
category_id: parseInt(currentCategoryId),
|
||||
x_start: xStart.toFixed(2),
|
||||
y_start: yStart.toFixed(2),
|
||||
x_end: xEnd.toFixed(2),
|
||||
y_end: yEnd.toFixed(2),
|
||||
shape: 'rect'
|
||||
};
|
||||
|
||||
let response;
|
||||
if (existingRegion) {
|
||||
// 수정
|
||||
response = await window.apiCall(`/workplaces/map-regions/${existingRegion.region_id}`, 'PUT', regionData);
|
||||
} else {
|
||||
// 신규 등록
|
||||
response = await window.apiCall('/workplaces/map-regions', 'POST', regionData);
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('영역이 성공적으로 저장되었습니다.', 'success');
|
||||
|
||||
// 데이터 새로고침
|
||||
await loadLayoutMapData();
|
||||
|
||||
// 현재 그림 초기화
|
||||
clearCurrentRegion();
|
||||
|
||||
// 작업장 선택 초기화
|
||||
document.getElementById('regionWorkplaceSelect').value = '';
|
||||
} else {
|
||||
throw new Error(response?.message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('영역 저장 오류:', error);
|
||||
window.showToast(error.message || '영역 저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 목록 렌더링
|
||||
*/
|
||||
function renderRegionList() {
|
||||
const listDiv = document.getElementById('regionList');
|
||||
if (!listDiv) return;
|
||||
|
||||
if (mapRegions.length === 0) {
|
||||
listDiv.innerHTML = '<p style="color: #94a3b8; text-align: center; padding: 20px;">정의된 영역이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let listHtml = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
mapRegions.forEach(region => {
|
||||
listHtml += `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: white; border: 1px solid #e5e7eb; border-radius: 6px;">
|
||||
<div>
|
||||
<span style="font-weight: 600; color: #1e293b;">${region.workplace_name}</span>
|
||||
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">
|
||||
(${region.x_start}%, ${region.y_start}%) ~ (${region.x_end}%, ${region.y_end}%)
|
||||
</span>
|
||||
</div>
|
||||
<button onclick="deleteRegion(${region.region_id})" class="btn-small btn-delete" style="padding: 4px 8px; font-size: 12px;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listHtml += '</div>';
|
||||
listDiv.innerHTML = listHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 삭제
|
||||
*/
|
||||
async function deleteRegion(regionId) {
|
||||
if (!confirm('이 영역을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/workplaces/map-regions/${regionId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
window.showToast('영역이 삭제되었습니다.', 'success');
|
||||
await loadLayoutMapData();
|
||||
} else {
|
||||
throw new Error(response?.message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('영역 삭제 오류:', error);
|
||||
window.showToast(error.message || '영역 삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.openLayoutMapModal = openLayoutMapModal;
|
||||
window.closeLayoutMapModal = closeLayoutMapModal;
|
||||
window.previewLayoutImage = previewLayoutImage;
|
||||
window.uploadLayoutImage = uploadLayoutImage;
|
||||
window.clearCurrentRegion = clearCurrentRegion;
|
||||
window.saveRegion = saveRegion;
|
||||
window.deleteRegion = deleteRegion;
|
||||
448
web-ui/js/workplace-status.js
Normal file
448
web-ui/js/workplace-status.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// 작업장 현황 JavaScript
|
||||
|
||||
let selectedCategory = null;
|
||||
let workplaceData = [];
|
||||
let mapRegions = []; // 작업장 영역 데이터
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let canvasImage = null;
|
||||
|
||||
// 금일 TBM 작업자 데이터
|
||||
let todayWorkers = [];
|
||||
|
||||
// 금일 출입 신청 데이터
|
||||
let todayVisitors = [];
|
||||
|
||||
// ==================== 초기화 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadCategories();
|
||||
|
||||
// 이벤트 리스너
|
||||
document.getElementById('categorySelect').addEventListener('change', onCategoryChange);
|
||||
document.getElementById('refreshMapBtn').addEventListener('click', refreshMapData);
|
||||
|
||||
// 기본값으로 제1공장 선택
|
||||
await selectFirstCategory();
|
||||
});
|
||||
|
||||
// ==================== 카테고리 (공장) 로드 ====================
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await window.apiCall('/workplaces/categories', 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const categories = response.data || [];
|
||||
const select = document.getElementById('categorySelect');
|
||||
|
||||
categories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.category_id;
|
||||
option.textContent = cat.category_name;
|
||||
option.dataset.layoutImage = cat.layout_image;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 번째 카테고리 자동 선택
|
||||
*/
|
||||
async function selectFirstCategory() {
|
||||
const select = document.getElementById('categorySelect');
|
||||
if (select.options.length > 1) {
|
||||
// 첫 번째 옵션 선택 (인덱스 0은 "공장을 선택하세요")
|
||||
select.selectedIndex = 1;
|
||||
// 변경 이벤트 트리거
|
||||
await onCategoryChange({ target: select });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 공장 선택 ====================
|
||||
|
||||
async function onCategoryChange(e) {
|
||||
const categoryId = e.target.value;
|
||||
|
||||
if (!categoryId) {
|
||||
document.getElementById('workplaceMapContainer').style.display = 'none';
|
||||
document.getElementById('mapPlaceholder').style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
const layoutImage = selectedOption.dataset.layoutImage;
|
||||
|
||||
selectedCategory = {
|
||||
category_id: categoryId,
|
||||
category_name: selectedOption.textContent,
|
||||
layout_image: layoutImage
|
||||
};
|
||||
|
||||
// 지도 로드
|
||||
await loadWorkplaceMap();
|
||||
|
||||
// 금일 작업 데이터 로드
|
||||
await loadTodayData();
|
||||
|
||||
// 지도 렌더링
|
||||
renderMap();
|
||||
}
|
||||
|
||||
// ==================== 작업장 지도 로드 ====================
|
||||
|
||||
async function loadWorkplaceMap() {
|
||||
try {
|
||||
// 작업장 데이터 로드
|
||||
const response = await window.apiCall(`/workplaces?category_id=${selectedCategory.category_id}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
workplaceData = response.data || [];
|
||||
}
|
||||
|
||||
// 작업장 영역 데이터 로드 (map-regions API)
|
||||
const regionsResponse = await window.apiCall(`/workplaces/categories/${selectedCategory.category_id}/map-regions`, 'GET');
|
||||
|
||||
if (regionsResponse && regionsResponse.success) {
|
||||
mapRegions = regionsResponse.data || [];
|
||||
console.log('[지도] 로드된 영역:', mapRegions);
|
||||
}
|
||||
|
||||
// 이미지 로드
|
||||
await loadMapImage();
|
||||
|
||||
// 지도 컨테이너 표시
|
||||
document.getElementById('mapPlaceholder').style.display = 'none';
|
||||
document.getElementById('workplaceMapContainer').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('작업장 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMapImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
const fullImageUrl = selectedCategory.layout_image.startsWith('http')
|
||||
? selectedCategory.layout_image
|
||||
: `${baseUrl}${selectedCategory.layout_image}`;
|
||||
|
||||
img.onload = () => {
|
||||
canvasImage = img;
|
||||
|
||||
// 캔버스 초기화
|
||||
canvas = document.getElementById('workplaceMapCanvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 클릭 이벤트
|
||||
canvas.addEventListener('click', onMapClick);
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('이미지 로드 실패:', fullImageUrl);
|
||||
reject();
|
||||
};
|
||||
|
||||
img.src = fullImageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 금일 데이터 로드 ====================
|
||||
|
||||
async function loadTodayData() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// TBM 작업자 데이터 로드
|
||||
await loadTodayWorkers(today);
|
||||
|
||||
// 출입 신청 데이터 로드
|
||||
await loadTodayVisitors(today);
|
||||
}
|
||||
|
||||
async function loadTodayWorkers(date) {
|
||||
try {
|
||||
const response = await window.apiCall(`/tbm/sessions/date/${date}`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const sessions = response.data || [];
|
||||
todayWorkers = [];
|
||||
|
||||
// 각 세션의 작업 정보 추가
|
||||
sessions.forEach(session => {
|
||||
if (session.workplace_id) {
|
||||
const memberCount = session.team_member_count || 0;
|
||||
const leaderCount = session.leader_id ? 1 : 0;
|
||||
const totalCount = memberCount + leaderCount;
|
||||
|
||||
todayWorkers.push({
|
||||
workplace_id: session.workplace_id,
|
||||
task_name: session.task_name || '작업',
|
||||
work_location: session.work_location || '',
|
||||
member_count: totalCount,
|
||||
project_name: session.project_name || ''
|
||||
});
|
||||
|
||||
console.log(`[TBM] 작업 추가: ${session.work_location || session.workplace_id} - ${session.task_name} (${totalCount}명)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('로드된 작업자:', todayWorkers);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TBM 작업자 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTodayVisitors(date) {
|
||||
try {
|
||||
// 날짜 형식 확인 (YYYY-MM-DD)
|
||||
const formattedDate = date.split('T')[0];
|
||||
|
||||
const response = await window.apiCall(`/workplace-visits/requests`, 'GET');
|
||||
|
||||
if (response && response.success) {
|
||||
const requests = response.data || [];
|
||||
|
||||
// 금일 날짜와 승인된 요청 필터링
|
||||
todayVisitors = requests.filter(req => {
|
||||
const visitDate = new Date(req.visit_date).toISOString().split('T')[0];
|
||||
return visitDate === formattedDate &&
|
||||
(req.status === 'approved' || req.status === 'training_completed');
|
||||
}).map(req => ({
|
||||
workplace_id: req.workplace_id,
|
||||
visitor_company: req.visitor_company,
|
||||
visitor_count: req.visitor_count,
|
||||
visit_time: req.visit_time,
|
||||
purpose_name: req.purpose_name,
|
||||
status: req.status
|
||||
}));
|
||||
|
||||
console.log('로드된 방문자:', todayVisitors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('출입 신청 데이터 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 지도 렌더링 ====================
|
||||
|
||||
function renderMap() {
|
||||
if (!canvas || !ctx || !canvasImage) return;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(canvasImage, 0, 0);
|
||||
|
||||
// 모든 작업장 영역 표시
|
||||
mapRegions.forEach(region => {
|
||||
// 해당 작업장의 작업자/방문자 인원 계산
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
||||
|
||||
const totalWorkerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
|
||||
const totalVisitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
||||
|
||||
// 영역 그리기
|
||||
drawWorkplaceRegion(region, totalWorkerCount, totalVisitorCount);
|
||||
});
|
||||
}
|
||||
|
||||
function drawWorkplaceRegion(region, workerCount, visitorCount) {
|
||||
// 사각형 좌표 변환
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const centerX = x1 + width / 2;
|
||||
const centerY = y1 + height / 2;
|
||||
|
||||
// 색상 결정
|
||||
let fillColor, strokeColor;
|
||||
const hasActivity = workerCount > 0 || visitorCount > 0;
|
||||
|
||||
if (workerCount > 0 && visitorCount > 0) {
|
||||
// 둘 다 있음 - 초록색
|
||||
fillColor = 'rgba(34, 197, 94, 0.3)';
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0) {
|
||||
// 내부 작업자만 - 파란색
|
||||
fillColor = 'rgba(59, 130, 246, 0.3)';
|
||||
strokeColor = 'rgb(59, 130, 246)';
|
||||
} else if (visitorCount > 0) {
|
||||
// 외부 방문자만 - 보라색
|
||||
fillColor = 'rgba(168, 85, 247, 0.3)';
|
||||
strokeColor = 'rgb(168, 85, 247)';
|
||||
} else {
|
||||
// 인원 없음 - 회색 테두리만
|
||||
fillColor = 'rgba(0, 0, 0, 0)'; // 투명
|
||||
strokeColor = 'rgb(156, 163, 175)'; // 회색
|
||||
}
|
||||
|
||||
// 사각형 그리기
|
||||
ctx.save();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(x1, y1, width, height);
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = hasActivity ? 3 : 2;
|
||||
ctx.strokeRect(x1, y1, width, height);
|
||||
ctx.restore();
|
||||
|
||||
// 인원수 표시 (인원이 있을 때만)
|
||||
if (hasActivity) {
|
||||
ctx.save();
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// 배경 원
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 20, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// 텍스트
|
||||
const totalCount = workerCount + visitorCount;
|
||||
ctx.fillStyle = strokeColor;
|
||||
ctx.fillText(totalCount.toString(), centerX, centerY);
|
||||
ctx.restore();
|
||||
} else {
|
||||
// 인원이 없을 때는 작업장 이름만 표시
|
||||
ctx.save();
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'rgb(107, 114, 128)';
|
||||
ctx.fillText(region.workplace_name, centerX, centerY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 지도 클릭 ====================
|
||||
|
||||
function onMapClick(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width * canvas.width;
|
||||
const y = (e.clientY - rect.top) / rect.height * canvas.height;
|
||||
|
||||
// 클릭한 위치의 작업장 영역 찾기
|
||||
for (const region of mapRegions) {
|
||||
if (isPointInRegion(x, y, region)) {
|
||||
// 작업장 정보를 찾아서 모달 표시
|
||||
const workplace = workplaceData.find(w => w.workplace_id === region.workplace_id);
|
||||
if (workplace) {
|
||||
showWorkplaceDetail({ ...workplace, ...region });
|
||||
} else {
|
||||
// 작업장 정보가 없으면 region 데이터만 사용
|
||||
showWorkplaceDetail(region);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPointInRegion(x, y, region) {
|
||||
// 사각형 영역 내부 체크
|
||||
const x1 = (region.x_start / 100) * canvas.width;
|
||||
const y1 = (region.y_start / 100) * canvas.height;
|
||||
const x2 = (region.x_end / 100) * canvas.width;
|
||||
const y2 = (region.y_end / 100) * canvas.height;
|
||||
|
||||
return x >= x1 && x <= x2 && y >= y1 && y <= y2;
|
||||
}
|
||||
|
||||
// ==================== 작업장 상세 정보 모달 ====================
|
||||
|
||||
function showWorkplaceDetail(workplace) {
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === workplace.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === workplace.workplace_id);
|
||||
|
||||
// 모달 제목
|
||||
document.getElementById('modalWorkplaceName').textContent = `${selectedCategory.category_name} - ${workplace.workplace_name}`;
|
||||
|
||||
// 내부 작업자 목록
|
||||
const workersList = document.getElementById('internalWorkersList');
|
||||
if (workers.length === 0) {
|
||||
workersList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 작업 예정 인원이 없습니다.</p>';
|
||||
} else {
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
workers.forEach(worker => {
|
||||
html += `
|
||||
<div style="padding: 12px; background: var(--blue-50); border-left: 4px solid var(--blue-500); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong style="font-size: var(--text-base);">${worker.task_name}</strong>
|
||||
<span style="margin-left: 8px; padding: 2px 8px; background: var(--blue-100); color: var(--blue-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${worker.member_count}명</span>
|
||||
</div>
|
||||
</div>
|
||||
${worker.work_location ? `<div style="margin-top: 8px; font-size: var(--text-sm); color: var(--gray-600);">📍 ${worker.work_location}</div>` : ''}
|
||||
${worker.project_name ? `<div style="margin-top: 4px; font-size: var(--text-sm); color: var(--gray-600);">📁 ${worker.project_name}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
workersList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 외부 방문자 목록
|
||||
const visitorsList = document.getElementById('externalVisitorsList');
|
||||
if (visitors.length === 0) {
|
||||
visitorsList.innerHTML = '<p style="color: var(--gray-500); font-size: var(--text-sm);">금일 방문 예정 인원이 없습니다.</p>';
|
||||
} else {
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
visitors.forEach(visitor => {
|
||||
const statusText = visitor.status === 'training_completed' ? '교육 완료' : '승인됨';
|
||||
const statusColor = visitor.status === 'training_completed' ? 'var(--green-500)' : 'var(--yellow-500)';
|
||||
|
||||
html += `
|
||||
<div style="padding: 12px; background: var(--purple-50); border-left: 4px solid var(--purple-500); border-radius: var(--radius-md);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div>
|
||||
<strong style="font-size: var(--text-base);">${visitor.visitor_company}</strong>
|
||||
<span style="margin-left: 8px; padding: 2px 8px; background: var(--purple-100); color: var(--purple-700); border-radius: var(--radius-sm); font-size: var(--text-xs);">${visitor.visitor_count}명</span>
|
||||
</div>
|
||||
<span style="padding: 2px 8px; background: ${statusColor}20; color: ${statusColor}; border-radius: var(--radius-sm); font-size: var(--text-xs); font-weight: 600;">${statusText}</span>
|
||||
</div>
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-600);">
|
||||
<div>⏰ 방문 시간: ${visitor.visit_time}</div>
|
||||
<div>📋 목적: ${visitor.purpose_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
visitorsList.innerHTML = html;
|
||||
}
|
||||
|
||||
// 모달 표시
|
||||
document.getElementById('workplaceDetailModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeWorkplaceModal() {
|
||||
document.getElementById('workplaceDetailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 새로고침 ====================
|
||||
|
||||
async function refreshMapData() {
|
||||
if (!selectedCategory) return;
|
||||
|
||||
await loadTodayData();
|
||||
renderMap();
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.closeWorkplaceModal = closeWorkplaceModal;
|
||||
@@ -116,8 +116,7 @@
|
||||
<select id="userRole" class="form-control" required>
|
||||
<option value="">역할 선택</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="user">작업자</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +129,15 @@
|
||||
<label class="form-label">전화번호</label>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -163,12 +171,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="pageAccessModalTitle">페이지 권한 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="page-access-user-info">
|
||||
<div class="user-avatar-small" id="pageAccessUserAvatar">U</div>
|
||||
<div>
|
||||
<h3 id="pageAccessUserName">사용자</h3>
|
||||
<p id="pageAccessUserRole">역할</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">접근 가능한 페이지</label>
|
||||
<small class="form-help">체크된 페이지에만 접근할 수 있습니다</small>
|
||||
<div id="pageAccessModalList" class="page-access-list">
|
||||
<!-- 페이지 체크박스 목록 -->
|
||||
</div>
|
||||
</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?v=4"></script>
|
||||
<script src="/js/admin-settings.js?v=5"></script>
|
||||
<script src="/js/admin-settings.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
493
web-ui/pages/admin/attendance-report-comparison.html
Normal file
493
web-ui/pages/admin/attendance-report-comparison.html
Normal file
@@ -0,0 +1,493 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.comparison-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.comparison-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
.discrepancy-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-match {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.badge-mismatch {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.badge-missing-attendance {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-missing-report {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
.detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.detail-label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
.detail-value {
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🔍</span>
|
||||
출퇴근-작업보고서 대조
|
||||
</h1>
|
||||
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" onclick="loadComparisonData()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="filter-section">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="startDate">시작일</label>
|
||||
<input type="date" id="startDate" class="form-control">
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="endDate">종료일</label>
|
||||
<input type="date" id="endDate" class="form-control">
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="workerFilter">작업자</label>
|
||||
<select id="workerFilter" class="form-control">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label for="statusFilter">상태</label>
|
||||
<select id="statusFilter" class="form-control">
|
||||
<option value="">전체</option>
|
||||
<option value="match">일치</option>
|
||||
<option value="mismatch">불일치</option>
|
||||
<option value="missing-attendance">출퇴근 누락</option>
|
||||
<option value="missing-report">보고서 누락</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="align-self: flex-end;">
|
||||
<button class="btn btn-primary" onclick="loadComparisonData()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 통계 -->
|
||||
<div class="content-section">
|
||||
<div class="summary-stats" id="summaryStats">
|
||||
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 대조 결과 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">대조 결과</h2>
|
||||
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="comparisonList" class="data-table-container">
|
||||
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let comparisonData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
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();
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(today.getDate() - 7);
|
||||
|
||||
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
|
||||
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
await loadWorkers();
|
||||
await loadComparisonData();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
|
||||
const select = document.getElementById('workerFilter');
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComparisonData() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const workerId = document.getElementById('workerFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 출퇴근 기록 로드
|
||||
const attendanceResponse = await axios.get('/attendance/records', {
|
||||
params: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
worker_id: workerId || undefined
|
||||
}
|
||||
});
|
||||
|
||||
// 작업 보고서 로드
|
||||
const reportsResponse = await axios.get('/daily-work-reports', {
|
||||
params: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
worker_id: workerId || undefined
|
||||
}
|
||||
});
|
||||
|
||||
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
|
||||
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
|
||||
|
||||
// 데이터 비교
|
||||
comparisonData = compareData(attendanceRecords, workReports);
|
||||
|
||||
// 필터 적용
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
if (statusFilter) {
|
||||
comparisonData = comparisonData.filter(item => item.status === statusFilter);
|
||||
}
|
||||
|
||||
renderSummary();
|
||||
renderComparisonList();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 오류:', error);
|
||||
document.getElementById('comparisonList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function compareData(attendanceRecords, workReports) {
|
||||
const results = [];
|
||||
const dateWorkerMap = new Map();
|
||||
|
||||
// 출퇴근 기록 맵핑
|
||||
attendanceRecords.forEach(record => {
|
||||
const key = `${record.attendance_date}_${record.worker_id}`;
|
||||
dateWorkerMap.set(key, {
|
||||
date: record.attendance_date,
|
||||
worker_id: record.worker_id,
|
||||
worker_name: record.worker_name,
|
||||
attendance: record,
|
||||
reports: []
|
||||
});
|
||||
});
|
||||
|
||||
// 작업 보고서 맵핑
|
||||
workReports.forEach(report => {
|
||||
const key = `${report.report_date}_${report.worker_id}`;
|
||||
if (dateWorkerMap.has(key)) {
|
||||
dateWorkerMap.get(key).reports.push(report);
|
||||
} else {
|
||||
dateWorkerMap.set(key, {
|
||||
date: report.report_date,
|
||||
worker_id: report.worker_id,
|
||||
worker_name: report.worker_name,
|
||||
attendance: null,
|
||||
reports: [report]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 비교 분석
|
||||
dateWorkerMap.forEach(item => {
|
||||
const attendanceHours = item.attendance?.total_hours || 0;
|
||||
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
|
||||
|
||||
let status = 'match';
|
||||
let message = '일치';
|
||||
|
||||
if (!item.attendance && item.reports.length > 0) {
|
||||
status = 'missing-attendance';
|
||||
message = '출퇴근 기록 누락';
|
||||
} else if (item.attendance && item.reports.length === 0) {
|
||||
status = 'missing-report';
|
||||
message = '작업보고서 누락';
|
||||
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
|
||||
status = 'mismatch';
|
||||
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
|
||||
}
|
||||
|
||||
results.push({
|
||||
...item,
|
||||
attendanceHours,
|
||||
reportTotalHours,
|
||||
status,
|
||||
message
|
||||
});
|
||||
});
|
||||
|
||||
// 날짜 역순 정렬
|
||||
return results.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const summaryStats = document.getElementById('summaryStats');
|
||||
|
||||
const total = comparisonData.length;
|
||||
const matches = comparisonData.filter(item => item.status === 'match').length;
|
||||
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
|
||||
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
|
||||
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
|
||||
|
||||
summaryStats.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">전체</div>
|
||||
<div class="stat-value">${total}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">일치</div>
|
||||
<div class="stat-value" style="color: #059669;">${matches}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">불일치</div>
|
||||
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">출퇴근 누락</div>
|
||||
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">보고서 누락</div>
|
||||
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderComparisonList() {
|
||||
const container = document.getElementById('comparisonList');
|
||||
|
||||
if (comparisonData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>비교 결과가 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>출퇴근 시간</th>
|
||||
<th>보고서 시간</th>
|
||||
<th>차이</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${comparisonData.map(item => {
|
||||
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
|
||||
const badgeClass = `badge-${item.status}`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${item.date}</td>
|
||||
<td><strong>${item.worker_name}</strong></td>
|
||||
<td>${item.attendanceHours.toFixed(1)}시간</td>
|
||||
<td>${item.reportTotalHours.toFixed(1)}시간</td>
|
||||
<td>${diff.toFixed(1)}시간</td>
|
||||
<td>
|
||||
<span class="discrepancy-badge ${badgeClass}">
|
||||
${item.message}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,6 +41,12 @@
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
|
||||
@@ -213,7 +213,60 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="/js/navbar-loader.js?v=5"></script>
|
||||
<script src="/js/equipment-management.js?v=1"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></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);
|
||||
axios.defaults.baseURL = window.API_BASE_URL;
|
||||
const token = localStorage.getItem('token');
|
||||
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('📤 요청:', config.method.toUpperCase(), config.url);
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// axios 응답 인터셉터 추가 (에러 처리)
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
console.log('✅ 응답:', response.status, response.config.url);
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
console.error('❌ 에러:', error.response?.status, error.config?.url, error.message);
|
||||
if (error.response?.status === 401) {
|
||||
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
|
||||
window.location.href = '/pages/login.html';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('✅ Axios 설정 완료:', axios.defaults.baseURL);
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/equipment-management.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
|
||||
291
web-ui/pages/admin/safety-management.html
Normal file
291
web-ui/pages/admin/safety-management.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!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=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>
|
||||
<style>
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
color: var(--primary-600);
|
||||
border-bottom-color: var(--primary-600);
|
||||
}
|
||||
|
||||
.request-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.request-table th {
|
||||
background: var(--gray-50);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
border-bottom: 2px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.request-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.request-table tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: var(--yellow-100);
|
||||
color: var(--yellow-700);
|
||||
}
|
||||
|
||||
.status-badge.approved {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.status-badge.rejected {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.status-badge.training_completed {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 32px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
</style>
|
||||
</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="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">승인 대기</div>
|
||||
<div class="stat-value" id="statPending">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--green-500);">
|
||||
<div class="stat-label">승인 완료</div>
|
||||
<div class="stat-value" id="statApproved">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--blue-500);">
|
||||
<div class="stat-label">교육 완료</div>
|
||||
<div class="stat-value" id="statTrainingCompleted">0</div>
|
||||
</div>
|
||||
<div class="stat-card" style="border-left-color: var(--red-500);">
|
||||
<div class="stat-label">반려</div>
|
||||
<div class="stat-value" id="statRejected">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="code-section">
|
||||
<div class="status-tabs">
|
||||
<button class="status-tab active" data-status="pending" onclick="switchTab('pending')">
|
||||
승인 대기
|
||||
</button>
|
||||
<button class="status-tab" data-status="approved" onclick="switchTab('approved')">
|
||||
승인 완료
|
||||
</button>
|
||||
<button class="status-tab" data-status="training_completed" onclick="switchTab('training_completed')">
|
||||
교육 완료
|
||||
</button>
|
||||
<button class="status-tab" data-status="rejected" onclick="switchTab('rejected')">
|
||||
반려
|
||||
</button>
|
||||
<button class="status-tab" data-status="all" onclick="switchTab('all')">
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div id="requestTableContainer">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>출입 신청 상세</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
<div id="detailContent">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 사유 입력 모달 -->
|
||||
<div id="rejectModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>반려 사유 입력</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeRejectModal()">취소</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="rejectionReason">반려 사유 *</label>
|
||||
<textarea id="rejectionReason" rows="4" style="width: 100%; padding: 12px; border: 1px solid var(--gray-300); border-radius: var(--radius-md);" placeholder="반려 사유를 입력하세요"></textarea>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
|
||||
<button class="btn btn-secondary" onclick="closeRejectModal()">취소</button>
|
||||
<button class="btn btn-danger" onclick="confirmReject()">반려 확정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/safety-management.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
327
web-ui/pages/admin/safety-training-conduct.html
Normal file
327
web-ui/pages/admin/safety-training-conduct.html
Normal file
@@ -0,0 +1,327 @@
|
||||
<!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=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>
|
||||
<style>
|
||||
.training-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.request-info-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.checklist-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.checklist-item:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checklist-item label {
|
||||
flex: 1;
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checklist-item input[type="checkbox"]:checked + label {
|
||||
color: var(--gray-500);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.signature-canvas-container {
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.signature-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: var(--yellow-50);
|
||||
border: 2px solid var(--yellow-300);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
color: var(--yellow-800);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.saved-signature-card {
|
||||
background: var(--gray-50);
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-signature-card img {
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-sm);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.saved-signature-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.saved-signature-number {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--primary-600);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.saved-signature-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.saved-signature-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
</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="training-container">
|
||||
<!-- 출입 신청 정보 -->
|
||||
<div class="request-info-card">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">출입 신청 정보</h2>
|
||||
<div id="requestInfo" class="info-grid">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전교육 체크리스트 -->
|
||||
<div class="checklist-section">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">안전교육 체크리스트</h2>
|
||||
<p style="color: var(--gray-600); margin-bottom: 20px;">
|
||||
방문자에게 다음 안전 사항을 교육하고 체크해주세요.
|
||||
</p>
|
||||
|
||||
<div id="checklistContainer">
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check1" name="safety-check" value="개인보호구 착용" onchange="updateCompleteButton()">
|
||||
<label for="check1">개인보호구(안전모, 안전화, 안전복) 착용 방법 교육</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check2" name="safety-check" value="작업장 위험요소" onchange="updateCompleteButton()">
|
||||
<label for="check2">작업장 내 위험요소 및 주의사항 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check3" name="safety-check" value="비상대피로" onchange="updateCompleteButton()">
|
||||
<label for="check3">비상대피로 및 비상연락망 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check4" name="safety-check" value="출입통제구역" onchange="updateCompleteButton()">
|
||||
<label for="check4">출입통제구역 및 금지사항 안내</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check5" name="safety-check" value="사고발생시 대응" onchange="updateCompleteButton()">
|
||||
<label for="check5">사고 발생 시 대응 절차 교육</label>
|
||||
</div>
|
||||
<div class="checklist-item">
|
||||
<input type="checkbox" id="check6" name="safety-check" value="안전수칙 준수" onchange="updateCompleteButton()">
|
||||
<label for="check6">현장 안전수칙 준수 서약</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 경고 -->
|
||||
<div class="warning-box">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<div class="warning-text">
|
||||
<strong>중요:</strong> 모든 체크리스트 항목을 완료하고 방문자의 서명을 받은 후 교육 완료 처리를 해주세요.
|
||||
교육 완료 후에는 수정할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서명 섹션 -->
|
||||
<div class="signature-section">
|
||||
<h2 class="section-title" style="margin-bottom: 16px;">방문자 서명 (<span id="signatureCount">0</span>명)</h2>
|
||||
<p style="color: var(--gray-600); margin-bottom: 20px;">
|
||||
각 방문자가 왼쪽에 이름을 쓰고 오른쪽에 서명한 후 "저장" 버튼을 눌러주세요.
|
||||
</p>
|
||||
|
||||
<div class="signature-canvas-container" style="position: relative;">
|
||||
<!-- 이름과 서명 구분선 및 라벨 -->
|
||||
<div style="position: absolute; top: 10px; left: 10px; right: 10px; display: flex; justify-content: space-between; z-index: 1; pointer-events: none;">
|
||||
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600;">이름</span>
|
||||
<span style="position: absolute; left: 250px; top: 0; bottom: 0; width: 2px; background: var(--gray-300);"></span>
|
||||
<span style="font-size: var(--text-sm); color: var(--gray-500); font-weight: 600); margin-left: auto;">서명</span>
|
||||
</div>
|
||||
<canvas id="signatureCanvas" class="signature-canvas" width="800" height="300"></canvas>
|
||||
<div id="signaturePlaceholder" class="signature-placeholder" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
|
||||
<div>왼쪽에 이름을 쓰고, 오른쪽에 서명해주세요</div>
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-400);">(마우스, 터치, 또는 Apple Pencil 사용)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signature-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearSignature()">
|
||||
서명 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSignature()">
|
||||
서명 저장
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="font-size: var(--text-sm); color: var(--gray-600); margin-top: 12px;">
|
||||
서명 날짜: <span id="signatureDate"></span>
|
||||
</div>
|
||||
|
||||
<!-- 저장된 서명 목록 -->
|
||||
<div id="savedSignatures" style="margin-top: 24px;">
|
||||
<!-- 동적으로 추가됨 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="goBack()">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTraining()" id="completeBtn" disabled>
|
||||
교육 완료 처리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/safety-training-conduct.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -41,6 +41,12 @@
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/equipments.html">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span class="menu-text">설비 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/tasks.html">
|
||||
<span class="menu-icon">📋</span>
|
||||
|
||||
143
web-ui/pages/common/annual-vacation-overview.html
Normal file
143
web-ui/pages/common/annual-vacation-overview.html
Normal file
@@ -0,0 +1,143 @@
|
||||
<!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/annual-vacation-overview.css">
|
||||
<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 src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
|
||||
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="page-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">📊 연간 연차 현황</h1>
|
||||
<p class="page-description">모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<section class="filter-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="filter-controls">
|
||||
<div class="form-group">
|
||||
<label for="yearSelect">조회 연도</label>
|
||||
<select id="yearSelect" class="form-select">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
<button id="refreshBtn" class="btn btn-primary">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<section class="tabs-section">
|
||||
<div class="tabs-nav">
|
||||
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
|
||||
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 1: 연간 사용 기록 -->
|
||||
<section id="annualUsageTab" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 휴가 사용 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="annualUsageChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 월별 상세 기록 -->
|
||||
<section id="monthlyDetailsTab" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 상세 기록</h2>
|
||||
<div class="month-controls">
|
||||
<select id="monthSelect" class="form-select">
|
||||
<option value="1">1월</option>
|
||||
<option value="2">2월</option>
|
||||
<option value="3">3월</option>
|
||||
<option value="4">4월</option>
|
||||
<option value="5">5월</option>
|
||||
<option value="6">6월</option>
|
||||
<option value="7">7월</option>
|
||||
<option value="8">8월</option>
|
||||
<option value="9">9월</option>
|
||||
<option value="10">10월</option>
|
||||
<option value="11">11월</option>
|
||||
<option value="12">12월</option>
|
||||
</select>
|
||||
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자명</th>
|
||||
<th>휴가 유형</th>
|
||||
<th>시작일</th>
|
||||
<th>종료일</th>
|
||||
<th>사용 일수</th>
|
||||
<th>사유</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monthlyTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
395
web-ui/pages/common/daily-attendance.html
Normal file
395
web-ui/pages/common/daily-attendance.html
Normal file
@@ -0,0 +1,395 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📅</span>
|
||||
일일 출퇴근 입력
|
||||
</h1>
|
||||
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 출퇴근 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">작업자 출퇴근 기록</h2>
|
||||
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="attendanceList" class="data-table-container">
|
||||
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
|
||||
<span>💾 전체 저장</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let attendanceRecords = [];
|
||||
|
||||
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
|
||||
const attendanceTypes = [
|
||||
{ value: 'on_time', label: '정시', hours: 8 },
|
||||
{ value: 'half_leave', label: '반차', hours: 4 },
|
||||
{ value: 'quarter_leave', label: '반반차', hours: 6 },
|
||||
{ value: 'early_leave', label: '조퇴', hours: 2 },
|
||||
{ value: 'weekend_work', label: '주말근무', hours: 0 },
|
||||
{ value: 'annual_leave', label: '연차', hours: 0 },
|
||||
{ value: 'custom', label: '특이사항', hours: 0 }
|
||||
];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
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().split('T')[0];
|
||||
document.getElementById('selectedDate').value = today;
|
||||
|
||||
try {
|
||||
await loadWorkers();
|
||||
await loadAttendanceRecords();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
|
||||
const [recordsResponse, checkinResponse] = await Promise.all([
|
||||
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
|
||||
]);
|
||||
|
||||
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
|
||||
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
|
||||
|
||||
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
|
||||
attendanceRecords = checkinList.map(worker => {
|
||||
const existingRecord = existingRecords.find(r => r.worker_id === worker.worker_id);
|
||||
const isOnVacation = worker.vacation_status === 'approved';
|
||||
|
||||
// 기존 기록이 있으면 사용, 없으면 초기화
|
||||
if (existingRecord) {
|
||||
return existingRecord;
|
||||
} else {
|
||||
return {
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: isOnVacation ? 0 : 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true,
|
||||
is_on_vacation: isOnVacation,
|
||||
vacation_type_name: worker.vacation_type_name
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
renderAttendanceList();
|
||||
} catch (error) {
|
||||
console.error('출퇴근 기록 로드 오류:', error);
|
||||
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAttendanceRecords() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
attendanceRecords = workers.map(worker => ({
|
||||
worker_id: worker.worker_id,
|
||||
worker_name: worker.worker_name,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: 8,
|
||||
overtime_hours: 0,
|
||||
attendance_type: 'on_time',
|
||||
is_custom: false,
|
||||
is_new: true
|
||||
}));
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
function renderAttendanceList() {
|
||||
const container = document.getElementById('attendanceList');
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
|
||||
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
|
||||
작업자 목록으로 초기화
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHTML = `
|
||||
<table class="data-table" style="font-size: 0.95rem;">
|
||||
<thead style="background-color: #f8f9fa;">
|
||||
<tr>
|
||||
<th style="width: 120px;">작업자</th>
|
||||
<th style="width: 180px;">근태 구분</th>
|
||||
<th style="width: 100px;">근무시간</th>
|
||||
<th style="width: 120px;">연장근로</th>
|
||||
<th style="width: 100px;">특이사항</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${attendanceRecords.map((record, index) => {
|
||||
const isCustom = record.is_custom || record.attendance_type === 'custom';
|
||||
const isHoursReadonly = !isCustom; // 특이사항이 아니면 근무시간은 읽기 전용
|
||||
|
||||
const isOnVacation = record.is_on_vacation || false;
|
||||
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
|
||||
<td style="padding: 0.75rem; font-weight: 600;">
|
||||
${record.worker_name}
|
||||
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<select class="form-control"
|
||||
onchange="updateAttendanceType(${index}, this.value)"
|
||||
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
|
||||
${attendanceTypes.map(type => `
|
||||
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
|
||||
${type.label}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="hours_${index}"
|
||||
value="${record.total_hours || 0}"
|
||||
min="0" max="24" step="0.5"
|
||||
${isHoursReadonly ? 'readonly' : ''}
|
||||
onchange="updateTotalHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
|
||||
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem;">
|
||||
<input type="number" class="form-control"
|
||||
id="overtime_${index}"
|
||||
value="${record.overtime_hours || 0}"
|
||||
min="0" max="12" step="0.5"
|
||||
onchange="updateOvertimeHours(${index}, this.value)"
|
||||
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem; background-color: white; text-align: center;">
|
||||
</td>
|
||||
<td style="padding: 0.75rem; text-align: center;">
|
||||
${isCustom ?
|
||||
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
|
||||
'<span style="color: #9ca3af;">-</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
function updateTotalHours(index, value) {
|
||||
attendanceRecords[index].total_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateOvertimeHours(index, value) {
|
||||
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
|
||||
}
|
||||
|
||||
function updateAttendanceType(index, value) {
|
||||
const record = attendanceRecords[index];
|
||||
record.attendance_type = value;
|
||||
|
||||
// 근태 구분에 따라 자동으로 근무시간 설정
|
||||
const attendanceType = attendanceTypes.find(t => t.value === value);
|
||||
|
||||
if (value === 'custom') {
|
||||
// 특이사항 선택 시 수동 입력 가능
|
||||
record.is_custom = true;
|
||||
// 기존 값 유지, 수동 입력 가능
|
||||
} else if (attendanceType) {
|
||||
// 다른 근태 구분 선택 시 근무시간만 자동 설정
|
||||
record.is_custom = false;
|
||||
record.total_hours = attendanceType.hours;
|
||||
// 연장근로는 유지 (별도 입력 가능)
|
||||
}
|
||||
|
||||
// UI 다시 렌더링
|
||||
renderAttendanceList();
|
||||
}
|
||||
|
||||
async function saveAllAttendance() {
|
||||
const selectedDate = document.getElementById('selectedDate').value;
|
||||
|
||||
if (!selectedDate) {
|
||||
alert('날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attendanceRecords.length === 0) {
|
||||
alert('저장할 출퇴근 기록이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 기록을 API 형식에 맞게 변환
|
||||
const recordsToSave = attendanceRecords.map(record => ({
|
||||
worker_id: record.worker_id,
|
||||
attendance_date: selectedDate,
|
||||
total_hours: record.total_hours || 0,
|
||||
overtime_hours: record.overtime_hours || 0,
|
||||
attendance_type: record.attendance_type || 'on_time',
|
||||
is_custom: record.is_custom || false
|
||||
}));
|
||||
|
||||
try {
|
||||
// 각 기록을 순차적으로 저장
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const data of recordsToSave) {
|
||||
try {
|
||||
const response = await axios.post('/attendance/records', data);
|
||||
if (response.data.success) {
|
||||
successCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`작업자 ${data.worker_id} 저장 오류:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount === 0) {
|
||||
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
|
||||
await loadAttendanceRecords(); // 저장 후 새로고침
|
||||
} else {
|
||||
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
490
web-ui/pages/common/monthly-attendance.html
Normal file
490
web-ui/pages/common/monthly-attendance.html
Normal file
@@ -0,0 +1,490 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.calendar-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: #e5e7eb;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.calendar-header {
|
||||
background-color: #f3f4f6;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.calendar-day {
|
||||
background-color: white;
|
||||
padding: 0.5rem;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
.calendar-day.today {
|
||||
background-color: #fef3c7;
|
||||
}
|
||||
.calendar-day.other-month {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.day-number {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.day-info {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.attendance-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-normal {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.badge-overtime {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.badge-annual {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.badge-half {
|
||||
background-color: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.badge-quarter {
|
||||
background-color: #f3e8ff;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.badge-early {
|
||||
background-color: #fce7f3;
|
||||
color: #9f1239;
|
||||
}
|
||||
.worker-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.summary-item {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📆</span>
|
||||
월별 출퇴근 현황
|
||||
</h1>
|
||||
<p class="page-description">이번 달 출퇴근 현황을 조회합니다</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions">
|
||||
<input type="month" id="selectedMonth" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-primary" onclick="loadMonthlyData()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 선택 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="worker-selector">
|
||||
<label for="workerSelect" style="font-weight: 600;">작업자:</label>
|
||||
<select id="workerSelect" class="form-control" style="width: 300px;" onchange="loadMonthlyData()">
|
||||
<option value="">작업자를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 월별 요약 통계 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">월별 요약</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="summary-card" id="summarySection">
|
||||
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 달력 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title" id="calendarTitle">출퇴근 달력</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-header">일</div>
|
||||
<div class="calendar-header">월</div>
|
||||
<div class="calendar-header">화</div>
|
||||
<div class="calendar-header">수</div>
|
||||
<div class="calendar-header">목</div>
|
||||
<div class="calendar-header">금</div>
|
||||
<div class="calendar-header">토</div>
|
||||
<div id="calendarDays">
|
||||
<!-- 달력 날짜가 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
// 전역 변수
|
||||
let workers = [];
|
||||
let attendanceRecords = [];
|
||||
let currentUser = null;
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
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 now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
document.getElementById('selectedMonth').value = yearMonth;
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
try {
|
||||
const userInfo = JSON.parse(localStorage.getItem('user'));
|
||||
currentUser = userInfo;
|
||||
|
||||
await loadWorkers();
|
||||
|
||||
// 관리자가 아니면 자동으로 자신 선택
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
const workerSelect = document.getElementById('workerSelect');
|
||||
if (currentUser.worker_id) {
|
||||
workerSelect.value = currentUser.worker_id;
|
||||
}
|
||||
}
|
||||
|
||||
await loadMonthlyData();
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await axios.get('/workers');
|
||||
if (response.data.success) {
|
||||
workers = response.data.data.filter(w => w.employment_status === 'employed');
|
||||
|
||||
const select = document.getElementById('workerSelect');
|
||||
select.innerHTML = '<option value="">작업자를 선택하세요</option>';
|
||||
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업자 목록 로드 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthlyData() {
|
||||
const selectedMonth = document.getElementById('selectedMonth').value;
|
||||
const workerId = document.getElementById('workerSelect').value;
|
||||
|
||||
if (!selectedMonth) {
|
||||
alert('월을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workerId) {
|
||||
alert('작업자를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 선택한 월의 첫날과 마지막 날 계산
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const startDate = `${year}-${month}-01`;
|
||||
const endDate = new Date(year, month, 0).getDate();
|
||||
const endDateStr = `${year}-${month}-${String(endDate).padStart(2, '0')}`;
|
||||
|
||||
// 출퇴근 기록 로드
|
||||
const response = await axios.get(`/attendance/records`, {
|
||||
params: {
|
||||
worker_id: workerId,
|
||||
start_date: startDate,
|
||||
end_date: endDateStr
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
attendanceRecords = response.data.data || [];
|
||||
renderCalendar();
|
||||
renderSummary();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('월별 데이터 로드 오류:', error);
|
||||
if (error.response?.status === 404) {
|
||||
attendanceRecords = [];
|
||||
renderCalendar();
|
||||
renderSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const selectedMonth = document.getElementById('selectedMonth').value;
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
const lastDay = new Date(year, month, 0);
|
||||
const today = new Date();
|
||||
|
||||
// 달력 제목 업데이트
|
||||
document.getElementById('calendarTitle').textContent = `${year}년 ${month}월 출퇴근 달력`;
|
||||
|
||||
// 달력 그리드 생성
|
||||
const calendarDays = document.getElementById('calendarDays');
|
||||
calendarDays.innerHTML = '';
|
||||
|
||||
// 이전 달의 빈 칸
|
||||
for (let i = 0; i < firstDay.getDay(); i++) {
|
||||
const emptyDay = document.createElement('div');
|
||||
emptyDay.className = 'calendar-day other-month';
|
||||
calendarDays.appendChild(emptyDay);
|
||||
}
|
||||
|
||||
// 현재 달의 날짜
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
const dayElement = document.createElement('div');
|
||||
const currentDate = new Date(year, month - 1, day);
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
|
||||
dayElement.className = 'calendar-day';
|
||||
|
||||
// 오늘 날짜 표시
|
||||
if (currentDate.toDateString() === today.toDateString()) {
|
||||
dayElement.classList.add('today');
|
||||
}
|
||||
|
||||
// 날짜 번호
|
||||
const dayNumber = document.createElement('div');
|
||||
dayNumber.className = 'day-number';
|
||||
dayNumber.textContent = day;
|
||||
dayElement.appendChild(dayNumber);
|
||||
|
||||
// 출퇴근 기록 찾기
|
||||
const record = attendanceRecords.find(r => r.attendance_date === dateStr);
|
||||
if (record) {
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'day-info';
|
||||
|
||||
// 근무시간
|
||||
const hoursSpan = document.createElement('div');
|
||||
hoursSpan.textContent = `${record.total_hours}시간`;
|
||||
infoDiv.appendChild(hoursSpan);
|
||||
|
||||
// 야근 표시
|
||||
if (record.is_overtime) {
|
||||
const overtimeBadge = document.createElement('span');
|
||||
overtimeBadge.className = 'attendance-badge badge-overtime';
|
||||
overtimeBadge.textContent = '야근';
|
||||
infoDiv.appendChild(overtimeBadge);
|
||||
}
|
||||
|
||||
// 근태 구분
|
||||
if (record.attendance_type && record.attendance_type !== 'normal') {
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'attendance-badge';
|
||||
|
||||
switch (record.attendance_type) {
|
||||
case 'annual_leave':
|
||||
typeBadge.classList.add('badge-annual');
|
||||
typeBadge.textContent = '연차';
|
||||
break;
|
||||
case 'half_leave':
|
||||
typeBadge.classList.add('badge-half');
|
||||
typeBadge.textContent = '반차';
|
||||
break;
|
||||
case 'quarter_leave':
|
||||
typeBadge.classList.add('badge-quarter');
|
||||
typeBadge.textContent = '반반차';
|
||||
break;
|
||||
case 'early_leave':
|
||||
typeBadge.classList.add('badge-early');
|
||||
typeBadge.textContent = '조퇴';
|
||||
break;
|
||||
default:
|
||||
typeBadge.classList.add('badge-normal');
|
||||
typeBadge.textContent = '정상';
|
||||
}
|
||||
|
||||
infoDiv.appendChild(typeBadge);
|
||||
}
|
||||
|
||||
dayElement.appendChild(infoDiv);
|
||||
}
|
||||
|
||||
calendarDays.appendChild(dayElement);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const summarySection = document.getElementById('summarySection');
|
||||
|
||||
// 통계 계산
|
||||
const totalDays = attendanceRecords.length;
|
||||
const totalHours = attendanceRecords.reduce((sum, r) => sum + (r.total_hours || 0), 0);
|
||||
const overtimeDays = attendanceRecords.filter(r => r.is_overtime).length;
|
||||
const annualLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'annual_leave').length;
|
||||
const halfLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'half_leave').length;
|
||||
const quarterLeaveDays = attendanceRecords.filter(r => r.attendance_type === 'quarter_leave').length;
|
||||
|
||||
summarySection.innerHTML = `
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">총 근무일수</div>
|
||||
<div class="summary-value">${totalDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">총 근무시간</div>
|
||||
<div class="summary-value">${totalHours.toFixed(1)}시간</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">야근일수</div>
|
||||
<div class="summary-value">${overtimeDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">연차 사용</div>
|
||||
<div class="summary-value">${annualLeaveDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">반차 사용</div>
|
||||
<div class="summary-value">${halfLeaveDays}일</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">반반차 사용</div>
|
||||
<div class="summary-value">${quarterLeaveDays}일</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
354
web-ui/pages/common/vacation-allocation.html
Normal file
354
web-ui/pages/common/vacation-allocation.html
Normal file
@@ -0,0 +1,354 @@
|
||||
<!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/vacation-allocation.css">
|
||||
<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/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="page-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">➕ 휴가 발생 입력</h1>
|
||||
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="tab-navigation">
|
||||
<button class="tab-button active" data-tab="individual">개별 입력</button>
|
||||
<button class="tab-button" data-tab="bulk">일괄 입력</button>
|
||||
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 1: 개별 입력 -->
|
||||
<section id="tab-individual" class="tab-content active">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">개별 작업자 휴가 입력</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- 입력 폼 -->
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
|
||||
<select id="individualWorker" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualYear">연도 <span class="required">*</span></label>
|
||||
<select id="individualYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
|
||||
<select id="individualVacationType" class="form-select" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 자동 계산 섹션 -->
|
||||
<div class="auto-calculate-section">
|
||||
<div class="section-header">
|
||||
<h3>자동 계산 (연차만 해당)</h3>
|
||||
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
|
||||
🔄 입사일 기준 자동 계산
|
||||
</button>
|
||||
</div>
|
||||
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
|
||||
<!-- 계산 결과 표시 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수동 입력 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="individualUsedDays">사용 일수</label>
|
||||
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label for="individualNotes">비고</label>
|
||||
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="individualSubmitBtn" class="btn btn-primary">
|
||||
저장
|
||||
</button>
|
||||
<button id="individualResetBtn" class="btn btn-secondary">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 데이터 테이블 -->
|
||||
<div class="existing-data-section">
|
||||
<h3>기존 입력 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<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 id="individualTableBody">
|
||||
<tr>
|
||||
<td colspan="8" class="loading-state">
|
||||
<p>작업자를 선택하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 2: 일괄 입력 -->
|
||||
<section id="tab-bulk" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
|
||||
<select id="bulkYear" class="form-select" required>
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bulkEmploymentStatus">재직 상태</label>
|
||||
<select id="bulkEmploymentStatus" class="form-select">
|
||||
<option value="employed">재직 중만</option>
|
||||
<option value="all">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button id="bulkPreviewBtn" class="btn btn-secondary">
|
||||
미리보기
|
||||
</button>
|
||||
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
|
||||
일괄 생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 테이블 -->
|
||||
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
|
||||
<h3>생성 예정 내역</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>입사일</th>
|
||||
<th>근속년수</th>
|
||||
<th>부여 연차</th>
|
||||
<th>계산 근거</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulkPreviewTableBody">
|
||||
<!-- JavaScript로 동적 생성 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 3: 특별 휴가 관리 -->
|
||||
<section id="tab-special" class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">특별 휴가 유형 관리</h2>
|
||||
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
|
||||
+ 새 휴가 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>유형명</th>
|
||||
<th>코드</th>
|
||||
<th>우선순위</th>
|
||||
<th>특별 휴가</th>
|
||||
<th>시스템 유형</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="specialTypesTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 모달: 휴가 유형 추가/수정 -->
|
||||
<div id="vacationTypeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">휴가 유형 추가</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="vacationTypeForm">
|
||||
<input type="hidden" id="modalTypeId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeName">유형명 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeName" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalTypeCode">코드 <span class="required">*</span></label>
|
||||
<input type="text" id="modalTypeCode" class="form-input" required>
|
||||
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modalPriority">우선순위 <span class="required">*</span></label>
|
||||
<input type="number" id="modalPriority" class="form-input" min="1" required>
|
||||
<small>낮을수록 먼저 차감</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="modalIsSpecial">
|
||||
특별 휴가
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modalDescription">설명</label>
|
||||
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모달: 휴가 수정 -->
|
||||
<div id="editBalanceModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>휴가 수정</h3>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editBalanceForm">
|
||||
<input type="hidden" id="editBalanceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
|
||||
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editNotes">비고</label>
|
||||
<input type="text" id="editNotes" class="form-input">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">저장</button>
|
||||
<button type="button" class="btn btn-secondary modal-close">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
267
web-ui/pages/common/vacation-approval.html
Normal file
267
web-ui/pages/common/vacation-approval.html
Normal file
@@ -0,0 +1,267 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">✅</span>
|
||||
휴가 승인 관리
|
||||
</h1>
|
||||
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="pendingTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
<span>🔄 전체</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'pending') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('pendingTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
294
web-ui/pages/common/vacation-input.html
Normal file
294
web-ui/pages/common/vacation-input.html
Normal file
@@ -0,0 +1,294 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📝</span>
|
||||
휴가 직접 입력
|
||||
</h1>
|
||||
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 직접 입력 폼 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>💾 즉시 입력 (자동 승인)</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadRecentInputs();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', loadRecentInputs);
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
worker_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null,
|
||||
auto_approve: true // 자동 승인 플래그
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
461
web-ui/pages/common/vacation-management.html
Normal file
461
web-ui/pages/common/vacation-management.html
Normal file
@@ -0,0 +1,461 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab:hover {
|
||||
color: #111827;
|
||||
}
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🏖️</span>
|
||||
휴가 관리
|
||||
</h1>
|
||||
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
|
||||
<button class="tab" onclick="switchTab('input')">직접 입력</button>
|
||||
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 목록 탭 -->
|
||||
<div id="approvalTab" class="tab-content active">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">승인 대기 목록</h2>
|
||||
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="pendingRequestsList" class="data-table-container">
|
||||
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 직접 입력 탭 -->
|
||||
<div id="inputTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 정보 직접 입력</h2>
|
||||
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="inputWorker">작업자 *</label>
|
||||
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputVacationType">휴가 유형 *</label>
|
||||
<select id="inputVacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputStartDate">시작일 *</label>
|
||||
<input type="date" id="inputStartDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputEndDate">종료일 *</label>
|
||||
<input type="date" id="inputEndDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="inputDaysUsed">사용 일수 *</label>
|
||||
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>작업자 휴가 잔여</label>
|
||||
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
|
||||
<span class="text-muted">작업자를 선택하세요</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="inputReason">사유</label>
|
||||
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>💾 즉시 입력 (자동 승인)</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">최근 입력 내역</h2>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-secondary" onclick="loadRecentInputs()">
|
||||
<span>🔄 새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="recentInputsList" class="data-table-container">
|
||||
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 신청 내역 탭 -->
|
||||
<div id="allTab" class="tab-content">
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">전체 신청 내역</h2>
|
||||
<div class="page-actions">
|
||||
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
|
||||
<span style="margin: 0 0.5rem;">~</span>
|
||||
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
|
||||
<button class="btn btn-secondary" onclick="filterAllRequests()">
|
||||
<span>🔍 조회</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetFilter()">
|
||||
<span>🔄 전체</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="allRequestsList" class="data-table-container">
|
||||
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
let allRequestsData = [];
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await loadWorkers();
|
||||
await loadVacationTypes();
|
||||
await loadPendingRequests();
|
||||
await loadAllRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadPendingRequests();
|
||||
loadAllRequests();
|
||||
});
|
||||
|
||||
// 날짜 필터 초기화 (최근 3개월)
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
||||
|
||||
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
|
||||
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
// 모든 탭과 컨텐츠 비활성화
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
|
||||
// 선택한 탭 활성화
|
||||
if (tabName === 'approval') {
|
||||
document.querySelector('.tab:nth-child(1)').classList.add('active');
|
||||
document.getElementById('approvalTab').classList.add('active');
|
||||
} else if (tabName === 'input') {
|
||||
document.querySelector('.tab:nth-child(2)').classList.add('active');
|
||||
document.getElementById('inputTab').classList.add('active');
|
||||
} else if (tabName === 'all') {
|
||||
document.querySelector('.tab:nth-child(3)').classList.add('active');
|
||||
document.getElementById('allTab').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests/pending');
|
||||
if (response.data.success) {
|
||||
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('승인 대기 목록 로드 오류:', error);
|
||||
document.getElementById('pendingRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>승인 대기 중인 신청이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllRequests() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
allRequestsData = response.data.data;
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('전체 신청 내역 로드 오류:', error);
|
||||
document.getElementById('allRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVacationBalance() {
|
||||
const workerId = document.getElementById('inputWorker').value;
|
||||
const balanceDiv = document.getElementById('workerVacationBalance');
|
||||
|
||||
if (!workerId) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
|
||||
if (response.data.success) {
|
||||
const balance = response.data.data;
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="display: inline-block; margin-right: 1rem;">
|
||||
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
|
||||
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
|
||||
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
balanceDiv.innerHTML = balanceHTML;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVacationInput(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
worker_id: parseInt(document.getElementById('inputWorker').value),
|
||||
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
|
||||
start_date: document.getElementById('inputStartDate').value,
|
||||
end_date: document.getElementById('inputEndDate').value,
|
||||
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
|
||||
reason: document.getElementById('inputReason').value || null
|
||||
};
|
||||
|
||||
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 휴가 신청 생성
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
const requestId = response.data.data.request_id;
|
||||
|
||||
// 즉시 승인 처리
|
||||
try {
|
||||
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
|
||||
if (approveResponse.data.success) {
|
||||
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
|
||||
document.getElementById('vacationInputForm').reset();
|
||||
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
loadRecentInputs();
|
||||
}
|
||||
} catch (approveError) {
|
||||
console.error('자동 승인 오류:', approveError);
|
||||
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 입력 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentInputs() {
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 최근 30일 이내 승인된 항목만 표시
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
|
||||
const recentApproved = response.data.data.filter(req =>
|
||||
req.status === 'approved' &&
|
||||
req.created_at >= thirtyDaysAgoStr
|
||||
).slice(0, 20); // 최근 20개만
|
||||
|
||||
renderVacationRequests(recentApproved, 'recentInputsList', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('최근 입력 내역 로드 오류:', error);
|
||||
document.getElementById('recentInputsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>최근 입력 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterAllRequests() {
|
||||
const startDate = document.getElementById('filterStartDate').value;
|
||||
const endDate = document.getElementById('filterEndDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allRequestsData.filter(req => {
|
||||
return req.start_date >= startDate && req.start_date <= endDate;
|
||||
});
|
||||
|
||||
renderVacationRequests(filtered, 'allRequestsList', false);
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
renderVacationRequests(allRequestsData, 'allRequestsList', false);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
272
web-ui/pages/common/vacation-request.html
Normal file
272
web-ui/pages/common/vacation-request.html
Normal file
@@ -0,0 +1,272 @@
|
||||
<!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/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="main-content">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📝</span>
|
||||
휴가 신청
|
||||
</h1>
|
||||
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 잔여 현황 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 잔여 현황</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 신청 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">휴가 신청</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="vacationType">휴가 유형 *</label>
|
||||
<select id="vacationType" class="form-control" required>
|
||||
<option value="">선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="daysUsed">사용 일수 *</label>
|
||||
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="startDate">시작일 *</label>
|
||||
<input type="date" id="startDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endDate">종료일 *</label>
|
||||
<input type="date" id="endDate" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: 1 / -1;">
|
||||
<label for="reason">사유</label>
|
||||
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 2rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
|
||||
<span>📝 신청하기</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 내역 -->
|
||||
<div class="content-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">내 신청 내역</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="myRequestsList" class="data-table-container">
|
||||
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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?v=5"></script>
|
||||
<script type="module">
|
||||
import '/js/api-config.js?v=3';
|
||||
</script>
|
||||
<script>
|
||||
// axios 기본 설정
|
||||
(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>
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForAxiosConfig();
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
try {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser || !currentUser.worker_id) {
|
||||
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVacationTypes();
|
||||
await loadVacationBalance();
|
||||
await loadMyRequests();
|
||||
|
||||
// 휴가 업데이트 이벤트 리스너
|
||||
window.addEventListener('vacation-updated', () => {
|
||||
loadVacationBalance();
|
||||
loadMyRequests();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('초기화 오류:', error);
|
||||
alert('페이지 초기화 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVacationBalance() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/attendance/vacation-balance/${currentUser.worker_id}`);
|
||||
if (response.data.success) {
|
||||
renderVacationBalance(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 잔여 조회 오류:', error);
|
||||
document.getElementById('vacationBalance').innerHTML = `
|
||||
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderVacationBalance(balance) {
|
||||
const container = document.getElementById('vacationBalance');
|
||||
|
||||
if (!balance || Object.keys(balance).length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const balanceHTML = Object.keys(balance).map(key => {
|
||||
const info = balance[key];
|
||||
return `
|
||||
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
|
||||
${info.remaining || 0}일
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
|
||||
사용: ${info.used || 0}일 / 전체: ${info.total || 0}일
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = balanceHTML;
|
||||
}
|
||||
|
||||
async function submitVacationRequest(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
const data = {
|
||||
worker_id: currentUser.worker_id,
|
||||
vacation_type_id: parseInt(document.getElementById('vacationType').value),
|
||||
start_date: document.getElementById('startDate').value,
|
||||
end_date: document.getElementById('endDate').value,
|
||||
days_used: parseFloat(document.getElementById('daysUsed').value),
|
||||
reason: document.getElementById('reason').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post('/vacation-requests', data);
|
||||
if (response.data.success) {
|
||||
alert('휴가 신청이 완료되었습니다.');
|
||||
document.getElementById('vacationRequestForm').reset();
|
||||
window.dispatchEvent(new Event('vacation-updated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('휴가 신청 오류:', error);
|
||||
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyRequests() {
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
try {
|
||||
const response = await axios.get('/vacation-requests');
|
||||
if (response.data.success) {
|
||||
// 내 신청만 필터링
|
||||
const myRequests = response.data.data.filter(req =>
|
||||
req.requested_by === currentUser.user_id || req.worker_id === currentUser.worker_id
|
||||
);
|
||||
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('내 신청 내역 로드 오류:', error);
|
||||
document.getElementById('myRequestsList').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,6 +17,7 @@
|
||||
<script type="module" src="/js/load-navbar.js"></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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -46,6 +47,22 @@
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🚪 출입 신청</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">작업장 출입 및 안전교육을 신청합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🛡️ 안전관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">출입 신청 승인 및 안전교육 관리</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-create.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>작업 보고서 작성</h3>
|
||||
@@ -77,46 +94,111 @@
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/daily-attendance.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📅 일일 출퇴근 입력</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">오늘의 출퇴근 기록을 입력합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/monthly-attendance.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📆 월별 출퇴근 현황</h3>
|
||||
<p>이번 달 출퇴근 현황을 조회합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-request.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📝 휴가 신청</h3>
|
||||
<p>휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-management.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🏖️ 휴가 관리</h3>
|
||||
<p>휴가 승인, 직접 입력, 전체 내역을 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/annual-vacation-overview.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>📊 연간 연차 현황</h3>
|
||||
<p>모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-allocation.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>➕ 휴가 발생 입력</h3>
|
||||
<p>작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/attendance-report-comparison.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🔍 출퇴근-작업보고서 대조</h3>
|
||||
<p>출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 오늘의 작업 현황 -->
|
||||
<section class="work-status-section">
|
||||
<!-- 작업장 현황 -->
|
||||
<section class="workplace-status-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="card-title">오늘의 작업 현황</h2>
|
||||
<div class="date-selector">
|
||||
<input type="date" id="selectedDate" class="date-input">
|
||||
<button class="btn btn-primary btn-sm" id="refreshBtn">
|
||||
<h2 class="card-title">작업장 현황</h2>
|
||||
<div class="flex items-center" style="gap: 12px;">
|
||||
<select id="categorySelect" class="form-select" style="width: 200px;">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" id="refreshMapBtn">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="work-status-table-container">
|
||||
<table class="work-status-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>상태</th>
|
||||
<th>작업시간</th>
|
||||
<th>작업건수</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workStatusTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>작업 현황을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 지도 영역 -->
|
||||
<div id="workplaceMapContainer" style="position: relative; min-height: 500px; display: none;">
|
||||
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas>
|
||||
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);">
|
||||
<h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(59, 130, 246, 0.3); border: 2px solid rgb(59, 130, 246); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">작업 중 (내부 작업자)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(168, 85, 247, 0.3); border: 2px solid rgb(168, 85, 247); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">방문 예정 (외부 인원)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="width: 16px; height: 16px; background: rgba(34, 197, 94, 0.3); border: 2px solid rgb(34, 197, 94); border-radius: 4px;"></div>
|
||||
<span style="font-size: var(--text-sm);">작업 + 방문</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안내 메시지 -->
|
||||
<div id="mapPlaceholder" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: var(--gray-500);">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🏭</div>
|
||||
<h3 style="margin-bottom: 8px;">공장을 선택하세요</h3>
|
||||
<p style="font-size: var(--text-sm);">위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,6 +226,28 @@
|
||||
<!-- 알림 토스트 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- 작업장 상세 정보 모달 -->
|
||||
<div id="workplaceDetailModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||
<div style="background: white; border-radius: var(--radius-lg); padding: 32px; max-width: 800px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: var(--shadow-2xl);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<h2 id="modalWorkplaceName" style="margin: 0; font-size: var(--text-2xl); font-weight: 700;"></h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeWorkplaceModal()">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 내부 작업자 -->
|
||||
<div id="internalWorkersSection" style="margin-bottom: 24px;">
|
||||
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--primary-600);">👷 내부 작업자</h3>
|
||||
<div id="internalWorkersList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 외부 방문자 -->
|
||||
<div id="externalVisitorsSection">
|
||||
<h3 style="font-size: var(--text-lg); font-weight: 600; margin-bottom: 16px; color: var(--purple-600);">🚪 외부 방문자</h3>
|
||||
<div id="externalVisitorsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
<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/daily-work-report.css?v=9">
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css?v=11">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
@@ -175,6 +175,6 @@
|
||||
<!-- 스크립트 -->
|
||||
<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/daily-work-report.js?v=24"></script>
|
||||
<script type="module" src="/js/daily-work-report.js?v=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 800px;">
|
||||
<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>
|
||||
@@ -151,60 +151,50 @@
|
||||
<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>
|
||||
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">팀장 *</label>
|
||||
<select id="leaderId" class="form-control" required>
|
||||
<option value="">팀장 선택...</option>
|
||||
</select>
|
||||
<label class="form-label">입력자 *</label>
|
||||
<input type="text" id="leaderName" class="form-control" readonly style="background: #e5e7eb;">
|
||||
<input type="hidden" id="leaderId">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<select id="projectId" class="form-control">
|
||||
<option value="">프로젝트 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 장소</label>
|
||||
<input type="text" id="workLocation" class="form-control" placeholder="작업 현장 위치">
|
||||
<!-- 작업자 및 작업 정보 섹션 -->
|
||||
<div class="form-section">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.1rem; font-weight: 600; color: #1f2937;">
|
||||
<span style="margin-right: 0.5rem;">👷</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;">
|
||||
<span>📋</span>
|
||||
일괄 설정
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="openWorkerSelectionModal()">
|
||||
<span style="font-size: 1.2rem; margin-right: 0.25rem;">+</span>
|
||||
작업자 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정 (Work Type) *</label>
|
||||
<select id="workTypeId" class="form-control" onchange="loadTasksByWorkType()" required>
|
||||
<option value="">공정 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 (Task) *</label>
|
||||
<select id="taskId" class="form-control" required>
|
||||
<option value="">작업 선택...</option>
|
||||
</select>
|
||||
<!-- 작업자 카드 리스트 -->
|
||||
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 0.75rem; min-height: 100px;">
|
||||
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
|
||||
<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;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;">👷♂️</div>
|
||||
<p>작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 내용</label>
|
||||
<textarea id="workDescription" class="form-control" rows="3" placeholder="오늘 진행할 작업 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">안전 관련 특이사항</label>
|
||||
<textarea id="safetyNotes" class="form-control" rows="2" placeholder="안전 주의사항이나 특이사항"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">시작 시간</label>
|
||||
<input type="time" id="startTime" class="form-control">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -212,7 +202,201 @@
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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 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>
|
||||
</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>
|
||||
</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="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;">
|
||||
📁 프로젝트 선택
|
||||
</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;">
|
||||
⚙️ 공정 선택
|
||||
</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>
|
||||
🔧 작업 선택
|
||||
</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;">
|
||||
📍 작업장 선택
|
||||
</button>
|
||||
<input type="hidden" id="bulkWorkplaceCategoryId">
|
||||
<input type="hidden" id="bulkWorkplaceId">
|
||||
</div>
|
||||
</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()">
|
||||
✓ 선택한 작업자에 적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div class="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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="confirmWorkerSelection()">
|
||||
✓ 선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn 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>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- 1단계: 공장 선택 -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">🏭</span>
|
||||
1. 공장 선택
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">📍</span>
|
||||
2. 작업장 선택
|
||||
</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;">
|
||||
<span style="margin-right: 0.25rem;">🗺️</span>
|
||||
지도에서 작업장을 클릭하여 선택하세요
|
||||
</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 style="margin-right: 0.25rem;">📋</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>
|
||||
</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>
|
||||
✓ 선택 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,6 +599,6 @@
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/tbm.js?v=2"></script>
|
||||
<script type="module" src="/js/tbm.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
371
web-ui/pages/work/visit-request.html
Normal file
371
web-ui/pages/work/visit-request.html
Normal file
@@ -0,0 +1,371 @@
|
||||
<!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=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>
|
||||
<style>
|
||||
.visit-form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workplace-selection {
|
||||
padding: 20px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px dashed var(--gray-300);
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.workplace-selection:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.workplace-selection.selected {
|
||||
border-color: var(--primary-500);
|
||||
border-style: solid;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.workplace-selection .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workplace-selection .text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--gray-600);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workplace-selection.selected .text {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* 지도 모달 스타일 */
|
||||
.map-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
.map-canvas-container {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
border: 2px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.workplace-info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border: 2px solid var(--primary-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.workplace-info-card .icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.workplace-info-card .details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workplace-info-card .name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--primary-700);
|
||||
}
|
||||
|
||||
.workplace-info-card .category {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-600);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.my-requests-section {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.request-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.request-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.request-status.pending {
|
||||
background: var(--yellow-100);
|
||||
color: var(--yellow-700);
|
||||
}
|
||||
|
||||
.request-status.approved {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.request-status.rejected {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.request-status.training_completed {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.request-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</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="visit-form-container">
|
||||
<div class="code-section">
|
||||
<h2 class="section-title">출입 정보 입력</h2>
|
||||
|
||||
<form id="visitRequestForm">
|
||||
<!-- 방문자 정보 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="visitorCompany">방문자 소속 *</label>
|
||||
<input type="text" id="visitorCompany" placeholder="예: (주)협력업체, 일용직 등" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="visitorCount">방문 인원 *</label>
|
||||
<input type="number" id="visitorCount" value="1" min="1" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 -->
|
||||
<div class="form-group">
|
||||
<label>방문 작업장 *</label>
|
||||
<div id="workplaceSelection" class="workplace-selection" onclick="openMapModal()">
|
||||
<div class="icon">📍</div>
|
||||
<div class="text">지도에서 작업장을 선택하세요</div>
|
||||
</div>
|
||||
<div id="selectedWorkplaceInfo" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 방문 일시 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="visitDate">방문 날짜 *</label>
|
||||
<input type="date" id="visitDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="visitTime">방문 시간 *</label>
|
||||
<input type="time" id="visitTime" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 방문 목적 -->
|
||||
<div class="form-group">
|
||||
<label for="visitPurpose">방문 목적 *</label>
|
||||
<select id="visitPurpose" required>
|
||||
<option value="">선택하세요</option>
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 비고 -->
|
||||
<div class="form-group">
|
||||
<label for="notes">비고 (선택)</label>
|
||||
<textarea id="notes" placeholder="추가 전달 사항이 있다면 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="resetForm()">초기화</button>
|
||||
<button type="submit" class="btn btn-primary">출입 신청 및 안전교육 신청</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 목록 -->
|
||||
<div class="my-requests-section">
|
||||
<div class="code-section">
|
||||
<h2 class="section-title">내 출입 신청 현황</h2>
|
||||
<div id="myRequestsList">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 모달 -->
|
||||
<div id="mapModal" class="map-modal">
|
||||
<div class="map-modal-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<h2 style="margin: 0;">작업장 선택</h2>
|
||||
<button class="btn btn-secondary" onclick="closeMapModal()">닫기</button>
|
||||
</div>
|
||||
|
||||
<!-- 구역(공장) 선택 -->
|
||||
<div class="form-group">
|
||||
<label for="categorySelect">구역(공장) 선택</label>
|
||||
<select id="categorySelect" onchange="loadWorkplaceMap()">
|
||||
<option value="">구역을 선택하세요</option>
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 지도 캔버스 -->
|
||||
<div id="mapCanvasContainer" style="display: none;">
|
||||
<div class="map-canvas-container">
|
||||
<canvas id="workplaceMapCanvas" class="map-canvas"></canvas>
|
||||
</div>
|
||||
<p style="margin-top: 12px; color: var(--gray-600); font-size: var(--text-sm);">
|
||||
지도에서 방문할 작업장을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script src="/js/visit-request.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user