feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성 - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동 - common/ → attendance/: 근태/휴가 관련 페이지 이동 - admin/ 정리: safety-* 파일들을 safety/로 이동 - 사이드바 네비게이션 메뉴 구현 - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리 - 접기/펼치기 기능 및 상태 저장 - 관리자 전용 메뉴 자동 표시/숨김 - 날씨 API 연동 (기상청 단기예보) - TBM 및 navbar에 현재 날씨 표시 - weatherService.js 추가 - 안전 체크리스트 확장 - 기본/날씨별/작업별 체크 유형 추가 - checklist-manage.html 페이지 추가 - 이슈 신고 시스템 구현 - workIssueController, workIssueModel, workIssueRoutes 추가 - DB 마이그레이션 파일 추가 (실행 대기) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,36 +92,74 @@ docker-compose up -d # 수동 실행
|
||||
- **일관된 헤더**: 모든 페이지에서 `<div id="navbar-container"></div>` 사용
|
||||
- **CSS 로딩 순서**: `design-system.css` → 페이지별 CSS
|
||||
|
||||
### 페이지 구조 (2026-01-20 개편)
|
||||
### 페이지 구조 (2026-02-02 현행)
|
||||
```
|
||||
web-ui/pages/
|
||||
├── dashboard.html # 메인 대시보드
|
||||
├── work/ # 작업 관련 페이지
|
||||
├── dashboard.html # 메인 대시보드 (작업장 현황 지도 포함)
|
||||
├── work/ # 작업 관련 페이지 (현장 입력/생산)
|
||||
│ ├── tbm.html # TBM(Tool Box Meeting) 관리
|
||||
│ ├── report-create.html # 작업보고서 작성
|
||||
│ ├── report-view.html # 작업보고서 조회
|
||||
│ └── analysis.html # 작업 분석
|
||||
├── admin/ # 관리자 기능
|
||||
│ ├── index.html # 관리 메뉴 허브
|
||||
│ ├── projects.html # 프로젝트 관리
|
||||
├── safety/ # 안전 관리 페이지 (신규)
|
||||
│ ├── issue-report.html # 이슈 신고
|
||||
│ ├── issue-list.html # 이슈 목록
|
||||
│ ├── issue-detail.html # 이슈 상세
|
||||
│ ├── visit-request.html # 출입 신청
|
||||
│ ├── management.html # 안전 관리 (출입 승인)
|
||||
│ ├── training-conduct.html # 안전교육 진행
|
||||
│ └── checklist-manage.html # 안전 체크리스트 관리
|
||||
├── attendance/ # 근태 관리 페이지 (common → attendance 변경)
|
||||
│ ├── daily.html # 일일 출퇴근 입력
|
||||
│ ├── monthly.html # 월별 출퇴근 현황
|
||||
│ ├── vacation-request.html # 휴가 신청
|
||||
│ ├── vacation-management.html # 휴가 관리 (통합)
|
||||
│ ├── vacation-approval.html # 휴가 승인 관리
|
||||
│ ├── vacation-input.html # 휴가 직접 입력
|
||||
│ ├── vacation-allocation.html # 휴가 발생 입력
|
||||
│ └── annual-overview.html # 연간 연차 현황
|
||||
├── admin/ # 시스템 관리 페이지
|
||||
│ ├── accounts.html # 계정 관리
|
||||
│ ├── page-access.html # 페이지 접근 권한 관리
|
||||
│ ├── workers.html # 작업자 관리
|
||||
│ ├── projects.html # 프로젝트 관리
|
||||
│ ├── tasks.html # 작업 관리
|
||||
│ ├── workplaces.html # 작업장 관리 (지도 구역 설정)
|
||||
│ ├── equipments.html # 설비 관리
|
||||
│ ├── codes.html # 코드 관리
|
||||
│ └── accounts.html # 계정 관리
|
||||
│ └── attendance-report.html # 출퇴근-작업보고서 대조
|
||||
├── profile/ # 사용자 프로필
|
||||
│ ├── info.html # 내 정보
|
||||
│ └── password.html # 비밀번호 변경
|
||||
└── .archived-*/ # 미사용 페이지 보관
|
||||
```
|
||||
|
||||
**폴더 분류 기준** (2026-02-02 변경):
|
||||
- `work/`: 현장 입력/생산 활동 (TBM, 작업보고서)
|
||||
- `safety/`: 안전 관리/분석 (이슈, 출입, 안전교육)
|
||||
- `attendance/`: 근태/휴가 관리
|
||||
- `admin/`: 시스템 관리 (관리자 전용)
|
||||
- `profile/`: 개인 설정 페이지
|
||||
|
||||
**네이밍 규칙**:
|
||||
- 메인 페이지: 단일 명사 (`dashboard.html`)
|
||||
- 관리 페이지: 복수형 명사 (`projects.html`, `workers.html`)
|
||||
- 기능 페이지: 동사-명사 (`report-create.html`, `report-view.html`)
|
||||
- 폴더명: 단수형, 소문자 (`work/`, `admin/`, `profile/`)
|
||||
- 기능 페이지: 동사-명사 또는 명사 (`report-create.html`, `daily.html`)
|
||||
- 폴더명: 단수형, 소문자 (`work/`, `safety/`, `attendance/`, `admin/`, `profile/`)
|
||||
|
||||
**네비게이션 구조**:
|
||||
- 1차: `dashboard.html` (메인 진입점)
|
||||
- 2차: `admin/index.html` (관리 허브)
|
||||
- 모든 페이지: navbar를 통해 profile, 작업 페이지로 이동 가능
|
||||
- 1차: `dashboard.html` (메인 진입점, 작업장 현황 지도)
|
||||
- 2차: 사이드 메뉴 또는 빠른 작업 카드를 통한 각 기능 페이지 이동
|
||||
- 모든 페이지: navbar를 통해 profile, 로그아웃 가능
|
||||
|
||||
### 대기 중인 DB 마이그레이션
|
||||
페이지 구조 변경에 따른 DB 마이그레이션이 필요합니다:
|
||||
```bash
|
||||
cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net
|
||||
npx knex migrate:latest
|
||||
```
|
||||
- 마이그레이션 파일: `db/migrations/20260202200000_reorganize_pages.js`
|
||||
- 내용: pages 테이블 경로 업데이트, role_default_pages 테이블 생성
|
||||
|
||||
### 표준 컴포넌트 (2026-01-20 업데이트)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ function setupRoutes(app) {
|
||||
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
|
||||
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
|
||||
const visitRequestRoutes = require('../routes/visitRequestRoutes');
|
||||
const workIssueRoutes = require('../routes/workIssueRoutes');
|
||||
|
||||
// Rate Limiters 설정
|
||||
const rateLimit = require('express-rate-limit');
|
||||
@@ -151,6 +152,7 @@ function setupRoutes(app) {
|
||||
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||
app.use('/api/work-issues', workIssueRoutes); // 문제 신고 시스템
|
||||
app.use('/api', uploadBgRoutes);
|
||||
|
||||
// Swagger API 문서
|
||||
|
||||
@@ -409,6 +409,268 @@ const TbmController = {
|
||||
});
|
||||
},
|
||||
|
||||
// ==================== 필터링된 안전 체크리스트 (확장) ====================
|
||||
|
||||
/**
|
||||
* 세션에 맞는 필터링된 안전 체크 항목 조회
|
||||
* 기본 + 날씨 + 작업별 체크항목 통합
|
||||
*/
|
||||
getFilteredSafetyChecks: async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
// 날씨 정보 확인 (이미 저장된 경우 사용, 없으면 새로 조회)
|
||||
const weatherService = require('../services/weatherService');
|
||||
let weatherRecord = await weatherService.getWeatherRecord(sessionId);
|
||||
let weatherConditions = [];
|
||||
|
||||
if (weatherRecord && weatherRecord.weather_conditions) {
|
||||
weatherConditions = weatherRecord.weather_conditions;
|
||||
} else {
|
||||
// 날씨 정보가 없으면 현재 날씨 조회
|
||||
const currentWeather = await weatherService.getCurrentWeather();
|
||||
weatherConditions = await weatherService.determineWeatherConditions(currentWeather);
|
||||
// 날씨 기록 저장
|
||||
await weatherService.saveWeatherRecord(sessionId, currentWeather, weatherConditions);
|
||||
}
|
||||
|
||||
TbmModel.getFilteredSafetyChecks(sessionId, weatherConditions, (err, results) => {
|
||||
if (err) {
|
||||
console.error('필터링된 안전 체크 조회 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('필터링된 안전 체크 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 날씨 조회
|
||||
*/
|
||||
getCurrentWeather: async (req, res) => {
|
||||
try {
|
||||
const weatherService = require('../services/weatherService');
|
||||
const { nx, ny } = req.query;
|
||||
|
||||
const weatherData = await weatherService.getCurrentWeather(nx, ny);
|
||||
const conditions = await weatherService.determineWeatherConditions(weatherData);
|
||||
const conditionList = await weatherService.getWeatherConditionList();
|
||||
|
||||
// 현재 조건의 상세 정보 매핑
|
||||
const activeConditions = conditionList.filter(c => conditions.includes(c.condition_code));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...weatherData,
|
||||
conditions,
|
||||
conditionDetails: activeConditions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('날씨 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '날씨 조회 중 오류가 발생했습니다.',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 세션 날씨 정보 저장
|
||||
*/
|
||||
saveSessionWeather: async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { weatherConditions } = req.body;
|
||||
|
||||
try {
|
||||
const weatherService = require('../services/weatherService');
|
||||
|
||||
// 현재 날씨 조회
|
||||
const weatherData = await weatherService.getCurrentWeather();
|
||||
const conditions = weatherConditions || await weatherService.determineWeatherConditions(weatherData);
|
||||
|
||||
// 저장
|
||||
await weatherService.saveWeatherRecord(sessionId, weatherData, conditions);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '날씨 정보가 저장되었습니다.',
|
||||
data: { conditions }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('날씨 저장 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '날씨 저장 중 오류가 발생했습니다.',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 세션 날씨 정보 조회
|
||||
*/
|
||||
getSessionWeather: async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
const weatherService = require('../services/weatherService');
|
||||
const weatherRecord = await weatherService.getWeatherRecord(sessionId);
|
||||
|
||||
if (!weatherRecord) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '날씨 기록이 없습니다.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weatherRecord
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('날씨 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '날씨 조회 중 오류가 발생했습니다.',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 날씨 조건 목록 조회
|
||||
*/
|
||||
getWeatherConditions: async (req, res) => {
|
||||
try {
|
||||
const weatherService = require('../services/weatherService');
|
||||
const conditions = await weatherService.getWeatherConditionList();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: conditions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('날씨 조건 조회 오류:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '날씨 조건 조회 중 오류가 발생했습니다.',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 안전 체크항목 관리 (관리자용) ====================
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 생성
|
||||
*/
|
||||
createSafetyCheck: (req, res) => {
|
||||
const checkData = req.body;
|
||||
|
||||
if (!checkData.check_category || !checkData.check_item) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '카테고리와 체크 항목은 필수입니다.'
|
||||
});
|
||||
}
|
||||
|
||||
TbmModel.createSafetyCheck(checkData, (err, result) => {
|
||||
if (err) {
|
||||
console.error('안전 체크 항목 생성 오류:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '안전 체크 항목 생성 중 오류가 발생했습니다.',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '안전 체크 항목이 생성되었습니다.',
|
||||
data: { check_id: result.insertId }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 수정
|
||||
*/
|
||||
updateSafetyCheck: (req, res) => {
|
||||
const { checkId } = req.params;
|
||||
const checkData = req.body;
|
||||
|
||||
TbmModel.updateSafetyCheck(checkId, checkData, (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: '안전 체크 항목이 수정되었습니다.'
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 삭제 (비활성화)
|
||||
*/
|
||||
deleteSafetyCheck: (req, res) => {
|
||||
const { checkId } = req.params;
|
||||
|
||||
TbmModel.deleteSafetyCheck(checkId, (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: '안전 체크 항목이 삭제되었습니다.'
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -218,7 +218,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
checkAdminPermission(req.user);
|
||||
|
||||
const { id } = req.params;
|
||||
const { username, name, email, phone, role, role_id, password } = req.body;
|
||||
const { username, name, email, role, role_id, password } = req.body;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
@@ -227,7 +227,7 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
logger.info('사용자 수정 요청', { userId: id, body: req.body });
|
||||
|
||||
// 최소 하나의 수정 필드가 필요
|
||||
if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) {
|
||||
if (!username && !name && email === undefined && !role && !role_id && !password) {
|
||||
throw new ValidationError('수정할 필드가 없습니다');
|
||||
}
|
||||
|
||||
@@ -278,11 +278,6 @@ const updateUser = asyncHandler(async (req, res) => {
|
||||
values.push(email || null);
|
||||
}
|
||||
|
||||
if (phone !== undefined) {
|
||||
updates.push('phone = ?');
|
||||
values.push(phone || null);
|
||||
}
|
||||
|
||||
// role_id 또는 role 문자열 처리
|
||||
if (role_id) {
|
||||
// role_id가 유효한지 확인
|
||||
@@ -497,6 +492,7 @@ const getUserPageAccess = asyncHandler(async (req, res) => {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
// 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용
|
||||
const query = `
|
||||
SELECT
|
||||
p.id as page_id,
|
||||
@@ -504,10 +500,10 @@ const getUserPageAccess = asyncHandler(async (req, res) => {
|
||||
p.page_name,
|
||||
p.page_path,
|
||||
p.category,
|
||||
COALESCE(upa.can_access, 0) as can_access
|
||||
p.is_default_accessible,
|
||||
COALESCE(upa.can_access, p.is_default_accessible) 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
|
||||
`;
|
||||
|
||||
@@ -595,6 +591,55 @@ const updateUserPageAccess = asyncHandler(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 사용자 비밀번호 초기화 (000000)
|
||||
*/
|
||||
const resetUserPassword = asyncHandler(async (req, res) => {
|
||||
checkAdminPermission(req.user);
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 사용자 ID입니다');
|
||||
}
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
// 사용자 존재 확인
|
||||
const [existing] = await db.execute('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw new NotFoundError('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 비밀번호를 000000으로 초기화
|
||||
const hashedPassword = await bcrypt.hash('000000', 10);
|
||||
await db.execute(
|
||||
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
|
||||
[hashedPassword, id]
|
||||
);
|
||||
|
||||
logger.info('사용자 비밀번호 초기화 성공', {
|
||||
userId: id,
|
||||
username: existing[0].username,
|
||||
resetBy: req.user.username
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '비밀번호가 000000으로 초기화되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError || error instanceof ValidationError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('비밀번호 초기화 실패', { userId: id, error: error.message });
|
||||
throw new DatabaseError('비밀번호 초기화에 실패했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
@@ -603,5 +648,6 @@ module.exports = {
|
||||
updateUserStatus,
|
||||
deleteUser,
|
||||
getUserPageAccess,
|
||||
updateUserPageAccess
|
||||
updateUserPageAccess,
|
||||
resetUserPassword
|
||||
};
|
||||
|
||||
643
api.hyungi.net/controllers/workIssueController.js
Normal file
643
api.hyungi.net/controllers/workIssueController.js
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* 작업 중 문제 신고 컨트롤러
|
||||
*/
|
||||
|
||||
const workIssueModel = require('../models/workIssueModel');
|
||||
const imageUploadService = require('../services/imageUploadService');
|
||||
|
||||
// ==================== 신고 카테고리 관리 ====================
|
||||
|
||||
/**
|
||||
* 모든 카테고리 조회
|
||||
*/
|
||||
exports.getAllCategories = (req, res) => {
|
||||
workIssueModel.getAllCategories((err, categories) => {
|
||||
if (err) {
|
||||
console.error('카테고리 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: categories });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 타입별 카테고리 조회
|
||||
*/
|
||||
exports.getCategoriesByType = (req, res) => {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!['nonconformity', 'safety'].includes(type)) {
|
||||
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
|
||||
}
|
||||
|
||||
workIssueModel.getCategoriesByType(type, (err, categories) => {
|
||||
if (err) {
|
||||
console.error('카테고리 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: categories });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
exports.createCategory = (req, res) => {
|
||||
const { category_type, category_name, description, display_order } = req.body;
|
||||
|
||||
if (!category_type || !category_name) {
|
||||
return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' });
|
||||
}
|
||||
|
||||
workIssueModel.createCategory(
|
||||
{ category_type, category_name, description, display_order },
|
||||
(err, categoryId) => {
|
||||
if (err) {
|
||||
console.error('카테고리 생성 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '카테고리 생성 실패' });
|
||||
}
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '카테고리가 생성되었습니다.',
|
||||
data: { category_id: categoryId }
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
exports.updateCategory = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { category_name, description, display_order, is_active } = req.body;
|
||||
|
||||
workIssueModel.updateCategory(
|
||||
id,
|
||||
{ category_name, description, display_order, is_active },
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
console.error('카테고리 수정 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '카테고리 수정 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '카테고리가 수정되었습니다.' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
exports.deleteCategory = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.deleteCategory(id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('카테고리 삭제 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '카테고리 삭제 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '카테고리가 삭제되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 사전 정의 항목 관리 ====================
|
||||
|
||||
/**
|
||||
* 카테고리별 항목 조회
|
||||
*/
|
||||
exports.getItemsByCategory = (req, res) => {
|
||||
const { categoryId } = req.params;
|
||||
|
||||
workIssueModel.getItemsByCategory(categoryId, (err, items) => {
|
||||
if (err) {
|
||||
console.error('항목 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '항목 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: items });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 항목 조회
|
||||
*/
|
||||
exports.getAllItems = (req, res) => {
|
||||
workIssueModel.getAllItems((err, items) => {
|
||||
if (err) {
|
||||
console.error('항목 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '항목 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: items });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 생성
|
||||
*/
|
||||
exports.createItem = (req, res) => {
|
||||
const { category_id, item_name, description, severity, display_order } = req.body;
|
||||
|
||||
if (!category_id || !item_name) {
|
||||
return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' });
|
||||
}
|
||||
|
||||
workIssueModel.createItem(
|
||||
{ category_id, item_name, description, severity, display_order },
|
||||
(err, itemId) => {
|
||||
if (err) {
|
||||
console.error('항목 생성 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '항목 생성 실패' });
|
||||
}
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '항목이 생성되었습니다.',
|
||||
data: { item_id: itemId }
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 수정
|
||||
*/
|
||||
exports.updateItem = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { item_name, description, severity, display_order, is_active } = req.body;
|
||||
|
||||
workIssueModel.updateItem(
|
||||
id,
|
||||
{ item_name, description, severity, display_order, is_active },
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
console.error('항목 수정 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '항목 수정 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '항목이 수정되었습니다.' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 삭제
|
||||
*/
|
||||
exports.deleteItem = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.deleteItem(id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('항목 삭제 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '항목 삭제 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '항목이 삭제되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 문제 신고 관리 ====================
|
||||
|
||||
/**
|
||||
* 신고 생성
|
||||
*/
|
||||
exports.createReport = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
factory_category_id,
|
||||
workplace_id,
|
||||
custom_location,
|
||||
tbm_session_id,
|
||||
visit_request_id,
|
||||
issue_category_id,
|
||||
issue_item_id,
|
||||
additional_description,
|
||||
photos = []
|
||||
} = req.body;
|
||||
|
||||
const reporter_id = req.user.user_id;
|
||||
|
||||
if (!issue_category_id) {
|
||||
return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' });
|
||||
}
|
||||
|
||||
// 위치 정보 검증 (지도 선택 또는 기타 위치)
|
||||
if (!factory_category_id && !custom_location) {
|
||||
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
|
||||
}
|
||||
|
||||
// 사진 저장 (최대 5장)
|
||||
const photoPaths = {
|
||||
photo_path1: null,
|
||||
photo_path2: null,
|
||||
photo_path3: null,
|
||||
photo_path4: null,
|
||||
photo_path5: null
|
||||
};
|
||||
|
||||
for (let i = 0; i < Math.min(photos.length, 5); i++) {
|
||||
if (photos[i]) {
|
||||
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
|
||||
if (savedPath) {
|
||||
photoPaths[`photo_path${i + 1}`] = savedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reportData = {
|
||||
reporter_id,
|
||||
factory_category_id: factory_category_id || null,
|
||||
workplace_id: workplace_id || null,
|
||||
custom_location: custom_location || null,
|
||||
tbm_session_id: tbm_session_id || null,
|
||||
visit_request_id: visit_request_id || null,
|
||||
issue_category_id,
|
||||
issue_item_id: issue_item_id || null,
|
||||
additional_description: additional_description || null,
|
||||
...photoPaths
|
||||
};
|
||||
|
||||
workIssueModel.createReport(reportData, (err, reportId) => {
|
||||
if (err) {
|
||||
console.error('신고 생성 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 생성 실패' });
|
||||
}
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '문제 신고가 등록되었습니다.',
|
||||
data: { report_id: reportId }
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('신고 생성 에러:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 목록 조회
|
||||
*/
|
||||
exports.getAllReports = (req, res) => {
|
||||
const filters = {
|
||||
status: req.query.status,
|
||||
category_type: req.query.category_type,
|
||||
issue_category_id: req.query.issue_category_id,
|
||||
factory_category_id: req.query.factory_category_id,
|
||||
workplace_id: req.query.workplace_id,
|
||||
assigned_user_id: req.query.assigned_user_id,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
search: req.query.search,
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset
|
||||
};
|
||||
|
||||
// 일반 사용자는 자신의 신고만 조회 (관리자 제외)
|
||||
const userLevel = req.user.access_level;
|
||||
if (!['admin', 'system', 'support_team'].includes(userLevel)) {
|
||||
filters.reporter_id = req.user.user_id;
|
||||
}
|
||||
|
||||
workIssueModel.getAllReports(filters, (err, reports) => {
|
||||
if (err) {
|
||||
console.error('신고 목록 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 목록 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: reports });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 상세 조회
|
||||
*/
|
||||
exports.getReportById = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.getReportById(id, (err, report) => {
|
||||
if (err) {
|
||||
console.error('신고 상세 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 상세 조회 실패' });
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 권한 확인: 본인, 담당자, 또는 관리자
|
||||
const userLevel = req.user.access_level;
|
||||
const isOwner = report.reporter_id === req.user.user_id;
|
||||
const isAssignee = report.assigned_user_id === req.user.user_id;
|
||||
const isManager = ['admin', 'system', 'support_team'].includes(userLevel);
|
||||
|
||||
if (!isOwner && !isAssignee && !isManager) {
|
||||
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: report });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 수정
|
||||
*/
|
||||
exports.updateReport = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 기존 신고 확인
|
||||
workIssueModel.getReportById(id, async (err, report) => {
|
||||
if (err) {
|
||||
console.error('신고 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 조회 실패' });
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
const userLevel = req.user.access_level;
|
||||
const isOwner = report.reporter_id === req.user.user_id;
|
||||
const isManager = ['admin', 'system'].includes(userLevel);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' });
|
||||
}
|
||||
|
||||
// 상태 확인: reported 상태에서만 수정 가능 (관리자 제외)
|
||||
if (!isManager && report.status !== 'reported') {
|
||||
return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const {
|
||||
factory_category_id,
|
||||
workplace_id,
|
||||
custom_location,
|
||||
issue_category_id,
|
||||
issue_item_id,
|
||||
additional_description,
|
||||
photos = []
|
||||
} = req.body;
|
||||
|
||||
// 사진 업데이트 처리
|
||||
const photoPaths = {};
|
||||
for (let i = 0; i < Math.min(photos.length, 5); i++) {
|
||||
if (photos[i]) {
|
||||
// 기존 사진 삭제
|
||||
const oldPath = report[`photo_path${i + 1}`];
|
||||
if (oldPath) {
|
||||
await imageUploadService.deleteFile(oldPath);
|
||||
}
|
||||
// 새 사진 저장
|
||||
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
|
||||
if (savedPath) {
|
||||
photoPaths[`photo_path${i + 1}`] = savedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
factory_category_id,
|
||||
workplace_id,
|
||||
custom_location,
|
||||
issue_category_id,
|
||||
issue_item_id,
|
||||
additional_description,
|
||||
...photoPaths
|
||||
};
|
||||
|
||||
workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => {
|
||||
if (updateErr) {
|
||||
console.error('신고 수정 실패:', updateErr);
|
||||
return res.status(500).json({ success: false, error: '신고 수정 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '신고가 수정되었습니다.' });
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('신고 수정 에러:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 삭제
|
||||
*/
|
||||
exports.deleteReport = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.getReportById(id, async (err, report) => {
|
||||
if (err) {
|
||||
console.error('신고 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '신고 조회 실패' });
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
const userLevel = req.user.access_level;
|
||||
const isOwner = report.reporter_id === req.user.user_id;
|
||||
const isManager = ['admin', 'system'].includes(userLevel);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' });
|
||||
}
|
||||
|
||||
workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => {
|
||||
if (deleteErr) {
|
||||
console.error('신고 삭제 실패:', deleteErr);
|
||||
return res.status(500).json({ success: false, error: '신고 삭제 실패' });
|
||||
}
|
||||
|
||||
// 사진 파일 삭제
|
||||
if (photos) {
|
||||
const allPhotos = [
|
||||
photos.photo_path1, photos.photo_path2, photos.photo_path3,
|
||||
photos.photo_path4, photos.photo_path5,
|
||||
photos.resolution_photo_path1, photos.resolution_photo_path2
|
||||
].filter(Boolean);
|
||||
await imageUploadService.deleteMultipleFiles(allPhotos);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '신고가 삭제되었습니다.' });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
/**
|
||||
* 신고 접수
|
||||
*/
|
||||
exports.receiveReport = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.receiveReport(id, req.user.user_id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('신고 접수 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '신고가 접수되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 담당자 배정
|
||||
*/
|
||||
exports.assignReport = (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { assigned_department, assigned_user_id } = req.body;
|
||||
|
||||
if (!assigned_user_id) {
|
||||
return res.status(400).json({ success: false, error: '담당자는 필수입니다.' });
|
||||
}
|
||||
|
||||
workIssueModel.assignReport(id, {
|
||||
assigned_department,
|
||||
assigned_user_id,
|
||||
assigned_by: req.user.user_id
|
||||
}, (err, result) => {
|
||||
if (err) {
|
||||
console.error('담당자 배정 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '담당자가 배정되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 처리 시작
|
||||
*/
|
||||
exports.startProcessing = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.startProcessing(id, req.user.user_id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('처리 시작 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '처리가 시작되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 처리 완료
|
||||
*/
|
||||
exports.completeReport = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { resolution_notes, resolution_photos = [] } = req.body;
|
||||
|
||||
// 완료 사진 저장
|
||||
let resolution_photo_path1 = null;
|
||||
let resolution_photo_path2 = null;
|
||||
|
||||
if (resolution_photos[0]) {
|
||||
resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution');
|
||||
}
|
||||
if (resolution_photos[1]) {
|
||||
resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
|
||||
}
|
||||
|
||||
workIssueModel.completeReport(id, {
|
||||
resolution_notes,
|
||||
resolution_photo_path1,
|
||||
resolution_photo_path2,
|
||||
resolved_by: req.user.user_id
|
||||
}, (err, result) => {
|
||||
if (err) {
|
||||
console.error('처리 완료 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '처리가 완료되었습니다.' });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('처리 완료 에러:', error);
|
||||
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 종료
|
||||
*/
|
||||
exports.closeReport = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.closeReport(id, req.user.user_id, (err, result) => {
|
||||
if (err) {
|
||||
console.error('신고 종료 실패:', err);
|
||||
return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' });
|
||||
}
|
||||
res.json({ success: true, message: '신고가 종료되었습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 상태 변경 이력 조회
|
||||
*/
|
||||
exports.getStatusLogs = (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
workIssueModel.getStatusLogs(id, (err, logs) => {
|
||||
if (err) {
|
||||
console.error('상태 이력 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '상태 이력 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: logs });
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
* 통계 요약
|
||||
*/
|
||||
exports.getStatsSummary = (req, res) => {
|
||||
const filters = {
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
factory_category_id: req.query.factory_category_id
|
||||
};
|
||||
|
||||
workIssueModel.getStatsSummary(filters, (err, stats) => {
|
||||
if (err) {
|
||||
console.error('통계 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: stats });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리별 통계
|
||||
*/
|
||||
exports.getStatsByCategory = (req, res) => {
|
||||
const filters = {
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date
|
||||
};
|
||||
|
||||
workIssueModel.getStatsByCategory(filters, (err, stats) => {
|
||||
if (err) {
|
||||
console.error('카테고리별 통계 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: stats });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업장별 통계
|
||||
*/
|
||||
exports.getStatsByWorkplace = (req, res) => {
|
||||
const filters = {
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
factory_category_id: req.query.factory_category_id
|
||||
};
|
||||
|
||||
workIssueModel.getStatsByWorkplace(filters, (err, stats) => {
|
||||
if (err) {
|
||||
console.error('작업장별 통계 조회 실패:', err);
|
||||
return res.status(500).json({ success: false, error: '통계 조회 실패' });
|
||||
}
|
||||
res.json({ success: true, data: stats });
|
||||
});
|
||||
};
|
||||
@@ -106,3 +106,70 @@ exports.getSummary = asyncHandler(async (req, res) => {
|
||||
message: '월간 요약 조회 성공'
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 부적합 원인 관리 API ==========
|
||||
|
||||
/**
|
||||
* 작업 보고서의 부적합 원인 목록 조회
|
||||
*/
|
||||
exports.getReportDefects = asyncHandler(async (req, res) => {
|
||||
const { reportId } = req.params;
|
||||
const rows = await workReportService.getReportDefectsService(reportId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
message: '부적합 원인 조회 성공'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 부적합 원인 저장 (전체 교체)
|
||||
* 기존 부적합 원인을 모두 삭제하고 새로 저장
|
||||
*/
|
||||
exports.saveReportDefects = asyncHandler(async (req, res) => {
|
||||
const { reportId } = req.params;
|
||||
const { defects } = req.body; // [{ error_type_id, defect_hours, note }]
|
||||
|
||||
const result = await workReportService.saveReportDefectsService(reportId, defects);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '부적합 원인이 저장되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 부적합 원인 추가 (단일)
|
||||
*/
|
||||
exports.addReportDefect = asyncHandler(async (req, res) => {
|
||||
const { reportId } = req.params;
|
||||
const { error_type_id, defect_hours, note } = req.body;
|
||||
|
||||
const result = await workReportService.addReportDefectService(reportId, {
|
||||
error_type_id,
|
||||
defect_hours,
|
||||
note
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '부적합 원인이 추가되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 부적합 원인 삭제
|
||||
*/
|
||||
exports.removeReportDefect = asyncHandler(async (req, res) => {
|
||||
const { defectId } = req.params;
|
||||
const result = await workReportService.removeReportDefectService(defectId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '부적합 원인이 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 마이그레이션: 작업 중 문제 신고 시스템
|
||||
* - 신고 카테고리 테이블 (부적합/안전)
|
||||
* - 사전 정의 신고 항목 테이블
|
||||
* - 문제 신고 메인 테이블
|
||||
* - 상태 변경 이력 테이블
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. 신고 카테고리 테이블 생성
|
||||
await knex.schema.createTable('issue_report_categories', function(table) {
|
||||
table.increments('category_id').primary().comment('카테고리 ID');
|
||||
table.enum('category_type', ['nonconformity', 'safety']).notNullable().comment('카테고리 유형 (부적합/안전)');
|
||||
table.string('category_name', 100).notNullable().comment('카테고리명');
|
||||
table.text('description').nullable().comment('카테고리 설명');
|
||||
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index('category_type', 'idx_irc_category_type');
|
||||
table.index('is_active', 'idx_irc_is_active');
|
||||
});
|
||||
|
||||
// 카테고리 초기 데이터 삽입
|
||||
await knex('issue_report_categories').insert([
|
||||
// 부적합 사항
|
||||
{ category_type: 'nonconformity', category_name: '자재누락', display_order: 1, is_active: true },
|
||||
{ category_type: 'nonconformity', category_name: '설계미스', display_order: 2, is_active: true },
|
||||
{ category_type: 'nonconformity', category_name: '입고불량', display_order: 3, is_active: true },
|
||||
{ category_type: 'nonconformity', category_name: '검사미스', display_order: 4, is_active: true },
|
||||
{ category_type: 'nonconformity', category_name: '기타 부적합', display_order: 99, is_active: true },
|
||||
// 안전 관련
|
||||
{ category_type: 'safety', category_name: '보호구 미착용', display_order: 1, is_active: true },
|
||||
{ category_type: 'safety', category_name: '위험구역 출입', display_order: 2, is_active: true },
|
||||
{ category_type: 'safety', category_name: '안전시설 파손', display_order: 3, is_active: true },
|
||||
{ category_type: 'safety', category_name: '안전수칙 위반', display_order: 4, is_active: true },
|
||||
{ category_type: 'safety', category_name: '기타 안전', display_order: 99, is_active: true }
|
||||
]);
|
||||
|
||||
// 2. 사전 정의 신고 항목 테이블 생성
|
||||
await knex.schema.createTable('issue_report_items', function(table) {
|
||||
table.increments('item_id').primary().comment('항목 ID');
|
||||
table.integer('category_id').unsigned().notNullable().comment('소속 카테고리 ID');
|
||||
table.string('item_name', 200).notNullable().comment('신고 항목명');
|
||||
table.text('description').nullable().comment('항목 설명');
|
||||
table.enum('severity', ['low', 'medium', 'high', 'critical']).defaultTo('medium').comment('심각도');
|
||||
table.integer('display_order').defaultTo(0).comment('표시 순서');
|
||||
table.boolean('is_active').defaultTo(true).comment('활성 여부');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.foreign('category_id')
|
||||
.references('category_id')
|
||||
.inTable('issue_report_categories')
|
||||
.onDelete('CASCADE')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.index('category_id', 'idx_iri_category_id');
|
||||
table.index('is_active', 'idx_iri_is_active');
|
||||
});
|
||||
|
||||
// 사전 정의 항목 초기 데이터 삽입
|
||||
await knex('issue_report_items').insert([
|
||||
// 자재누락 (category_id: 1)
|
||||
{ category_id: 1, item_name: '배관 자재 미입고', severity: 'high', display_order: 1 },
|
||||
{ category_id: 1, item_name: '피팅류 부족', severity: 'medium', display_order: 2 },
|
||||
{ category_id: 1, item_name: '밸브류 미입고', severity: 'high', display_order: 3 },
|
||||
{ category_id: 1, item_name: '가스켓/볼트류 부족', severity: 'low', display_order: 4 },
|
||||
{ category_id: 1, item_name: '서포트 자재 부족', severity: 'medium', display_order: 5 },
|
||||
|
||||
// 설계미스 (category_id: 2)
|
||||
{ category_id: 2, item_name: '도면 치수 오류', severity: 'high', display_order: 1 },
|
||||
{ category_id: 2, item_name: '스펙 불일치', severity: 'high', display_order: 2 },
|
||||
{ category_id: 2, item_name: '누락된 상세도', severity: 'medium', display_order: 3 },
|
||||
{ category_id: 2, item_name: '간섭 발생', severity: 'critical', display_order: 4 },
|
||||
|
||||
// 입고불량 (category_id: 3)
|
||||
{ category_id: 3, item_name: '외관 불량', severity: 'medium', display_order: 1 },
|
||||
{ category_id: 3, item_name: '치수 불량', severity: 'high', display_order: 2 },
|
||||
{ category_id: 3, item_name: '수량 부족', severity: 'medium', display_order: 3 },
|
||||
{ category_id: 3, item_name: '재질 불일치', severity: 'critical', display_order: 4 },
|
||||
|
||||
// 검사미스 (category_id: 4)
|
||||
{ category_id: 4, item_name: '치수 검사 누락', severity: 'high', display_order: 1 },
|
||||
{ category_id: 4, item_name: '외관 검사 누락', severity: 'medium', display_order: 2 },
|
||||
{ category_id: 4, item_name: '용접 검사 누락', severity: 'critical', display_order: 3 },
|
||||
{ category_id: 4, item_name: '도장 검사 누락', severity: 'medium', display_order: 4 },
|
||||
|
||||
// 보호구 미착용 (category_id: 6)
|
||||
{ category_id: 6, item_name: '안전모 미착용', severity: 'high', display_order: 1 },
|
||||
{ category_id: 6, item_name: '안전화 미착용', severity: 'high', display_order: 2 },
|
||||
{ category_id: 6, item_name: '보안경 미착용', severity: 'medium', display_order: 3 },
|
||||
{ category_id: 6, item_name: '안전대 미착용', severity: 'critical', display_order: 4 },
|
||||
{ category_id: 6, item_name: '귀마개 미착용', severity: 'low', display_order: 5 },
|
||||
{ category_id: 6, item_name: '안전장갑 미착용', severity: 'medium', display_order: 6 },
|
||||
|
||||
// 위험구역 출입 (category_id: 7)
|
||||
{ category_id: 7, item_name: '통제구역 무단 출입', severity: 'critical', display_order: 1 },
|
||||
{ category_id: 7, item_name: '고소 작업 구역 무단 출입', severity: 'critical', display_order: 2 },
|
||||
{ category_id: 7, item_name: '밀폐공간 무단 진입', severity: 'critical', display_order: 3 },
|
||||
{ category_id: 7, item_name: '장비 가동 구역 무단 접근', severity: 'high', display_order: 4 },
|
||||
|
||||
// 안전시설 파손 (category_id: 8)
|
||||
{ category_id: 8, item_name: '안전난간 파손', severity: 'high', display_order: 1 },
|
||||
{ category_id: 8, item_name: '경고 표지판 훼손', severity: 'medium', display_order: 2 },
|
||||
{ category_id: 8, item_name: '안전망 파손', severity: 'high', display_order: 3 },
|
||||
{ category_id: 8, item_name: '비상조명 고장', severity: 'medium', display_order: 4 },
|
||||
{ category_id: 8, item_name: '소화설비 파손', severity: 'critical', display_order: 5 },
|
||||
|
||||
// 안전수칙 위반 (category_id: 9)
|
||||
{ category_id: 9, item_name: '지정 통로 미사용', severity: 'medium', display_order: 1 },
|
||||
{ category_id: 9, item_name: '고소 작업 안전 미준수', severity: 'critical', display_order: 2 },
|
||||
{ category_id: 9, item_name: '화기 작업 절차 미준수', severity: 'critical', display_order: 3 },
|
||||
{ category_id: 9, item_name: '정리정돈 미흡', severity: 'low', display_order: 4 },
|
||||
{ category_id: 9, item_name: '장비 조작 절차 미준수', severity: 'high', display_order: 5 }
|
||||
]);
|
||||
|
||||
// 3. 문제 신고 메인 테이블 생성
|
||||
await knex.schema.createTable('work_issue_reports', function(table) {
|
||||
table.increments('report_id').primary().comment('신고 ID');
|
||||
|
||||
// 신고자 정보
|
||||
table.integer('reporter_id').notNullable().comment('신고자 user_id');
|
||||
table.datetime('report_date').defaultTo(knex.fn.now()).comment('신고 일시');
|
||||
|
||||
// 위치 정보
|
||||
table.integer('factory_category_id').unsigned().nullable().comment('공장 카테고리 ID (지도 외 위치 시 null)');
|
||||
table.integer('workplace_id').unsigned().nullable().comment('작업장 ID (지도 외 위치 시 null)');
|
||||
table.string('custom_location', 200).nullable().comment('기타 위치 (지도 외 선택 시)');
|
||||
|
||||
// 작업 연결 정보 (선택적)
|
||||
table.integer('tbm_session_id').unsigned().nullable().comment('연결된 TBM 세션');
|
||||
table.integer('visit_request_id').unsigned().nullable().comment('연결된 출입 신청');
|
||||
|
||||
// 신고 내용
|
||||
table.integer('issue_category_id').unsigned().notNullable().comment('신고 카테고리 ID');
|
||||
table.integer('issue_item_id').unsigned().nullable().comment('사전 정의 신고 항목 ID');
|
||||
table.text('additional_description').nullable().comment('추가 설명');
|
||||
|
||||
// 사진 (최대 5장)
|
||||
table.string('photo_path1', 255).nullable().comment('사진 1');
|
||||
table.string('photo_path2', 255).nullable().comment('사진 2');
|
||||
table.string('photo_path3', 255).nullable().comment('사진 3');
|
||||
table.string('photo_path4', 255).nullable().comment('사진 4');
|
||||
table.string('photo_path5', 255).nullable().comment('사진 5');
|
||||
|
||||
// 상태 관리
|
||||
table.enum('status', ['reported', 'received', 'in_progress', 'completed', 'closed'])
|
||||
.defaultTo('reported')
|
||||
.comment('상태: 신고→접수→처리중→완료→종료');
|
||||
|
||||
// 담당자 배정
|
||||
table.string('assigned_department', 100).nullable().comment('담당 부서');
|
||||
table.integer('assigned_user_id').nullable().comment('담당자 user_id');
|
||||
table.datetime('assigned_at').nullable().comment('배정 일시');
|
||||
table.integer('assigned_by').nullable().comment('배정자 user_id');
|
||||
|
||||
// 처리 정보
|
||||
table.text('resolution_notes').nullable().comment('처리 내용');
|
||||
table.string('resolution_photo_path1', 255).nullable().comment('처리 완료 사진 1');
|
||||
table.string('resolution_photo_path2', 255).nullable().comment('처리 완료 사진 2');
|
||||
table.datetime('resolved_at').nullable().comment('처리 완료 일시');
|
||||
table.integer('resolved_by').nullable().comment('처리 완료자 user_id');
|
||||
|
||||
// 수정 이력 (JSON)
|
||||
table.json('modification_history').nullable().comment('수정 이력 추적');
|
||||
|
||||
// 타임스탬프
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 외래키
|
||||
table.foreign('reporter_id')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('factory_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');
|
||||
|
||||
table.foreign('issue_category_id')
|
||||
.references('category_id')
|
||||
.inTable('issue_report_categories')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('issue_item_id')
|
||||
.references('item_id')
|
||||
.inTable('issue_report_items')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('assigned_user_id')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('assigned_by')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('resolved_by')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('SET NULL')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
// 인덱스
|
||||
table.index('reporter_id', 'idx_wir_reporter_id');
|
||||
table.index('status', 'idx_wir_status');
|
||||
table.index('report_date', 'idx_wir_report_date');
|
||||
table.index(['factory_category_id', 'workplace_id'], 'idx_wir_workplace');
|
||||
table.index('issue_category_id', 'idx_wir_issue_category');
|
||||
table.index('assigned_user_id', 'idx_wir_assigned_user');
|
||||
});
|
||||
|
||||
// 4. 상태 변경 이력 테이블 생성
|
||||
await knex.schema.createTable('work_issue_status_logs', function(table) {
|
||||
table.increments('log_id').primary().comment('로그 ID');
|
||||
table.integer('report_id').unsigned().notNullable().comment('신고 ID');
|
||||
table.string('previous_status', 50).nullable().comment('이전 상태');
|
||||
table.string('new_status', 50).notNullable().comment('새 상태');
|
||||
table.integer('changed_by').notNullable().comment('변경자 user_id');
|
||||
table.text('change_reason').nullable().comment('변경 사유');
|
||||
table.timestamp('changed_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.foreign('report_id')
|
||||
.references('report_id')
|
||||
.inTable('work_issue_reports')
|
||||
.onDelete('CASCADE')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.foreign('changed_by')
|
||||
.references('user_id')
|
||||
.inTable('users')
|
||||
.onDelete('RESTRICT')
|
||||
.onUpdate('CASCADE');
|
||||
|
||||
table.index('report_id', 'idx_wisl_report_id');
|
||||
table.index('changed_at', 'idx_wisl_changed_at');
|
||||
});
|
||||
|
||||
console.log('작업 중 문제 신고 시스템 테이블 생성 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 역순으로 테이블 삭제
|
||||
await knex.schema.dropTableIfExists('work_issue_status_logs');
|
||||
await knex.schema.dropTableIfExists('work_issue_reports');
|
||||
await knex.schema.dropTableIfExists('issue_report_items');
|
||||
await knex.schema.dropTableIfExists('issue_report_categories');
|
||||
|
||||
console.log('작업 중 문제 신고 시스템 테이블 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 마이그레이션: 문제 신고 관련 페이지 등록
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 문제 신고 등록 페이지
|
||||
await knex('pages').insert({
|
||||
page_key: 'issue-report',
|
||||
page_name: '문제 신고',
|
||||
page_path: '/pages/work/issue-report.html',
|
||||
category: 'work',
|
||||
description: '작업 중 문제(부적합/안전) 신고 등록',
|
||||
is_admin_only: 0,
|
||||
display_order: 16
|
||||
});
|
||||
|
||||
// 신고 목록 페이지
|
||||
await knex('pages').insert({
|
||||
page_key: 'issue-list',
|
||||
page_name: '신고 목록',
|
||||
page_path: '/pages/work/issue-list.html',
|
||||
category: 'work',
|
||||
description: '문제 신고 목록 조회 및 관리',
|
||||
is_admin_only: 0,
|
||||
display_order: 17
|
||||
});
|
||||
|
||||
// 신고 상세 페이지
|
||||
await knex('pages').insert({
|
||||
page_key: 'issue-detail',
|
||||
page_name: '신고 상세',
|
||||
page_path: '/pages/work/issue-detail.html',
|
||||
category: 'work',
|
||||
description: '문제 신고 상세 조회',
|
||||
is_admin_only: 0,
|
||||
display_order: 18
|
||||
});
|
||||
|
||||
console.log('✅ 문제 신고 페이지 등록 완료');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex('pages').whereIn('page_key', [
|
||||
'issue-report',
|
||||
'issue-list',
|
||||
'issue-detail'
|
||||
]).delete();
|
||||
|
||||
console.log('✅ 문제 신고 페이지 삭제 완료');
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 작업보고서 부적합 상세 테이블 마이그레이션
|
||||
*
|
||||
* 기존: error_hours, error_type_id (단일 값)
|
||||
* 변경: 여러 부적합 원인 + 각 원인별 시간 저장 가능
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
// 1. work_report_defects 테이블 생성
|
||||
.createTable('work_report_defects', function(table) {
|
||||
table.increments('defect_id').primary();
|
||||
table.integer('report_id').notNullable()
|
||||
.comment('daily_work_reports의 id');
|
||||
table.integer('error_type_id').notNullable()
|
||||
.comment('error_types의 id (부적합 원인)');
|
||||
table.decimal('defect_hours', 4, 1).notNullable().defaultTo(0)
|
||||
.comment('해당 원인의 부적합 시간');
|
||||
table.text('note').nullable()
|
||||
.comment('추가 메모');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 외래키
|
||||
table.foreign('report_id').references('id').inTable('daily_work_reports').onDelete('CASCADE');
|
||||
table.foreign('error_type_id').references('id').inTable('error_types');
|
||||
|
||||
// 인덱스
|
||||
table.index('report_id');
|
||||
table.index('error_type_id');
|
||||
|
||||
// 같은 보고서에 같은 원인이 중복되지 않도록
|
||||
table.unique(['report_id', 'error_type_id']);
|
||||
})
|
||||
// 2. 기존 데이터 마이그레이션 (error_hours > 0인 경우)
|
||||
.then(function() {
|
||||
return knex.raw(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, created_at)
|
||||
SELECT id, error_type_id, error_hours, created_at
|
||||
FROM daily_work_reports
|
||||
WHERE error_hours > 0 AND error_type_id IS NOT NULL
|
||||
`);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTableIfExists('work_report_defects');
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 안전 체크리스트 확장 마이그레이션
|
||||
*
|
||||
* 1. tbm_safety_checks 테이블 확장 (check_type, weather_condition, task_id)
|
||||
* 2. weather_conditions 테이블 생성 (날씨 조건 코드)
|
||||
* 3. tbm_weather_records 테이블 생성 (세션별 날씨 기록)
|
||||
* 4. 초기 날씨별 체크항목 데이터
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema
|
||||
// 1. tbm_safety_checks 테이블 확장
|
||||
.alterTable('tbm_safety_checks', function(table) {
|
||||
table.enum('check_type', ['basic', 'weather', 'task']).defaultTo('basic').after('check_category');
|
||||
table.string('weather_condition', 50).nullable().after('check_type');
|
||||
table.integer('task_id').unsigned().nullable().after('weather_condition');
|
||||
|
||||
// 인덱스 추가
|
||||
table.index('check_type');
|
||||
table.index('weather_condition');
|
||||
table.index('task_id');
|
||||
})
|
||||
|
||||
// 2. weather_conditions 테이블 생성
|
||||
.createTable('weather_conditions', function(table) {
|
||||
table.string('condition_code', 50).primary();
|
||||
table.string('condition_name', 100).notNullable();
|
||||
table.text('description').nullable();
|
||||
table.string('icon', 50).nullable();
|
||||
table.decimal('temp_threshold_min', 4, 1).nullable(); // 최소 기온 기준
|
||||
table.decimal('temp_threshold_max', 4, 1).nullable(); // 최대 기온 기준
|
||||
table.decimal('wind_threshold', 4, 1).nullable(); // 풍속 기준 (m/s)
|
||||
table.decimal('precip_threshold', 5, 1).nullable(); // 강수량 기준 (mm)
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.integer('display_order').defaultTo(0);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
})
|
||||
|
||||
// 3. tbm_weather_records 테이블 생성
|
||||
.createTable('tbm_weather_records', function(table) {
|
||||
table.increments('record_id').primary();
|
||||
table.integer('session_id').unsigned().notNullable();
|
||||
table.date('weather_date').notNullable();
|
||||
table.decimal('temperature', 4, 1).nullable(); // 기온 (섭씨)
|
||||
table.integer('humidity').nullable(); // 습도 (%)
|
||||
table.decimal('wind_speed', 4, 1).nullable(); // 풍속 (m/s)
|
||||
table.decimal('precipitation', 5, 1).nullable(); // 강수량 (mm)
|
||||
table.string('sky_condition', 50).nullable(); // 하늘 상태
|
||||
table.string('weather_condition', 50).nullable(); // 주요 날씨 상태
|
||||
table.json('weather_conditions').nullable(); // 복수 조건 ['rain', 'wind']
|
||||
table.string('data_source', 50).defaultTo('api'); // 데이터 출처
|
||||
table.timestamp('fetched_at').nullable();
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// 외래키
|
||||
table.foreign('session_id').references('session_id').inTable('tbm_sessions').onDelete('CASCADE');
|
||||
|
||||
// 인덱스
|
||||
table.index('weather_date');
|
||||
table.unique(['session_id']);
|
||||
})
|
||||
|
||||
// 4. 초기 데이터 삽입
|
||||
.then(function() {
|
||||
// 기존 체크항목을 'basic' 유형으로 업데이트
|
||||
return knex('tbm_safety_checks').update({ check_type: 'basic' });
|
||||
})
|
||||
.then(function() {
|
||||
// 날씨 조건 코드 삽입
|
||||
return knex('weather_conditions').insert([
|
||||
{ condition_code: 'clear', condition_name: '맑음', description: '맑은 날씨', icon: 'sunny', display_order: 1 },
|
||||
{ condition_code: 'rain', condition_name: '비', description: '비 오는 날씨', icon: 'rainy', precip_threshold: 0.1, display_order: 2 },
|
||||
{ condition_code: 'snow', condition_name: '눈', description: '눈 오는 날씨', icon: 'snowy', display_order: 3 },
|
||||
{ condition_code: 'heat', condition_name: '폭염', description: '기온 35도 이상', icon: 'hot', temp_threshold_min: 35, display_order: 4 },
|
||||
{ condition_code: 'cold', condition_name: '한파', description: '기온 영하 10도 이하', icon: 'cold', temp_threshold_max: -10, display_order: 5 },
|
||||
{ condition_code: 'wind', condition_name: '강풍', description: '풍속 10m/s 이상', icon: 'windy', wind_threshold: 10, display_order: 6 },
|
||||
{ condition_code: 'fog', condition_name: '안개', description: '시정 1km 미만', icon: 'foggy', display_order: 7 },
|
||||
{ condition_code: 'dust', condition_name: '미세먼지', description: '미세먼지 나쁨 이상', icon: 'dusty', display_order: 8 }
|
||||
]);
|
||||
})
|
||||
.then(function() {
|
||||
// 날씨별 안전 체크항목 삽입
|
||||
return knex('tbm_safety_checks').insert([
|
||||
// 비 (rain)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '우의/우산 준비 확인', description: '비 오는 날 우의 또는 우산 준비 여부', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '미끄럼 방지 조치 확인', description: '빗물로 인한 미끄러움 방지 조치', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '전기 작업 중단 여부 확인', description: '우천 시 전기 작업 중단 필요성 확인', is_required: true, display_order: 3 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '배수 상태 확인', description: '작업장 배수 상태 점검', is_required: false, display_order: 4 },
|
||||
|
||||
// 눈 (snow)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '제설 작업 완료 확인', description: '작업장 주변 제설 작업 완료 여부', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '동파 방지 조치 확인', description: '배관 및 설비 동파 방지 조치', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '미끄럼 방지 모래/염화칼슘 비치', description: '미끄럼 방지를 위한 모래 또는 염화칼슘 비치', is_required: true, display_order: 3 },
|
||||
|
||||
// 폭염 (heat)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '그늘막/휴게소 확보', description: '무더위 휴식을 위한 그늘막 또는 휴게소 확보', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '음료수/식염 포도당 비치', description: '열사병 예방을 위한 음료수 및 염분 보충제 비치', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '무더위 휴식 시간 확보', description: '10~15시 사이 충분한 휴식 시간 확보', is_required: true, display_order: 3 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '작업자 건강 상태 확인', description: '열사병 증상 체크 및 건강 상태 확인', is_required: true, display_order: 4 },
|
||||
|
||||
// 한파 (cold)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '방한복/방한장갑 착용 확인', description: '동상 방지를 위한 방한복 및 방한장갑 착용', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '난방시설 가동 확인', description: '휴게 공간 난방시설 가동 상태 확인', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '온열 음료 비치', description: '체온 유지를 위한 따뜻한 음료 비치', is_required: false, display_order: 3 },
|
||||
|
||||
// 강풍 (wind)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '고소 작업 중단 여부 확인', description: '강풍 시 고소 작업 중단 필요성 확인', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '자재/장비 결박 확인', description: '바람에 날릴 수 있는 자재 및 장비 고정', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '가설물 안전 점검', description: '가설 구조물 및 비계 안전 상태 점검', is_required: true, display_order: 3 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '크레인 작업 중단 여부 확인', description: '강풍 시 크레인 작업 중단 필요성 확인', is_required: true, display_order: 4 },
|
||||
|
||||
// 안개 (fog)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '경광등/조명 확보', description: '시정 확보를 위한 경광등 및 조명 설치', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '차량 운행 주의 안내', description: '안개로 인한 차량 운행 주의 안내', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '작업 구역 표시 강화', description: '시인성 확보를 위한 작업 구역 표시 강화', is_required: false, display_order: 3 },
|
||||
|
||||
// 미세먼지 (dust)
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '보호 마스크 착용 확인', description: 'KF94 이상 마스크 착용 여부 확인', is_required: true, display_order: 1 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '실외 작업 시간 조정', description: '미세먼지 농도에 따른 실외 작업 시간 조정', is_required: true, display_order: 2 },
|
||||
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '호흡기 질환자 실내 배치', description: '호흡기 질환 작업자 실내 작업 배치', is_required: false, display_order: 3 }
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists('tbm_weather_records')
|
||||
.dropTableIfExists('weather_conditions')
|
||||
.then(function() {
|
||||
return knex.schema.alterTable('tbm_safety_checks', function(table) {
|
||||
table.dropIndex('check_type');
|
||||
table.dropIndex('weather_condition');
|
||||
table.dropIndex('task_id');
|
||||
table.dropColumn('check_type');
|
||||
table.dropColumn('weather_condition');
|
||||
table.dropColumn('task_id');
|
||||
});
|
||||
});
|
||||
};
|
||||
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal file
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 페이지 구조 재구성 마이그레이션
|
||||
* - 페이지 경로 업데이트 (safety/, attendance/ 폴더로 이동)
|
||||
* - 카테고리 재분류
|
||||
* - 역할별 기본 페이지 권한 테이블 생성
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// 1. 페이지 경로 업데이트 - safety 폴더로 이동된 페이지들
|
||||
const safetyPageUpdates = [
|
||||
{
|
||||
old_key: 'issue-report',
|
||||
new_key: 'safety.issue_report',
|
||||
new_path: '/pages/safety/issue-report.html',
|
||||
new_category: 'safety',
|
||||
new_name: '이슈 신고'
|
||||
},
|
||||
{
|
||||
old_key: 'issue-list',
|
||||
new_key: 'safety.issue_list',
|
||||
new_path: '/pages/safety/issue-list.html',
|
||||
new_category: 'safety',
|
||||
new_name: '이슈 목록'
|
||||
},
|
||||
{
|
||||
old_key: 'issue-detail',
|
||||
new_key: 'safety.issue_detail',
|
||||
new_path: '/pages/safety/issue-detail.html',
|
||||
new_category: 'safety',
|
||||
new_name: '이슈 상세'
|
||||
},
|
||||
{
|
||||
old_key: 'visit-request',
|
||||
new_key: 'safety.visit_request',
|
||||
new_path: '/pages/safety/visit-request.html',
|
||||
new_category: 'safety',
|
||||
new_name: '방문 요청'
|
||||
},
|
||||
{
|
||||
old_key: 'safety-management',
|
||||
new_key: 'safety.management',
|
||||
new_path: '/pages/safety/management.html',
|
||||
new_category: 'safety',
|
||||
new_name: '안전 관리'
|
||||
},
|
||||
{
|
||||
old_key: 'safety-training-conduct',
|
||||
new_key: 'safety.training_conduct',
|
||||
new_path: '/pages/safety/training-conduct.html',
|
||||
new_category: 'safety',
|
||||
new_name: '안전교육 진행'
|
||||
}
|
||||
];
|
||||
|
||||
// 2. 페이지 경로 업데이트 - attendance 폴더로 이동된 페이지들
|
||||
const attendancePageUpdates = [
|
||||
{
|
||||
old_key: 'daily-attendance',
|
||||
new_key: 'attendance.daily',
|
||||
new_path: '/pages/attendance/daily.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '일일 출퇴근'
|
||||
},
|
||||
{
|
||||
old_key: 'monthly-attendance',
|
||||
new_key: 'attendance.monthly',
|
||||
new_path: '/pages/attendance/monthly.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '월간 근태'
|
||||
},
|
||||
{
|
||||
old_key: 'annual-vacation-overview',
|
||||
new_key: 'attendance.annual_overview',
|
||||
new_path: '/pages/attendance/annual-overview.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '연간 휴가 현황'
|
||||
},
|
||||
{
|
||||
old_key: 'vacation-request',
|
||||
new_key: 'attendance.vacation_request',
|
||||
new_path: '/pages/attendance/vacation-request.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '휴가 신청'
|
||||
},
|
||||
{
|
||||
old_key: 'vacation-management',
|
||||
new_key: 'attendance.vacation_management',
|
||||
new_path: '/pages/attendance/vacation-management.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '휴가 관리'
|
||||
},
|
||||
{
|
||||
old_key: 'vacation-allocation',
|
||||
new_key: 'attendance.vacation_allocation',
|
||||
new_path: '/pages/attendance/vacation-allocation.html',
|
||||
new_category: 'attendance',
|
||||
new_name: '휴가 발생 입력'
|
||||
}
|
||||
];
|
||||
|
||||
// 3. admin 폴더 내 파일명 변경
|
||||
const adminPageUpdates = [
|
||||
{
|
||||
old_key: 'attendance-report-comparison',
|
||||
new_key: 'admin.attendance_report',
|
||||
new_path: '/pages/admin/attendance-report.html',
|
||||
new_category: 'admin',
|
||||
new_name: '출퇴근-보고서 대조'
|
||||
}
|
||||
];
|
||||
|
||||
// 모든 업데이트 실행
|
||||
const allUpdates = [...safetyPageUpdates, ...attendancePageUpdates, ...adminPageUpdates];
|
||||
|
||||
for (const update of allUpdates) {
|
||||
await knex('pages')
|
||||
.where('page_key', update.old_key)
|
||||
.update({
|
||||
page_key: update.new_key,
|
||||
page_path: update.new_path,
|
||||
category: update.new_category,
|
||||
page_name: update.new_name
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 안전 체크리스트 관리 페이지 추가 (새로 생성된 페이지)
|
||||
const existingChecklistPage = await knex('pages')
|
||||
.where('page_key', 'safety.checklist_manage')
|
||||
.orWhere('page_key', 'safety-checklist-manage')
|
||||
.first();
|
||||
|
||||
if (!existingChecklistPage) {
|
||||
await knex('pages').insert({
|
||||
page_key: 'safety.checklist_manage',
|
||||
page_name: '안전 체크리스트 관리',
|
||||
page_path: '/pages/safety/checklist-manage.html',
|
||||
category: 'safety',
|
||||
description: '안전 체크리스트 항목 관리',
|
||||
is_admin_only: 1,
|
||||
display_order: 50
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 휴가 승인/직접입력 페이지 추가 (새로 생성된 페이지인 경우)
|
||||
const vacationPages = [
|
||||
{
|
||||
page_key: 'attendance.vacation_approval',
|
||||
page_name: '휴가 승인 관리',
|
||||
page_path: '/pages/attendance/vacation-approval.html',
|
||||
category: 'attendance',
|
||||
description: '휴가 신청 승인/거부',
|
||||
is_admin_only: 1,
|
||||
display_order: 65
|
||||
},
|
||||
{
|
||||
page_key: 'attendance.vacation_input',
|
||||
page_name: '휴가 직접 입력',
|
||||
page_path: '/pages/attendance/vacation-input.html',
|
||||
category: 'attendance',
|
||||
description: '관리자 휴가 직접 입력',
|
||||
is_admin_only: 1,
|
||||
display_order: 66
|
||||
}
|
||||
];
|
||||
|
||||
for (const page of vacationPages) {
|
||||
const existing = await knex('pages').where('page_key', page.page_key).first();
|
||||
if (!existing) {
|
||||
await knex('pages').insert(page);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. role_default_pages 테이블 생성 (역할별 기본 페이지 권한)
|
||||
const tableExists = await knex.schema.hasTable('role_default_pages');
|
||||
if (!tableExists) {
|
||||
await knex.schema.createTable('role_default_pages', (table) => {
|
||||
table.integer('role_id').unsigned().notNullable()
|
||||
.references('id').inTable('roles').onDelete('CASCADE');
|
||||
table.integer('page_id').unsigned().notNullable()
|
||||
.references('id').inTable('pages').onDelete('CASCADE');
|
||||
table.primary(['role_id', 'page_id']);
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// 7. 기본 역할-페이지 매핑 데이터 삽입
|
||||
// 역할 조회
|
||||
const roles = await knex('roles').select('id', 'name');
|
||||
const pages = await knex('pages').select('id', 'page_key', 'category');
|
||||
|
||||
const roleMap = {};
|
||||
roles.forEach(r => { roleMap[r.name] = r.id; });
|
||||
|
||||
const pageMap = {};
|
||||
pages.forEach(p => { pageMap[p.page_key] = p.id; });
|
||||
|
||||
// Worker 역할 기본 페이지 (대시보드, 작업보고서, 휴가신청)
|
||||
const workerPages = [
|
||||
'dashboard',
|
||||
'work.report_create',
|
||||
'work.report_view',
|
||||
'attendance.vacation_request'
|
||||
];
|
||||
|
||||
// Leader 역할 기본 페이지 (Worker + TBM, 안전, 근태 일부)
|
||||
const leaderPages = [
|
||||
...workerPages,
|
||||
'work.tbm',
|
||||
'work.analysis',
|
||||
'safety.issue_report',
|
||||
'safety.issue_list',
|
||||
'attendance.daily',
|
||||
'attendance.monthly'
|
||||
];
|
||||
|
||||
// SafetyManager 역할 기본 페이지 (Leader + 안전 전체)
|
||||
const safetyManagerPages = [
|
||||
...leaderPages,
|
||||
'safety.issue_detail',
|
||||
'safety.visit_request',
|
||||
'safety.management',
|
||||
'safety.training_conduct',
|
||||
'safety.checklist_manage'
|
||||
];
|
||||
|
||||
// 역할별 페이지 매핑 삽입
|
||||
const rolePageMappings = [];
|
||||
|
||||
if (roleMap['Worker']) {
|
||||
workerPages.forEach(pageKey => {
|
||||
if (pageMap[pageKey]) {
|
||||
rolePageMappings.push({ role_id: roleMap['Worker'], page_id: pageMap[pageKey] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (roleMap['Leader']) {
|
||||
leaderPages.forEach(pageKey => {
|
||||
if (pageMap[pageKey]) {
|
||||
rolePageMappings.push({ role_id: roleMap['Leader'], page_id: pageMap[pageKey] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (roleMap['SafetyManager']) {
|
||||
safetyManagerPages.forEach(pageKey => {
|
||||
if (pageMap[pageKey]) {
|
||||
rolePageMappings.push({ role_id: roleMap['SafetyManager'], page_id: pageMap[pageKey] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 중복 제거 후 삽입
|
||||
for (const mapping of rolePageMappings) {
|
||||
const existing = await knex('role_default_pages')
|
||||
.where('role_id', mapping.role_id)
|
||||
.where('page_id', mapping.page_id)
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
await knex('role_default_pages').insert(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('페이지 구조 재구성 완료');
|
||||
console.log(`- 업데이트된 페이지: ${allUpdates.length}개`);
|
||||
console.log(`- 역할별 기본 페이지 매핑: ${rolePageMappings.length}개`);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// 1. role_default_pages 테이블 삭제
|
||||
await knex.schema.dropTableIfExists('role_default_pages');
|
||||
|
||||
// 2. 페이지 경로 원복 - safety → work/admin
|
||||
const safetyRevert = [
|
||||
{ new_key: 'safety.issue_report', old_key: 'issue-report', old_path: '/pages/work/issue-report.html', old_category: 'work' },
|
||||
{ new_key: 'safety.issue_list', old_key: 'issue-list', old_path: '/pages/work/issue-list.html', old_category: 'work' },
|
||||
{ new_key: 'safety.issue_detail', old_key: 'issue-detail', old_path: '/pages/work/issue-detail.html', old_category: 'work' },
|
||||
{ new_key: 'safety.visit_request', old_key: 'visit-request', old_path: '/pages/work/visit-request.html', old_category: 'work' },
|
||||
{ new_key: 'safety.management', old_key: 'safety-management', old_path: '/pages/admin/safety-management.html', old_category: 'admin' },
|
||||
{ new_key: 'safety.training_conduct', old_key: 'safety-training-conduct', old_path: '/pages/admin/safety-training-conduct.html', old_category: 'admin' },
|
||||
];
|
||||
|
||||
// 3. 페이지 경로 원복 - attendance → common
|
||||
const attendanceRevert = [
|
||||
{ new_key: 'attendance.daily', old_key: 'daily-attendance', old_path: '/pages/common/daily-attendance.html', old_category: 'common' },
|
||||
{ new_key: 'attendance.monthly', old_key: 'monthly-attendance', old_path: '/pages/common/monthly-attendance.html', old_category: 'common' },
|
||||
{ new_key: 'attendance.annual_overview', old_key: 'annual-vacation-overview', old_path: '/pages/common/annual-vacation-overview.html', old_category: 'common' },
|
||||
{ new_key: 'attendance.vacation_request', old_key: 'vacation-request', old_path: '/pages/common/vacation-request.html', old_category: 'common' },
|
||||
{ new_key: 'attendance.vacation_management', old_key: 'vacation-management', old_path: '/pages/common/vacation-management.html', old_category: 'common' },
|
||||
{ new_key: 'attendance.vacation_allocation', old_key: 'vacation-allocation', old_path: '/pages/common/vacation-allocation.html', old_category: 'common' },
|
||||
];
|
||||
|
||||
// 4. admin 파일명 원복
|
||||
const adminRevert = [
|
||||
{ new_key: 'admin.attendance_report', old_key: 'attendance-report-comparison', old_path: '/pages/admin/attendance-report-comparison.html', old_category: 'admin' }
|
||||
];
|
||||
|
||||
const allReverts = [...safetyRevert, ...attendanceRevert, ...adminRevert];
|
||||
|
||||
for (const revert of allReverts) {
|
||||
await knex('pages')
|
||||
.where('page_key', revert.new_key)
|
||||
.update({
|
||||
page_key: revert.old_key,
|
||||
page_path: revert.old_path,
|
||||
category: revert.old_category
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 새로 추가된 페이지 삭제
|
||||
await knex('pages').whereIn('page_key', [
|
||||
'safety.checklist_manage',
|
||||
'attendance.vacation_approval',
|
||||
'attendance.vacation_input'
|
||||
]).del();
|
||||
|
||||
console.log('페이지 구조 재구성 롤백 완료');
|
||||
};
|
||||
@@ -698,6 +698,296 @@ const TbmModel = {
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 안전 체크리스트 확장 메서드 ==========
|
||||
|
||||
/**
|
||||
* 유형별 안전 체크 항목 조회
|
||||
* @param {string} checkType - 체크 유형 (basic, weather, task)
|
||||
* @param {Object} options - 추가 옵션 (weatherCondition, taskId)
|
||||
*/
|
||||
getSafetyChecksByType: async (checkType, options = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT sc.*,
|
||||
wc.condition_name as weather_condition_name,
|
||||
wc.icon as weather_icon,
|
||||
t.task_name
|
||||
FROM tbm_safety_checks sc
|
||||
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
|
||||
LEFT JOIN tasks t ON sc.task_id = t.task_id
|
||||
WHERE sc.is_active = 1 AND sc.check_type = ?
|
||||
`;
|
||||
const params = [checkType];
|
||||
|
||||
if (checkType === 'weather' && options.weatherCondition) {
|
||||
sql += ' AND sc.weather_condition = ?';
|
||||
params.push(options.weatherCondition);
|
||||
}
|
||||
|
||||
if (checkType === 'task' && options.taskId) {
|
||||
sql += ' AND sc.task_id = ?';
|
||||
params.push(options.taskId);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY sc.check_category, sc.display_order';
|
||||
|
||||
const [rows] = await db.query(sql, params);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 날씨 조건별 안전 체크 항목 조회 (복수 조건)
|
||||
* @param {string[]} conditions - 날씨 조건 배열 ['rain', 'wind']
|
||||
*/
|
||||
getSafetyChecksByWeather: async (conditions, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return callback(null, []);
|
||||
}
|
||||
|
||||
const placeholders = conditions.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT sc.*,
|
||||
wc.condition_name as weather_condition_name,
|
||||
wc.icon as weather_icon
|
||||
FROM tbm_safety_checks sc
|
||||
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
|
||||
WHERE sc.is_active = 1
|
||||
AND sc.check_type = 'weather'
|
||||
AND sc.weather_condition IN (${placeholders})
|
||||
ORDER BY sc.weather_condition, sc.display_order
|
||||
`;
|
||||
|
||||
const [rows] = await db.query(sql, conditions);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 작업별 안전 체크 항목 조회 (복수 작업)
|
||||
* @param {number[]} taskIds - 작업 ID 배열
|
||||
*/
|
||||
getSafetyChecksByTasks: async (taskIds, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
if (!taskIds || taskIds.length === 0) {
|
||||
return callback(null, []);
|
||||
}
|
||||
|
||||
const placeholders = taskIds.map(() => '?').join(',');
|
||||
const sql = `
|
||||
SELECT sc.*,
|
||||
t.task_name,
|
||||
wt.name as work_type_name
|
||||
FROM tbm_safety_checks sc
|
||||
LEFT JOIN tasks t ON sc.task_id = t.task_id
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
WHERE sc.is_active = 1
|
||||
AND sc.check_type = 'task'
|
||||
AND sc.task_id IN (${placeholders})
|
||||
ORDER BY sc.task_id, sc.display_order
|
||||
`;
|
||||
|
||||
const [rows] = await db.query(sql, taskIds);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션에 맞는 필터링된 안전 체크 항목 조회
|
||||
* 기본 + 날씨 + 작업별 체크항목 통합 조회
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
* @param {string[]} weatherConditions - 날씨 조건 배열 (optional)
|
||||
*/
|
||||
getFilteredSafetyChecks: async (sessionId, weatherConditions = [], callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 1. 세션 정보에서 작업 ID 목록 조회
|
||||
const [assignments] = await db.query(`
|
||||
SELECT DISTINCT task_id
|
||||
FROM tbm_team_assignments
|
||||
WHERE session_id = ? AND task_id IS NOT NULL
|
||||
`, [sessionId]);
|
||||
|
||||
const taskIds = assignments.map(a => a.task_id);
|
||||
|
||||
// 2. 기본 체크항목 조회
|
||||
const [basicChecks] = await db.query(`
|
||||
SELECT sc.*, 'basic' as section_type
|
||||
FROM tbm_safety_checks sc
|
||||
WHERE sc.is_active = 1 AND sc.check_type = 'basic'
|
||||
ORDER BY sc.check_category, sc.display_order
|
||||
`);
|
||||
|
||||
// 3. 날씨별 체크항목 조회
|
||||
let weatherChecks = [];
|
||||
if (weatherConditions && weatherConditions.length > 0) {
|
||||
const wcPlaceholders = weatherConditions.map(() => '?').join(',');
|
||||
const [rows] = await db.query(`
|
||||
SELECT sc.*, wc.condition_name as weather_condition_name, wc.icon as weather_icon,
|
||||
'weather' as section_type
|
||||
FROM tbm_safety_checks sc
|
||||
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
|
||||
WHERE sc.is_active = 1
|
||||
AND sc.check_type = 'weather'
|
||||
AND sc.weather_condition IN (${wcPlaceholders})
|
||||
ORDER BY sc.weather_condition, sc.display_order
|
||||
`, weatherConditions);
|
||||
weatherChecks = rows;
|
||||
}
|
||||
|
||||
// 4. 작업별 체크항목 조회
|
||||
let taskChecks = [];
|
||||
if (taskIds.length > 0) {
|
||||
const taskPlaceholders = taskIds.map(() => '?').join(',');
|
||||
const [rows] = await db.query(`
|
||||
SELECT sc.*, t.task_name, wt.name as work_type_name,
|
||||
'task' as section_type
|
||||
FROM tbm_safety_checks sc
|
||||
LEFT JOIN tasks t ON sc.task_id = t.task_id
|
||||
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
||||
WHERE sc.is_active = 1
|
||||
AND sc.check_type = 'task'
|
||||
AND sc.task_id IN (${taskPlaceholders})
|
||||
ORDER BY sc.task_id, sc.display_order
|
||||
`, taskIds);
|
||||
taskChecks = rows;
|
||||
}
|
||||
|
||||
// 5. 기존 체크 기록 조회
|
||||
const [existingRecords] = await db.query(`
|
||||
SELECT check_id, is_checked, notes
|
||||
FROM tbm_safety_records
|
||||
WHERE session_id = ?
|
||||
`, [sessionId]);
|
||||
|
||||
const recordMap = {};
|
||||
existingRecords.forEach(r => {
|
||||
recordMap[r.check_id] = { is_checked: r.is_checked, notes: r.notes };
|
||||
});
|
||||
|
||||
// 6. 기록과 병합
|
||||
const mergeWithRecords = (checks) => {
|
||||
return checks.map(check => ({
|
||||
...check,
|
||||
is_checked: recordMap[check.check_id]?.is_checked || false,
|
||||
notes: recordMap[check.check_id]?.notes || null
|
||||
}));
|
||||
};
|
||||
|
||||
const result = {
|
||||
basic: mergeWithRecords(basicChecks),
|
||||
weather: mergeWithRecords(weatherChecks),
|
||||
task: mergeWithRecords(taskChecks),
|
||||
totalCount: basicChecks.length + weatherChecks.length + taskChecks.length,
|
||||
weatherConditions: weatherConditions
|
||||
};
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 생성 (관리자용)
|
||||
*/
|
||||
createSafetyCheck: async (checkData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO tbm_safety_checks
|
||||
(check_category, check_type, weather_condition, task_id, check_item, description, is_required, display_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const values = [
|
||||
checkData.check_category,
|
||||
checkData.check_type || 'basic',
|
||||
checkData.weather_condition || null,
|
||||
checkData.task_id || null,
|
||||
checkData.check_item,
|
||||
checkData.description || null,
|
||||
checkData.is_required !== false,
|
||||
checkData.display_order || 0
|
||||
];
|
||||
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, { insertId: result.insertId });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 수정 (관리자용)
|
||||
*/
|
||||
updateSafetyCheck: async (checkId, checkData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
UPDATE tbm_safety_checks
|
||||
SET check_category = ?,
|
||||
check_type = ?,
|
||||
weather_condition = ?,
|
||||
task_id = ?,
|
||||
check_item = ?,
|
||||
description = ?,
|
||||
is_required = ?,
|
||||
display_order = ?,
|
||||
is_active = ?,
|
||||
updated_at = NOW()
|
||||
WHERE check_id = ?
|
||||
`;
|
||||
|
||||
const values = [
|
||||
checkData.check_category,
|
||||
checkData.check_type || 'basic',
|
||||
checkData.weather_condition || null,
|
||||
checkData.task_id || null,
|
||||
checkData.check_item,
|
||||
checkData.description || null,
|
||||
checkData.is_required !== false,
|
||||
checkData.display_order || 0,
|
||||
checkData.is_active !== false,
|
||||
checkId
|
||||
];
|
||||
|
||||
const [result] = await db.query(sql, values);
|
||||
callback(null, { affectedRows: result.affectedRows });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 안전 체크 항목 삭제 (비활성화)
|
||||
*/
|
||||
deleteSafetyCheck: async (checkId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
// 실제 삭제 대신 비활성화
|
||||
const sql = `UPDATE tbm_safety_checks SET is_active = 0 WHERE check_id = ?`;
|
||||
|
||||
const [result] = await db.query(sql, [checkId]);
|
||||
callback(null, { affectedRows: result.affectedRows });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
881
api.hyungi.net/models/workIssueModel.js
Normal file
881
api.hyungi.net/models/workIssueModel.js
Normal file
@@ -0,0 +1,881 @@
|
||||
/**
|
||||
* 작업 중 문제 신고 모델
|
||||
* 부적합/안전 신고 관련 DB 쿼리
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// ==================== 신고 카테고리 관리 ====================
|
||||
|
||||
/**
|
||||
* 모든 신고 카테고리 조회
|
||||
*/
|
||||
const getAllCategories = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
|
||||
FROM issue_report_categories
|
||||
ORDER BY category_type, display_order, category_id`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 타입별 활성 카테고리 조회 (nonconformity/safety)
|
||||
*/
|
||||
const getCategoriesByType = async (categoryType, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT category_id, category_type, category_name, description, display_order
|
||||
FROM issue_report_categories
|
||||
WHERE category_type = ? AND is_active = TRUE
|
||||
ORDER BY display_order, category_id`,
|
||||
[categoryType]
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
const createCategory = async (categoryData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[category_type, category_name, description, display_order]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
const updateCategory = async (categoryId, categoryData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { category_name, description, display_order, is_active } = categoryData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE issue_report_categories
|
||||
SET category_name = ?, description = ?, display_order = ?, is_active = ?
|
||||
WHERE category_id = ?`,
|
||||
[category_name, description, display_order, is_active, categoryId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
const deleteCategory = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM issue_report_categories WHERE category_id = ?`,
|
||||
[categoryId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 사전 정의 신고 항목 관리 ====================
|
||||
|
||||
/**
|
||||
* 카테고리별 활성 항목 조회
|
||||
*/
|
||||
const getItemsByCategory = async (categoryId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT item_id, category_id, item_name, description, severity, display_order
|
||||
FROM issue_report_items
|
||||
WHERE category_id = ? AND is_active = TRUE
|
||||
ORDER BY display_order, item_id`,
|
||||
[categoryId]
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 항목 조회 (관리용)
|
||||
*/
|
||||
const getAllItems = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
|
||||
iri.severity, iri.display_order, iri.is_active, iri.created_at,
|
||||
irc.category_name, irc.category_type
|
||||
FROM issue_report_items iri
|
||||
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 생성
|
||||
*/
|
||||
const createItem = async (itemData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[category_id, item_name, description, severity, display_order]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 수정
|
||||
*/
|
||||
const updateItem = async (itemId, itemData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { item_name, description, severity, display_order, is_active } = itemData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE issue_report_items
|
||||
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
|
||||
WHERE item_id = ?`,
|
||||
[item_name, description, severity, display_order, is_active, itemId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 항목 삭제
|
||||
*/
|
||||
const deleteItem = async (itemId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM issue_report_items WHERE item_id = ?`,
|
||||
[itemId]
|
||||
);
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 문제 신고 관리 ====================
|
||||
|
||||
/**
|
||||
* 신고 생성
|
||||
*/
|
||||
const createReport = async (reportData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const {
|
||||
reporter_id,
|
||||
factory_category_id = null,
|
||||
workplace_id = null,
|
||||
custom_location = null,
|
||||
tbm_session_id = null,
|
||||
visit_request_id = null,
|
||||
issue_category_id,
|
||||
issue_item_id = null,
|
||||
additional_description = null,
|
||||
photo_path1 = null,
|
||||
photo_path2 = null,
|
||||
photo_path3 = null,
|
||||
photo_path4 = null,
|
||||
photo_path5 = null
|
||||
} = reportData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO work_issue_reports
|
||||
(reporter_id, factory_category_id, workplace_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[reporter_id, factory_category_id, workplace_id, custom_location,
|
||||
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
|
||||
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
|
||||
);
|
||||
|
||||
// 상태 변경 로그 기록
|
||||
await db.query(
|
||||
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
|
||||
VALUES (?, NULL, 'reported', ?)`,
|
||||
[result.insertId, reporter_id]
|
||||
);
|
||||
|
||||
callback(null, result.insertId);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 목록 조회 (필터 옵션 포함)
|
||||
*/
|
||||
const getAllReports = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
let query = `
|
||||
SELECT
|
||||
wir.report_id, wir.reporter_id, wir.report_date,
|
||||
wir.factory_category_id, wir.workplace_id, wir.custom_location,
|
||||
wir.tbm_session_id, wir.visit_request_id,
|
||||
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
|
||||
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
|
||||
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
|
||||
wir.resolution_notes, wir.resolved_at,
|
||||
wir.created_at, wir.updated_at,
|
||||
u.username as reporter_name, u.name as reporter_full_name,
|
||||
wc.category_name as factory_name,
|
||||
w.workplace_name,
|
||||
irc.category_type, irc.category_name as issue_category_name,
|
||||
iri.item_name as issue_item_name, iri.severity,
|
||||
assignee.username as assigned_user_name, assignee.name as assigned_full_name
|
||||
FROM work_issue_reports wir
|
||||
INNER JOIN users u ON wir.reporter_id = u.user_id
|
||||
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
|
||||
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
|
||||
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
|
||||
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
|
||||
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
// 필터 적용
|
||||
if (filters.status) {
|
||||
query += ` AND wir.status = ?`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
if (filters.category_type) {
|
||||
query += ` AND irc.category_type = ?`;
|
||||
params.push(filters.category_type);
|
||||
}
|
||||
|
||||
if (filters.issue_category_id) {
|
||||
query += ` AND wir.issue_category_id = ?`;
|
||||
params.push(filters.issue_category_id);
|
||||
}
|
||||
|
||||
if (filters.factory_category_id) {
|
||||
query += ` AND wir.factory_category_id = ?`;
|
||||
params.push(filters.factory_category_id);
|
||||
}
|
||||
|
||||
if (filters.workplace_id) {
|
||||
query += ` AND wir.workplace_id = ?`;
|
||||
params.push(filters.workplace_id);
|
||||
}
|
||||
|
||||
if (filters.reporter_id) {
|
||||
query += ` AND wir.reporter_id = ?`;
|
||||
params.push(filters.reporter_id);
|
||||
}
|
||||
|
||||
if (filters.assigned_user_id) {
|
||||
query += ` AND wir.assigned_user_id = ?`;
|
||||
params.push(filters.assigned_user_id);
|
||||
}
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
|
||||
const searchTerm = `%${filters.search}%`;
|
||||
params.push(searchTerm, searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
|
||||
|
||||
// 페이지네이션
|
||||
if (filters.limit) {
|
||||
query += ` LIMIT ?`;
|
||||
params.push(parseInt(filters.limit));
|
||||
|
||||
if (filters.offset) {
|
||||
query += ` OFFSET ?`;
|
||||
params.push(parseInt(filters.offset));
|
||||
}
|
||||
}
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 상세 조회
|
||||
*/
|
||||
const getReportById = async (reportId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
wir.report_id, wir.reporter_id, wir.report_date,
|
||||
wir.factory_category_id, wir.workplace_id, wir.custom_location,
|
||||
wir.tbm_session_id, wir.visit_request_id,
|
||||
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
|
||||
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
|
||||
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
|
||||
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
|
||||
wir.resolved_at, wir.resolved_by,
|
||||
wir.modification_history,
|
||||
wir.created_at, wir.updated_at,
|
||||
u.username as reporter_name, u.name as reporter_full_name,
|
||||
wc.category_name as factory_name,
|
||||
w.workplace_name,
|
||||
irc.category_type, irc.category_name as issue_category_name,
|
||||
iri.item_name as issue_item_name, iri.severity,
|
||||
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
|
||||
assigner.username as assigned_by_name,
|
||||
resolver.username as resolved_by_name
|
||||
FROM work_issue_reports wir
|
||||
INNER JOIN users u ON wir.reporter_id = u.user_id
|
||||
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
|
||||
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
|
||||
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
|
||||
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
|
||||
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
|
||||
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
|
||||
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
|
||||
WHERE wir.report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 수정
|
||||
*/
|
||||
const updateReport = async (reportId, reportData, userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 기존 데이터 조회
|
||||
const [existing] = await db.query(
|
||||
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
const current = existing[0];
|
||||
|
||||
// 수정 이력 생성
|
||||
const modifications = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const key of Object.keys(reportData)) {
|
||||
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
|
||||
modifications.push({
|
||||
field: key,
|
||||
old_value: current[key],
|
||||
new_value: reportData[key],
|
||||
modified_at: now,
|
||||
modified_by: userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 이력과 병합
|
||||
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
|
||||
const newHistory = [...existingHistory, ...modifications];
|
||||
|
||||
const {
|
||||
factory_category_id,
|
||||
workplace_id,
|
||||
custom_location,
|
||||
issue_category_id,
|
||||
issue_item_id,
|
||||
additional_description,
|
||||
photo_path1,
|
||||
photo_path2,
|
||||
photo_path3,
|
||||
photo_path4,
|
||||
photo_path5
|
||||
} = reportData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET factory_category_id = COALESCE(?, factory_category_id),
|
||||
workplace_id = COALESCE(?, workplace_id),
|
||||
custom_location = COALESCE(?, custom_location),
|
||||
issue_category_id = COALESCE(?, issue_category_id),
|
||||
issue_item_id = COALESCE(?, issue_item_id),
|
||||
additional_description = COALESCE(?, additional_description),
|
||||
photo_path1 = COALESCE(?, photo_path1),
|
||||
photo_path2 = COALESCE(?, photo_path2),
|
||||
photo_path3 = COALESCE(?, photo_path3),
|
||||
photo_path4 = COALESCE(?, photo_path4),
|
||||
photo_path5 = COALESCE(?, photo_path5),
|
||||
modification_history = ?,
|
||||
updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[factory_category_id, workplace_id, custom_location,
|
||||
issue_category_id, issue_item_id, additional_description,
|
||||
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
|
||||
JSON.stringify(newHistory), reportId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 삭제
|
||||
*/
|
||||
const deleteReport = async (reportId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 먼저 사진 경로 조회 (삭제용)
|
||||
const [photos] = await db.query(
|
||||
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
|
||||
resolution_photo_path1, resolution_photo_path2
|
||||
FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
// 삭제할 사진 경로 반환
|
||||
callback(null, { result, photos: photos[0] });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
/**
|
||||
* 신고 접수 (reported → received)
|
||||
*/
|
||||
const receiveReport = async (reportId, userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 상태 확인
|
||||
const [current] = await db.query(
|
||||
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (current.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
if (current[0].status !== 'reported') {
|
||||
return callback(new Error('접수 대기 상태가 아닙니다.'));
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET status = 'received', updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
// 상태 변경 로그
|
||||
await db.query(
|
||||
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
|
||||
VALUES (?, 'reported', 'received', ?)`,
|
||||
[reportId, userId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 담당자 배정
|
||||
*/
|
||||
const assignReport = async (reportId, assignData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { assigned_department, assigned_user_id, assigned_by } = assignData;
|
||||
|
||||
// 현재 상태 확인
|
||||
const [current] = await db.query(
|
||||
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (current.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
// 접수 상태 이상이어야 배정 가능
|
||||
const validStatuses = ['received', 'in_progress'];
|
||||
if (!validStatuses.includes(current[0].status)) {
|
||||
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET assigned_department = ?, assigned_user_id = ?,
|
||||
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[assigned_department, assigned_user_id, assigned_by, reportId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 처리 시작 (received → in_progress)
|
||||
*/
|
||||
const startProcessing = async (reportId, userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 상태 확인
|
||||
const [current] = await db.query(
|
||||
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (current.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
if (current[0].status !== 'received') {
|
||||
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET status = 'in_progress', updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
// 상태 변경 로그
|
||||
await db.query(
|
||||
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
|
||||
VALUES (?, 'received', 'in_progress', ?)`,
|
||||
[reportId, userId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 처리 완료 (in_progress → completed)
|
||||
*/
|
||||
const completeReport = async (reportId, completionData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
|
||||
|
||||
// 현재 상태 확인
|
||||
const [current] = await db.query(
|
||||
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (current.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
if (current[0].status !== 'in_progress') {
|
||||
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET status = 'completed', resolution_notes = ?,
|
||||
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
|
||||
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
|
||||
);
|
||||
|
||||
// 상태 변경 로그
|
||||
await db.query(
|
||||
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
|
||||
VALUES (?, 'in_progress', 'completed', ?, ?)`,
|
||||
[reportId, resolved_by, resolution_notes]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 신고 종료 (completed → closed)
|
||||
*/
|
||||
const closeReport = async (reportId, userId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
// 현재 상태 확인
|
||||
const [current] = await db.query(
|
||||
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (current.length === 0) {
|
||||
return callback(new Error('신고를 찾을 수 없습니다.'));
|
||||
}
|
||||
|
||||
if (current[0].status !== 'completed') {
|
||||
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE work_issue_reports
|
||||
SET status = 'closed', updated_at = NOW()
|
||||
WHERE report_id = ?`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
// 상태 변경 로그
|
||||
await db.query(
|
||||
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
|
||||
VALUES (?, 'completed', 'closed', ?)`,
|
||||
[reportId, userId]
|
||||
);
|
||||
|
||||
callback(null, result);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 상태 변경 이력 조회
|
||||
*/
|
||||
const getStatusLogs = async (reportId, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(
|
||||
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
|
||||
wisl.changed_by, wisl.change_reason, wisl.changed_at,
|
||||
u.username as changed_by_name, u.name as changed_by_full_name
|
||||
FROM work_issue_status_logs wisl
|
||||
INNER JOIN users u ON wisl.changed_by = u.user_id
|
||||
WHERE wisl.report_id = ?
|
||||
ORDER BY wisl.changed_at ASC`,
|
||||
[reportId]
|
||||
);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
/**
|
||||
* 신고 통계 요약
|
||||
*/
|
||||
const getStatsSummary = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
let whereClause = '1=1';
|
||||
const params = [];
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.factory_category_id) {
|
||||
whereClause += ` AND factory_category_id = ?`;
|
||||
params.push(filters.factory_category_id);
|
||||
}
|
||||
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
|
||||
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||
FROM work_issue_reports
|
||||
WHERE ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
callback(null, rows[0]);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리별 통계
|
||||
*/
|
||||
const getStatsByCategory = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
let whereClause = '1=1';
|
||||
const params = [];
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
irc.category_type, irc.category_name,
|
||||
COUNT(*) as count
|
||||
FROM work_issue_reports wir
|
||||
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY irc.category_id
|
||||
ORDER BY irc.category_type, count DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업장별 통계
|
||||
*/
|
||||
const getStatsByWorkplace = async (filters = {}, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
|
||||
let whereClause = 'wir.workplace_id IS NOT NULL';
|
||||
const params = [];
|
||||
|
||||
if (filters.start_date && filters.end_date) {
|
||||
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
|
||||
if (filters.factory_category_id) {
|
||||
whereClause += ` AND wir.factory_category_id = ?`;
|
||||
params.push(filters.factory_category_id);
|
||||
}
|
||||
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
wir.factory_category_id, wc.category_name as factory_name,
|
||||
wir.workplace_id, w.workplace_name,
|
||||
COUNT(*) as count
|
||||
FROM work_issue_reports wir
|
||||
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
|
||||
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY wir.factory_category_id, wir.workplace_id
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 카테고리
|
||||
getAllCategories,
|
||||
getCategoriesByType,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
|
||||
// 항목
|
||||
getItemsByCategory,
|
||||
getAllItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
|
||||
// 신고
|
||||
createReport,
|
||||
getAllReports,
|
||||
getReportById,
|
||||
updateReport,
|
||||
deleteReport,
|
||||
|
||||
// 상태 관리
|
||||
receiveReport,
|
||||
assignReport,
|
||||
startProcessing,
|
||||
completeReport,
|
||||
closeReport,
|
||||
getStatusLogs,
|
||||
|
||||
// 통계
|
||||
getStatsSummary,
|
||||
getStatsByCategory,
|
||||
getStatsByWorkplace
|
||||
};
|
||||
18
api.hyungi.net/package-lock.json
generated
18
api.hyungi.net/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"async-retry": "^1.3.3",
|
||||
"axios": "^1.6.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.8.1",
|
||||
@@ -1956,7 +1957,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
@@ -1968,6 +1968,17 @@
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -2700,7 +2711,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@@ -3043,7 +3053,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -3320,7 +3329,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -3691,7 +3699,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@@ -4004,7 +4011,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.1.1",
|
||||
"async-retry": "^1.3.3",
|
||||
"axios": "^1.6.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.8.1",
|
||||
|
||||
@@ -46,12 +46,38 @@ router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.
|
||||
// 모든 안전 체크 항목 조회
|
||||
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
|
||||
|
||||
// 안전 체크 항목 생성 (관리자용)
|
||||
router.post('/safety-checks', requireAuth, TbmController.createSafetyCheck);
|
||||
|
||||
// 안전 체크 항목 수정 (관리자용)
|
||||
router.put('/safety-checks/:checkId', requireAuth, TbmController.updateSafetyCheck);
|
||||
|
||||
// 안전 체크 항목 삭제 (관리자용)
|
||||
router.delete('/safety-checks/:checkId', requireAuth, TbmController.deleteSafetyCheck);
|
||||
|
||||
// TBM 세션의 안전 체크 기록 조회
|
||||
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
|
||||
|
||||
// 안전 체크 일괄 저장
|
||||
router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords);
|
||||
|
||||
// 필터링된 안전 체크리스트 조회 (기본 + 날씨 + 작업별)
|
||||
router.get('/sessions/:sessionId/safety-checks/filtered', requireAuth, TbmController.getFilteredSafetyChecks);
|
||||
|
||||
// ==================== 날씨 관련 ====================
|
||||
|
||||
// 현재 날씨 조회
|
||||
router.get('/weather/current', requireAuth, TbmController.getCurrentWeather);
|
||||
|
||||
// 날씨 조건 목록 조회
|
||||
router.get('/weather/conditions', requireAuth, TbmController.getWeatherConditions);
|
||||
|
||||
// 세션 날씨 정보 조회
|
||||
router.get('/sessions/:sessionId/weather', requireAuth, TbmController.getSessionWeather);
|
||||
|
||||
// 세션 날씨 정보 저장
|
||||
router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather);
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
// 작업 인계 생성
|
||||
|
||||
@@ -104,6 +104,24 @@ router.get('/me/monthly-stats', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 자신의 페이지 권한 조회 (Admin 불필요) ==========
|
||||
// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin)
|
||||
router.get('/:id/page-access', (req, res, next) => {
|
||||
const requestedId = parseInt(req.params.id);
|
||||
const currentUserId = req.user?.user_id;
|
||||
const userRole = req.user?.role?.toLowerCase();
|
||||
|
||||
// 자신의 권한 조회이거나 Admin인 경우 허용
|
||||
if (requestedId === currentUserId || userRole === 'admin' || userRole === 'system admin') {
|
||||
return userController.getUserPageAccess(req, res, next);
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '자신의 페이지 권한만 조회할 수 있습니다'
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 관리자 전용 API ==========
|
||||
/**
|
||||
* 모든 라우트에 관리자 권한 적용
|
||||
@@ -125,13 +143,13 @@ router.put('/:id', userController.updateUser);
|
||||
// 🔄 사용자 상태 변경
|
||||
router.put('/:id/status', userController.updateUserStatus);
|
||||
|
||||
// 🔑 사용자 비밀번호 초기화 (000000)
|
||||
router.post('/:id/reset-password', userController.resetUserPassword);
|
||||
|
||||
// 🗑️ 사용자 삭제
|
||||
router.delete('/:id', userController.deleteUser);
|
||||
|
||||
// 📄 사용자 페이지 접근 권한 조회
|
||||
router.get('/:id/page-access', userController.getUserPageAccess);
|
||||
|
||||
// 🔐 사용자 페이지 접근 권한 업데이트
|
||||
// 🔐 사용자 페이지 접근 권한 업데이트 (Admin만)
|
||||
router.put('/:id/page-access', userController.updateUserPageAccess);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
92
api.hyungi.net/routes/workIssueRoutes.js
Normal file
92
api.hyungi.net/routes/workIssueRoutes.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 작업 중 문제 신고 라우터
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const workIssueController = require('../controllers/workIssueController');
|
||||
const { requireMinLevel } = require('../middlewares/auth');
|
||||
|
||||
// ==================== 카테고리 관리 ====================
|
||||
|
||||
// 모든 카테고리 조회
|
||||
router.get('/categories', workIssueController.getAllCategories);
|
||||
|
||||
// 타입별 카테고리 조회 (nonconformity/safety)
|
||||
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
|
||||
|
||||
// 카테고리 생성 (admin 이상)
|
||||
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory);
|
||||
|
||||
// 카테고리 수정 (admin 이상)
|
||||
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory);
|
||||
|
||||
// 카테고리 삭제 (admin 이상)
|
||||
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory);
|
||||
|
||||
// ==================== 사전 정의 항목 관리 ====================
|
||||
|
||||
// 모든 항목 조회
|
||||
router.get('/items', workIssueController.getAllItems);
|
||||
|
||||
// 카테고리별 항목 조회
|
||||
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
|
||||
|
||||
// 항목 생성 (admin 이상)
|
||||
router.post('/items', requireMinLevel('admin'), workIssueController.createItem);
|
||||
|
||||
// 항목 수정 (admin 이상)
|
||||
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem);
|
||||
|
||||
// 항목 삭제 (admin 이상)
|
||||
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem);
|
||||
|
||||
// ==================== 통계 ====================
|
||||
|
||||
// 통계 요약 (support_team 이상)
|
||||
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary);
|
||||
|
||||
// 카테고리별 통계 (support_team 이상)
|
||||
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory);
|
||||
|
||||
// 작업장별 통계 (support_team 이상)
|
||||
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace);
|
||||
|
||||
// ==================== 문제 신고 관리 ====================
|
||||
|
||||
// 신고 목록 조회
|
||||
router.get('/', workIssueController.getAllReports);
|
||||
|
||||
// 신고 생성
|
||||
router.post('/', workIssueController.createReport);
|
||||
|
||||
// 신고 상세 조회
|
||||
router.get('/:id', workIssueController.getReportById);
|
||||
|
||||
// 신고 수정
|
||||
router.put('/:id', workIssueController.updateReport);
|
||||
|
||||
// 신고 삭제
|
||||
router.delete('/:id', workIssueController.deleteReport);
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
|
||||
// 신고 접수 (support_team 이상)
|
||||
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport);
|
||||
|
||||
// 담당자 배정 (support_team 이상)
|
||||
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport);
|
||||
|
||||
// 처리 시작
|
||||
router.put('/:id/start', workIssueController.startProcessing);
|
||||
|
||||
// 처리 완료
|
||||
router.put('/:id/complete', workIssueController.completeReport);
|
||||
|
||||
// 신고 종료 (admin 이상)
|
||||
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport);
|
||||
|
||||
// 상태 변경 이력 조회
|
||||
router.get('/:id/logs', workIssueController.getStatusLogs);
|
||||
|
||||
module.exports = router;
|
||||
@@ -23,4 +23,17 @@ router.put('/:id', workReportController.updateWorkReport);
|
||||
// DELETE
|
||||
router.delete('/:id', workReportController.removeWorkReport);
|
||||
|
||||
// ========== 부적합 원인 관리 ==========
|
||||
// 작업 보고서의 부적합 원인 목록 조회
|
||||
router.get('/:reportId/defects', workReportController.getReportDefects);
|
||||
|
||||
// 부적합 원인 저장 (전체 교체)
|
||||
router.put('/:reportId/defects', workReportController.saveReportDefects);
|
||||
|
||||
// 부적합 원인 추가 (단일)
|
||||
router.post('/:reportId/defects', workReportController.addReportDefect);
|
||||
|
||||
// 부적합 원인 삭제
|
||||
router.delete('/defects/:defectId', workReportController.removeReportDefect);
|
||||
|
||||
module.exports = router;
|
||||
209
api.hyungi.net/services/imageUploadService.js
Normal file
209
api.hyungi.net/services/imageUploadService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 이미지 업로드 서비스
|
||||
* Base64 인코딩된 이미지를 파일로 저장
|
||||
*
|
||||
* 사용 전 sharp 패키지 설치 필요:
|
||||
* npm install sharp
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장)
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (e) {
|
||||
console.warn('sharp 패키지가 설치되어 있지 않습니다. 이미지 리사이징이 비활성화됩니다.');
|
||||
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
|
||||
}
|
||||
|
||||
// 업로드 디렉토리 설정
|
||||
const UPLOAD_DIR = path.join(__dirname, '../public/uploads/issues');
|
||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||
const QUALITY = 85;
|
||||
|
||||
/**
|
||||
* 업로드 디렉토리 확인 및 생성
|
||||
*/
|
||||
async function ensureUploadDir() {
|
||||
try {
|
||||
await fs.access(UPLOAD_DIR);
|
||||
} catch {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID 생성 (간단한 버전)
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프 문자열 생성
|
||||
*/
|
||||
function getTimestamp() {
|
||||
const now = new Date();
|
||||
return now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 문자열에서 이미지 형식 추출
|
||||
* @param {string} base64String - Base64 인코딩된 이미지
|
||||
* @returns {string} 이미지 확장자 (jpg, png, etc)
|
||||
*/
|
||||
function getImageExtension(base64String) {
|
||||
const match = base64String.match(/^data:image\/(\w+);base64,/);
|
||||
if (match) {
|
||||
const format = match[1].toLowerCase();
|
||||
// jpeg를 jpg로 변환
|
||||
return format === 'jpeg' ? 'jpg' : format;
|
||||
}
|
||||
return 'jpg'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 이미지를 파일로 저장
|
||||
* @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식)
|
||||
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution')
|
||||
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
|
||||
*/
|
||||
async function saveBase64Image(base64String, prefix = 'issue') {
|
||||
try {
|
||||
if (!base64String || typeof base64String !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 헤더가 없는 경우 처리
|
||||
let base64Data = base64String;
|
||||
if (base64String.includes('base64,')) {
|
||||
base64Data = base64String.split('base64,')[1];
|
||||
}
|
||||
|
||||
// Base64 디코딩
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
if (buffer.length === 0) {
|
||||
console.error('이미지 데이터가 비어있습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 디렉토리 확인
|
||||
await ensureUploadDir();
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = getTimestamp();
|
||||
const uniqueId = generateId();
|
||||
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
|
||||
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
|
||||
const filepath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
// sharp가 설치되어 있으면 리사이징 및 최적화
|
||||
if (sharp) {
|
||||
try {
|
||||
await sharp(buffer)
|
||||
.resize(MAX_SIZE.width, MAX_SIZE.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: QUALITY })
|
||||
.toFile(filepath);
|
||||
} catch (sharpError) {
|
||||
console.error('sharp 처리 실패, 원본 저장:', sharpError.message);
|
||||
// sharp 실패 시 원본 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
} else {
|
||||
// sharp가 없으면 원본 그대로 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
|
||||
// 웹 접근 경로 반환
|
||||
return `/uploads/issues/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('이미지 저장 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Base64 이미지를 한번에 저장
|
||||
* @param {string[]} base64Images - Base64 이미지 배열
|
||||
* @param {string} prefix - 파일명 접두사
|
||||
* @returns {Promise<string[]>} 저장된 파일 경로 배열
|
||||
*/
|
||||
async function saveMultipleImages(base64Images, prefix = 'issue') {
|
||||
const paths = [];
|
||||
|
||||
for (const base64 of base64Images) {
|
||||
if (base64) {
|
||||
const savedPath = await saveBase64Image(base64, prefix);
|
||||
if (savedPath) {
|
||||
paths.push(savedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* @param {string} webPath - 웹 경로 (예: /uploads/issues/filename.jpg)
|
||||
* @returns {Promise<boolean>} 삭제 성공 여부
|
||||
*/
|
||||
async function deleteFile(webPath) {
|
||||
try {
|
||||
if (!webPath || typeof webPath !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 보안: uploads 경로만 삭제 허용
|
||||
if (!webPath.startsWith('/uploads/')) {
|
||||
console.error('삭제 불가: uploads 외부 경로', webPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(webPath);
|
||||
const fullPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (accessError) {
|
||||
// 파일이 없으면 성공으로 처리
|
||||
if (accessError.code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
throw accessError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 파일 삭제
|
||||
* @param {string[]} webPaths - 웹 경로 배열
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteMultipleFiles(webPaths) {
|
||||
for (const webPath of webPaths) {
|
||||
if (webPath) {
|
||||
await deleteFile(webPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveBase64Image,
|
||||
saveMultipleImages,
|
||||
deleteFile,
|
||||
deleteMultipleFiles,
|
||||
UPLOAD_DIR
|
||||
};
|
||||
401
api.hyungi.net/services/weatherService.js
Normal file
401
api.hyungi.net/services/weatherService.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 날씨 API 서비스
|
||||
*
|
||||
* 기상청 단기예보 API를 사용하여 현재 날씨 정보를 조회
|
||||
* 날씨 조건에 따른 안전 체크리스트 필터링 지원
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// 기상청 API 설정
|
||||
const WEATHER_BASE_URL = process.env.WEATHER_API_URL || 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
|
||||
const WEATHER_API = {
|
||||
baseUrl: WEATHER_BASE_URL,
|
||||
ultraShortUrl: `${WEATHER_BASE_URL}/getUltraSrtNcst`,
|
||||
shortForecastUrl: `${WEATHER_BASE_URL}/getVilageFcst`,
|
||||
apiKey: process.env.WEATHER_API_KEY || '',
|
||||
// 화성시 남양읍 좌표 (격자 좌표)
|
||||
// 위도: 37.2072, 경도: 126.8232
|
||||
defaultLocation: {
|
||||
nx: 57, // 화성시 남양읍 X 좌표
|
||||
ny: 119 // 화성시 남양읍 Y 좌표
|
||||
}
|
||||
};
|
||||
|
||||
// PTY (강수형태) 코드
|
||||
const PTY_CODES = {
|
||||
0: 'none', // 없음
|
||||
1: 'rain', // 비
|
||||
2: 'rain', // 비/눈 (혼합)
|
||||
3: 'snow', // 눈
|
||||
4: 'rain', // 소나기
|
||||
5: 'rain', // 빗방울
|
||||
6: 'rain', // 빗방울/눈날림
|
||||
7: 'snow' // 눈날림
|
||||
};
|
||||
|
||||
// SKY (하늘상태) 코드
|
||||
const SKY_CODES = {
|
||||
1: 'clear', // 맑음
|
||||
3: 'cloudy', // 구름많음
|
||||
4: 'overcast' // 흐림
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 날씨 정보 조회 (초단기실황)
|
||||
* @param {number} nx - 격자 X 좌표 (optional)
|
||||
* @param {number} ny - 격자 Y 좌표 (optional)
|
||||
* @returns {Promise<Object>} 날씨 데이터
|
||||
*/
|
||||
async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATHER_API.defaultLocation.ny) {
|
||||
if (!WEATHER_API.apiKey) {
|
||||
logger.warn('날씨 API 키가 설정되지 않음. 기본값 반환');
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
|
||||
try {
|
||||
// 현재 시간 기준으로 base_date, base_time 계산
|
||||
const now = new Date();
|
||||
const baseDate = formatDate(now);
|
||||
const baseTime = getBaseTime(now);
|
||||
|
||||
logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny });
|
||||
|
||||
// Encoding 키는 이미 URL 인코딩되어 있으므로 직접 URL에 추가 (이중 인코딩 방지)
|
||||
const url = `${WEATHER_API.ultraShortUrl}?serviceKey=${WEATHER_API.apiKey}` +
|
||||
`&pageNo=1&numOfRows=10&dataType=JSON` +
|
||||
`&base_date=${baseDate}&base_time=${baseTime}` +
|
||||
`&nx=${nx}&ny=${ny}`;
|
||||
|
||||
const response = await axios.get(url, { timeout: 5000 });
|
||||
|
||||
if (response.data?.response?.header?.resultCode !== '00') {
|
||||
throw new Error(`API 오류: ${response.data?.response?.header?.resultMsg}`);
|
||||
}
|
||||
|
||||
const items = response.data.response.body.items.item;
|
||||
const weatherData = parseWeatherItems(items);
|
||||
|
||||
logger.info('날씨 데이터 파싱 완료', weatherData);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
logger.error('날씨 API 호출 실패', { error: error.message });
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 API 응답 파싱
|
||||
*/
|
||||
function parseWeatherItems(items) {
|
||||
const data = {
|
||||
temperature: null,
|
||||
humidity: null,
|
||||
windSpeed: null,
|
||||
precipitation: null,
|
||||
precipitationType: null,
|
||||
skyCondition: null
|
||||
};
|
||||
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
switch (item.category) {
|
||||
case 'T1H': // 기온
|
||||
data.temperature = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'REH': // 습도
|
||||
data.humidity = parseInt(item.obsrValue);
|
||||
break;
|
||||
case 'WSD': // 풍속
|
||||
data.windSpeed = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'RN1': // 1시간 강수량
|
||||
data.precipitation = parseFloat(item.obsrValue) || 0;
|
||||
break;
|
||||
case 'PTY': // 강수형태
|
||||
data.precipitationType = parseInt(item.obsrValue);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 데이터를 기반으로 조건 판단
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @returns {Promise<string[]>} 해당하는 날씨 조건 코드 배열
|
||||
*/
|
||||
async function determineWeatherConditions(weatherData) {
|
||||
const conditions = [];
|
||||
|
||||
// DB에서 날씨 조건 기준 조회
|
||||
const db = await getDb();
|
||||
const [thresholds] = await db.execute(`
|
||||
SELECT condition_code, temp_threshold_min, temp_threshold_max,
|
||||
wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
`);
|
||||
|
||||
// 조건 판단
|
||||
thresholds.forEach(threshold => {
|
||||
let matches = false;
|
||||
|
||||
switch (threshold.condition_code) {
|
||||
case 'rain':
|
||||
// 강수형태가 비(1,2,4,5,6) 또는 강수량 > 0
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'rain') {
|
||||
matches = true;
|
||||
} else if (weatherData.precipitation > 0 && threshold.precip_threshold !== null) {
|
||||
matches = weatherData.precipitation >= threshold.precip_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'snow':
|
||||
// 강수형태가 눈(3,7)
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'snow') {
|
||||
matches = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'heat':
|
||||
// 기온이 폭염 기준 이상
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_min !== null) {
|
||||
matches = weatherData.temperature >= threshold.temp_threshold_min;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
// 기온이 한파 기준 이하
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_max !== null) {
|
||||
matches = weatherData.temperature <= threshold.temp_threshold_max;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'wind':
|
||||
// 풍속이 강풍 기준 이상
|
||||
if (weatherData.windSpeed !== null && threshold.wind_threshold !== null) {
|
||||
matches = weatherData.windSpeed >= threshold.wind_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clear':
|
||||
// 강수 없고 기온이 정상 범위
|
||||
if (!weatherData.precipitationType || weatherData.precipitationType === 0) {
|
||||
if (weatherData.temperature !== null &&
|
||||
weatherData.temperature > -10 && weatherData.temperature < 35) {
|
||||
matches = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
conditions.push(threshold.condition_code);
|
||||
}
|
||||
});
|
||||
|
||||
// 조건이 없으면 기본으로 'clear' 추가
|
||||
if (conditions.length === 0) {
|
||||
conditions.push('clear');
|
||||
}
|
||||
|
||||
logger.info('날씨 조건 판단 완료', { weatherData, conditions });
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션에 날씨 정보 저장
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @param {string[]} conditions - 날씨 조건 배열
|
||||
*/
|
||||
async function saveWeatherRecord(sessionId, weatherData, conditions) {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
const weatherDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
await db.execute(`
|
||||
INSERT INTO tbm_weather_records
|
||||
(session_id, weather_date, temperature, humidity, wind_speed, precipitation,
|
||||
weather_condition, weather_conditions, data_source, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'api', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
temperature = VALUES(temperature),
|
||||
humidity = VALUES(humidity),
|
||||
wind_speed = VALUES(wind_speed),
|
||||
precipitation = VALUES(precipitation),
|
||||
weather_condition = VALUES(weather_condition),
|
||||
weather_conditions = VALUES(weather_conditions),
|
||||
fetched_at = NOW()
|
||||
`, [
|
||||
sessionId,
|
||||
weatherDate,
|
||||
weatherData.temperature,
|
||||
weatherData.humidity,
|
||||
weatherData.windSpeed,
|
||||
weatherData.precipitation,
|
||||
conditions[0] || 'clear', // 주요 조건
|
||||
JSON.stringify(conditions) // 모든 조건
|
||||
]);
|
||||
|
||||
logger.info('날씨 기록 저장 완료', { sessionId, conditions });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('날씨 기록 저장 실패', { sessionId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션의 날씨 기록 조회
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
*/
|
||||
async function getWeatherRecord(sessionId) {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT wr.*, wc.condition_name, wc.icon
|
||||
FROM tbm_weather_records wr
|
||||
LEFT JOIN weather_conditions wc ON wr.weather_condition = wc.condition_code
|
||||
WHERE wr.session_id = ?
|
||||
`, [sessionId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = rows[0];
|
||||
// JSON 문자열 파싱
|
||||
if (record.weather_conditions && typeof record.weather_conditions === 'string') {
|
||||
try {
|
||||
record.weather_conditions = JSON.parse(record.weather_conditions);
|
||||
} catch (e) {
|
||||
record.weather_conditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 코드 목록 조회
|
||||
*/
|
||||
async function getWeatherConditionList() {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT condition_code, condition_name, description, icon,
|
||||
temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order
|
||||
`);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 날씨 데이터 반환 (API 실패 시)
|
||||
*/
|
||||
function getDefaultWeatherData() {
|
||||
return {
|
||||
temperature: 20,
|
||||
humidity: 50,
|
||||
windSpeed: 2,
|
||||
precipitation: 0,
|
||||
precipitationType: 0,
|
||||
skyCondition: 'clear',
|
||||
isDefault: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYYMMDD)
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 초단기실황 API용 기준시간 계산
|
||||
* 매시간 정각에 생성되고 10분 후에 제공됨
|
||||
*/
|
||||
function getBaseTime(date) {
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
|
||||
// 10분 이전이면 이전 시간 데이터 사용
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) hours = 23;
|
||||
}
|
||||
|
||||
return String(hours).padStart(2, '0') + '00';
|
||||
}
|
||||
|
||||
/**
|
||||
* 위경도를 기상청 격자 좌표로 변환
|
||||
* LCC (Lambert Conformal Conic) 투영법 사용
|
||||
*/
|
||||
function convertToGrid(lat, lon) {
|
||||
const RE = 6371.00877; // 지구 반경(km)
|
||||
const GRID = 5.0; // 격자 간격(km)
|
||||
const SLAT1 = 30.0; // 투영 위도1(degree)
|
||||
const SLAT2 = 60.0; // 투영 위도2(degree)
|
||||
const OLON = 126.0; // 기준점 경도(degree)
|
||||
const OLAT = 38.0; // 기준점 위도(degree)
|
||||
const XO = 43; // 기준점 X좌표(GRID)
|
||||
const YO = 136; // 기준점 Y좌표(GRID)
|
||||
|
||||
const DEGRAD = Math.PI / 180.0;
|
||||
|
||||
const re = RE / GRID;
|
||||
const slat1 = SLAT1 * DEGRAD;
|
||||
const slat2 = SLAT2 * DEGRAD;
|
||||
const olon = OLON * DEGRAD;
|
||||
const olat = OLAT * DEGRAD;
|
||||
|
||||
let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
|
||||
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn;
|
||||
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
|
||||
ro = re * sf / Math.pow(ro, sn);
|
||||
|
||||
let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5);
|
||||
ra = re * sf / Math.pow(ra, sn);
|
||||
let theta = lon * DEGRAD - olon;
|
||||
if (theta > Math.PI) theta -= 2.0 * Math.PI;
|
||||
if (theta < -Math.PI) theta += 2.0 * Math.PI;
|
||||
theta *= sn;
|
||||
|
||||
const x = Math.floor(ra * Math.sin(theta) + XO + 0.5);
|
||||
const y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5);
|
||||
|
||||
return { nx: x, ny: y };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentWeather,
|
||||
determineWeatherConditions,
|
||||
saveWeatherRecord,
|
||||
getWeatherRecord,
|
||||
getWeatherConditionList,
|
||||
convertToGrid,
|
||||
getDefaultWeatherData
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
const workReportModel = require('../models/workReportModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* 작업 보고서 생성 (단일 또는 다중)
|
||||
@@ -269,6 +270,170 @@ const getSummaryService = async (year, month) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 부적합 원인 관리 서비스 ==========
|
||||
|
||||
/**
|
||||
* 작업 보고서의 부적합 원인 목록 조회
|
||||
*/
|
||||
const getReportDefectsService = async (reportId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [rows] = await db.execute(`
|
||||
SELECT
|
||||
d.defect_id,
|
||||
d.report_id,
|
||||
d.error_type_id,
|
||||
d.defect_hours,
|
||||
d.note,
|
||||
d.created_at,
|
||||
et.name as error_type_name,
|
||||
et.severity
|
||||
FROM work_report_defects d
|
||||
JOIN error_types et ON d.error_type_id = et.id
|
||||
WHERE d.report_id = ?
|
||||
ORDER BY d.created_at
|
||||
`, [reportId]);
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 조회 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 저장 (전체 교체)
|
||||
*/
|
||||
const saveReportDefectsService = async (reportId, defects) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.query('START TRANSACTION');
|
||||
|
||||
// 기존 부적합 원인 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE report_id = ?', [reportId]);
|
||||
|
||||
// 새 부적합 원인 추가
|
||||
if (defects && defects.length > 0) {
|
||||
for (const defect of defects) {
|
||||
await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defect.error_type_id, defect.defect_hours || 0, defect.note || null]);
|
||||
}
|
||||
}
|
||||
|
||||
// 총 부적합 시간 계산 및 daily_work_reports 업데이트
|
||||
const totalErrorHours = defects
|
||||
? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0)
|
||||
: 0;
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
defects && defects.length > 0 ? defects[0].error_type_id : null,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
|
||||
await db.query('COMMIT');
|
||||
|
||||
logger.info('부적합 원인 저장 성공', { reportId, count: defects?.length || 0 });
|
||||
return { success: true, count: defects?.length || 0, totalErrorHours };
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK');
|
||||
logger.error('부적합 원인 저장 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 저장 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 추가 (단일)
|
||||
*/
|
||||
const addReportDefectService = async (reportId, defectData) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [result] = await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defectData.error_type_id, defectData.defect_hours || 0, defectData.note || null]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 추가 성공', { reportId, defectId: result.insertId });
|
||||
return { success: true, defect_id: result.insertId };
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 추가 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 추가 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 삭제
|
||||
*/
|
||||
const removeReportDefectService = async (defectId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
// report_id 먼저 조회
|
||||
const [defect] = await db.execute('SELECT report_id FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
if (defect.length === 0) {
|
||||
throw new NotFoundError('부적합 원인을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const reportId = defect[0].report_id;
|
||||
|
||||
// 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 삭제 성공', { defectId, reportId });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error('부적합 원인 삭제 실패', { defectId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 총 부적합 시간 업데이트 헬퍼
|
||||
*/
|
||||
const updateTotalErrorHours = async (reportId) => {
|
||||
const db = await getDb();
|
||||
const [result] = await db.execute(`
|
||||
SELECT COALESCE(SUM(defect_hours), 0) as total
|
||||
FROM work_report_defects
|
||||
WHERE report_id = ?
|
||||
`, [reportId]);
|
||||
|
||||
const totalErrorHours = result[0].total || 0;
|
||||
|
||||
// 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용
|
||||
const [firstDefect] = await db.execute(`
|
||||
SELECT error_type_id FROM work_report_defects WHERE report_id = ? ORDER BY created_at LIMIT 1
|
||||
`, [reportId]);
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
firstDefect.length > 0 ? firstDefect[0].error_type_id : null,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createWorkReportService,
|
||||
getWorkReportsByDateService,
|
||||
@@ -276,5 +441,10 @@ module.exports = {
|
||||
getWorkReportByIdService,
|
||||
updateWorkReportService,
|
||||
removeWorkReportService,
|
||||
getSummaryService
|
||||
getSummaryService,
|
||||
// 부적합 원인 관리
|
||||
getReportDefectsService,
|
||||
saveReportDefectsService,
|
||||
addReportDefectService,
|
||||
removeReportDefectService
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ services:
|
||||
- JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-30d}
|
||||
- REDIS_HOST=redis # New Redis host
|
||||
- REDIS_PORT=6379 # New Redis port
|
||||
- WEATHER_API_URL=${WEATHER_API_URL:-https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0}
|
||||
- WEATHER_API_KEY=${WEATHER_API_KEY:-} # 기상청 API 키
|
||||
volumes:
|
||||
- ./api.hyungi.net/public/img:/usr/src/app/public/img:ro
|
||||
- ./api.hyungi.net/uploads:/usr/src/app/uploads
|
||||
|
||||
@@ -13,9 +13,16 @@
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<div class="current-time" id="currentTime">
|
||||
<span class="time-label">현재 시각</span>
|
||||
<span class="time-value" id="timeValue">--:--:--</span>
|
||||
<div class="datetime-weather-box">
|
||||
<div class="date-time-section">
|
||||
<span class="date-value" id="dateValue">--월 --일 (--)</span>
|
||||
<span class="time-value" id="timeValue">--:--:--</span>
|
||||
</div>
|
||||
<div class="weather-section" id="weatherSection">
|
||||
<span class="weather-icon" id="weatherIcon">🌤️</span>
|
||||
<span class="weather-temp" id="weatherTemp">--°C</span>
|
||||
<span class="weather-desc" id="weatherDesc">날씨 로딩중</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,29 +116,58 @@
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.header-center .current-time {
|
||||
/* 날짜/시간/날씨 박스 */
|
||||
.datetime-weather-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: center;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-2) var(--space-5);
|
||||
}
|
||||
|
||||
.time-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.8;
|
||||
margin-bottom: var(--space-1);
|
||||
.date-time-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-right: var(--space-4);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.date-value {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.9;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
display: block;
|
||||
font-size: var(--text-lg);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.weather-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.weather-desc {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-right .user-profile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
337
web-ui/components/sidebar-nav.html
Normal file
337
web-ui/components/sidebar-nav.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!-- components/sidebar-nav.html -->
|
||||
<!-- 카테고리별 사이드 네비게이션 메뉴 -->
|
||||
<aside class="sidebar-nav" id="sidebarNav">
|
||||
<div class="sidebar-toggle" id="sidebarToggle">
|
||||
<span class="toggle-icon">☰</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-menu">
|
||||
<!-- 대시보드 -->
|
||||
<a href="/pages/dashboard.html" class="nav-item" data-page-key="dashboard">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span class="nav-text">대시보드</span>
|
||||
</a>
|
||||
|
||||
<!-- 작업 관리 -->
|
||||
<div class="nav-category" data-category="work">
|
||||
<button class="nav-category-header">
|
||||
<span class="nav-icon">📝</span>
|
||||
<span class="nav-text">작업 관리</span>
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="/pages/work/tbm.html" class="nav-item" data-page-key="work.tbm">
|
||||
<span class="nav-text">TBM 관리</span>
|
||||
</a>
|
||||
<a href="/pages/work/report-create.html" class="nav-item" data-page-key="work.report_create">
|
||||
<span class="nav-text">작업보고서 작성</span>
|
||||
</a>
|
||||
<a href="/pages/work/report-view.html" class="nav-item" data-page-key="work.report_view">
|
||||
<span class="nav-text">작업보고서 조회</span>
|
||||
</a>
|
||||
<a href="/pages/work/analysis.html" class="nav-item admin-only" data-page-key="work.analysis">
|
||||
<span class="nav-text">작업 분석</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 관리 -->
|
||||
<div class="nav-category" data-category="safety">
|
||||
<button class="nav-category-header">
|
||||
<span class="nav-icon">🛡</span>
|
||||
<span class="nav-text">안전 관리</span>
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="/pages/safety/issue-report.html" class="nav-item" data-page-key="safety.issue_report">
|
||||
<span class="nav-text">이슈 신고</span>
|
||||
</a>
|
||||
<a href="/pages/safety/issue-list.html" class="nav-item" data-page-key="safety.issue_list">
|
||||
<span class="nav-text">이슈 목록</span>
|
||||
</a>
|
||||
<a href="/pages/safety/visit-request.html" class="nav-item" data-page-key="safety.visit_request">
|
||||
<span class="nav-text">출입 신청</span>
|
||||
</a>
|
||||
<a href="/pages/safety/management.html" class="nav-item admin-only" data-page-key="safety.management">
|
||||
<span class="nav-text">안전 관리</span>
|
||||
</a>
|
||||
<a href="/pages/safety/checklist-manage.html" class="nav-item admin-only" data-page-key="safety.checklist_manage">
|
||||
<span class="nav-text">체크리스트 관리</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 근태 관리 -->
|
||||
<div class="nav-category" data-category="attendance">
|
||||
<button class="nav-category-header">
|
||||
<span class="nav-icon">📅</span>
|
||||
<span class="nav-text">근태 관리</span>
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="/pages/attendance/daily.html" class="nav-item" data-page-key="attendance.daily">
|
||||
<span class="nav-text">일일 출퇴근</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/monthly.html" class="nav-item" data-page-key="attendance.monthly">
|
||||
<span class="nav-text">월간 근태</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/vacation-request.html" class="nav-item" data-page-key="attendance.vacation_request">
|
||||
<span class="nav-text">휴가 신청</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/vacation-management.html" class="nav-item admin-only" data-page-key="attendance.vacation_management">
|
||||
<span class="nav-text">휴가 관리</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/vacation-allocation.html" class="nav-item admin-only" data-page-key="attendance.vacation_allocation">
|
||||
<span class="nav-text">휴가 발생 입력</span>
|
||||
</a>
|
||||
<a href="/pages/attendance/annual-overview.html" class="nav-item admin-only" data-page-key="attendance.annual_overview">
|
||||
<span class="nav-text">연간 휴가 현황</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 관리 (관리자 전용) -->
|
||||
<div class="nav-category admin-only" data-category="admin">
|
||||
<button class="nav-category-header">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span class="nav-text">시스템 관리</span>
|
||||
<span class="nav-arrow">▾</span>
|
||||
</button>
|
||||
<div class="nav-category-items">
|
||||
<a href="/pages/admin/accounts.html" class="nav-item" data-page-key="admin.accounts">
|
||||
<span class="nav-text">계정 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/page-access.html" class="nav-item" data-page-key="admin.page_access">
|
||||
<span class="nav-text">권한 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/workers.html" class="nav-item" data-page-key="admin.workers">
|
||||
<span class="nav-text">작업자 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/projects.html" class="nav-item" data-page-key="admin.projects">
|
||||
<span class="nav-text">프로젝트 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/tasks.html" class="nav-item" data-page-key="admin.tasks">
|
||||
<span class="nav-text">작업 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/workplaces.html" class="nav-item" data-page-key="admin.workplaces">
|
||||
<span class="nav-text">작업장 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/equipments.html" class="nav-item" data-page-key="admin.equipments">
|
||||
<span class="nav-text">설비 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/codes.html" class="nav-item" data-page-key="admin.codes">
|
||||
<span class="nav-text">코드 관리</span>
|
||||
</a>
|
||||
<a href="/pages/admin/attendance-report.html" class="nav-item" data-page-key="admin.attendance_report">
|
||||
<span class="nav-text">출퇴근-보고서 대조</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
/* 사이드바 기본 스타일 */
|
||||
.sidebar-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 80px; /* 헤더 높이만큼 아래로 */
|
||||
height: calc(100vh - 80px);
|
||||
width: 260px;
|
||||
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
|
||||
color: #e2e8f0;
|
||||
z-index: 99; /* 헤더보다 낮게 */
|
||||
transition: transform 0.3s ease, width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sidebar-nav.collapsed {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-nav.collapsed .nav-text,
|
||||
.sidebar-nav.collapsed .nav-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav.collapsed .nav-category-items {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 토글 버튼 */
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 1.5rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 메뉴 스타일 */
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* 네비게이션 아이템 */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
color: #38bdf8;
|
||||
border-left-color: #38bdf8;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 카테고리 헤더 */
|
||||
.nav-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nav-category-header:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.nav-category.expanded .nav-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 카테고리 아이템 */
|
||||
.nav-category-items {
|
||||
display: none;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-category.expanded .nav-category-items {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-category-items .nav-item {
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 관리자 전용 숨김 */
|
||||
.admin-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-only.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-category.admin-only.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
.sidebar-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 메인 콘텐츠 여백 */
|
||||
body.has-sidebar .dashboard-container,
|
||||
body.has-sidebar .main-content,
|
||||
body.has-sidebar .page-container {
|
||||
margin-left: 260px;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
body.has-sidebar.sidebar-collapsed .dashboard-container,
|
||||
body.has-sidebar.sidebar-collapsed .main-content,
|
||||
body.has-sidebar.sidebar-collapsed .page-container {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-nav {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar-nav.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
body.has-sidebar .dashboard-container,
|
||||
body.has-sidebar .main-content,
|
||||
body.has-sidebar .page-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -749,6 +749,18 @@
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 비밀번호 초기화 버튼 스타일 */
|
||||
.action-btn.reset-pw {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.reset-pw:hover {
|
||||
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* 페이지 권한 모달 사용자 정보 */
|
||||
.page-access-user-info {
|
||||
display: flex;
|
||||
|
||||
@@ -1008,3 +1008,178 @@
|
||||
.tbm-session-group:not(.manual-input-section) {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 부적합 원인 관리 버튼 */
|
||||
.btn-defect-manage {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-defect-manage:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-defect-manage span {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-defect-manage span[style*="color: #dc2626"] {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 부적합 토글 버튼 (인라인 방식) */
|
||||
.btn-defect-toggle {
|
||||
background: #f9fafb;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-defect-toggle:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.btn-defect-toggle.has-defect {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-defect-toggle span {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-defect-toggle.has-defect span {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 부적합 인라인 영역 */
|
||||
.defect-row td {
|
||||
border-bottom: 1px solid #fcd34d !important;
|
||||
}
|
||||
|
||||
.defect-inline-area {
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.defect-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.defect-inline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.defect-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.defect-select:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.defect-time-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.defect-time-input:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.defect-time-value {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.defect-time-unit {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-remove-defect {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-remove-defect:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.btn-add-defect-inline {
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-add-defect-inline:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,9 @@ function renderUsersTable() {
|
||||
권한
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="action-btn reset-pw" onclick="resetPassword(${user.user_id}, '${user.username}')" title="비밀번호 000000으로 초기화">
|
||||
비번초기화
|
||||
</button>
|
||||
<button class="action-btn toggle" onclick="toggleUserStatus(${user.user_id})">
|
||||
${user.is_active ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
@@ -410,6 +413,27 @@ function closeDeleteModal() {
|
||||
currentEditingUser = null;
|
||||
}
|
||||
|
||||
// ========== 비밀번호 초기화 ========== //
|
||||
async function resetPassword(userId, username) {
|
||||
if (!confirm(`${username} 사용자의 비밀번호를 000000으로 초기화하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await window.apiCall(`/users/${userId}/reset-password`, 'POST');
|
||||
|
||||
if (response.success) {
|
||||
showToast(`${username}의 비밀번호가 000000으로 초기화되었습니다.`, 'success');
|
||||
} else {
|
||||
showToast(response.message || '비밀번호 초기화에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('비밀번호 초기화 오류:', error);
|
||||
showToast('비밀번호 초기화 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
window.resetPassword = resetPassword;
|
||||
|
||||
// ========== 사용자 CRUD ========== //
|
||||
async function saveUser() {
|
||||
try {
|
||||
@@ -417,8 +441,7 @@ async function saveUser() {
|
||||
name: elements.userNameInput?.value,
|
||||
username: elements.userIdInput?.value,
|
||||
role: elements.userRoleSelect?.value, // HTML select value는 이미 'admin' 또는 'user'
|
||||
email: elements.userEmailInput?.value,
|
||||
phone: elements.userPhoneInput?.value
|
||||
email: elements.userEmailInput?.value
|
||||
};
|
||||
|
||||
console.log('저장할 데이터:', formData);
|
||||
@@ -647,24 +670,30 @@ function renderPageAccessList(userRole) {
|
||||
}
|
||||
|
||||
// 페이지 권한 저장
|
||||
async function savePageAccess(userId) {
|
||||
async function savePageAccess(userId, containerId = null) {
|
||||
try {
|
||||
const checkboxes = document.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
const pageAccessData = [];
|
||||
|
||||
// 특정 컨테이너가 지정되면 그 안에서만 체크박스 선택
|
||||
const container = containerId ? document.getElementById(containerId) : document;
|
||||
const checkboxes = container.querySelectorAll('.page-access-checkbox:not([disabled])');
|
||||
|
||||
// 중복 page_id 제거 (Map 사용)
|
||||
const pageAccessMap = new Map();
|
||||
checkboxes.forEach(checkbox => {
|
||||
pageAccessData.push({
|
||||
page_id: parseInt(checkbox.dataset.pageId),
|
||||
const pageId = parseInt(checkbox.dataset.pageId);
|
||||
pageAccessMap.set(pageId, {
|
||||
page_id: pageId,
|
||||
can_access: checkbox.checked ? 1 : 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const pageAccessData = Array.from(pageAccessMap.values());
|
||||
|
||||
console.log('📤 페이지 권한 저장:', userId, pageAccessData);
|
||||
|
||||
|
||||
await apiCall(`/users/${userId}/page-access`, 'PUT', {
|
||||
pageAccess: pageAccessData
|
||||
});
|
||||
|
||||
|
||||
console.log('✅ 페이지 권한 저장 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
@@ -845,12 +874,13 @@ async function savePageAccessFromModal() {
|
||||
}
|
||||
|
||||
try {
|
||||
await savePageAccess(currentPageAccessUser.user_id);
|
||||
// 모달 컨테이너 지정
|
||||
await savePageAccess(currentPageAccessUser.user_id, 'pageAccessModalList');
|
||||
showToast('페이지 권한이 저장되었습니다.', 'success');
|
||||
|
||||
|
||||
// 캐시 삭제 (사용자가 다시 로그인하거나 페이지 새로고침 필요)
|
||||
localStorage.removeItem('userPageAccess');
|
||||
|
||||
|
||||
closePageAccessModal();
|
||||
} catch (error) {
|
||||
console.error('❌ 페이지 권한 저장 오류:', error);
|
||||
|
||||
@@ -246,4 +246,4 @@ setInterval(() => {
|
||||
}, config.app.tokenRefreshInterval); // 5분마다 확인
|
||||
|
||||
// ES6 모듈 export
|
||||
export { API_URL as API_BASE_URL };
|
||||
export { API_URL as API_BASE_URL, API_URL as API, apiCall, getAuthHeaders };
|
||||
@@ -79,7 +79,7 @@ async function checkPageAccess(pageKey) {
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -26,10 +26,12 @@ export const config = {
|
||||
|
||||
// 공용 컴포넌트 경로 설정
|
||||
components: {
|
||||
// 사이드바 HTML 파일 경로
|
||||
// 사이드바 HTML 파일 경로 (구버전)
|
||||
sidebar: '/components/sidebar.html',
|
||||
// 네비게이션 바 HTML 파일 경로 (예상)
|
||||
navbar: '/components/navbar.html',
|
||||
// 새 사이드바 네비게이션 (카테고리별)
|
||||
'sidebar-nav': '/components/sidebar-nav.html',
|
||||
// 네비게이션 바 HTML 파일 경로
|
||||
navbar: '/components/navbar.html',
|
||||
},
|
||||
|
||||
// 애플리케이션 관련 기타 설정
|
||||
|
||||
@@ -18,6 +18,10 @@ let editingWorkId = null; // 수정 중인 작업 ID
|
||||
let incompleteTbms = []; // 미완료 TBM 작업 목록
|
||||
let currentTab = 'tbm'; // 현재 활성 탭
|
||||
|
||||
// 부적합 원인 관리
|
||||
let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
|
||||
let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
|
||||
|
||||
// 작업장소 지도 관련 변수
|
||||
let mapCanvas = null;
|
||||
let mapCtx = null;
|
||||
@@ -156,9 +160,8 @@ function renderTbmWorkList() {
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>작업시간</th>
|
||||
<th>부적합</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -190,9 +193,8 @@ function renderTbmWorkList() {
|
||||
<th>공정</th>
|
||||
<th>작업</th>
|
||||
<th>작업장소</th>
|
||||
<th>작업시간<br>(시간)</th>
|
||||
<th>부적합<br>(시간)</th>
|
||||
<th>부적합 원인</th>
|
||||
<th>작업시간</th>
|
||||
<th>부적합</th>
|
||||
<th>제출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -226,18 +228,13 @@ function renderTbmWorkList() {
|
||||
</td>
|
||||
<td>
|
||||
<input type="hidden" id="errorHours_${index}" value="0">
|
||||
<div class="time-input-trigger has-value"
|
||||
id="errorHoursDisplay_${index}"
|
||||
onclick="openTimePicker(${index}, 'error')">
|
||||
0시간
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-input-compact" id="errorType_${index}" style="display: none;">
|
||||
<option value="">선택</option>
|
||||
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
|
||||
</select>
|
||||
<span id="errorTypeNone_${index}">-</span>
|
||||
<input type="hidden" id="errorType_${index}" value="">
|
||||
<button type="button"
|
||||
class="btn-defect-toggle"
|
||||
id="defectToggle_${index}"
|
||||
onclick="toggleDefectArea(${index})">
|
||||
<span id="defectSummary_${index}">없음</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
@@ -247,6 +244,18 @@ function renderTbmWorkList() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="defect-row" id="defectRow_${index}" style="display: none;">
|
||||
<td colspan="8" style="padding: 0; background: #fef3c7;">
|
||||
<div class="defect-inline-area" id="defectArea_${index}">
|
||||
<div class="defect-list" id="defectList_${index}">
|
||||
<!-- 부적합 원인 목록 -->
|
||||
</div>
|
||||
<button type="button" class="btn-add-defect-inline" onclick="addInlineDefect(${index})">
|
||||
+ 부적합 추가
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
@@ -293,8 +302,11 @@ window.submitTbmWorkReport = async function(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;
|
||||
const defects = tempDefects[index] || [];
|
||||
|
||||
// 총 부적합 시간 계산
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!totalHours || totalHours <= 0) {
|
||||
@@ -307,8 +319,10 @@ window.submitTbmWorkReport = async function(index) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > 0 && !errorTypeId) {
|
||||
showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error');
|
||||
// 부적합 원인 유효성 검사
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,12 +344,12 @@ window.submitTbmWorkReport = async function(index) {
|
||||
end_time: null,
|
||||
total_hours: totalHours,
|
||||
error_hours: errorHours,
|
||||
error_type_id: errorTypeId || null,
|
||||
error_type_id: errorTypeId,
|
||||
work_status_id: errorHours > 0 ? 2 : 1
|
||||
};
|
||||
|
||||
console.log('🔍 TBM 제출 데이터:', JSON.stringify(reportData, null, 2));
|
||||
console.log('🔍 tbm 객체:', tbm);
|
||||
console.log('🔍 부적합 원인:', defects);
|
||||
|
||||
try {
|
||||
const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData);
|
||||
@@ -344,6 +358,16 @@ window.submitTbmWorkReport = async function(index) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장
|
||||
if (defects.length > 0 && response.data?.report_id) {
|
||||
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
@@ -353,6 +377,9 @@ window.submitTbmWorkReport = async function(index) {
|
||||
response.data.completion_status
|
||||
);
|
||||
|
||||
// 임시 부적합 데이터 삭제
|
||||
delete tempDefects[index];
|
||||
|
||||
// 목록 새로고침
|
||||
await loadIncompleteTbms();
|
||||
} catch (error) {
|
||||
@@ -576,18 +603,13 @@ window.addManualWorkRow = function() {
|
||||
</td>
|
||||
<td>
|
||||
<input type="hidden" id="errorHours_${manualIndex}" value="0">
|
||||
<div class="time-input-trigger has-value"
|
||||
id="errorHoursDisplay_${manualIndex}"
|
||||
onclick="openTimePicker('${manualIndex}', 'error')">
|
||||
0시간
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-input-compact" id="errorType_${manualIndex}" style="display: none;">
|
||||
<option value="">선택</option>
|
||||
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
|
||||
</select>
|
||||
<span id="errorTypeNone_${manualIndex}">-</span>
|
||||
<input type="hidden" id="errorType_${manualIndex}" value="">
|
||||
<button type="button"
|
||||
class="btn-defect-toggle"
|
||||
id="defectToggle_${manualIndex}"
|
||||
onclick="toggleDefectArea('${manualIndex}')">
|
||||
<span id="defectSummary_${manualIndex}">없음</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-submit-compact" onclick="submitManualWorkReport('${manualIndex}')">
|
||||
@@ -600,6 +622,26 @@ window.addManualWorkRow = function() {
|
||||
`;
|
||||
|
||||
tbody.appendChild(newRow);
|
||||
|
||||
// 부적합 인라인 영역 행 추가
|
||||
const defectRow = document.createElement('tr');
|
||||
defectRow.className = 'defect-row';
|
||||
defectRow.id = `defectRow_${manualIndex}`;
|
||||
defectRow.style.display = 'none';
|
||||
defectRow.innerHTML = `
|
||||
<td colspan="9" style="padding: 0; background: #fef3c7;">
|
||||
<div class="defect-inline-area" id="defectArea_${manualIndex}">
|
||||
<div class="defect-list" id="defectList_${manualIndex}">
|
||||
<!-- 부적합 원인 목록 -->
|
||||
</div>
|
||||
<button type="button" class="btn-add-defect-inline" onclick="addInlineDefect('${manualIndex}')">
|
||||
+ 부적합 추가
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(defectRow);
|
||||
|
||||
showMessage('새 작업 행이 추가되었습니다. 정보를 입력하고 제출하세요.', 'info');
|
||||
};
|
||||
|
||||
@@ -608,9 +650,15 @@ window.addManualWorkRow = function() {
|
||||
*/
|
||||
window.removeManualWorkRow = function(manualIndex) {
|
||||
const row = document.querySelector(`tr[data-index="${manualIndex}"]`);
|
||||
const defectRow = document.getElementById(`defectRow_${manualIndex}`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
if (defectRow) {
|
||||
defectRow.remove();
|
||||
}
|
||||
// 임시 부적합 데이터도 삭제
|
||||
delete tempDefects[manualIndex];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -976,8 +1024,11 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
const workplaceCategoryId = document.getElementById(`workplaceCategory_${manualIndex}`).value;
|
||||
const workplaceId = document.getElementById(`workplace_${manualIndex}`).value;
|
||||
const totalHours = parseFloat(document.getElementById(`totalHours_${manualIndex}`).value);
|
||||
const errorHours = parseFloat(document.getElementById(`errorHours_${manualIndex}`).value) || 0;
|
||||
const errorTypeId = document.getElementById(`errorType_${manualIndex}`).value;
|
||||
|
||||
// 부적합 원인 가져오기
|
||||
const defects = tempDefects[manualIndex] || [];
|
||||
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
const errorTypeId = defects.length > 0 && defects[0].error_type_id ? defects[0].error_type_id : null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!workerId) {
|
||||
@@ -1014,8 +1065,10 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorHours > 0 && !errorTypeId) {
|
||||
showMessage('부적합 처리 시간이 있는 경우 원인을 선택해주세요.', 'error');
|
||||
// 부적합 원인 유효성 검사
|
||||
const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id);
|
||||
if (invalidDefects.length > 0) {
|
||||
showMessage('부적합 시간이 있는 항목은 원인을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1042,13 +1095,23 @@ window.submitManualWorkReport = async function(manualIndex) {
|
||||
throw new Error(response.message || '작업보고서 제출 실패');
|
||||
}
|
||||
|
||||
// 부적합 원인이 있으면 저장
|
||||
if (defects.length > 0 && response.data?.workReport_ids?.[0]) {
|
||||
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
|
||||
if (validDefects.length > 0) {
|
||||
await window.apiCall(`/daily-work-reports/${response.data.workReport_ids[0]}/defects`, 'PUT', {
|
||||
defects: validDefects
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showSaveResultModal(
|
||||
'success',
|
||||
'작업보고서 제출 완료',
|
||||
'작업보고서가 성공적으로 제출되었습니다.'
|
||||
);
|
||||
|
||||
// 행 제거
|
||||
// 행 제거 (부적합 임시 데이터도 함께 삭제됨)
|
||||
removeManualWorkRow(manualIndex);
|
||||
|
||||
// 목록 새로고침
|
||||
@@ -2438,17 +2501,37 @@ function updateTimeDisplay() {
|
||||
*/
|
||||
window.confirmTimeSelection = function() {
|
||||
if (!currentEditingField) return;
|
||||
|
||||
const { index, type } = currentEditingField;
|
||||
|
||||
const { index, type, defectIndex } = currentEditingField;
|
||||
|
||||
// 부적합 시간 선택인 경우
|
||||
if (type === 'defect') {
|
||||
if (tempDefects[index] && tempDefects[index][defectIndex] !== undefined) {
|
||||
tempDefects[index][defectIndex].defect_hours = currentTimeValue;
|
||||
|
||||
// 시간 표시 업데이트
|
||||
const timeDisplay = document.getElementById(`defectTime_${index}_${defectIndex}`);
|
||||
if (timeDisplay) {
|
||||
timeDisplay.textContent = currentTimeValue;
|
||||
}
|
||||
|
||||
// 요약 및 hidden 필드 업데이트
|
||||
updateDefectSummary(index);
|
||||
}
|
||||
closeTimePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 total/error 시간 선택
|
||||
const inputId = type === 'total' ? `totalHours_${index}` : `errorHours_${index}`;
|
||||
const displayId = type === 'total' ? `totalHoursDisplay_${index}` : `errorHoursDisplay_${index}`;
|
||||
|
||||
|
||||
// hidden input 값 설정
|
||||
const hiddenInput = document.getElementById(inputId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = currentTimeValue;
|
||||
}
|
||||
|
||||
|
||||
// 표시 영역 업데이트
|
||||
const displayDiv = document.getElementById(displayId);
|
||||
if (displayDiv) {
|
||||
@@ -2456,8 +2539,8 @@ window.confirmTimeSelection = function() {
|
||||
displayDiv.classList.remove('placeholder');
|
||||
displayDiv.classList.add('has-value');
|
||||
}
|
||||
|
||||
// 부적합 시간 입력 시 에러 타입 토글
|
||||
|
||||
// 부적합 시간 입력 시 에러 타입 토글 (기존 방식 - 이제 사용안함)
|
||||
if (type === 'error') {
|
||||
if (index.toString().startsWith('manual_')) {
|
||||
toggleManualErrorType(index);
|
||||
@@ -2465,7 +2548,7 @@ window.confirmTimeSelection = function() {
|
||||
calculateRegularHours(index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
closeTimePicker();
|
||||
};
|
||||
|
||||
@@ -2477,10 +2560,200 @@ window.closeTimePicker = function() {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
currentEditingField = null;
|
||||
currentTimeValue = 0;
|
||||
|
||||
|
||||
// ESC 키 리스너 제거
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// 부적합 원인 관리 (인라인 방식)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* 부적합 영역 토글
|
||||
*/
|
||||
window.toggleDefectArea = function(index) {
|
||||
const defectRow = document.getElementById(`defectRow_${index}`);
|
||||
if (!defectRow) return;
|
||||
|
||||
const isVisible = defectRow.style.display !== 'none';
|
||||
|
||||
if (isVisible) {
|
||||
// 숨기기
|
||||
defectRow.style.display = 'none';
|
||||
} else {
|
||||
// 보이기 - 부적합 원인이 없으면 자동으로 하나 추가
|
||||
if (!tempDefects[index] || tempDefects[index].length === 0) {
|
||||
tempDefects[index] = [{
|
||||
error_type_id: '',
|
||||
defect_hours: 0,
|
||||
note: ''
|
||||
}];
|
||||
}
|
||||
renderInlineDefectList(index);
|
||||
defectRow.style.display = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 인라인 부적합 목록 렌더링
|
||||
*/
|
||||
function renderInlineDefectList(index) {
|
||||
const listContainer = document.getElementById(`defectList_${index}`);
|
||||
if (!listContainer) return;
|
||||
|
||||
const defects = tempDefects[index] || [];
|
||||
|
||||
listContainer.innerHTML = defects.map((defect, i) => `
|
||||
<div class="defect-inline-item" data-defect-index="${i}">
|
||||
<select class="defect-select"
|
||||
onchange="updateInlineDefect('${index}', ${i}, 'error_type_id', this.value)">
|
||||
<option value="">원인 선택</option>
|
||||
${errorTypes.map(et => `<option value="${et.id}" ${defect.error_type_id == et.id ? 'selected' : ''}>${et.name}</option>`).join('')}
|
||||
</select>
|
||||
<div class="defect-time-input"
|
||||
onclick="openDefectTimePicker('${index}', ${i})">
|
||||
<span class="defect-time-value" id="defectTime_${index}_${i}">${defect.defect_hours || 0}</span>
|
||||
<span class="defect-time-unit">시간</span>
|
||||
</div>
|
||||
<button type="button" class="btn-remove-defect" onclick="removeInlineDefect('${index}', ${i})">−</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
updateDefectSummary(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인라인 부적합 추가
|
||||
*/
|
||||
window.addInlineDefect = function(index) {
|
||||
if (!tempDefects[index]) {
|
||||
tempDefects[index] = [];
|
||||
}
|
||||
|
||||
tempDefects[index].push({
|
||||
error_type_id: '',
|
||||
defect_hours: 0,
|
||||
note: ''
|
||||
});
|
||||
|
||||
renderInlineDefectList(index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 인라인 부적합 수정
|
||||
*/
|
||||
window.updateInlineDefect = function(index, defectIndex, field, value) {
|
||||
if (tempDefects[index] && tempDefects[index][defectIndex]) {
|
||||
if (field === 'defect_hours') {
|
||||
tempDefects[index][defectIndex][field] = parseFloat(value) || 0;
|
||||
} else {
|
||||
tempDefects[index][defectIndex][field] = value;
|
||||
}
|
||||
updateDefectSummary(index);
|
||||
updateHiddenDefectFields(index);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 인라인 부적합 삭제
|
||||
*/
|
||||
window.removeInlineDefect = function(index, defectIndex) {
|
||||
if (tempDefects[index]) {
|
||||
tempDefects[index].splice(defectIndex, 1);
|
||||
|
||||
// 모든 부적합이 삭제되면 영역 숨기기
|
||||
if (tempDefects[index].length === 0) {
|
||||
const defectRow = document.getElementById(`defectRow_${index}`);
|
||||
if (defectRow) {
|
||||
defectRow.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
renderInlineDefectList(index);
|
||||
}
|
||||
|
||||
updateDefectSummary(index);
|
||||
updateHiddenDefectFields(index);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 시간 선택기 열기 (시간 선택 팝오버 재사용)
|
||||
*/
|
||||
window.openDefectTimePicker = function(index, defectIndex) {
|
||||
currentEditingField = { index, type: 'defect', defectIndex };
|
||||
|
||||
// 현재 값 가져오기
|
||||
const defects = tempDefects[index] || [];
|
||||
currentTimeValue = defects[defectIndex]?.defect_hours || 0;
|
||||
|
||||
// 팝오버 표시
|
||||
const overlay = document.getElementById('timePickerOverlay');
|
||||
const title = document.getElementById('timePickerTitle');
|
||||
|
||||
title.textContent = '부적합 시간 선택';
|
||||
updateTimeDisplay();
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
// ESC 키로 닫기
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* hidden input 필드 업데이트
|
||||
*/
|
||||
function updateHiddenDefectFields(index) {
|
||||
const defects = tempDefects[index] || [];
|
||||
|
||||
// 총 부적합 시간 계산
|
||||
const totalErrorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
|
||||
|
||||
// hidden input에 대표 error_type_id 저장 (첫 번째 값)
|
||||
const errorTypeInput = document.getElementById(`errorType_${index}`);
|
||||
if (errorTypeInput && defects.length > 0 && defects[0].error_type_id) {
|
||||
errorTypeInput.value = defects[0].error_type_id;
|
||||
} else if (errorTypeInput) {
|
||||
errorTypeInput.value = '';
|
||||
}
|
||||
|
||||
// 부적합 시간 input 업데이트
|
||||
const errorHoursInput = document.getElementById(`errorHours_${index}`);
|
||||
if (errorHoursInput) {
|
||||
errorHoursInput.value = totalErrorHours;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부적합 요약 텍스트 업데이트
|
||||
*/
|
||||
function updateDefectSummary(index) {
|
||||
const summaryEl = document.getElementById(`defectSummary_${index}`);
|
||||
const toggleBtn = document.getElementById(`defectToggle_${index}`);
|
||||
if (!summaryEl) return;
|
||||
|
||||
const defects = tempDefects[index] || [];
|
||||
const validDefects = defects.filter(d => d.error_type_id && d.defect_hours > 0);
|
||||
|
||||
if (validDefects.length === 0) {
|
||||
summaryEl.textContent = '없음';
|
||||
summaryEl.style.color = '#6b7280';
|
||||
if (toggleBtn) toggleBtn.classList.remove('has-defect');
|
||||
} else {
|
||||
const totalHours = validDefects.reduce((sum, d) => sum + d.defect_hours, 0);
|
||||
if (validDefects.length === 1) {
|
||||
const typeName = errorTypes.find(et => et.id == validDefects[0].error_type_id)?.name || '부적합';
|
||||
summaryEl.textContent = `${typeName} ${totalHours}h`;
|
||||
} else {
|
||||
summaryEl.textContent = `${validDefects.length}건 ${totalHours}h`;
|
||||
}
|
||||
summaryEl.style.color = '#dc2626';
|
||||
if (toggleBtn) toggleBtn.classList.add('has-defect');
|
||||
}
|
||||
|
||||
// hidden 필드도 업데이트
|
||||
updateHiddenDefectFields(index);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ async function processNavbarDom(doc) {
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
|
||||
// Admin은 모든 메뉴 표시
|
||||
if (userRole === 'admin' || userRole === 'system') {
|
||||
// Admin은 모든 메뉴 표시 + .admin-only 요소 활성화
|
||||
if (userRole === 'admin' || userRole === 'system admin') {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ async function filterMenuByPageAccess(doc, currentUser) {
|
||||
|
||||
// 캐시가 없으면 API 호출
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/api/users/${currentUser.user_id}/page-access`, {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -153,14 +154,118 @@ function setupNavbarEvents() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 시간을 업데이트하는 함수
|
||||
* 현재 날짜와 시간을 업데이트하는 함수
|
||||
*/
|
||||
function updateTime() {
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
const now = new Date();
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
function updateDateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// 시간 업데이트
|
||||
const timeElement = document.getElementById('timeValue');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false });
|
||||
}
|
||||
|
||||
// 날짜 업데이트
|
||||
const dateElement = document.getElementById('dateValue');
|
||||
if (dateElement) {
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const day = days[now.getDay()];
|
||||
dateElement.textContent = `${month}월 ${date}일 (${day})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 날씨 아이콘 매핑
|
||||
const WEATHER_ICONS = {
|
||||
clear: '☀️',
|
||||
rain: '🌧️',
|
||||
snow: '❄️',
|
||||
heat: '🔥',
|
||||
cold: '🥶',
|
||||
wind: '💨',
|
||||
fog: '🌫️',
|
||||
dust: '😷',
|
||||
cloudy: '⛅',
|
||||
overcast: '☁️'
|
||||
};
|
||||
|
||||
// 날씨 조건명
|
||||
const WEATHER_NAMES = {
|
||||
clear: '맑음',
|
||||
rain: '비',
|
||||
snow: '눈',
|
||||
heat: '폭염',
|
||||
cold: '한파',
|
||||
wind: '강풍',
|
||||
fog: '안개',
|
||||
dust: '미세먼지',
|
||||
cloudy: '구름많음',
|
||||
overcast: '흐림'
|
||||
};
|
||||
|
||||
/**
|
||||
* 날씨 정보를 가져와서 업데이트하는 함수
|
||||
*/
|
||||
async function updateWeather() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch(`${window.API_BASE_URL}/tbm/weather/current`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('날씨 API 호출 실패');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { temperature, conditions, weatherData } = result.data;
|
||||
|
||||
// 온도 표시
|
||||
const tempElement = document.getElementById('weatherTemp');
|
||||
if (tempElement && temperature !== null && temperature !== undefined) {
|
||||
tempElement.textContent = `${Math.round(temperature)}°C`;
|
||||
}
|
||||
|
||||
// 날씨 아이콘 및 설명
|
||||
const iconElement = document.getElementById('weatherIcon');
|
||||
const descElement = document.getElementById('weatherDesc');
|
||||
|
||||
if (conditions && conditions.length > 0) {
|
||||
const primaryCondition = conditions[0];
|
||||
if (iconElement) {
|
||||
iconElement.textContent = WEATHER_ICONS[primaryCondition] || '🌤️';
|
||||
}
|
||||
if (descElement) {
|
||||
descElement.textContent = WEATHER_NAMES[primaryCondition] || '맑음';
|
||||
}
|
||||
} else {
|
||||
if (iconElement) iconElement.textContent = '☀️';
|
||||
if (descElement) descElement.textContent = '맑음';
|
||||
}
|
||||
|
||||
// 날씨 섹션 표시
|
||||
const weatherSection = document.getElementById('weatherSection');
|
||||
if (weatherSection) {
|
||||
weatherSection.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('날씨 정보 로드 실패:', error.message);
|
||||
// 실패해도 기본값 표시
|
||||
const descElement = document.getElementById('weatherDesc');
|
||||
if (descElement) {
|
||||
descElement.textContent = '날씨 정보 없음';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 로직: DOMContentLoaded 시 실행
|
||||
@@ -168,12 +273,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (getUser()) {
|
||||
// 1. 컴포넌트 로드 및 DOM 수정
|
||||
await loadComponent('navbar', '#navbar-container', processNavbarDom);
|
||||
|
||||
|
||||
// 2. DOM에 삽입된 후에 이벤트 리스너 설정
|
||||
setupNavbarEvents();
|
||||
|
||||
// 3. 실시간 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
// 3. 실시간 날짜/시간 업데이트 시작
|
||||
updateDateTime();
|
||||
setInterval(updateDateTime, 1000);
|
||||
|
||||
// 4. 날씨 정보 로드 (10분마다 갱신)
|
||||
updateWeather();
|
||||
setInterval(updateWeather, 10 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
@@ -1,47 +1,191 @@
|
||||
// /js/load-sidebar.js
|
||||
// 사이드바 네비게이션 로더 및 컨트롤러
|
||||
|
||||
import { getUser } from './auth.js';
|
||||
import { loadComponent } from './component-loader.js';
|
||||
|
||||
/**
|
||||
* 사용자 역할에 따라 사이드바 메뉴 항목을 필터링하는 DOM 프로세서입니다.
|
||||
* @param {Document} doc - 파싱된 HTML 문서 객체
|
||||
* 사이드바 DOM을 사용자 권한에 맞게 처리
|
||||
*/
|
||||
function filterSidebarByRole(doc) {
|
||||
async function processSidebarDom(doc) {
|
||||
const currentUser = getUser();
|
||||
if (!currentUser) return; // 비로그인 상태면 필터링하지 않음
|
||||
if (!currentUser) return;
|
||||
|
||||
const userRole = currentUser.role;
|
||||
const userRole = (currentUser.role || '').toLowerCase();
|
||||
const isAdmin = userRole === 'admin' || userRole === 'system admin' || userRole === 'system';
|
||||
|
||||
// 'system' 역할은 모든 메뉴를 볼 수 있으므로 필터링하지 않음
|
||||
if (userRole === 'system') {
|
||||
return;
|
||||
// 1. 관리자 전용 메뉴 표시/숨김
|
||||
if (isAdmin) {
|
||||
doc.querySelectorAll('.admin-only').forEach(el => el.classList.add('visible'));
|
||||
} else {
|
||||
// 비관리자: 페이지 접근 권한에 따라 메뉴 필터링
|
||||
await filterMenuByPageAccess(doc, currentUser);
|
||||
}
|
||||
|
||||
// 역할과 그에 해당하는 클래스 선택자 매핑
|
||||
const roleClassMap = {
|
||||
admin: '.admin-only',
|
||||
leader: '.leader-only',
|
||||
user: '.user-only',
|
||||
support: '.support-only'
|
||||
};
|
||||
|
||||
// 모든 역할 기반 선택자를 가져옴
|
||||
const allRoleSelectors = Object.values(roleClassMap).join(', ');
|
||||
const allRoleElements = doc.querySelectorAll(allRoleSelectors);
|
||||
// 2. 현재 페이지 활성화
|
||||
highlightCurrentPage(doc);
|
||||
|
||||
allRoleElements.forEach(el => {
|
||||
// 요소가 현재 사용자 역할에 해당하는 클래스를 가지고 있는지 확인
|
||||
const userRoleSelector = roleClassMap[userRole];
|
||||
if (!userRoleSelector || !el.matches(userRoleSelector)) {
|
||||
el.remove();
|
||||
// 3. 저장된 상태 복원
|
||||
restoreSidebarState(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자의 페이지 접근 권한에 따라 메뉴 필터링
|
||||
*/
|
||||
async function filterMenuByPageAccess(doc, currentUser) {
|
||||
try {
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
let accessiblePages = null;
|
||||
|
||||
if (cached) {
|
||||
const cacheData = JSON.parse(cached);
|
||||
if (Date.now() - cacheData.timestamp < 5 * 60 * 1000) {
|
||||
accessiblePages = cacheData.pages;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessiblePages) {
|
||||
const response = await fetch(`${window.API_BASE_URL}/users/${currentUser.user_id}/page-access`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
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);
|
||||
|
||||
// 메뉴 항목 필터링
|
||||
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.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 관리자 전용 카테고리 제거
|
||||
doc.querySelectorAll('.nav-category.admin-only').forEach(el => el.remove());
|
||||
|
||||
} catch (error) {
|
||||
console.error('사이드바 메뉴 필터링 오류:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지 하이라이트
|
||||
*/
|
||||
function highlightCurrentPage(doc) {
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
doc.querySelectorAll('.nav-item').forEach(item => {
|
||||
const href = item.getAttribute('href');
|
||||
if (href && currentPath.includes(href.replace(/^\//, ''))) {
|
||||
item.classList.add('active');
|
||||
|
||||
// 부모 카테고리 열기
|
||||
const category = item.closest('.nav-category');
|
||||
if (category) {
|
||||
category.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시 사이드바를 로드하고 역할에 따라 필터링합니다.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 'getUser'를 통해 로그인 상태 확인. 비로그인 시 아무 작업도 하지 않음.
|
||||
if (getUser()) {
|
||||
loadComponent('sidebar', '#sidebar-container', filterSidebarByRole);
|
||||
/**
|
||||
* 사이드바 상태 복원
|
||||
*/
|
||||
function restoreSidebarState(doc) {
|
||||
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||||
const sidebar = doc.querySelector('.sidebar-nav');
|
||||
|
||||
if (isCollapsed && sidebar) {
|
||||
sidebar.classList.add('collapsed');
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
// 확장된 카테고리 복원
|
||||
const expandedCategories = JSON.parse(localStorage.getItem('sidebarExpanded') || '[]');
|
||||
expandedCategories.forEach(category => {
|
||||
const el = doc.querySelector(`[data-category="${category}"]`);
|
||||
if (el) el.classList.add('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 이벤트 설정
|
||||
*/
|
||||
function setupSidebarEvents() {
|
||||
const sidebar = document.getElementById('sidebarNav');
|
||||
const toggle = document.getElementById('sidebarToggle');
|
||||
|
||||
if (!sidebar || !toggle) return;
|
||||
|
||||
// 토글 버튼 클릭
|
||||
toggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
});
|
||||
|
||||
// 카테고리 헤더 클릭
|
||||
sidebar.querySelectorAll('.nav-category-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const category = header.closest('.nav-category');
|
||||
category.classList.toggle('expanded');
|
||||
|
||||
// 상태 저장
|
||||
const expanded = [];
|
||||
sidebar.querySelectorAll('.nav-category.expanded').forEach(cat => {
|
||||
const categoryName = cat.getAttribute('data-category');
|
||||
if (categoryName) expanded.push(categoryName);
|
||||
});
|
||||
localStorage.setItem('sidebarExpanded', JSON.stringify(expanded));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드바 초기화
|
||||
*/
|
||||
async function initSidebar() {
|
||||
// 사이드바 컨테이너가 없으면 생성
|
||||
let container = document.getElementById('sidebar-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'sidebar-container';
|
||||
document.body.prepend(container);
|
||||
}
|
||||
|
||||
if (getUser()) {
|
||||
await loadComponent('sidebar-nav', '#sidebar-container', processSidebarDom);
|
||||
document.body.classList.add('has-sidebar');
|
||||
setupSidebarEvents();
|
||||
}
|
||||
}
|
||||
|
||||
// DOMContentLoaded 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initSidebar);
|
||||
|
||||
export { initSidebar };
|
||||
718
web-ui/js/safety-checklist-manage.js
Normal file
718
web-ui/js/safety-checklist-manage.js
Normal file
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* 안전 체크리스트 관리 페이지 스크립트
|
||||
*
|
||||
* 3가지 유형의 체크리스트 항목을 관리:
|
||||
* 1. 기본 사항 - 항상 표시
|
||||
* 2. 날씨별 - 날씨 조건에 따라 표시
|
||||
* 3. 작업별 - 선택한 작업에 따라 표시
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
import { apiCall } from './api-config.js';
|
||||
|
||||
// 전역 상태
|
||||
let allChecks = [];
|
||||
let weatherConditions = [];
|
||||
let workTypes = [];
|
||||
let tasks = [];
|
||||
let currentTab = 'basic';
|
||||
let editingCheckId = null;
|
||||
|
||||
// 카테고리 정보
|
||||
const CATEGORIES = {
|
||||
PPE: { name: 'PPE (개인보호장비)', icon: '🦺' },
|
||||
EQUIPMENT: { name: 'EQUIPMENT (장비점검)', icon: '🔧' },
|
||||
ENVIRONMENT: { name: 'ENVIRONMENT (작업환경)', icon: '🏗️' },
|
||||
EMERGENCY: { name: 'EMERGENCY (비상대응)', icon: '🚨' },
|
||||
WEATHER: { name: 'WEATHER (날씨)', icon: '🌤️' },
|
||||
TASK: { name: 'TASK (작업)', icon: '📋' }
|
||||
};
|
||||
|
||||
// 날씨 아이콘 매핑
|
||||
const WEATHER_ICONS = {
|
||||
clear: '☀️',
|
||||
rain: '🌧️',
|
||||
snow: '❄️',
|
||||
heat: '🔥',
|
||||
cold: '🥶',
|
||||
wind: '💨',
|
||||
fog: '🌫️',
|
||||
dust: '😷'
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지 초기화
|
||||
*/
|
||||
async function initPage() {
|
||||
try {
|
||||
console.log('📋 안전 체크리스트 관리 페이지 초기화...');
|
||||
|
||||
await Promise.all([
|
||||
loadAllChecks(),
|
||||
loadWeatherConditions(),
|
||||
loadWorkTypes()
|
||||
]);
|
||||
|
||||
renderCurrentTab();
|
||||
console.log('✅ 초기화 완료. 체크항목:', allChecks.length, '개');
|
||||
} catch (error) {
|
||||
console.error('초기화 실패:', error);
|
||||
showToast('데이터를 불러오는데 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// DOMContentLoaded 이벤트
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
|
||||
/**
|
||||
* 모든 안전 체크 항목 로드
|
||||
*/
|
||||
async function loadAllChecks() {
|
||||
try {
|
||||
const response = await apiCall('/tbm/safety-checks');
|
||||
if (response && response.success) {
|
||||
allChecks = response.data || [];
|
||||
console.log('✅ 체크 항목 로드:', allChecks.length, '개');
|
||||
} else {
|
||||
console.warn('체크 항목 응답 실패:', response);
|
||||
allChecks = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('체크 항목 로드 실패:', error);
|
||||
allChecks = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 목록 로드
|
||||
*/
|
||||
async function loadWeatherConditions() {
|
||||
try {
|
||||
const response = await apiCall('/tbm/weather/conditions');
|
||||
if (response && response.success) {
|
||||
weatherConditions = response.data || [];
|
||||
populateWeatherSelects();
|
||||
console.log('✅ 날씨 조건 로드:', weatherConditions.length, '개');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('날씨 조건 로드 실패:', error);
|
||||
weatherConditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정(작업 유형) 목록 로드
|
||||
*/
|
||||
async function loadWorkTypes() {
|
||||
try {
|
||||
const response = await apiCall('/daily-work-reports/work-types');
|
||||
if (response && response.success) {
|
||||
workTypes = response.data || [];
|
||||
populateWorkTypeSelects();
|
||||
console.log('✅ 공정 목록 로드:', workTypes.length, '개');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공정 목록 로드 실패:', error);
|
||||
workTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 셀렉트 박스 채우기
|
||||
*/
|
||||
function populateWeatherSelects() {
|
||||
const filterSelect = document.getElementById('weatherFilter');
|
||||
const modalSelect = document.getElementById('weatherCondition');
|
||||
|
||||
const options = weatherConditions.map(wc =>
|
||||
`<option value="${wc.condition_code}">${WEATHER_ICONS[wc.condition_code] || ''} ${wc.condition_name}</option>`
|
||||
).join('');
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = `<option value="">모든 날씨 조건</option>${options}`;
|
||||
}
|
||||
|
||||
if (modalSelect) {
|
||||
modalSelect.innerHTML = options || '<option value="">날씨 조건 없음</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 셀렉트 박스 채우기
|
||||
*/
|
||||
function populateWorkTypeSelects() {
|
||||
const filterSelect = document.getElementById('workTypeFilter');
|
||||
const modalSelect = document.getElementById('modalWorkType');
|
||||
|
||||
const options = workTypes.map(wt =>
|
||||
`<option value="${wt.work_type_id}">${wt.work_type_name}</option>`
|
||||
).join('');
|
||||
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
|
||||
}
|
||||
|
||||
if (modalSelect) {
|
||||
modalSelect.innerHTML = `<option value="">공정 선택</option>${options}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 전환
|
||||
*/
|
||||
function switchTab(tabName) {
|
||||
currentTab = tabName;
|
||||
|
||||
// 탭 버튼 상태 업데이트
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// 탭 콘텐츠 표시/숨김
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.toggle('active', content.id === `${tabName}Tab`);
|
||||
});
|
||||
|
||||
renderCurrentTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 탭 렌더링
|
||||
*/
|
||||
function renderCurrentTab() {
|
||||
switch (currentTab) {
|
||||
case 'basic':
|
||||
renderBasicChecks();
|
||||
break;
|
||||
case 'weather':
|
||||
renderWeatherChecks();
|
||||
break;
|
||||
case 'task':
|
||||
renderTaskChecks();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 체크 항목 렌더링
|
||||
*/
|
||||
function renderBasicChecks() {
|
||||
const container = document.getElementById('basicChecklistContainer');
|
||||
const basicChecks = allChecks.filter(c => c.check_type === 'basic');
|
||||
|
||||
console.log('기본 체크항목:', basicChecks.length, '개');
|
||||
|
||||
if (basicChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('기본 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = groupByCategory(basicChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([category, items]) =>
|
||||
renderChecklistGroup(category, items)
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨별 체크 항목 렌더링
|
||||
*/
|
||||
function renderWeatherChecks() {
|
||||
const container = document.getElementById('weatherChecklistContainer');
|
||||
const filterValue = document.getElementById('weatherFilter')?.value;
|
||||
|
||||
let weatherChecks = allChecks.filter(c => c.check_type === 'weather');
|
||||
|
||||
if (filterValue) {
|
||||
weatherChecks = weatherChecks.filter(c => c.weather_condition === filterValue);
|
||||
}
|
||||
|
||||
if (weatherChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('날씨별 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 날씨 조건별로 그룹화
|
||||
const grouped = groupByWeather(weatherChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([condition, items]) => {
|
||||
const conditionInfo = weatherConditions.find(wc => wc.condition_code === condition);
|
||||
const icon = WEATHER_ICONS[condition] || '🌤️';
|
||||
const name = conditionInfo?.condition_name || condition;
|
||||
|
||||
return renderChecklistGroup(`${icon} ${name}`, items, condition);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업별 체크 항목 렌더링
|
||||
*/
|
||||
function renderTaskChecks() {
|
||||
const container = document.getElementById('taskChecklistContainer');
|
||||
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
||||
const taskId = document.getElementById('taskFilter')?.value;
|
||||
|
||||
let taskChecks = allChecks.filter(c => c.check_type === 'task');
|
||||
|
||||
if (taskId) {
|
||||
taskChecks = taskChecks.filter(c => c.task_id == taskId);
|
||||
} else if (workTypeId && tasks.length > 0) {
|
||||
const workTypeTasks = tasks.filter(t => t.work_type_id == workTypeId);
|
||||
const taskIds = workTypeTasks.map(t => t.task_id);
|
||||
taskChecks = taskChecks.filter(c => taskIds.includes(c.task_id));
|
||||
}
|
||||
|
||||
if (taskChecks.length === 0) {
|
||||
container.innerHTML = renderEmptyState('작업별 체크 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업별로 그룹화
|
||||
const grouped = groupByTask(taskChecks);
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([taskId, items]) => {
|
||||
const task = tasks.find(t => t.task_id == taskId);
|
||||
const taskName = task?.task_name || `작업 ${taskId}`;
|
||||
|
||||
return renderChecklistGroup(`📋 ${taskName}`, items, null, taskId);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 그룹화
|
||||
*/
|
||||
function groupByCategory(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const category = check.check_category || 'OTHER';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건별 그룹화
|
||||
*/
|
||||
function groupByWeather(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const condition = check.weather_condition || 'other';
|
||||
if (!acc[condition]) acc[condition] = [];
|
||||
acc[condition].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업별 그룹화
|
||||
*/
|
||||
function groupByTask(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const taskId = check.task_id || 0;
|
||||
if (!acc[taskId]) acc[taskId] = [];
|
||||
acc[taskId].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 그룹 렌더링
|
||||
*/
|
||||
function renderChecklistGroup(title, items, weatherCondition = null, taskId = null) {
|
||||
const categoryInfo = CATEGORIES[title] || { name: title, icon: '' };
|
||||
const displayTitle = categoryInfo.name !== title ? categoryInfo.name : title;
|
||||
const icon = categoryInfo.icon || '';
|
||||
|
||||
// 표시 순서로 정렬
|
||||
items.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
|
||||
|
||||
return `
|
||||
<div class="checklist-group">
|
||||
<div class="group-header">
|
||||
<div class="group-title">
|
||||
<span class="group-icon">${icon}</span>
|
||||
<span>${displayTitle}</span>
|
||||
</div>
|
||||
<span class="group-count">${items.length}개</span>
|
||||
</div>
|
||||
<div class="checklist-items">
|
||||
${items.map(item => renderChecklistItem(item)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 렌더링
|
||||
*/
|
||||
function renderChecklistItem(item) {
|
||||
const requiredBadge = item.is_required
|
||||
? '<span class="item-badge badge-required">필수</span>'
|
||||
: '<span class="item-badge badge-optional">선택</span>';
|
||||
|
||||
return `
|
||||
<div class="checklist-item" data-check-id="${item.check_id}">
|
||||
<div class="item-info">
|
||||
<div class="item-name">${item.check_item}</div>
|
||||
<div class="item-meta">
|
||||
${requiredBadge}
|
||||
${item.description ? `<span>${item.description}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn-icon btn-edit" onclick="openEditModal(${item.check_id})" title="수정">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon btn-delete" onclick="confirmDelete(${item.check_id})" title="삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 상태 렌더링
|
||||
*/
|
||||
function renderEmptyState(message) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📋</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 필터 변경
|
||||
*/
|
||||
function filterByWeather() {
|
||||
renderWeatherChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 필터 변경
|
||||
*/
|
||||
async function filterByWorkType() {
|
||||
const workTypeId = document.getElementById('workTypeFilter')?.value;
|
||||
const taskSelect = document.getElementById('taskFilter');
|
||||
|
||||
// workTypeId가 없거나 빈 문자열이면 early return
|
||||
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
|
||||
if (taskSelect) {
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>';
|
||||
}
|
||||
tasks = [];
|
||||
renderTaskChecks();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
||||
if (response && response.success) {
|
||||
tasks = response.data || [];
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
tasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 실패:', error);
|
||||
tasks = [];
|
||||
}
|
||||
|
||||
renderTaskChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 필터 변경
|
||||
*/
|
||||
function filterByTask() {
|
||||
renderTaskChecks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달의 작업 목록 로드
|
||||
*/
|
||||
async function loadModalTasks() {
|
||||
const workTypeId = document.getElementById('modalWorkType')?.value;
|
||||
const taskSelect = document.getElementById('modalTask');
|
||||
|
||||
// workTypeId가 없거나 빈 문자열이면 early return
|
||||
if (!workTypeId || workTypeId === '' || workTypeId === 'undefined') {
|
||||
if (taskSelect) {
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/tasks/work-type/${workTypeId}`);
|
||||
if (response && response.success) {
|
||||
const modalTasks = response.data || [];
|
||||
taskSelect.innerHTML = '<option value="">작업 선택</option>' +
|
||||
modalTasks.map(t => `<option value="${t.task_id}">${t.task_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('작업 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 토글
|
||||
*/
|
||||
function toggleConditionalFields() {
|
||||
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
|
||||
|
||||
document.getElementById('basicFields').classList.toggle('show', checkType === 'basic');
|
||||
document.getElementById('weatherFields').classList.toggle('show', checkType === 'weather');
|
||||
document.getElementById('taskFields').classList.toggle('show', checkType === 'task');
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 모달 열기
|
||||
*/
|
||||
function openAddModal() {
|
||||
editingCheckId = null;
|
||||
document.getElementById('modalTitle').textContent = '체크 항목 추가';
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('checkForm').reset();
|
||||
document.getElementById('checkId').value = '';
|
||||
|
||||
// 현재 탭에 맞는 유형 선택
|
||||
const typeRadio = document.querySelector(`input[name="checkType"][value="${currentTab}"]`);
|
||||
if (typeRadio) {
|
||||
typeRadio.checked = true;
|
||||
}
|
||||
|
||||
toggleConditionalFields();
|
||||
showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 모달 열기
|
||||
*/
|
||||
async function openEditModal(checkId) {
|
||||
editingCheckId = checkId;
|
||||
const check = allChecks.find(c => c.check_id === checkId);
|
||||
|
||||
if (!check) {
|
||||
showToast('항목을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('modalTitle').textContent = '체크 항목 수정';
|
||||
document.getElementById('checkId').value = checkId;
|
||||
|
||||
// 유형 선택
|
||||
const typeRadio = document.querySelector(`input[name="checkType"][value="${check.check_type}"]`);
|
||||
if (typeRadio) {
|
||||
typeRadio.checked = true;
|
||||
}
|
||||
|
||||
toggleConditionalFields();
|
||||
|
||||
// 카테고리
|
||||
if (check.check_type === 'basic') {
|
||||
document.getElementById('checkCategory').value = check.check_category || 'PPE';
|
||||
}
|
||||
|
||||
// 날씨 조건
|
||||
if (check.check_type === 'weather') {
|
||||
document.getElementById('weatherCondition').value = check.weather_condition || '';
|
||||
}
|
||||
|
||||
// 작업
|
||||
if (check.check_type === 'task' && check.task_id) {
|
||||
// 먼저 공정 찾기 (task를 통해)
|
||||
const task = tasks.find(t => t.task_id === check.task_id);
|
||||
if (task) {
|
||||
document.getElementById('modalWorkType').value = task.work_type_id;
|
||||
await loadModalTasks();
|
||||
document.getElementById('modalTask').value = check.task_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드
|
||||
document.getElementById('checkItem').value = check.check_item || '';
|
||||
document.getElementById('checkDescription').value = check.description || '';
|
||||
document.getElementById('isRequired').checked = check.is_required === 1 || check.is_required === true;
|
||||
document.getElementById('displayOrder').value = check.display_order || 0;
|
||||
|
||||
showModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 표시
|
||||
*/
|
||||
function showModal() {
|
||||
document.getElementById('checkModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 닫기
|
||||
*/
|
||||
function closeModal() {
|
||||
document.getElementById('checkModal').style.display = 'none';
|
||||
editingCheckId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 항목 저장
|
||||
*/
|
||||
async function saveCheck() {
|
||||
const checkType = document.querySelector('input[name="checkType"]:checked')?.value;
|
||||
const checkItem = document.getElementById('checkItem').value.trim();
|
||||
|
||||
if (!checkItem) {
|
||||
showToast('체크 항목을 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
check_type: checkType,
|
||||
check_item: checkItem,
|
||||
description: document.getElementById('checkDescription').value.trim() || null,
|
||||
is_required: document.getElementById('isRequired').checked,
|
||||
display_order: parseInt(document.getElementById('displayOrder').value) || 0
|
||||
};
|
||||
|
||||
// 유형별 추가 데이터
|
||||
switch (checkType) {
|
||||
case 'basic':
|
||||
data.check_category = document.getElementById('checkCategory').value;
|
||||
break;
|
||||
case 'weather':
|
||||
data.check_category = 'WEATHER';
|
||||
data.weather_condition = document.getElementById('weatherCondition').value;
|
||||
if (!data.weather_condition) {
|
||||
showToast('날씨 조건을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case 'task':
|
||||
data.check_category = 'TASK';
|
||||
data.task_id = document.getElementById('modalTask').value;
|
||||
if (!data.task_id) {
|
||||
showToast('작업을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingCheckId) {
|
||||
// 수정
|
||||
response = await apiCall(`/tbm/safety-checks/${editingCheckId}`, 'PUT', data);
|
||||
} else {
|
||||
// 추가
|
||||
response = await apiCall('/tbm/safety-checks', 'POST', data);
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
showToast(editingCheckId ? '항목이 수정되었습니다.' : '항목이 추가되었습니다.', 'success');
|
||||
closeModal();
|
||||
await loadAllChecks();
|
||||
renderCurrentTab();
|
||||
} else {
|
||||
showToast(response?.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 확인
|
||||
*/
|
||||
function confirmDelete(checkId) {
|
||||
const check = allChecks.find(c => c.check_id === checkId);
|
||||
|
||||
if (!check) {
|
||||
showToast('항목을 찾을 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`"${check.check_item}" 항목을 삭제하시겠습니까?`)) {
|
||||
deleteCheck(checkId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크 항목 삭제
|
||||
*/
|
||||
async function deleteCheck(checkId) {
|
||||
try {
|
||||
const response = await apiCall(`/tbm/safety-checks/${checkId}`, 'DELETE');
|
||||
|
||||
if (response && response.success) {
|
||||
showToast('항목이 삭제되었습니다.', 'success');
|
||||
await loadAllChecks();
|
||||
renderCurrentTab();
|
||||
} else {
|
||||
showToast(response?.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 메시지 표시
|
||||
*/
|
||||
function showToast(message, type = 'info') {
|
||||
// 기존 토스트 제거
|
||||
const existingToast = document.querySelector('.toast-message');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-message toast-${type}`;
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
${type === 'success' ? 'background: #10b981; color: white;' : ''}
|
||||
${type === 'error' ? 'background: #ef4444; color: white;' : ''}
|
||||
${type === 'info' ? 'background: #3b82f6; color: white;' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOut 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('checkModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML onclick에서 호출할 수 있도록 전역에 노출
|
||||
window.switchTab = switchTab;
|
||||
window.openAddModal = openAddModal;
|
||||
window.openEditModal = openEditModal;
|
||||
window.closeModal = closeModal;
|
||||
window.saveCheck = saveCheck;
|
||||
window.confirmDelete = confirmDelete;
|
||||
window.filterByWeather = filterByWeather;
|
||||
window.filterByWorkType = filterByWorkType;
|
||||
window.filterByTask = filterByTask;
|
||||
window.loadModalTasks = loadModalTasks;
|
||||
window.toggleConditionalFields = toggleConditionalFields;
|
||||
@@ -432,7 +432,7 @@ async function confirmReject() {
|
||||
* 안전교육 진행 페이지로 이동
|
||||
*/
|
||||
function startTraining(requestId) {
|
||||
window.location.href = `/pages/admin/safety-training-conduct.html?request_id=${requestId}`;
|
||||
window.location.href = `/pages/safety/training-conduct.html?request_id=${requestId}`;
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
|
||||
@@ -98,7 +98,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!requestId) {
|
||||
showToast('출입 신청 ID가 없습니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ async function loadRequestInfo() {
|
||||
if (requestData.status !== 'approved') {
|
||||
showToast('이미 처리되었거나 승인되지 않은 신청입니다.', 'error');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
@@ -518,7 +518,7 @@ async function completeTraining() {
|
||||
if (successCount === savedSignatures.length) {
|
||||
showToast(`${successCount}명의 안전교육이 완료되었습니다.`, 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}, 1500);
|
||||
} else if (successCount > 0) {
|
||||
showToast(`${successCount}/${savedSignatures.length}명의 교육만 저장되었습니다.`, 'warning');
|
||||
@@ -540,7 +540,7 @@ function goBack() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/admin/safety-management.html';
|
||||
window.location.href = '/pages/safety/management.html';
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
|
||||
455
web-ui/js/tbm.js
455
web-ui/js/tbm.js
@@ -26,6 +26,11 @@ let selectedWorkplaceName = '';
|
||||
let isBulkMode = false; // 일괄 설정 모드인지 여부
|
||||
let bulkSelectedWorkers = new Set(); // 일괄 설정에서 선택된 작업자 인덱스
|
||||
|
||||
// TBM 관리 탭용 변수
|
||||
let loadedDaysCount = 7; // 처음에 로드할 일수
|
||||
let dateGroupedSessions = {}; // 날짜별로 그룹화된 세션
|
||||
let allLoadedSessions = []; // 전체 로드된 세션
|
||||
|
||||
// ==================== 유틸리티 함수 ====================
|
||||
|
||||
/**
|
||||
@@ -87,8 +92,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 오늘 날짜 설정 (서울 시간대 기준)
|
||||
const today = getTodayKST();
|
||||
document.getElementById('tbmDate').value = today;
|
||||
document.getElementById('sessionDate').value = today;
|
||||
const tbmDateEl = document.getElementById('tbmDate');
|
||||
const sessionDateEl = document.getElementById('sessionDate');
|
||||
if (tbmDateEl) tbmDateEl.value = today;
|
||||
if (sessionDateEl) sessionDateEl.value = today;
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
@@ -100,22 +107,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
function setupEventListeners() {
|
||||
const tbmDateInput = document.getElementById('tbmDate');
|
||||
if (tbmDateInput) {
|
||||
tbmDateInput.addEventListener('change', () => {
|
||||
const date = tbmDateInput.value;
|
||||
loadTbmSessionsByDate(date);
|
||||
});
|
||||
}
|
||||
// 날짜 선택기 제거됨 - 날짜별 그룹 뷰 사용
|
||||
}
|
||||
|
||||
// 초기 데이터 로드
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
// 현재 로그인한 사용자 정보 가져오기
|
||||
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}');
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
currentUser = userInfo;
|
||||
console.log('👤 로그인 사용자:', currentUser);
|
||||
console.log('👤 로그인 사용자:', currentUser, 'worker_id:', currentUser?.worker_id);
|
||||
|
||||
// 작업자 목록 로드
|
||||
const workersResponse = await window.apiCall('/workers?limit=1000');
|
||||
@@ -202,12 +203,7 @@ function switchTbmTab(tabName) {
|
||||
if (tabName === 'tbm-input') {
|
||||
loadTodayOnlyTbm();
|
||||
} else if (tabName === 'tbm-manage') {
|
||||
const tbmDate = document.getElementById('tbmDate');
|
||||
if (tbmDate && tbmDate.value) {
|
||||
loadTbmSessionsByDate(tbmDate.value);
|
||||
} else {
|
||||
loadTodayTbm();
|
||||
}
|
||||
loadRecentTbmGroupedByDate();
|
||||
}
|
||||
}
|
||||
window.switchTbmTab = switchTbmTab;
|
||||
@@ -268,36 +264,175 @@ function displayTodayTbmSessions() {
|
||||
|
||||
// ==================== TBM 관리 탭 ====================
|
||||
|
||||
// 오늘 TBM 로드 (TBM 관리 탭용)
|
||||
// 오늘 TBM 로드 (TBM 관리 탭용) - 레거시 호환
|
||||
async function loadTodayTbm() {
|
||||
const today = getTodayKST();
|
||||
document.getElementById('tbmDate').value = today;
|
||||
await loadTbmSessionsByDate(today);
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
window.loadTodayTbm = loadTodayTbm;
|
||||
|
||||
// 전체 TBM 로드
|
||||
// 전체 TBM 로드 - 레거시 호환
|
||||
async function loadAllTbm() {
|
||||
try {
|
||||
const response = await window.apiCall('/tbm/sessions');
|
||||
|
||||
if (response && response.success) {
|
||||
allSessions = response.data || [];
|
||||
document.getElementById('tbmDate').value = '';
|
||||
displayTbmSessions();
|
||||
} else {
|
||||
allSessions = [];
|
||||
displayTbmSessions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 전체 TBM 조회 오류:', error);
|
||||
showToast('전체 TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
allSessions = [];
|
||||
displayTbmSessions();
|
||||
}
|
||||
loadedDaysCount = 30; // 30일치 로드
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
window.loadAllTbm = loadAllTbm;
|
||||
|
||||
// ==================== 날짜별 그룹 TBM 로드 (새 기능) ====================
|
||||
|
||||
/**
|
||||
* 사용자가 Admin인지 확인
|
||||
*/
|
||||
function isAdminUser() {
|
||||
if (!currentUser) return false;
|
||||
return currentUser.role === 'Admin' || currentUser.role === 'System Admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 TBM을 날짜별로 그룹화하여 로드
|
||||
*/
|
||||
async function loadRecentTbmGroupedByDate() {
|
||||
try {
|
||||
const today = new Date();
|
||||
const dates = [];
|
||||
|
||||
// 최근 N일의 날짜 생성
|
||||
for (let i = 0; i < loadedDaysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
dates.push(dateStr);
|
||||
}
|
||||
|
||||
// 각 날짜의 TBM 로드
|
||||
dateGroupedSessions = {};
|
||||
allLoadedSessions = [];
|
||||
|
||||
const promises = dates.map(date => window.apiCall(`/tbm/sessions/date/${date}`));
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
results.forEach((response, index) => {
|
||||
const date = dates[index];
|
||||
if (response && response.success && response.data && response.data.length > 0) {
|
||||
let sessions = response.data;
|
||||
|
||||
// admin이 아니면 본인이 작성한 TBM만 필터링
|
||||
if (!isAdminUser()) {
|
||||
const userId = currentUser?.user_id;
|
||||
const workerId = currentUser?.worker_id;
|
||||
sessions = sessions.filter(s => {
|
||||
return s.created_by === userId ||
|
||||
s.leader_id === workerId ||
|
||||
s.created_by_name === currentUser?.name;
|
||||
});
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
dateGroupedSessions[date] = sessions;
|
||||
allLoadedSessions = allLoadedSessions.concat(sessions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 날짜별 그룹 표시
|
||||
displayTbmGroupedByDate();
|
||||
|
||||
// 뷰 모드 표시
|
||||
updateViewModeIndicator();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 날짜별 로드 오류:', error);
|
||||
showToast('TBM을 불러오는 중 오류가 발생했습니다.', 'error');
|
||||
dateGroupedSessions = {};
|
||||
displayTbmGroupedByDate();
|
||||
}
|
||||
}
|
||||
window.loadRecentTbmGroupedByDate = loadRecentTbmGroupedByDate;
|
||||
|
||||
/**
|
||||
* 뷰 모드 표시 업데이트
|
||||
*/
|
||||
function updateViewModeIndicator() {
|
||||
const indicator = document.getElementById('viewModeIndicator');
|
||||
const text = document.getElementById('viewModeText');
|
||||
|
||||
if (indicator && text) {
|
||||
if (isAdminUser()) {
|
||||
indicator.style.display = 'none'; // Admin은 표시 안 함 (전체가 기본)
|
||||
} else {
|
||||
indicator.style.display = 'inline-flex';
|
||||
text.textContent = '내 TBM';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜별 그룹으로 TBM 표시
|
||||
*/
|
||||
function displayTbmGroupedByDate() {
|
||||
const container = document.getElementById('tbmDateGroupsContainer');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const totalSessionsEl = document.getElementById('totalSessions');
|
||||
const completedSessionsEl = document.getElementById('completedSessions');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// 날짜별로 정렬 (최신순)
|
||||
const sortedDates = Object.keys(dateGroupedSessions).sort((a, b) => new Date(b) - new Date(a));
|
||||
|
||||
if (sortedDates.length === 0 || allLoadedSessions.length === 0) {
|
||||
container.innerHTML = '';
|
||||
if (emptyState) emptyState.style.display = 'flex';
|
||||
if (totalSessionsEl) totalSessionsEl.textContent = '0';
|
||||
if (completedSessionsEl) completedSessionsEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyState) emptyState.style.display = 'none';
|
||||
|
||||
// 통계 업데이트
|
||||
const completedCount = allLoadedSessions.filter(s => s.status === 'completed').length;
|
||||
if (totalSessionsEl) totalSessionsEl.textContent = allLoadedSessions.length;
|
||||
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
|
||||
|
||||
// 날짜별 그룹 HTML 생성
|
||||
const today = getTodayKST();
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
|
||||
container.innerHTML = sortedDates.map(date => {
|
||||
const sessions = dateGroupedSessions[date];
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
const dayName = dayNames[dateObj.getDay()];
|
||||
const isToday = date === today;
|
||||
|
||||
// 날짜 포맷팅 (YYYY-MM-DD → MM월 DD일)
|
||||
const [year, month, day] = date.split('-');
|
||||
const displayDate = `${parseInt(month)}월 ${parseInt(day)}일`;
|
||||
|
||||
return `
|
||||
<div class="date-group">
|
||||
<div class="date-group-header ${isToday ? 'today' : ''}">
|
||||
<span class="date-group-date">${displayDate}</span>
|
||||
<span class="date-group-day">${dayName}요일${isToday ? ' (오늘)' : ''}</span>
|
||||
<span class="date-group-count">${sessions.length}건</span>
|
||||
</div>
|
||||
<div class="date-group-grid">
|
||||
${sessions.map(session => createSessionCard(session)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 더 많은 날짜 로드
|
||||
*/
|
||||
async function loadMoreTbmDays() {
|
||||
loadedDaysCount += 7; // 7일씩 추가
|
||||
await loadRecentTbmGroupedByDate();
|
||||
showToast(`최근 ${loadedDaysCount}일의 TBM을 로드했습니다.`, 'success');
|
||||
}
|
||||
window.loadMoreTbmDays = loadMoreTbmDays;
|
||||
|
||||
// 특정 날짜의 TBM 세션 목록 로드
|
||||
async function loadTbmSessionsByDate(date) {
|
||||
try {
|
||||
@@ -318,28 +453,22 @@ async function loadTbmSessionsByDate(date) {
|
||||
}
|
||||
}
|
||||
|
||||
// TBM 세션 목록 표시 (관리 탭용)
|
||||
// TBM 세션 목록 표시 (관리 탭용) - 레거시 호환 (날짜별 그룹 뷰 사용)
|
||||
function displayTbmSessions() {
|
||||
const grid = document.getElementById('tbmSessionsGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const totalSessionsEl = document.getElementById('totalSessions');
|
||||
const completedSessionsEl = document.getElementById('completedSessions');
|
||||
|
||||
if (allSessions.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
emptyState.style.display = 'flex';
|
||||
totalSessionsEl.textContent = '0';
|
||||
completedSessionsEl.textContent = '0';
|
||||
return;
|
||||
// 새 날짜별 그룹 뷰로 리다이렉트
|
||||
if (allSessions.length > 0) {
|
||||
// allSessions를 날짜별로 그룹화
|
||||
dateGroupedSessions = {};
|
||||
allSessions.forEach(session => {
|
||||
const date = formatDate(session.session_date);
|
||||
if (!dateGroupedSessions[date]) {
|
||||
dateGroupedSessions[date] = [];
|
||||
}
|
||||
dateGroupedSessions[date].push(session);
|
||||
});
|
||||
allLoadedSessions = allSessions;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
const completedCount = allSessions.filter(s => s.status === 'completed').length;
|
||||
totalSessionsEl.textContent = allSessions.length;
|
||||
completedSessionsEl.textContent = completedCount;
|
||||
|
||||
grid.innerHTML = allSessions.map(session => createSessionCard(session)).join('');
|
||||
displayTbmGroupedByDate();
|
||||
}
|
||||
|
||||
// TBM 세션 카드 생성 (공통)
|
||||
@@ -432,12 +561,12 @@ function openNewTbmModal() {
|
||||
if (currentUser && currentUser.worker_id) {
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
document.getElementById('leaderName').value = `${worker.worker_name} (${worker.job_type || ''})`;
|
||||
document.getElementById('leaderName').value = worker.worker_name;
|
||||
document.getElementById('leaderId').value = worker.worker_id;
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
// 관리자: 관리자로 표시
|
||||
document.getElementById('leaderName').value = `${currentUser.name} (관리자)`;
|
||||
// 관리자: 이름만 표시
|
||||
document.getElementById('leaderName').value = currentUser.name;
|
||||
document.getElementById('leaderId').value = '';
|
||||
}
|
||||
|
||||
@@ -459,7 +588,8 @@ function populateLeaderSelect() {
|
||||
// 작업자와 연결된 경우: 자동으로 선택하고 비활성화
|
||||
const worker = allWorkers.find(w => w.worker_id === currentUser.worker_id);
|
||||
if (worker) {
|
||||
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name} (${worker.job_type || ''})</option>`;
|
||||
const jobTypeText = worker.job_type ? ` (${worker.job_type})` : '';
|
||||
leaderSelect.innerHTML = `<option value="${worker.worker_id}" selected>${worker.worker_name}${jobTypeText}</option>`;
|
||||
leaderSelect.disabled = true;
|
||||
console.log('✅ 입력자 자동 설정:', worker.worker_name);
|
||||
} else {
|
||||
@@ -474,9 +604,10 @@ function populateLeaderSelect() {
|
||||
);
|
||||
|
||||
leaderSelect.innerHTML = '<option value="">입력자 선택...</option>' +
|
||||
leaders.map(w => `
|
||||
<option value="${w.worker_id}">${w.worker_name} (${w.job_type || ''})</option>
|
||||
`).join('');
|
||||
leaders.map(w => {
|
||||
const jobTypeText = w.job_type ? ` (${w.job_type})` : '';
|
||||
return `<option value="${w.worker_id}">${w.worker_name}${jobTypeText}</option>`;
|
||||
}).join('');
|
||||
leaderSelect.disabled = false;
|
||||
console.log('✅ 관리자: 입력자 선택 가능');
|
||||
}
|
||||
@@ -1856,65 +1987,92 @@ async function saveTeamComposition() {
|
||||
}
|
||||
window.saveTeamComposition = saveTeamComposition;
|
||||
|
||||
// 안전 체크 모달 열기
|
||||
// 안전 체크 모달 열기 (기본 + 날씨별 + 작업별)
|
||||
async function openSafetyCheckModal(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// 기존 안전 체크 기록 로드
|
||||
try {
|
||||
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety`);
|
||||
const existingRecords = response && response.success ? response.data : [];
|
||||
// 필터링된 체크리스트 조회 (기본 + 날씨 + 작업별)
|
||||
const response = await window.apiCall(`/tbm/sessions/${sessionId}/safety-checks/filtered`);
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = {};
|
||||
allSafetyChecks.forEach(check => {
|
||||
if (!grouped[check.check_category]) {
|
||||
grouped[check.check_category] = [];
|
||||
}
|
||||
if (!response || !response.success) {
|
||||
throw new Error(response?.message || '체크리스트를 불러올 수 없습니다.');
|
||||
}
|
||||
|
||||
const existingRecord = existingRecords.find(r => r.check_id === check.check_id);
|
||||
grouped[check.check_category].push({
|
||||
...check,
|
||||
is_checked: existingRecord ? existingRecord.is_checked : false,
|
||||
notes: existingRecord ? existingRecord.notes : ''
|
||||
});
|
||||
});
|
||||
const { basic, weather, task, weatherInfo } = response.data;
|
||||
|
||||
const categoryNames = {
|
||||
'PPE': '개인 보호 장비',
|
||||
'EQUIPMENT': '장비 점검',
|
||||
'ENVIRONMENT': '작업 환경',
|
||||
'EMERGENCY': '비상 대응'
|
||||
'EMERGENCY': '비상 대응',
|
||||
'WEATHER': '날씨',
|
||||
'TASK': '작업'
|
||||
};
|
||||
|
||||
const weatherIcons = {
|
||||
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
|
||||
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
|
||||
};
|
||||
|
||||
const container = document.getElementById('safetyChecklistContainer');
|
||||
container.innerHTML = Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 600; font-size: 0.9375rem; color: #374151; padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem; margin-bottom: 0.5rem;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${grouped[category].map(check => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
|
||||
<input type="checkbox"
|
||||
class="safety-check"
|
||||
data-check-id="${check.check_id}"
|
||||
${check.is_checked ? 'checked' : ''}
|
||||
${check.is_required ? 'required' : ''}
|
||||
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">
|
||||
${check.check_item}
|
||||
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
|
||||
</div>
|
||||
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('');
|
||||
let html = '';
|
||||
|
||||
// 1. 기본 사항 섹션
|
||||
if (basic && basic.length > 0) {
|
||||
const basicGrouped = groupChecksByCategory(basic);
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>📋</span> 기본 안전 사항 (${basic.length}개)
|
||||
</div>
|
||||
${renderCategoryGroups(basicGrouped, categoryNames)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 2. 날씨별 섹션
|
||||
if (weather && weather.length > 0) {
|
||||
const weatherConditions = weatherInfo?.weather_conditions || [];
|
||||
const conditionNames = weatherConditions.map(c => {
|
||||
const icon = weatherIcons[c] || '🌤️';
|
||||
return `${icon} ${getWeatherConditionName(c)}`;
|
||||
}).join(', ') || '맑음';
|
||||
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f59e0b, #d97706); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>🌤️</span> 오늘 날씨 관련 (${conditionNames}) - ${weather.length}개
|
||||
</div>
|
||||
${renderCheckItems(weather)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 3. 작업별 섹션
|
||||
if (task && task.length > 0) {
|
||||
const taskGrouped = groupChecksByTask(task);
|
||||
html += `
|
||||
<div class="safety-section" style="margin-bottom: 1.5rem;">
|
||||
<div style="font-weight: 700; font-size: 1rem; color: #1f2937; padding: 0.75rem 1rem; background: linear-gradient(135deg, #10b981, #059669); color: white; border-radius: 0.5rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>🔧</span> 작업별 안전 사항 - ${task.length}개
|
||||
</div>
|
||||
${renderTaskGroups(taskGrouped)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 체크리스트가 없는 경우
|
||||
if ((!basic || basic.length === 0) && (!weather || weather.length === 0) && (!task || task.length === 0)) {
|
||||
html = `
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div style="font-size: 2rem; margin-bottom: 0.5rem;">📋</div>
|
||||
<p>등록된 안전 체크 항목이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
document.getElementById('safetyModal').style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
@@ -1925,6 +2083,83 @@ async function openSafetyCheckModal(sessionId) {
|
||||
}
|
||||
window.openSafetyCheckModal = openSafetyCheckModal;
|
||||
|
||||
// 카테고리별 그룹화
|
||||
function groupChecksByCategory(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const category = check.check_category || 'OTHER';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// 작업별 그룹화
|
||||
function groupChecksByTask(checks) {
|
||||
return checks.reduce((acc, check) => {
|
||||
const taskId = check.task_id || 0;
|
||||
const taskName = check.task_name || '기타 작업';
|
||||
if (!acc[taskId]) acc[taskId] = { name: taskName, items: [] };
|
||||
acc[taskId].items.push(check);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// 날씨 조건명 반환
|
||||
function getWeatherConditionName(code) {
|
||||
const names = {
|
||||
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
|
||||
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
|
||||
};
|
||||
return names[code] || code;
|
||||
}
|
||||
|
||||
// 카테고리 그룹 렌더링
|
||||
function renderCategoryGroups(grouped, categoryNames) {
|
||||
return Object.keys(grouped).map(category => `
|
||||
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
|
||||
${categoryNames[category] || category}
|
||||
</div>
|
||||
${renderCheckItems(grouped[category])}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 작업 그룹 렌더링
|
||||
function renderTaskGroups(grouped) {
|
||||
return Object.values(grouped).map(group => `
|
||||
<div style="margin-bottom: 1rem; background: white; border-radius: 0.5rem; border: 1px solid #e5e7eb; overflow: hidden;">
|
||||
<div style="font-weight: 600; font-size: 0.875rem; color: #374151; padding: 0.625rem 0.875rem; background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
|
||||
📋 ${group.name}
|
||||
</div>
|
||||
${renderCheckItems(group.items)}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 체크 항목 렌더링
|
||||
function renderCheckItems(items) {
|
||||
return items.map(check => `
|
||||
<div style="padding: 0.75rem; border-bottom: 1px solid #f3f4f6;">
|
||||
<label style="display: flex; align-items: start; gap: 0.75rem; cursor: pointer;">
|
||||
<input type="checkbox"
|
||||
class="safety-check"
|
||||
data-check-id="${check.check_id}"
|
||||
${check.is_checked ? 'checked' : ''}
|
||||
${check.is_required ? 'required' : ''}
|
||||
style="width: 18px; height: 18px; margin-top: 0.125rem; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; color: #111827;">
|
||||
${check.check_item}
|
||||
${check.is_required ? '<span style="color: #ef4444;">*</span>' : ''}
|
||||
</div>
|
||||
${check.description ? `<div style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">${check.description}</div>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 안전 체크 모달 닫기
|
||||
function closeSafetyModal() {
|
||||
document.getElementById('safetyModal').style.display = 'none';
|
||||
|
||||
221
web-ui/js/work-issue-list.js
Normal file
221
web-ui/js/work-issue-list.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 문제 신고 목록 페이지 JavaScript
|
||||
*/
|
||||
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 상태 한글 변환
|
||||
const STATUS_LABELS = {
|
||||
reported: '신고',
|
||||
received: '접수',
|
||||
in_progress: '처리중',
|
||||
completed: '완료',
|
||||
closed: '종료'
|
||||
};
|
||||
|
||||
// 유형 한글 변환
|
||||
const TYPE_LABELS = {
|
||||
nonconformity: '부적합',
|
||||
safety: '안전'
|
||||
};
|
||||
|
||||
// DOM 요소
|
||||
let issueList;
|
||||
let filterStatus, filterType, filterStartDate, filterEndDate;
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
issueList = document.getElementById('issueList');
|
||||
filterStatus = document.getElementById('filterStatus');
|
||||
filterType = document.getElementById('filterType');
|
||||
filterStartDate = document.getElementById('filterStartDate');
|
||||
filterEndDate = document.getElementById('filterEndDate');
|
||||
|
||||
// 필터 이벤트 리스너
|
||||
filterStatus.addEventListener('change', loadIssues);
|
||||
filterType.addEventListener('change', loadIssues);
|
||||
filterStartDate.addEventListener('change', loadIssues);
|
||||
filterEndDate.addEventListener('change', loadIssues);
|
||||
|
||||
// 데이터 로드
|
||||
await Promise.all([loadStats(), loadIssues()]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 통계 로드
|
||||
*/
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/stats/summary`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 권한이 없는 경우 (일반 사용자)
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
document.getElementById('statReported').textContent = data.data.reported || 0;
|
||||
document.getElementById('statReceived').textContent = data.data.received || 0;
|
||||
document.getElementById('statProgress').textContent = data.data.in_progress || 0;
|
||||
document.getElementById('statCompleted').textContent = data.data.completed || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
document.getElementById('statsGrid').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 목록 로드
|
||||
*/
|
||||
async function loadIssues() {
|
||||
try {
|
||||
// 필터 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterStatus.value) params.append('status', filterStatus.value);
|
||||
if (filterType.value) params.append('category_type', filterType.value);
|
||||
if (filterStartDate.value) params.append('start_date', filterStartDate.value);
|
||||
if (filterEndDate.value) params.append('end_date', filterEndDate.value);
|
||||
|
||||
const response = await fetch(`${API_BASE}/work-issues?${params.toString()}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('목록 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
renderIssues(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 목록 로드 실패:', error);
|
||||
issueList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">목록을 불러올 수 없습니다</div>
|
||||
<p>잠시 후 다시 시도해주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 목록 렌더링
|
||||
*/
|
||||
function renderIssues(issues) {
|
||||
if (issues.length === 0) {
|
||||
issueList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">등록된 신고가 없습니다</div>
|
||||
<p>새로운 문제를 신고하려면 '새 신고' 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
|
||||
issueList.innerHTML = issues.map(issue => {
|
||||
const reportDate = new Date(issue.report_date).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 위치 정보
|
||||
let location = issue.custom_location || '';
|
||||
if (issue.factory_name) {
|
||||
location = issue.factory_name;
|
||||
if (issue.workplace_name) {
|
||||
location += ` - ${issue.workplace_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 제목 (항목명 또는 카테고리명)
|
||||
const title = issue.issue_item_name || issue.issue_category_name || '신고';
|
||||
|
||||
// 사진 목록
|
||||
const photos = [
|
||||
issue.photo_path1,
|
||||
issue.photo_path2,
|
||||
issue.photo_path3,
|
||||
issue.photo_path4,
|
||||
issue.photo_path5
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="issue-card" onclick="viewIssue(${issue.report_id})">
|
||||
<div class="issue-header">
|
||||
<span class="issue-id">#${issue.report_id}</span>
|
||||
<span class="issue-status ${issue.status}">${STATUS_LABELS[issue.status] || issue.status}</span>
|
||||
</div>
|
||||
|
||||
<div class="issue-title">
|
||||
<span class="issue-type-badge ${issue.category_type}">${TYPE_LABELS[issue.category_type] || ''}</span>
|
||||
${title}
|
||||
</div>
|
||||
|
||||
<div class="issue-meta">
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
${issue.reporter_full_name || issue.reporter_name}
|
||||
</span>
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
${reportDate}
|
||||
</span>
|
||||
${location ? `
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
${location}
|
||||
</span>
|
||||
` : ''}
|
||||
${issue.assigned_full_name ? `
|
||||
<span class="issue-meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
담당: ${issue.assigned_full_name}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${photos.length > 0 ? `
|
||||
<div class="issue-photos">
|
||||
${photos.slice(0, 3).map(p => `
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
`).join('')}
|
||||
${photos.length > 3 ? `<span style="display: flex; align-items: center; color: var(--gray-500);">+${photos.length - 3}</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 상세 보기
|
||||
*/
|
||||
function viewIssue(reportId) {
|
||||
window.location.href = `/pages/safety/issue-detail.html?id=${reportId}`;
|
||||
}
|
||||
740
web-ui/js/work-issue-report.js
Normal file
740
web-ui/js/work-issue-report.js
Normal file
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* 문제 신고 등록 페이지 JavaScript
|
||||
*/
|
||||
|
||||
// API 설정
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
|
||||
// 상태 변수
|
||||
let selectedFactoryId = null;
|
||||
let selectedWorkplaceId = null;
|
||||
let selectedWorkplaceName = null;
|
||||
let selectedType = null; // 'nonconformity' | 'safety'
|
||||
let selectedCategoryId = null;
|
||||
let selectedCategoryName = null;
|
||||
let selectedItemId = null;
|
||||
let selectedTbmSessionId = null;
|
||||
let selectedVisitRequestId = null;
|
||||
let photos = [null, null, null, null, null];
|
||||
|
||||
// 지도 관련 변수
|
||||
let canvas, ctx, canvasImage;
|
||||
let mapRegions = [];
|
||||
let todayWorkers = [];
|
||||
let todayVisitors = [];
|
||||
|
||||
// DOM 요소
|
||||
let factorySelect, issueMapCanvas;
|
||||
let photoInput, currentPhotoIndex;
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
factorySelect = document.getElementById('factorySelect');
|
||||
issueMapCanvas = document.getElementById('issueMapCanvas');
|
||||
photoInput = document.getElementById('photoInput');
|
||||
|
||||
canvas = issueMapCanvas;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 공장 목록 로드
|
||||
await loadFactories();
|
||||
});
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 설정
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// 공장 선택
|
||||
factorySelect.addEventListener('change', onFactoryChange);
|
||||
|
||||
// 지도 클릭
|
||||
canvas.addEventListener('click', onMapClick);
|
||||
|
||||
// 기타 위치 토글
|
||||
document.getElementById('useCustomLocation').addEventListener('change', (e) => {
|
||||
const customInput = document.getElementById('customLocationInput');
|
||||
customInput.classList.toggle('visible', e.target.checked);
|
||||
|
||||
if (e.target.checked) {
|
||||
// 지도 선택 초기화
|
||||
selectedWorkplaceId = null;
|
||||
selectedWorkplaceName = null;
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
updateLocationInfo();
|
||||
}
|
||||
});
|
||||
|
||||
// 유형 버튼 클릭
|
||||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => onTypeSelect(btn.dataset.type));
|
||||
});
|
||||
|
||||
// 사진 슬롯 클릭
|
||||
document.querySelectorAll('.photo-slot').forEach(slot => {
|
||||
slot.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('remove-btn')) return;
|
||||
currentPhotoIndex = parseInt(slot.dataset.index);
|
||||
photoInput.click();
|
||||
});
|
||||
});
|
||||
|
||||
// 사진 삭제 버튼
|
||||
document.querySelectorAll('.photo-slot .remove-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const slot = btn.closest('.photo-slot');
|
||||
const index = parseInt(slot.dataset.index);
|
||||
removePhoto(index);
|
||||
});
|
||||
});
|
||||
|
||||
// 사진 선택
|
||||
photoInput.addEventListener('change', onPhotoSelect);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공장 목록 로드
|
||||
*/
|
||||
async function loadFactories() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories/active/list`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('공장 목록 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
data.data.forEach(factory => {
|
||||
const option = document.createElement('option');
|
||||
option.value = factory.category_id;
|
||||
option.textContent = factory.category_name;
|
||||
factorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 첫 번째 공장 자동 선택
|
||||
if (data.data.length > 0) {
|
||||
factorySelect.value = data.data[0].category_id;
|
||||
onFactoryChange();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('공장 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공장 변경 시
|
||||
*/
|
||||
async function onFactoryChange() {
|
||||
selectedFactoryId = factorySelect.value;
|
||||
if (!selectedFactoryId) return;
|
||||
|
||||
// 위치 선택 초기화
|
||||
selectedWorkplaceId = null;
|
||||
selectedWorkplaceName = null;
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
updateLocationInfo();
|
||||
|
||||
// 지도 데이터 로드
|
||||
await Promise.all([
|
||||
loadMapImage(),
|
||||
loadMapRegions(),
|
||||
loadTodayData()
|
||||
]);
|
||||
|
||||
renderMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치도 이미지 로드
|
||||
*/
|
||||
async function loadMapImage() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const selectedCategory = data.data.find(c => c.category_id == selectedFactoryId);
|
||||
if (selectedCategory && selectedCategory.layout_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}`;
|
||||
|
||||
canvasImage = new Image();
|
||||
canvasImage.onload = () => renderMap();
|
||||
canvasImage.src = fullImageUrl;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배치도 이미지 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 영역 로드
|
||||
*/
|
||||
async function loadMapRegions() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/workplaces/categories/${selectedFactoryId}/map-regions`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
mapRegions = data.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('지도 영역 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 TBM/출입신청 데이터 로드
|
||||
*/
|
||||
async function loadTodayData() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
// TBM 세션 로드
|
||||
const tbmResponse = await fetch(`${API_BASE}/tbm/sessions/date/${today}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (tbmResponse.ok) {
|
||||
const tbmData = await tbmResponse.json();
|
||||
todayWorkers = tbmData.data || [];
|
||||
}
|
||||
|
||||
// 출입 신청 로드
|
||||
const visitResponse = await fetch(`${API_BASE}/workplace-visits/requests`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (visitResponse.ok) {
|
||||
const visitData = await visitResponse.json();
|
||||
todayVisitors = (visitData.data || []).filter(v =>
|
||||
v.visit_date === today &&
|
||||
(v.status === 'approved' || v.status === 'training_completed')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('오늘 데이터 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 렌더링
|
||||
*/
|
||||
function renderMap() {
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
// 캔버스 크기 설정
|
||||
const container = canvas.parentElement;
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = 400;
|
||||
|
||||
// 배경 그리기
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 배치도 이미지
|
||||
if (canvasImage && canvasImage.complete) {
|
||||
const scale = Math.min(canvas.width / canvasImage.width, canvas.height / canvasImage.height);
|
||||
const x = (canvas.width - canvasImage.width * scale) / 2;
|
||||
const y = (canvas.height - canvasImage.height * scale) / 2;
|
||||
ctx.drawImage(canvasImage, x, y, canvasImage.width * scale, canvasImage.height * scale);
|
||||
}
|
||||
|
||||
// 작업장 영역 그리기
|
||||
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 workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
|
||||
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
||||
|
||||
drawWorkplaceRegion(region, workerCount, visitorCount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 영역 그리기
|
||||
*/
|
||||
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 isSelected = region.workplace_id === selectedWorkplaceId;
|
||||
|
||||
// 색상 결정
|
||||
let fillColor, strokeColor;
|
||||
if (isSelected) {
|
||||
fillColor = 'rgba(34, 197, 94, 0.3)'; // 초록색
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0 && visitorCount > 0) {
|
||||
fillColor = 'rgba(34, 197, 94, 0.2)'; // 초록색 (작업+방문)
|
||||
strokeColor = 'rgb(34, 197, 94)';
|
||||
} else if (workerCount > 0) {
|
||||
fillColor = 'rgba(59, 130, 246, 0.2)'; // 파란색 (작업만)
|
||||
strokeColor = 'rgb(59, 130, 246)';
|
||||
} else if (visitorCount > 0) {
|
||||
fillColor = 'rgba(168, 85, 247, 0.2)'; // 보라색 (방문만)
|
||||
strokeColor = 'rgb(168, 85, 247)';
|
||||
} else {
|
||||
fillColor = 'rgba(156, 163, 175, 0.2)'; // 회색 (없음)
|
||||
strokeColor = 'rgb(156, 163, 175)';
|
||||
}
|
||||
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = isSelected ? 3 : 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x1, y1, width, height);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 작업장명 표시
|
||||
const centerX = x1 + width / 2;
|
||||
const centerY = y1 + height / 2;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(region.workplace_name, centerX, centerY);
|
||||
|
||||
// 인원수 표시
|
||||
const total = workerCount + visitorCount;
|
||||
if (total > 0) {
|
||||
ctx.fillStyle = strokeColor;
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText(`(${total}명)`, centerX, centerY + 16);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 클릭 처리
|
||||
*/
|
||||
function onMapClick(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업장 선택
|
||||
*/
|
||||
function selectWorkplace(region) {
|
||||
// 기타 위치 체크박스 해제
|
||||
document.getElementById('useCustomLocation').checked = false;
|
||||
document.getElementById('customLocationInput').classList.remove('visible');
|
||||
|
||||
selectedWorkplaceId = region.workplace_id;
|
||||
selectedWorkplaceName = region.workplace_name;
|
||||
|
||||
// 해당 작업장의 TBM/출입신청 확인
|
||||
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
|
||||
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
|
||||
|
||||
if (workers.length > 0 || visitors.length > 0) {
|
||||
// 작업 선택 모달 표시
|
||||
showWorkSelectionModal(workers, visitors);
|
||||
} else {
|
||||
selectedTbmSessionId = null;
|
||||
selectedVisitRequestId = null;
|
||||
}
|
||||
|
||||
updateLocationInfo();
|
||||
renderMap();
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 선택 모달 표시
|
||||
*/
|
||||
function showWorkSelectionModal(workers, visitors) {
|
||||
const modal = document.getElementById('workSelectionModal');
|
||||
const optionsList = document.getElementById('workOptionsList');
|
||||
|
||||
optionsList.innerHTML = '';
|
||||
|
||||
// TBM 작업 옵션
|
||||
workers.forEach(w => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">TBM: ${w.task_name || '작업'}</div>
|
||||
<div class="work-option-desc">${w.project_name || ''} - ${w.member_count || 0}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedTbmSessionId = w.session_id;
|
||||
selectedVisitRequestId = null;
|
||||
closeWorkModal();
|
||||
updateLocationInfo();
|
||||
};
|
||||
optionsList.appendChild(option);
|
||||
});
|
||||
|
||||
// 출입신청 옵션
|
||||
visitors.forEach(v => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'work-option';
|
||||
option.innerHTML = `
|
||||
<div class="work-option-title">출입: ${v.visitor_company}</div>
|
||||
<div class="work-option-desc">${v.purpose_name || '방문'} - ${v.visitor_count || 0}명</div>
|
||||
`;
|
||||
option.onclick = () => {
|
||||
selectedVisitRequestId = v.request_id;
|
||||
selectedTbmSessionId = null;
|
||||
closeWorkModal();
|
||||
updateLocationInfo();
|
||||
};
|
||||
optionsList.appendChild(option);
|
||||
});
|
||||
|
||||
modal.classList.add('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 선택 모달 닫기
|
||||
*/
|
||||
function closeWorkModal() {
|
||||
document.getElementById('workSelectionModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 위치 정보 업데이트
|
||||
*/
|
||||
function updateLocationInfo() {
|
||||
const infoBox = document.getElementById('selectedLocationInfo');
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
|
||||
if (useCustom && customLocation) {
|
||||
infoBox.classList.remove('empty');
|
||||
infoBox.innerHTML = `<strong>선택된 위치:</strong> ${customLocation}`;
|
||||
} else if (selectedWorkplaceName) {
|
||||
infoBox.classList.remove('empty');
|
||||
let html = `<strong>선택된 위치:</strong> ${selectedWorkplaceName}`;
|
||||
|
||||
if (selectedTbmSessionId) {
|
||||
const worker = todayWorkers.find(w => w.session_id === selectedTbmSessionId);
|
||||
if (worker) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${worker.task_name} (TBM)</span>`;
|
||||
}
|
||||
} else if (selectedVisitRequestId) {
|
||||
const visitor = todayVisitors.find(v => v.request_id === selectedVisitRequestId);
|
||||
if (visitor) {
|
||||
html += `<br><span style="color: var(--primary-600);">연결 작업: ${visitor.visitor_company} (출입)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
infoBox.innerHTML = html;
|
||||
} else {
|
||||
infoBox.classList.add('empty');
|
||||
infoBox.textContent = '지도에서 작업장을 클릭하여 위치를 선택하세요';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형 선택
|
||||
*/
|
||||
function onTypeSelect(type) {
|
||||
selectedType = type;
|
||||
selectedCategoryId = null;
|
||||
selectedCategoryName = null;
|
||||
selectedItemId = null;
|
||||
|
||||
// 버튼 상태 업데이트
|
||||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.dataset.type === type);
|
||||
});
|
||||
|
||||
// 카테고리 로드
|
||||
loadCategories(type);
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 로드
|
||||
*/
|
||||
async function loadCategories(type) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/categories/type/${type}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('카테고리 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderCategories(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 렌더링
|
||||
*/
|
||||
function renderCategories(categories) {
|
||||
const container = document.getElementById('categoryContainer');
|
||||
const grid = document.getElementById('categoryGrid');
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
categories.forEach(cat => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'category-btn';
|
||||
btn.textContent = cat.category_name;
|
||||
btn.onclick = () => onCategorySelect(cat);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 선택
|
||||
*/
|
||||
function onCategorySelect(category) {
|
||||
selectedCategoryId = category.category_id;
|
||||
selectedCategoryName = category.category_name;
|
||||
selectedItemId = null;
|
||||
|
||||
// 버튼 상태 업데이트
|
||||
document.querySelectorAll('.category-btn').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.textContent === category.category_name);
|
||||
});
|
||||
|
||||
// 항목 로드
|
||||
loadItems(category.category_id);
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 로드
|
||||
*/
|
||||
async function loadItems(categoryId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/items/category/${categoryId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('항목 조회 실패');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderItems(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('항목 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 렌더링
|
||||
*/
|
||||
function renderItems(items) {
|
||||
const grid = document.getElementById('itemGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = '<p style="color: var(--gray-400);">등록된 항목이 없습니다</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'item-btn';
|
||||
btn.textContent = item.item_name;
|
||||
btn.dataset.severity = item.severity;
|
||||
btn.onclick = () => onItemSelect(item, btn);
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 선택
|
||||
*/
|
||||
function onItemSelect(item, btn) {
|
||||
// 단일 선택 (기존 선택 해제)
|
||||
document.querySelectorAll('.item-btn').forEach(b => b.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
|
||||
selectedItemId = item.item_id;
|
||||
updateStepStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 선택
|
||||
*/
|
||||
function onPhotoSelect(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
photos[currentPhotoIndex] = event.target.result;
|
||||
updatePhotoSlot(currentPhotoIndex);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// 입력 초기화
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 슬롯 업데이트
|
||||
*/
|
||||
function updatePhotoSlot(index) {
|
||||
const slot = document.querySelector(`.photo-slot[data-index="${index}"]`);
|
||||
|
||||
if (photos[index]) {
|
||||
slot.classList.add('has-photo');
|
||||
let img = slot.querySelector('img');
|
||||
if (!img) {
|
||||
img = document.createElement('img');
|
||||
slot.insertBefore(img, slot.firstChild);
|
||||
}
|
||||
img.src = photos[index];
|
||||
} else {
|
||||
slot.classList.remove('has-photo');
|
||||
const img = slot.querySelector('img');
|
||||
if (img) img.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사진 삭제
|
||||
*/
|
||||
function removePhoto(index) {
|
||||
photos[index] = null;
|
||||
updatePhotoSlot(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단계 상태 업데이트
|
||||
*/
|
||||
function updateStepStatus() {
|
||||
const steps = document.querySelectorAll('.step');
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
|
||||
// Step 1: 위치
|
||||
const step1Complete = (useCustom && customLocation) || selectedWorkplaceId;
|
||||
steps[0].classList.toggle('completed', step1Complete);
|
||||
steps[1].classList.toggle('active', step1Complete);
|
||||
|
||||
// Step 2: 유형
|
||||
const step2Complete = selectedType && selectedCategoryId;
|
||||
steps[1].classList.toggle('completed', step2Complete);
|
||||
steps[2].classList.toggle('active', step2Complete);
|
||||
|
||||
// Step 3: 항목
|
||||
const step3Complete = selectedItemId;
|
||||
steps[2].classList.toggle('completed', step3Complete);
|
||||
steps[3].classList.toggle('active', step3Complete);
|
||||
|
||||
// 제출 버튼 활성화
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const hasPhoto = photos.some(p => p !== null);
|
||||
submitBtn.disabled = !(step1Complete && step2Complete && hasPhoto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신고 제출
|
||||
*/
|
||||
async function submitReport() {
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '제출 중...';
|
||||
|
||||
try {
|
||||
const useCustom = document.getElementById('useCustomLocation').checked;
|
||||
const customLocation = document.getElementById('customLocation').value;
|
||||
const additionalDescription = document.getElementById('additionalDescription').value;
|
||||
|
||||
const requestBody = {
|
||||
factory_category_id: useCustom ? null : selectedFactoryId,
|
||||
workplace_id: useCustom ? null : selectedWorkplaceId,
|
||||
custom_location: useCustom ? customLocation : null,
|
||||
tbm_session_id: selectedTbmSessionId,
|
||||
visit_request_id: selectedVisitRequestId,
|
||||
issue_category_id: selectedCategoryId,
|
||||
issue_item_id: selectedItemId,
|
||||
additional_description: additionalDescription || null,
|
||||
photos: photos.filter(p => p !== null)
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE}/work-issues`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('문제 신고가 등록되었습니다.');
|
||||
window.location.href = '/pages/safety/issue-list.html';
|
||||
} else {
|
||||
throw new Error(data.error || '신고 등록 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 제출 실패:', error);
|
||||
alert('신고 등록에 실패했습니다: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '신고 제출';
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 위치 입력 시 위치 정보 업데이트
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const customLocationInput = document.getElementById('customLocation');
|
||||
if (customLocationInput) {
|
||||
customLocationInput.addEventListener('input', () => {
|
||||
updateLocationInfo();
|
||||
updateStepStatus();
|
||||
});
|
||||
}
|
||||
});
|
||||
1
web-ui/pages.backup.20260202/admin/.gitkeep
Normal file
1
web-ui/pages.backup.20260202/admin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to create admin directory
|
||||
215
web-ui/pages.backup.20260202/admin/accounts.html
Normal file
215
web-ui/pages.backup.20260202/admin/accounts.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>관리자 설정 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">⚙️</span>
|
||||
관리자 설정
|
||||
</h1>
|
||||
<p class="page-description">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 관리 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">👥</span>
|
||||
사용자 계정 관리
|
||||
</h2>
|
||||
<button class="btn btn-primary" id="addUserBtn">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-header">
|
||||
<div class="search-box">
|
||||
<input type="text" id="userSearch" placeholder="사용자 검색..." class="search-input">
|
||||
<span class="search-icon">🔍</span>
|
||||
</div>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="admin">관리자</button>
|
||||
<button class="filter-btn" data-filter="leader">그룹장</button>
|
||||
<button class="filter-btn" data-filter="user">작업자</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>상태</th>
|
||||
<th>최종 로그인</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- 사용자 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>새 사용자를 추가해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 추가/수정 모달 -->
|
||||
<div id="userModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 사용자 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeUserModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="userForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label">사용자명 *</label>
|
||||
<input type="text" id="userName" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디 *</label>
|
||||
<input type="text" id="userId" class="form-control" required>
|
||||
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
<label class="form-label">비밀번호 *</label>
|
||||
<input type="password" id="userPassword" class="form-control" required>
|
||||
<small class="form-help">최소 6자 이상</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">역할 *</label>
|
||||
<select id="userRole" class="form-control" required>
|
||||
<option value="">역할 선택</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="user">사용자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="userEmail" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="saveUserBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 삭제 확인 모달 -->
|
||||
<div id="deleteModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container small">
|
||||
<div class="modal-header">
|
||||
<h2>사용자 삭제</h2>
|
||||
<button class="modal-close-btn" onclick="closeDeleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="delete-warning">
|
||||
<div class="warning-icon">⚠️</div>
|
||||
<p>정말로 이 사용자를 삭제하시겠습니까?</p>
|
||||
<p class="warning-text">삭제된 사용자는 복구할 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">삭제</button>
|
||||
</div>
|
||||
</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=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
302
web-ui/pages.backup.20260202/admin/codes.html
Normal file
302
web-ui/pages.backup.20260202/admin/codes.html
Normal file
@@ -0,0 +1,302 @@
|
||||
<!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>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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-secondary" onclick="refreshAllCodes()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
전체 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 코드 유형 탭 -->
|
||||
<div class="code-tabs">
|
||||
<button class="tab-btn active" data-tab="work-status" onclick="switchCodeTab('work-status')">
|
||||
<span class="tab-icon">📊</span>
|
||||
작업 상태 유형
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="error-types" onclick="switchCodeTab('error-types')">
|
||||
<span class="tab-icon">⚠️</span>
|
||||
오류 유형
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="work-types" onclick="switchCodeTab('work-types')">
|
||||
<span class="tab-icon">🔧</span>
|
||||
작업 유형
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 작업 상태 유형 관리 -->
|
||||
<div id="work-status-tab" class="code-tab-content active">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">📊</span>
|
||||
작업 상태 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-status')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 상태 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="workStatusCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
정상 <span id="normalStatusCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">❌</span>
|
||||
오류 <span id="errorStatusCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workStatusGrid">
|
||||
<!-- 작업 상태 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오류 유형 관리 -->
|
||||
<div id="error-types-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">⚠️</span>
|
||||
오류 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('error-types')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 오류 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">⚠️</span>
|
||||
총 <span id="errorTypesCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item critical-stat">
|
||||
<span class="stat-icon">🔴</span>
|
||||
심각 <span id="criticalErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item high-stat">
|
||||
<span class="stat-icon">🟠</span>
|
||||
높음 <span id="highErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item medium-stat">
|
||||
<span class="stat-icon">🟡</span>
|
||||
보통 <span id="mediumErrorsCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item low-stat">
|
||||
<span class="stat-icon">🟢</span>
|
||||
낮음 <span id="lowErrorsCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="errorTypesGrid">
|
||||
<!-- 오류 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 유형 관리 -->
|
||||
<div id="work-types-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🔧</span>
|
||||
작업 유형 관리
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openCodeModal('work-types')">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업 유형 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">🔧</span>
|
||||
총 <span id="workTypesCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📁</span>
|
||||
카테고리 <span id="workCategoriesCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workTypesGrid">
|
||||
<!-- 작업 유형 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 코드 추가/수정 모달 -->
|
||||
<div id="codeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">코드 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeCodeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="codeForm" onsubmit="event.preventDefault(); saveCode();">
|
||||
<input type="hidden" id="codeId">
|
||||
<input type="hidden" id="codeType">
|
||||
|
||||
<!-- 공통 필드 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">이름 *</label>
|
||||
<input type="text" id="codeName" class="form-control" placeholder="코드명을 입력하세요" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="codeDescription" class="form-control" rows="3" placeholder="상세 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 작업 상태 유형 전용 필드 -->
|
||||
<div class="form-group" id="isErrorGroup" style="display: none;">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" id="isError" class="form-checkbox">
|
||||
오류 상태로 분류
|
||||
</label>
|
||||
<small class="form-help">체크하면 이 상태는 오류로 간주됩니다</small>
|
||||
</div>
|
||||
|
||||
<!-- 오류 유형 전용 필드 -->
|
||||
<div class="form-group" id="severityGroup" style="display: none;">
|
||||
<label class="form-label">심각도 *</label>
|
||||
<select id="severity" class="form-control">
|
||||
<option value="low">낮음 (Low)</option>
|
||||
<option value="medium" selected>보통 (Medium)</option>
|
||||
<option value="high">높음 (High)</option>
|
||||
<option value="critical">심각 (Critical)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="solutionGuideGroup" style="display: none;">
|
||||
<label class="form-label">해결 가이드</label>
|
||||
<textarea id="solutionGuide" class="form-control" rows="4" placeholder="이 오류 발생 시 해결 방법을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 작업 유형 전용 필드 -->
|
||||
<div class="form-group" id="categoryGroup" style="display: none;">
|
||||
<label class="form-label">카테고리</label>
|
||||
<input type="text" id="category" class="form-control" placeholder="작업 카테고리 (예: PKG, Vessel)" list="categoryList">
|
||||
<datalist id="categoryList">
|
||||
<!-- 기존 카테고리 목록이 여기에 동적으로 생성됩니다 -->
|
||||
</datalist>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCodeModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCodeBtn" onclick="deleteCode()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCode()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/code-management.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
272
web-ui/pages.backup.20260202/admin/equipments.html
Normal file
272
web-ui/pages.backup.20260202/admin/equipments.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>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<span class="menu-text">작업장 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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="openEquipmentModal()">
|
||||
<span>+ 설비 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<label for="filterWorkplace">작업장</label>
|
||||
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterType">설비 유형</label>
|
||||
<select id="filterType" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filterStatus">상태</label>
|
||||
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
|
||||
<option value="">전체</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="searchInput">검색</label>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="설비명 또는 코드 검색" oninput="filterEquipments()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설비 목록 -->
|
||||
<div class="content-section">
|
||||
<div id="equipmentList" class="data-table-container">
|
||||
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 설비 추가/수정 모달 -->
|
||||
<div id="equipmentModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">설비 추가</h2>
|
||||
<button class="btn-close" onclick="closeEquipmentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="equipmentForm">
|
||||
<input type="hidden" id="equipmentId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentCode">설비 코드 *</label>
|
||||
<input type="text" id="equipmentCode" class="form-control" placeholder="예: CNC-01" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="equipmentName">설비명 *</label>
|
||||
<input type="text" id="equipmentName" class="form-control" placeholder="예: CNC 머시닝 센터" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="equipmentType">설비 유형</label>
|
||||
<input type="text" id="equipmentType" class="form-control" placeholder="예: CNC, 선반, 밀링 등">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="workplaceId">작업장</label>
|
||||
<select id="workplaceId" class="form-control">
|
||||
<option value="">선택 안함</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="manufacturer">제조사</label>
|
||||
<input type="text" id="manufacturer" class="form-control" placeholder="예: DMG MORI">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="modelName">모델명</label>
|
||||
<input type="text" id="modelName" class="form-control" placeholder="예: NHX-5000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialNumber">시리얼 번호</label>
|
||||
<input type="text" id="serialNumber" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="installationDate">설치일</label>
|
||||
<input type="date" id="installationDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="equipmentStatus">상태</label>
|
||||
<select id="equipmentStatus" class="form-control">
|
||||
<option value="active">활성</option>
|
||||
<option value="maintenance">정비중</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="specifications">사양 정보</label>
|
||||
<textarea id="specifications" class="form-control" rows="3" placeholder="설비 사양 정보를 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module">
|
||||
// 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>
|
||||
140
web-ui/pages.backup.20260202/admin/page-access.html
Normal file
140
web-ui/pages.backup.20260202/admin/page-access.html
Normal file
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>페이지 권한 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=1">
|
||||
<link rel="stylesheet" href="/css/admin-settings.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<div class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">🔐</span>
|
||||
페이지 접근 권한 관리
|
||||
</h1>
|
||||
<p class="page-description">작업자에게 특정 페이지 접근 권한을 부여하거나 회수합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 섹션 -->
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">👥</span>
|
||||
사용자 목록
|
||||
</h2>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter="all">전체</button>
|
||||
<button class="filter-btn" data-filter="with-access">권한 있음</button>
|
||||
<button class="filter-btn" data-filter="no-access">권한 없음</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-container">
|
||||
<div class="users-table-container">
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자명</th>
|
||||
<th>아이디</th>
|
||||
<th>역할</th>
|
||||
<th>작업자</th>
|
||||
<th>접근 가능 페이지</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner"></div>
|
||||
<p>사용자 목록을 불러오는 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 사용자가 없습니다</h3>
|
||||
<p>권한을 부여할 사용자 계정이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 설정 모달 -->
|
||||
<div id="pageAccessModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">페이지 권한 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closePageAccessModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<div style="width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600;">
|
||||
<span id="modalUserInitial">-</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1rem; color: #111827;" id="modalUserName">사용자명</div>
|
||||
<div style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span id="modalUsername">username</span>
|
||||
<span style="margin: 0 0.5rem;">•</span>
|
||||
<span id="modalWorkerName">작업자</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-access-list">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; color: #374151; margin-bottom: 1rem;">
|
||||
접근 가능 페이지 선택
|
||||
</h3>
|
||||
|
||||
<div id="pageListContainer" style="max-height: 400px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<div style="text-align: center; padding: 2rem; color: #6b7280;">
|
||||
<div class="spinner" style="margin: 0 auto 0.5rem;"></div>
|
||||
페이지 목록을 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding: 1rem; background: #fffbeb; border: 1px solid #fde047; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; font-size: 0.875rem; color: #92400e;">
|
||||
<strong>💡 참고:</strong> Admin 및 System Admin은 모든 페이지에 자동으로 접근할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="savePageAccessBtn">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=4"></script>
|
||||
<script src="/js/page-access-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
258
web-ui/pages.backup.20260202/admin/projects.html
Normal file
258
web-ui/pages.backup.20260202/admin/projects.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!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=6">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 프로젝트 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshProjectList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="프로젝트명 또는 Job No.로 검색..." class="search-input">
|
||||
<button class="search-btn" onclick="searchProjects()">
|
||||
<span class="search-icon">🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-options">
|
||||
<select id="statusFilter" class="filter-select" onchange="filterProjects()">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="active">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="paused">중단</option>
|
||||
</select>
|
||||
|
||||
<select id="sortBy" class="filter-select" onchange="sortProjects()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="project_name">프로젝트명순</option>
|
||||
<option value="due_date">납기일순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">등록된 프로젝트</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 프로젝트만 보기">
|
||||
<span class="stat-icon">🟢</span>
|
||||
활성 <span id="activeProjects">0</span>개
|
||||
</span>
|
||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 프로젝트만 보기">
|
||||
<span class="stat-icon">🔴</span>
|
||||
비활성 <span id="inactiveProjects">0</span>개
|
||||
</span>
|
||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 프로젝트 보기">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="totalProjects">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="projects-grid" id="projectsGrid">
|
||||
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>등록된 프로젝트가 없습니다</h3>
|
||||
<p>새 프로젝트를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openProjectModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
첫 번째 프로젝트 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 프로젝트 등록/수정 모달 -->
|
||||
<div id="projectModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 프로젝트 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeProjectModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="projectForm">
|
||||
<input type="hidden" id="projectId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Job No. *</label>
|
||||
<input type="text" id="jobNo" class="form-control" required placeholder="예: TK-2024-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트명 *</label>
|
||||
<input type="text" id="projectName" class="form-control" required placeholder="프로젝트명을 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">계약일</label>
|
||||
<input type="date" id="contractDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">납기일</label>
|
||||
<input type="date" id="dueDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">납품방법</label>
|
||||
<select id="deliveryMethod" class="form-control">
|
||||
<option value="">선택하세요</option>
|
||||
<option value="직접납품">직접납품</option>
|
||||
<option value="택배">택배</option>
|
||||
<option value="화물">화물</option>
|
||||
<option value="현장설치">현장설치</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현장</label>
|
||||
<input type="text" id="site" class="form-control" placeholder="현장 위치를 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">PM (프로젝트 매니저)</label>
|
||||
<input type="text" id="pm" class="form-control" placeholder="담당 PM을 입력하세요">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트 상태</label>
|
||||
<select id="projectStatus" class="form-control">
|
||||
<option value="planning">📋 계획</option>
|
||||
<option value="active" selected>🚀 진행중</option>
|
||||
<option value="completed">✅ 완료</option>
|
||||
<option value="cancelled">❌ 취소</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">완료일 (납품일)</label>
|
||||
<input type="date" id="completedDate" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="isActive" checked style="margin: 0;">
|
||||
<span>프로젝트 활성화</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.8rem;">체크 해제 시 작업보고서 입력에서 숨겨집니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeProjectModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteProjectBtn" onclick="deleteProject()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveProject()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/project-management.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
596
web-ui/pages.backup.20260202/admin/safety-checklist-manage.html
Normal file
596
web-ui/pages.backup.20260202/admin/safety-checklist-manage.html
Normal file
@@ -0,0 +1,596 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전 체크리스트 관리 - TK-FB</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css">
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
/* 탭 메뉴 */
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 체크리스트 카드 */
|
||||
.checklist-group {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checklist-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* 필터/검색 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-radio-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-radio input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conditional-fields {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.conditional-fields.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 날씨 아이콘 */
|
||||
.weather-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon.rain::before { content: '🌧️'; }
|
||||
.weather-icon.snow::before { content: '❄️'; }
|
||||
.weather-icon.heat::before { content: '🔥'; }
|
||||
.weather-icon.cold::before { content: '🥶'; }
|
||||
.weather-icon.wind::before { content: '💨'; }
|
||||
.weather-icon.fog::before { content: '🌫️'; }
|
||||
.weather-icon.dust::before { content: '😷'; }
|
||||
.weather-icon.clear::before { content: '☀️'; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.checklist-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">안전 체크리스트 관리</h1>
|
||||
<button class="btn-add" onclick="openAddModal()">
|
||||
<span>+</span> 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu">
|
||||
<button class="tab-btn active" data-tab="basic" onclick="switchTab('basic')">
|
||||
기본 사항
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="weather" onclick="switchTab('weather')">
|
||||
날씨별
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="task" onclick="switchTab('task')">
|
||||
작업별
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기본 사항 탭 -->
|
||||
<div id="basicTab" class="tab-content active">
|
||||
<div id="basicChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 탭 -->
|
||||
<div id="weatherTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="weatherFilter" class="filter-select" onchange="filterByWeather()">
|
||||
<option value="">모든 날씨 조건</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="weatherChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 탭 -->
|
||||
<div id="taskTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="workTypeFilter" class="filter-select" onchange="filterByWorkType()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
<select id="taskFilter" class="filter-select" onchange="filterByTask()">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="taskChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<div id="checkModal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">체크 항목 추가</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="checkForm">
|
||||
<input type="hidden" id="checkId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">유형</label>
|
||||
<div class="form-radio-group">
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="basic" checked onchange="toggleConditionalFields()">
|
||||
<span>기본</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="weather" onchange="toggleConditionalFields()">
|
||||
<span>날씨별</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="task" onchange="toggleConditionalFields()">
|
||||
<span>작업별</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 유형: 카테고리 선택 -->
|
||||
<div id="basicFields" class="conditional-fields show">
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select id="checkCategory" class="form-select">
|
||||
<option value="PPE">PPE (개인보호장비)</option>
|
||||
<option value="EQUIPMENT">EQUIPMENT (장비점검)</option>
|
||||
<option value="ENVIRONMENT">ENVIRONMENT (작업환경)</option>
|
||||
<option value="EMERGENCY">EMERGENCY (비상대응)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 유형: 날씨 조건 선택 -->
|
||||
<div id="weatherFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">날씨 조건</label>
|
||||
<select id="weatherCondition" class="form-select">
|
||||
<!-- 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 유형: 공정/작업 선택 -->
|
||||
<div id="taskFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정</label>
|
||||
<select id="modalWorkType" class="form-select" onchange="loadModalTasks()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업</label>
|
||||
<select id="modalTask" class="form-select">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">체크 항목</label>
|
||||
<input type="text" id="checkItem" class="form-input" placeholder="예: 안전모 착용 확인" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명 (선택)</label>
|
||||
<textarea id="checkDescription" class="form-textarea" placeholder="항목에 대한 상세 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="isRequired" checked>
|
||||
<span>필수 체크 항목</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-input" value="0" min="0">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button type="button" class="btn-primary" onclick="saveCheck()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
236
web-ui/pages.backup.20260202/admin/tasks.html
Normal file
236
web-ui/pages.backup.20260202/admin/tasks.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<!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>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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="openWorkTypeModal()">
|
||||
<span class="btn-icon">🔧</span>
|
||||
공정 추가
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openTaskModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
작업 추가
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshTasks()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공정(work_types) 탭 -->
|
||||
<div class="code-tabs" id="workTypeTabs">
|
||||
<button class="tab-btn active" data-work-type="" onclick="switchWorkType('')">
|
||||
<span class="tab-icon">📋</span>
|
||||
전체
|
||||
</button>
|
||||
<!-- 공정 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 작업 목록 -->
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🔧</span>
|
||||
작업 목록
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-stats" id="taskStats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
전체 <span id="totalCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
활성 <span id="activeCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="taskGrid">
|
||||
<!-- 작업 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 추가/수정 모달 -->
|
||||
<div id="taskModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="taskModalTitle">작업 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeTaskModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="taskForm" onsubmit="event.preventDefault(); saveTask();">
|
||||
<input type="hidden" id="taskId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공정 *</label>
|
||||
<select id="taskWorkTypeId" class="form-control" required>
|
||||
<option value="">공정 선택...</option>
|
||||
<!-- 공정 목록이 동적으로 생성됩니다 -->
|
||||
</select>
|
||||
<small class="form-help">이 작업이 속한 공정을 선택하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업명 *</label>
|
||||
<input type="text" id="taskName" class="form-control" placeholder="예: 서스 용접, 프레임 조립" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="taskDescription" class="form-control" rows="4" placeholder="작업에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="taskIsActive" checked style="margin: 0;">
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
<small class="form-help">비활성화하면 TBM 입력 시 표시되지 않습니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTaskModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteTaskBtn" onclick="deleteTask()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTask()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공정 추가/수정 모달 -->
|
||||
<div id="workTypeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workTypeModalTitle">공정 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkTypeModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workTypeForm" onsubmit="event.preventDefault(); saveWorkType();">
|
||||
<input type="hidden" id="workTypeId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정명 *</label>
|
||||
<input type="text" id="workTypeName" class="form-control" placeholder="예: Base(구조물), Vessel(용기)" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<input type="text" id="workTypeCategory" class="form-control" placeholder="예: 제작, 조립">
|
||||
<small class="form-help">공정을 그룹화할 카테고리 (선택사항)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workTypeDescription" class="form-control" rows="3" placeholder="공정에 대한 설명"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkTypeModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkTypeBtn" onclick="deleteWorkType()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkType()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/task-management.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
291
web-ui/pages.backup.20260202/admin/workers.html
Normal file
291
web-ui/pages.backup.20260202/admin/workers.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/admin-pages.css?v=6">
|
||||
<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>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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="openWorkerModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 작업자 등록
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkerList()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="작업자명, 직책, 전화번호로 검색...">
|
||||
<button class="search-btn" onclick="searchWorkers()">
|
||||
<span>🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-options">
|
||||
<select id="jobTypeFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 직책</option>
|
||||
<option value="leader">그룹장</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="admin">관리자</option>
|
||||
</select>
|
||||
|
||||
<select id="statusFilter" class="filter-select" onchange="filterWorkers()">
|
||||
<option value="">모든 상태</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
|
||||
<select id="sortBy" class="filter-select" onchange="sortWorkers()">
|
||||
<option value="created_at">등록일순</option>
|
||||
<option value="worker_name">이름순</option>
|
||||
<option value="job_type">직책순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 목록 -->
|
||||
<div class="projects-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">등록된 작업자</h2>
|
||||
<div class="project-stats">
|
||||
<span class="stat-item active-stat" onclick="filterByStatus('active')" title="활성 작업자만 보기">
|
||||
<span class="stat-icon">🟢</span>
|
||||
활성 <span id="activeWorkers">0</span>명
|
||||
</span>
|
||||
<span class="stat-item inactive-stat" onclick="filterByStatus('inactive')" title="비활성 작업자만 보기">
|
||||
<span class="stat-icon">🔴</span>
|
||||
비활성 <span id="inactiveWorkers">0</span>명
|
||||
</span>
|
||||
<span class="stat-item total-stat" onclick="filterByStatus('all')" title="전체 작업자 보기">
|
||||
<span class="stat-icon">📊</span>
|
||||
총 <span id="totalWorkers">0</span>명
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 테이블 -->
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="workersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">상태</th>
|
||||
<th style="width: 100px;">이름</th>
|
||||
<th style="width: 100px;">직책</th>
|
||||
<th style="width: 130px;">전화번호</th>
|
||||
<th style="width: 180px;">이메일</th>
|
||||
<th style="width: 100px;">입사일</th>
|
||||
<th style="width: 100px;">부서</th>
|
||||
<th style="width: 80px;">계정</th>
|
||||
<th style="width: 80px;">현장직</th>
|
||||
<th style="width: 120px;">등록일</th>
|
||||
<th style="width: 100px;">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workersGrid">
|
||||
<!-- 작업자 행들이 여기에 동적으로 생성됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>등록된 작업자가 없습니다.</h3>
|
||||
<p>"새 작업자 등록" 버튼을 눌러 작업자를 등록해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openWorkerModal()">
|
||||
➕ 첫 작업자 등록하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업자 추가/수정 모달 -->
|
||||
<div id="workerModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 작업자 등록</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkerModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workerForm" onsubmit="event.preventDefault(); saveWorker();">
|
||||
<input type="hidden" id="workerId">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자명 *</label>
|
||||
<input type="text" id="workerName" class="form-control" placeholder="작업자 이름을 입력하세요" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">직책</label>
|
||||
<select id="jobType" class="form-control">
|
||||
<option value="worker">👷 작업자</option>
|
||||
<option value="leader">👨💼 그룹장</option>
|
||||
<option value="admin">👨💻 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">전화번호</label>
|
||||
<input type="tel" id="phoneNumber" class="form-control" placeholder="010-0000-0000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">이메일</label>
|
||||
<input type="email" id="email" class="form-control" placeholder="example@company.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">입사일</label>
|
||||
<input type="date" id="hireDate" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">부서</label>
|
||||
<input type="text" id="department" class="form-control" placeholder="소속 부서">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">비고</label>
|
||||
<textarea id="notes" class="form-control" rows="3" placeholder="추가 정보나 특이사항을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 상태 관리 섹션 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="font-weight: 600; margin-bottom: 0.75rem; display: block;">상태 관리</label>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<!-- 계정 생성/연동 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="hasAccount" style="margin: 0; cursor: pointer;">
|
||||
<span>🔐 계정 생성/연동</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크 시 로그인 계정이 자동 생성됩니다 (나의 대시보드, 연차/출퇴근 관리 가능)
|
||||
</small>
|
||||
|
||||
<!-- 현장직/사무직 구분 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isActive" checked style="margin: 0; cursor: pointer;">
|
||||
<span>🏭 현장직 (활성화)</span>
|
||||
</label>
|
||||
<small style="color: #6b7280; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
체크: 현장직 (출퇴근 관리 필요) / 체크 해제: 사무직 (출퇴근 관리 불필요)
|
||||
</small>
|
||||
|
||||
<!-- 퇴사 처리 -->
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||
<input type="checkbox" id="isResigned" style="margin: 0; cursor: pointer;">
|
||||
<span style="color: #ef4444;">🚪 퇴사 처리</span>
|
||||
</label>
|
||||
<small style="color: #ef4444; font-size: 0.75rem; margin-top: -0.5rem; margin-left: 1.5rem;">
|
||||
퇴사한 작업자로 표시됩니다. 작업 보고서에서 제외됩니다
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkerModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkerBtn" onclick="deleteWorker()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorker()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/worker-management.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
414
web-ui/pages.backup.20260202/admin/workplaces.html
Normal file
414
web-ui/pages.backup.20260202/admin/workplaces.html
Normal file
@@ -0,0 +1,414 @@
|
||||
<!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>
|
||||
|
||||
<!-- 메인 레이아웃: 사이드바 + 콘텐츠 -->
|
||||
<div class="page-container">
|
||||
<!-- 사이드바 -->
|
||||
<aside class="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-header">
|
||||
<h3 class="sidebar-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<ul class="sidebar-menu">
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/projects.html">
|
||||
<span class="menu-icon">📁</span>
|
||||
<span class="menu-text">프로젝트 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/workers.html">
|
||||
<span class="menu-icon">👥</span>
|
||||
<span class="menu-text">작업자 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item active">
|
||||
<a href="/pages/admin/workplaces.html">
|
||||
<span class="menu-icon">🏗️</span>
|
||||
<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>
|
||||
<span class="menu-text">작업 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/admin/codes.html">
|
||||
<span class="menu-icon">🏷️</span>
|
||||
<span class="menu-text">코드 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li class="menu-item">
|
||||
<a href="/pages/dashboard.html">
|
||||
<span class="menu-icon">🏠</span>
|
||||
<span class="menu-text">대시보드로</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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="openCategoryModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
공장 추가
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openWorkplaceModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
작업장 추가
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="refreshWorkplaces()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공장(카테고리) 탭 -->
|
||||
<div class="code-tabs" id="categoryTabs">
|
||||
<button class="tab-btn active" data-category="" onclick="switchCategory('')">
|
||||
<span class="tab-icon">🏗️</span>
|
||||
전체
|
||||
</button>
|
||||
<!-- 공장 탭들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 공장 레이아웃 지도 관리 섹션 (카테고리가 선택된 경우에만 표시) -->
|
||||
<div class="code-section" id="layoutMapSection" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🗺️</span>
|
||||
<span id="selectedCategoryName"></span> 레이아웃 지도
|
||||
</h2>
|
||||
<button class="btn btn-secondary" onclick="openLayoutMapModal()">
|
||||
<span class="btn-icon">⚙️</span>
|
||||
지도 설정
|
||||
</button>
|
||||
</div>
|
||||
<div id="layoutMapPreview" style="padding: 20px; background: #f9fafb; border-radius: 8px; text-align: center;">
|
||||
<!-- 레이아웃 이미지 미리보기가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 목록 -->
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🏭</span>
|
||||
작업장 목록
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="code-stats" id="workplaceStats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">🏗️</span>
|
||||
전체 <span id="totalCount">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
활성 <span id="activeCount">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="workplaceGrid">
|
||||
<!-- 작업장 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 공장(카테고리) 추가/수정 모달 -->
|
||||
<div id="categoryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="categoryModalTitle">공장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeCategoryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="categoryForm" onsubmit="event.preventDefault(); saveCategory();">
|
||||
<input type="hidden" id="categoryId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">공장명 *</label>
|
||||
<input type="text" id="categoryName" class="form-control" placeholder="예: 제 1공장" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="categoryDescription" class="form-control" rows="3" placeholder="공장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="categoryOrder" class="form-control" value="0" min="0">
|
||||
<small class="form-help">작은 숫자가 먼저 표시됩니다</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCategoryModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteCategoryBtn" onclick="deleteCategory()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveCategory()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 추가/수정 모달 -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceModalTitle">작업장 추가</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="workplaceForm" onsubmit="event.preventDefault(); saveWorkplace();">
|
||||
<input type="hidden" id="workplaceId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">소속 공장</label>
|
||||
<select id="workplaceCategoryId" class="form-control">
|
||||
<option value="">공장 선택</option>
|
||||
<!-- 공장 목록이 동적으로 생성됩니다 -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장명 *</label>
|
||||
<input type="text" id="workplaceName" class="form-control" placeholder="예: 서스작업장, 조립구역" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 용도</label>
|
||||
<select id="workplacePurpose" class="form-control">
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="작업구역">작업구역</option>
|
||||
<option value="설비">설비</option>
|
||||
<option value="휴게시설">휴게시설</option>
|
||||
<option value="회의실">회의실</option>
|
||||
<option value="창고">창고</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<small class="form-help">작업장의 주요 용도를 선택하세요</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayPriority" class="form-control" value="0" min="0">
|
||||
<small class="form-help">숫자가 작을수록 먼저 표시됩니다</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명</label>
|
||||
<textarea id="workplaceDescription" class="form-control" rows="4" placeholder="작업장에 대한 설명을 입력하세요"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkplaceBtn" onclick="deleteWorkplace()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplace()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 지도 관리 모달 -->
|
||||
<div id="workplaceMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2 id="workplaceMapModalTitle">작업장 지도 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkplaceMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 작업장 레이아웃 이미지 업로드</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장의 상세 레이아웃 이미지를 업로드하세요
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="workplaceLayoutPreview" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center; min-height: 200px;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="workplaceLayoutFile" accept="image/*" class="form-control" onchange="previewWorkplaceLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="uploadWorkplaceLayout()">
|
||||
📤 이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 설비/영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 설비 위치 정의 (선택사항)</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
작업장 이미지 위에 마우스로 드래그하여 각 설비의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="workplaceCanvasContainer">
|
||||
<canvas id="workplaceRegionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 설비 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">설비 이름 입력</label>
|
||||
<input type="text" id="equipmentNameInput" class="form-control" placeholder="예: CNC-01, 선반기-A" style="margin-bottom: 12px;">
|
||||
<small class="form-help">드래그로 영역을 선택한 후 설비 이름을 입력하고 저장하세요</small>
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearWorkplaceCurrentRegion()">
|
||||
🗑️ 현재 영역 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveWorkplaceEquipmentRegion()">
|
||||
💾 설비 위치 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 설비 목록</label>
|
||||
<div id="workplaceEquipmentList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<p style="color: #94a3b8; text-align: center;">아직 정의된 설비가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 레이아웃 지도 설정 모달 -->
|
||||
<div id="layoutMapModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 90vw; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>🗺️ 공장 레이아웃 지도 설정</h2>
|
||||
<button class="modal-close-btn" onclick="closeLayoutMapModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="overflow-y: auto;">
|
||||
<!-- Step 1: 이미지 업로드 -->
|
||||
<div class="form-section" style="border-bottom: 2px solid #e5e7eb; padding-bottom: 20px; margin-bottom: 20px;">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 1. 공장 레이아웃 이미지 업로드</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">현재 이미지</label>
|
||||
<div id="currentLayoutImage" style="background: #f9fafb; border: 2px dashed #cbd5e1; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<span style="color: #94a3b8;">업로드된 이미지가 없습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">새 이미지 업로드</label>
|
||||
<input type="file" id="layoutImageFile" accept="image/*" class="form-control" onchange="previewLayoutImage(event)">
|
||||
<small class="form-help">JPG, PNG, GIF 형식 지원 (최대 5MB)</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="uploadLayoutImage()">
|
||||
📤 이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 작업장 영역 정의 -->
|
||||
<div class="form-section">
|
||||
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">Step 2. 작업장 영역 정의</h3>
|
||||
<p style="color: #64748b; font-size: 14px; margin-bottom: 16px;">
|
||||
이미지 위에 마우스로 드래그하여 각 작업장의 위치를 지정하세요
|
||||
</p>
|
||||
|
||||
<!-- 영역 그리기 캔버스 -->
|
||||
<div style="position: relative; display: inline-block; margin-bottom: 20px;" id="canvasContainer">
|
||||
<canvas id="regionCanvas" style="border: 2px solid #cbd5e1; cursor: crosshair; max-width: 100%;"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업장 선택 및 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업장 선택</label>
|
||||
<select id="regionWorkplaceSelect" class="form-control" style="margin-bottom: 12px;">
|
||||
<option value="">작업장을 선택하세요</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="clearCurrentRegion()">
|
||||
🗑️ 현재 영역 지우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveRegion()">
|
||||
💾 선택 영역 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정의된 영역 목록 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">정의된 영역 목록</label>
|
||||
<div id="regionList" style="background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; min-height: 100px;">
|
||||
<!-- 영역 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeLayoutMapModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/workplace-management.js?v=3"></script>
|
||||
<script type="module" src="/js/workplace-layout-map.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
277
web-ui/pages.backup.20260202/dashboard.html
Normal file
277
web-ui/pages.backup.20260202/dashboard.html
Normal file
@@ -0,0 +1,277 @@
|
||||
<!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/modern-dashboard.css?v=2">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
|
||||
<!-- 스크립트 (순서 중요: api-config.js가 먼저 로드되어야 함) -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/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>
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="dashboard-container">
|
||||
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- 빠른 작업 섹션 -->
|
||||
<section class="quick-actions-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">빠른 작업</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="quick-actions-grid-full">
|
||||
<!-- TBM 관리 (페이지 권한 체크) -->
|
||||
<a href="/pages/work/tbm.html" class="quick-action-card" id="tbmQuickAction" style="display: none; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🛠️ TBM 관리</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/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/admin/safety-checklist-manage.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📋 안전 체크리스트 관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">TBM 안전 체크 항목 관리 (기본/날씨/작업별)</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/issue-report.html" class="quick-action-card" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 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/issue-list.html" class="quick-action-card" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 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>
|
||||
<p>오늘의 작업 내용을 입력하고 관리합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-view.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>작업 현황 확인</h3>
|
||||
<p>팀원들의 작업 현황을 실시간으로 조회합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/analysis.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/projects.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/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="workplace-status-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="flex justify-between items-center">
|
||||
<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 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>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<footer class="dashboard-footer">
|
||||
<div class="footer-content">
|
||||
<p class="footer-text">
|
||||
© 2025 (주)테크니컬코리아. 모든 권리 보유.
|
||||
</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">도움말</a>
|
||||
<a href="#" class="footer-link">문의하기</a>
|
||||
<a href="#" class="footer-link">개인정보처리방침</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 알림 토스트 -->
|
||||
<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>
|
||||
317
web-ui/pages.backup.20260202/profile/info.html
Normal file
317
web-ui/pages.backup.20260202/profile/info.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!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/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: white;
|
||||
margin: 0 auto 20px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-cards {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.05rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="profile-page">
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar" id="profileAvatar">👤</div>
|
||||
<h1 class="profile-name" id="profileName">사용자</h1>
|
||||
<p class="profile-role" id="profileRole">역할</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-cards">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>📋</span>
|
||||
<span>기본 정보</span>
|
||||
</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자 ID</span>
|
||||
<span class="info-value" id="userId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">사용자명</span>
|
||||
<span class="info-value" id="username">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이름</span>
|
||||
<span class="info-value" id="fullName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">권한 레벨</span>
|
||||
<span class="info-value" id="accessLevel">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">작업자 ID</span>
|
||||
<span class="info-value" id="workerId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">가입일</span>
|
||||
<span class="info-value" id="createdAt">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 활동 정보 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>📊</span>
|
||||
<span>활동 정보</span>
|
||||
</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">마지막 로그인</span>
|
||||
<span class="info-value" id="lastLogin">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">이메일</span>
|
||||
<span class="info-value" id="email">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 간단한 통계 (준비중) -->
|
||||
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">작업 보고서</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">이번 달 활동</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number">-</span>
|
||||
<span class="stat-label">팀 기여도</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 작업 -->
|
||||
<div class="profile-card">
|
||||
<h2 class="card-title">
|
||||
<span>⚡</span>
|
||||
<span>빠른 작업</span>
|
||||
</h2>
|
||||
<div class="action-buttons">
|
||||
<a href="/pages/profile/change-password.html" class="action-btn btn-warning">
|
||||
<span>🔐</span>
|
||||
<span>비밀번호 변경</span>
|
||||
</a>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
<span>✏️</span>
|
||||
<span>프로필 수정 (준비중)</span>
|
||||
</button>
|
||||
<button class="action-btn btn-secondary" disabled>
|
||||
<span>⚙️</span>
|
||||
<span>설정 (준비중)</span>
|
||||
</button>
|
||||
<a href="javascript:history.back()" class="action-btn btn-secondary">
|
||||
<span>←</span>
|
||||
<span>돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/my-profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
391
web-ui/pages.backup.20260202/profile/password.html
Normal file
391
web-ui/pages.backup.20260202/profile/password.html
Normal file
@@ -0,0 +1,391 @@
|
||||
<!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/main-layout.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
|
||||
<style>
|
||||
/* 페이지 전용 스타일 */
|
||||
.password-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.password-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
||||
color: white;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 알림 박스 */
|
||||
.info-box {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1565c0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 폼 스타일 */
|
||||
.password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 14px 48px 14px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #f57c00;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 메시지 */
|
||||
.message-box {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message-box.error {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.message-box.success {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 하단 링크 */
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: #1565c0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.password-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-no-sidebar">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="password-page">
|
||||
<div class="page-title">
|
||||
<h1>🔐 비밀번호 변경</h1>
|
||||
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
|
||||
</div>
|
||||
|
||||
<div class="password-card">
|
||||
<div class="card-header">
|
||||
<h2>
|
||||
<span>🔑</span>
|
||||
<span>새 비밀번호 설정</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-area"></div>
|
||||
|
||||
<!-- 안내 정보 -->
|
||||
<div class="info-box">
|
||||
<h4>
|
||||
<span>ℹ️</span>
|
||||
<span>비밀번호 요구사항</span>
|
||||
</h4>
|
||||
<ul>
|
||||
<li>최소 6자 이상 입력해주세요</li>
|
||||
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
|
||||
<li>개인정보나 쉬운 단어는 피해주세요</li>
|
||||
<li>이전 비밀번호와 다르게 설정해주세요</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 폼 -->
|
||||
<form id="changePasswordForm" class="password-form">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">
|
||||
<span>🔓</span>
|
||||
<span>현재 비밀번호</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
class="form-control"
|
||||
placeholder="현재 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="currentPassword">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">
|
||||
<span>🔐</span>
|
||||
<span>새 비밀번호</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="newPassword">👁️</button>
|
||||
</div>
|
||||
<div id="passwordStrength"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">
|
||||
<span>✅</span>
|
||||
<span>새 비밀번호 확인</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-control"
|
||||
placeholder="새 비밀번호를 다시 입력하세요"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="password-toggle" data-target="confirmPassword">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<span>🔑</span>
|
||||
<span>비밀번호 변경</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="resetBtn">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="back-link">
|
||||
<a href="javascript:history.back()">
|
||||
<span>←</span>
|
||||
<span>이전 페이지로 돌아가기</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
web-ui/pages.backup.20260202/work/.gitkeep
Normal file
1
web-ui/pages.backup.20260202/work/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to create work directory
|
||||
2900
web-ui/pages.backup.20260202/work/analysis.html
Normal file
2900
web-ui/pages.backup.20260202/work/analysis.html
Normal file
File diff suppressed because it is too large
Load Diff
946
web-ui/pages.backup.20260202/work/issue-detail.html
Normal file
946
web-ui/pages.backup.20260202/work/issue-detail.html
Normal file
@@ -0,0 +1,946 @@
|
||||
<!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>
|
||||
.detail-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8px 20px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.reported { background: var(--blue-100); color: var(--blue-700); }
|
||||
.status-badge.received { background: var(--orange-100); color: var(--orange-700); }
|
||||
.status-badge.in_progress { background: var(--purple-100); color: var(--purple-700); }
|
||||
.status-badge.completed { background: var(--green-100); color: var(--green-700); }
|
||||
.status-badge.closed { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 12px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.severity-badge.critical { background: var(--red-100); color: var(--red-700); }
|
||||
.severity-badge.high { background: var(--orange-100); color: var(--orange-700); }
|
||||
.severity-badge.medium { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.severity-badge.low { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-300);
|
||||
background: white;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.action-btn.success {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.action-btn.success:hover {
|
||||
background: var(--green-600);
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--red-600);
|
||||
}
|
||||
|
||||
/* 상태 변경 이력 */
|
||||
.status-timeline {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.status-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -18px;
|
||||
top: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-500);
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 담당자 배정 모달 */
|
||||
.modal-overlay {
|
||||
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-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-form-group input,
|
||||
.modal-form-group select,
|
||||
.modal-form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 사진 확대 모달 */
|
||||
.photo-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1001;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-modal img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.photo-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="detail-container">
|
||||
<a href="/pages/work/issue-list.html" style="color: var(--primary-600); text-decoration: none; margin-bottom: 16px; display: inline-block;">
|
||||
← 목록으로
|
||||
</a>
|
||||
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-id" id="reportId"></div>
|
||||
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
|
||||
</div>
|
||||
<span class="status-badge" id="statusBadge"></span>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 정보</h2>
|
||||
<div class="info-grid" id="basicInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 신고 내용 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 내용</h2>
|
||||
<div id="issueContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="detail-section" id="photoSection" style="display: none;">
|
||||
<h2 class="section-title">첨부 사진</h2>
|
||||
<div class="photo-gallery" id="photoGallery"></div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 정보 (담당자 배정 시) -->
|
||||
<div class="detail-section" id="processSection" style="display: none;">
|
||||
<h2 class="section-title">처리 정보</h2>
|
||||
<div id="processInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 상태 이력 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">상태 변경 이력</h2>
|
||||
<div class="status-timeline" id="statusTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="action-buttons" id="actionButtons"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 담당자 배정 모달 -->
|
||||
<div class="modal-overlay" id="assignModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">담당자 배정</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>담당 부서</label>
|
||||
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
|
||||
</div>
|
||||
<div class="modal-form-group">
|
||||
<label>담당자</label>
|
||||
<select id="assignUser">
|
||||
<option value="">담당자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeAssignModal()">취소</button>
|
||||
<button class="action-btn primary" onclick="submitAssign()">배정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 완료 모달 -->
|
||||
<div class="modal-overlay" id="completeModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">처리 완료</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>처리 내용</label>
|
||||
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
|
||||
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 확대 모달 -->
|
||||
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
|
||||
<span class="photo-modal-close">×</span>
|
||||
<img id="photoModalImg" src="" alt="">
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script>
|
||||
const API_BASE = window.API_BASE_URL || 'http://localhost:20005/api';
|
||||
let currentReport = null;
|
||||
let currentUserLevel = null;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
reported: '신고',
|
||||
received: '접수',
|
||||
in_progress: '처리중',
|
||||
completed: '완료',
|
||||
closed: '종료'
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
nonconformity: '부적합',
|
||||
safety: '안전'
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS = {
|
||||
critical: '심각',
|
||||
high: '높음',
|
||||
medium: '보통',
|
||||
low: '낮음'
|
||||
};
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const reportId = urlParams.get('id');
|
||||
|
||||
if (!reportId) {
|
||||
alert('잘못된 접근입니다.');
|
||||
window.location.href = '/pages/work/issue-list.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
currentUserLevel = payload.access_level;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
await loadReport(reportId);
|
||||
await loadStatusLogs(reportId);
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
// 신고 상세 로드
|
||||
async function loadReport(reportId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${reportId}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
alert('신고를 찾을 수 없습니다.');
|
||||
} else if (response.status === 403) {
|
||||
alert('권한이 없습니다.');
|
||||
}
|
||||
window.location.href = '/pages/work/issue-list.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
currentReport = data.data;
|
||||
renderReport(currentReport);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('신고 로드 실패:', error);
|
||||
alert('데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 신고 렌더링
|
||||
function renderReport(report) {
|
||||
const baseUrl = (window.API_BASE_URL || 'http://localhost:20005').replace('/api', '');
|
||||
|
||||
// 헤더
|
||||
document.getElementById('reportId').textContent = `#${report.report_id}`;
|
||||
document.getElementById('reportTitle').textContent = report.issue_item_name || report.issue_category_name || '신고';
|
||||
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
statusBadge.textContent = STATUS_LABELS[report.status] || report.status;
|
||||
statusBadge.className = `status-badge ${report.status}`;
|
||||
|
||||
// 기본 정보
|
||||
const reportDate = new Date(report.report_date).toLocaleString('ko-KR');
|
||||
let location = report.custom_location || '';
|
||||
if (report.factory_name) {
|
||||
location = report.factory_name;
|
||||
if (report.workplace_name) {
|
||||
location += ` - ${report.workplace_name}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('basicInfo').innerHTML = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고자</div>
|
||||
<div class="info-value">${report.reporter_full_name || report.reporter_name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고 일시</div>
|
||||
<div class="info-value">${reportDate}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">발생 위치</div>
|
||||
<div class="info-value">${location || '-'}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">문제 유형</div>
|
||||
<div class="info-value">
|
||||
<span class="type-badge ${report.category_type}">${TYPE_LABELS[report.category_type]}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 신고 내용
|
||||
let contentHtml = `
|
||||
<div class="info-grid" style="margin-bottom: 16px;">
|
||||
<div class="info-item">
|
||||
<div class="info-label">카테고리</div>
|
||||
<div class="info-value">${report.issue_category_name}</div>
|
||||
</div>
|
||||
${report.issue_item_name ? `
|
||||
<div class="info-item">
|
||||
<div class="info-label">신고 항목</div>
|
||||
<div class="info-value">
|
||||
${report.issue_item_name}
|
||||
${report.severity ? `<span class="severity-badge ${report.severity}">${SEVERITY_LABELS[report.severity]}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (report.additional_description) {
|
||||
contentHtml += `
|
||||
<div class="info-label" style="margin-bottom: 8px;">추가 설명</div>
|
||||
<div class="description-text">${report.additional_description}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('issueContent').innerHTML = contentHtml;
|
||||
|
||||
// 사진
|
||||
const photos = [
|
||||
report.photo_path1,
|
||||
report.photo_path2,
|
||||
report.photo_path3,
|
||||
report.photo_path4,
|
||||
report.photo_path5
|
||||
].filter(Boolean);
|
||||
|
||||
if (photos.length > 0) {
|
||||
document.getElementById('photoSection').style.display = 'block';
|
||||
document.getElementById('photoGallery').innerHTML = photos.map(p => `
|
||||
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
|
||||
<img src="${baseUrl}${p}" alt="신고 사진" loading="lazy">
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 처리 정보
|
||||
if (report.assigned_user_id || report.resolution_notes) {
|
||||
document.getElementById('processSection').style.display = 'block';
|
||||
|
||||
let processHtml = '<div class="info-grid">';
|
||||
|
||||
if (report.assigned_full_name) {
|
||||
processHtml += `
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당자</div>
|
||||
<div class="info-value">${report.assigned_full_name}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (report.assigned_department) {
|
||||
processHtml += `
|
||||
<div class="info-item">
|
||||
<div class="info-label">담당 부서</div>
|
||||
<div class="info-value">${report.assigned_department}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
processHtml += '</div>';
|
||||
|
||||
if (report.resolution_notes) {
|
||||
processHtml += `
|
||||
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 내용</div>
|
||||
<div class="description-text">${report.resolution_notes}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 처리 완료 사진
|
||||
const resolutionPhotos = [
|
||||
report.resolution_photo_path1,
|
||||
report.resolution_photo_path2
|
||||
].filter(Boolean);
|
||||
|
||||
if (resolutionPhotos.length > 0) {
|
||||
processHtml += `
|
||||
<div class="info-label" style="margin-top: 16px; margin-bottom: 8px;">처리 완료 사진</div>
|
||||
<div class="photo-gallery">
|
||||
${resolutionPhotos.map(p => `
|
||||
<div class="photo-item" onclick="showPhoto('${baseUrl}${p}')">
|
||||
<img src="${baseUrl}${p}" alt="처리 완료 사진" loading="lazy">
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('processInfo').innerHTML = processHtml;
|
||||
}
|
||||
|
||||
// 액션 버튼
|
||||
renderActionButtons(report);
|
||||
}
|
||||
|
||||
// 액션 버튼 렌더링
|
||||
function renderActionButtons(report) {
|
||||
const buttons = [];
|
||||
const isManager = ['admin', 'system', 'support_team'].includes(currentUserLevel);
|
||||
|
||||
switch (report.status) {
|
||||
case 'reported':
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn primary" onclick="receiveReport()">접수</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'received':
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 배정</button>`);
|
||||
buttons.push(`<button class="action-btn primary" onclick="startProcessing()">처리 시작</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'in_progress':
|
||||
buttons.push(`<button class="action-btn success" onclick="showCompleteModal()">처리 완료</button>`);
|
||||
if (isManager) {
|
||||
buttons.push(`<button class="action-btn" onclick="showAssignModal()">담당자 변경</button>`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
if (['admin', 'system'].includes(currentUserLevel)) {
|
||||
buttons.push(`<button class="action-btn" onclick="closeReport()">종료</button>`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
document.getElementById('actionButtons').innerHTML = buttons.join('');
|
||||
}
|
||||
|
||||
// 상태 이력 로드
|
||||
async function loadStatusLogs(reportId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${reportId}/logs`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
renderStatusLogs(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상태 이력 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 이력 렌더링
|
||||
function renderStatusLogs(logs) {
|
||||
const timeline = document.getElementById('statusTimeline');
|
||||
|
||||
if (logs.length === 0) {
|
||||
timeline.innerHTML = '<p style="color: var(--gray-500);">이력이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
timeline.innerHTML = logs.map(log => {
|
||||
const date = new Date(log.changed_at).toLocaleString('ko-KR');
|
||||
const prevStatus = log.previous_status ? STATUS_LABELS[log.previous_status] : '(없음)';
|
||||
const newStatus = STATUS_LABELS[log.new_status] || log.new_status;
|
||||
|
||||
return `
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-status">${prevStatus} → ${newStatus}</div>
|
||||
<div class="timeline-meta">
|
||||
${log.changed_by_full_name || log.changed_by_name} | ${date}
|
||||
${log.change_reason ? `<br><span style="color: var(--gray-600);">${log.change_reason}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 사용자 목록 로드 (담당자 배정용)
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/users`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
const select = document.getElementById('assignUser');
|
||||
data.data.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.user_id;
|
||||
option.textContent = `${user.name} (${user.username})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 목록 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 접수
|
||||
async function receiveReport() {
|
||||
if (!confirm('이 신고를 접수하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/receive`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('접수되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('접수 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리 시작
|
||||
async function startProcessing() {
|
||||
if (!confirm('처리를 시작하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/start`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('처리가 시작되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('처리 시작 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 배정 모달
|
||||
function showAssignModal() {
|
||||
document.getElementById('assignModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assignModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function submitAssign() {
|
||||
const department = document.getElementById('assignDepartment').value;
|
||||
const userId = document.getElementById('assignUser').value;
|
||||
|
||||
if (!userId) {
|
||||
alert('담당자를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/assign`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
assigned_department: department,
|
||||
assigned_user_id: parseInt(userId)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('담당자가 배정되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('담당자 배정 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 처리 완료 모달
|
||||
function showCompleteModal() {
|
||||
document.getElementById('completeModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeCompleteModal() {
|
||||
document.getElementById('completeModal').classList.remove('visible');
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
const resolutionNotes = document.getElementById('resolutionNotes').value;
|
||||
|
||||
if (!resolutionNotes.trim()) {
|
||||
alert('처리 내용을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/complete`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resolution_notes: resolutionNotes
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('처리가 완료되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('처리 완료 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 종료
|
||||
async function closeReport() {
|
||||
if (!confirm('이 신고를 종료하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/work-issues/${currentReport.report_id}/close`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert('종료되었습니다.');
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('종료 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 확대
|
||||
function showPhoto(src) {
|
||||
document.getElementById('photoModalImg').src = src;
|
||||
document.getElementById('photoModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closePhotoModal() {
|
||||
document.getElementById('photoModal').classList.remove('visible');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
301
web-ui/pages.backup.20260202/work/issue-list.html
Normal file
301
web-ui/pages.backup.20260202/work/issue-list.html
Normal file
@@ -0,0 +1,301 @@
|
||||
<!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>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.stat-card.reported .stat-number { color: var(--blue-600); }
|
||||
.stat-card.received .stat-number { color: var(--orange-600); }
|
||||
.stat-card.in_progress .stat-number { color: var(--purple-600); }
|
||||
.stat-card.completed .stat-number { color: var(--green-600); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.filter-bar select:focus,
|
||||
.filter-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
margin-left: auto;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-new-report:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.issue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.issue-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-status.reported {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.issue-status.received {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-status.in_progress {
|
||||
background: var(--purple-100);
|
||||
color: var(--purple-700);
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.issue-status.closed {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.issue-photos {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.issue-photos img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고 목록</h1>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card reported">
|
||||
<div class="stat-number" id="statReported">-</div>
|
||||
<div class="stat-label">신고</div>
|
||||
</div>
|
||||
<div class="stat-card received">
|
||||
<div class="stat-number" id="statReceived">-</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress">
|
||||
<div class="stat-number" id="statProgress">-</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-number" id="statCompleted">-</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 바 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="reported">신고</option>
|
||||
<option value="received">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
|
||||
<select id="filterType">
|
||||
<option value="">전체 유형</option>
|
||||
<option value="nonconformity">부적합</option>
|
||||
<option value="safety">안전</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="filterStartDate" title="시작일">
|
||||
<input type="date" id="filterEndDate" title="종료일">
|
||||
|
||||
<a href="/pages/work/issue-report.html" class="btn-new-report">
|
||||
+ 새 신고
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="issue-list" id="issueList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-list.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
618
web-ui/pages.backup.20260202/work/issue-report.html
Normal file
618
web-ui/pages.backup.20260202/work/issue-report.html
Normal file
@@ -0,0 +1,618 @@
|
||||
<!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>
|
||||
.issue-form-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
/* 지도 선택 영역 */
|
||||
.map-container {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#issueMapCanvas {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selected-location-info {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.selected-location-info.empty {
|
||||
background: var(--gray-50);
|
||||
border-left-color: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-location-toggle {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-location-toggle input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.custom-location-input {
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-location-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 유형 선택 버튼 */
|
||||
.type-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.nonconformity.selected {
|
||||
border-color: var(--orange-500);
|
||||
background: var(--orange-50);
|
||||
}
|
||||
|
||||
.type-btn.safety.selected {
|
||||
border-color: var(--red-500);
|
||||
background: var(--red-50);
|
||||
}
|
||||
|
||||
.type-btn-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-btn-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 카테고리 선택 */
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.category-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 사전 정의 항목 선택 */
|
||||
.item-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.item-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.item-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"] {
|
||||
border-color: var(--red-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"].selected {
|
||||
background: var(--red-500);
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"] {
|
||||
border-color: var(--orange-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"].selected {
|
||||
background: var(--orange-500);
|
||||
border-color: var(--orange-500);
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.photo-slot:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.photo-slot.has-photo {
|
||||
border-style: solid;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.photo-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.photo-slot .remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .add-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 추가 설명 */
|
||||
.additional-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.additional-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 16px 48px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* 작업 선택 모달 */
|
||||
.work-selection-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;
|
||||
}
|
||||
|
||||
.work-selection-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-selection-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.work-selection-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.work-option {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.work-option:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.work-option-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.work-option-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.type-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.photo-upload-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고</h1>
|
||||
<p class="page-description">작업 중 발견된 부적합 사항 또는 안전 문제를 신고합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="issue-form-container">
|
||||
<!-- 단계 표시 -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">위치 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">유형 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">항목 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="4">
|
||||
<span class="step-number">4</span>
|
||||
<span class="step-text">사진/설명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 위치 선택 -->
|
||||
<div class="form-section" id="step1Section">
|
||||
<h2 class="form-section-title">1. 발생 위치 선택</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="factorySelect">공장 선택</label>
|
||||
<select id="factorySelect">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="selected-location-info empty" id="selectedLocationInfo">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
<div class="custom-location-toggle">
|
||||
<input type="checkbox" id="useCustomLocation">
|
||||
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-location-input" id="customLocationInput">
|
||||
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 문제 유형 선택 -->
|
||||
<div class="form-section" id="step2Section">
|
||||
<h2 class="form-section-title">2. 문제 유형 선택</h2>
|
||||
|
||||
<div class="type-buttons">
|
||||
<div class="type-btn nonconformity" data-type="nonconformity">
|
||||
<div class="type-btn-title">부적합 사항</div>
|
||||
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
|
||||
</div>
|
||||
<div class="type-btn safety" data-type="safety">
|
||||
<div class="type-btn-title">안전 관련</div>
|
||||
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categoryContainer" style="display: none;">
|
||||
<label style="font-weight: 600; margin-bottom: 12px; display: block;">세부 카테고리</label>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 신고 항목 선택 -->
|
||||
<div class="form-section" id="step3Section">
|
||||
<h2 class="form-section-title">3. 신고 항목 선택</h2>
|
||||
<p style="color: var(--gray-500); margin-bottom: 16px;">해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.</p>
|
||||
|
||||
<div class="item-grid" id="itemGrid">
|
||||
<p style="color: var(--gray-400);">먼저 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 사진 및 추가 설명 -->
|
||||
<div class="form-section" id="step4Section">
|
||||
<h2 class="form-section-title">4. 사진 및 추가 설명</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사진 첨부 (최대 5장)</label>
|
||||
<div class="photo-upload-grid">
|
||||
<div class="photo-slot" data-index="0">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="1">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="2">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="3">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="4">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="additionalDescription">추가 설명 (선택)</label>
|
||||
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 선택 모달 -->
|
||||
<div class="work-selection-modal" id="workSelectionModal">
|
||||
<div class="work-selection-content">
|
||||
<h3 class="work-selection-title">작업 선택</h3>
|
||||
<p style="margin-bottom: 16px; color: var(--gray-600);">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
|
||||
<div id="workOptionsList"></div>
|
||||
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 12px; margin-top: 8px; background: var(--gray-100); border: none; border-radius: var(--radius-md); cursor: pointer;">
|
||||
작업 연결 없이 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-report.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
180
web-ui/pages.backup.20260202/work/report-create.html
Normal file
180
web-ui/pages.backup.20260202/work/report-create.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!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/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>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="work-report-main">
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu" style="margin-bottom: var(--space-6);">
|
||||
<button class="tab-btn active" id="tbmReportTab" onclick="switchTab('tbm')">
|
||||
작업보고서 작성
|
||||
</button>
|
||||
<button class="tab-btn" id="completedReportTab" onclick="switchTab('completed')">
|
||||
작성 완료 보고서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- TBM 작업보고 섹션 -->
|
||||
<div id="tbmReportSection" class="step-section active">
|
||||
<!-- TBM 작업 목록 -->
|
||||
<div id="tbmWorkList">
|
||||
<!-- TBM 작업 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성 완료 보고서 섹션 -->
|
||||
<div id="completedReportSection" class="step-section" style="display: none;">
|
||||
<!-- 날짜 선택 필터 -->
|
||||
<div class="form-group" style="max-width: 300px; margin-bottom: var(--space-5);">
|
||||
<label for="completedReportDate" class="form-label">조회 날짜</label>
|
||||
<input type="date" id="completedReportDate" class="form-input" onchange="loadCompletedReports()">
|
||||
</div>
|
||||
|
||||
<!-- 완료된 보고서 목록 -->
|
||||
<div id="completedReportsList">
|
||||
<!-- 완료된 보고서들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 저장 결과 모달 -->
|
||||
<div id="saveResultModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container result-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="resultModalTitle">저장 결과</h2>
|
||||
<button class="modal-close-btn" onclick="closeSaveResultModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="resultModalContent" class="result-content">
|
||||
<!-- 결과 내용이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="closeSaveResultModal()">
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업장소 선택 모달 (지도 기반) -->
|
||||
<div id="workplaceModal" class="modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1002; align-items: center; justify-content: center; overflow-y: auto; padding: 2rem 0;">
|
||||
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 1000px; width: 90%; max-height: none; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column;">
|
||||
<div class="modal-header" style="padding: 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0;">
|
||||
<span style="margin-right: 0.5rem;">🗺️</span>작업장소 선택
|
||||
</h2>
|
||||
<button class="modal-close" onclick="closeWorkplaceModal()" style="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;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 1.5rem; flex: 1; overflow-y: visible;">
|
||||
<!-- 1단계: 카테고리 선택 -->
|
||||
<div id="categorySelectionArea">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">🏭</span>공장 선택
|
||||
</h3>
|
||||
<div id="workplaceCategoryList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 카테고리 버튼들 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2단계: 작업장 선택 (지도 + 리스트) -->
|
||||
<div id="workplaceSelectionArea" style="display: none; margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
|
||||
<span style="margin-right: 0.5rem;">📍</span>
|
||||
<span id="selectedCategoryTitle">작업장 선택</span>
|
||||
</h3>
|
||||
|
||||
<!-- 지도 기반 선택 영역 -->
|
||||
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">
|
||||
<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 style="margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||
<span style="font-size: 0.875rem; color: #6b7280;">
|
||||
<span style="margin-right: 0.25rem;">📋</span>
|
||||
리스트에서 선택
|
||||
</span>
|
||||
</div>
|
||||
<div id="workplaceListArea" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;">
|
||||
<!-- 작업장소 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmWorkplaceBtn" onclick="confirmWorkplaceSelection()" disabled>선택 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시간 선택 팝오버 -->
|
||||
<div id="timePickerOverlay" class="time-picker-overlay" style="display: none;" onclick="closeTimePicker()">
|
||||
<div class="time-picker-popup" onclick="event.stopPropagation()">
|
||||
<div class="time-picker-header">
|
||||
<h3 id="timePickerTitle">작업시간 선택</h3>
|
||||
<button class="time-picker-close" onclick="closeTimePicker()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="quick-time-grid">
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(0.5)">
|
||||
<span class="time-value">30분</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(1)">
|
||||
<span class="time-value">1시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(2)">
|
||||
<span class="time-value">2시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(4)">
|
||||
<span class="time-value">4시간</span>
|
||||
</button>
|
||||
<button type="button" class="time-btn" onclick="setTimeValue(8)">
|
||||
<span class="time-value">8시간</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="time-adjust-area">
|
||||
<span class="current-time-label">현재:</span>
|
||||
<strong id="currentTimeDisplay" class="current-time-value">0시간</strong>
|
||||
<div class="adjust-buttons">
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(-0.5)">-30분</button>
|
||||
<button type="button" class="adjust-btn" onclick="adjustTime(0.5)">+30분</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="confirm-btn" onclick="confirmTimeSelection()">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<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=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
294
web-ui/pages.backup.20260202/work/report-view.html
Normal file
294
web-ui/pages.backup.20260202/work/report-view.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>작업 현황 확인 - TK 건설</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=13">
|
||||
<link rel="stylesheet" href="/css/modern-dashboard.css?v=14">
|
||||
<link rel="stylesheet" href="/css/work-report-calendar.css?v=29">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 헤더 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="dashboard-main">
|
||||
<div class="calendar-page-container">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="page-title-section">
|
||||
<h2 class="page-title">📅 작업 현황 확인</h2>
|
||||
<p class="page-subtitle">월별 작업자 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 카드 -->
|
||||
<div class="calendar-card">
|
||||
<!-- 월 네비게이션 -->
|
||||
<div class="calendar-nav">
|
||||
<button id="prevMonthBtn" class="nav-btn prev-btn">
|
||||
<span class="nav-icon">‹</span>
|
||||
<span class="nav-text">이전</span>
|
||||
</button>
|
||||
|
||||
<div class="calendar-title">
|
||||
<h3 id="monthYearTitle">2025년 11월</h3>
|
||||
<button id="todayBtn" class="today-btn">오늘</button>
|
||||
</div>
|
||||
|
||||
<button id="nextMonthBtn" class="nav-btn next-btn">
|
||||
<span class="nav-text">다음</span>
|
||||
<span class="nav-icon">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 범례 -->
|
||||
<div class="calendar-legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-overtime-warning"></div>
|
||||
<span>확인필요</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-errors"></div>
|
||||
<span>미입력</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-issues"></div>
|
||||
<span>부분입력</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot has-normal"></div>
|
||||
<span>이상 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 -->
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-header">
|
||||
<div class="day-header sunday">일</div>
|
||||
<div class="day-header">월</div>
|
||||
<div class="day-header">화</div>
|
||||
<div class="day-header">수</div>
|
||||
<div class="day-header">목</div>
|
||||
<div class="day-header">금</div>
|
||||
<div class="day-header saturday">토</div>
|
||||
</div>
|
||||
<div class="calendar-days" id="calendarDays">
|
||||
<!-- 캘린더 날짜들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 로딩 스피너 -->
|
||||
<div id="loadingSpinner" class="loading-overlay" style="display: none;">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일일 작업 현황 모달 -->
|
||||
<div id="dailyWorkModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container large-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">2025년 11월 3일 작업 현황</h2>
|
||||
<button class="modal-close-btn" onclick="closeDailyWorkModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 요약 정보 -->
|
||||
<div class="daily-summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon success">👥</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">총 작업자</div>
|
||||
<div class="summary-value" id="modalTotalWorkers">0명</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon primary">⏰</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">총 작업시간</div>
|
||||
<div class="summary-value" id="modalTotalHours">0.0h</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon warning">📝</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">작업 건수</div>
|
||||
<div class="summary-value" id="modalTotalTasks">0건</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon error">⚠️</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-label">오류 건수</div>
|
||||
<div class="summary-value" id="modalErrorCount">0건</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 현황 리스트 -->
|
||||
<div class="modal-work-status">
|
||||
<div class="work-status-header">
|
||||
<h3>작업자별 현황</h3>
|
||||
<div class="status-filter">
|
||||
<select id="statusFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="incomplete">미입력</option>
|
||||
<option value="partial">부분입력</option>
|
||||
<option value="complete">완료</option>
|
||||
<option value="overtime">연장근로</option>
|
||||
<option value="error">오류</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalWorkersList" class="worker-status-list">
|
||||
<!-- 작업자 리스트가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="modalNoData" class="empty-state" style="display: none;">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>해당 날짜의 작업 보고서가 없습니다</h3>
|
||||
<p>다른 날짜를 선택해 주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 입력/수정 모달 -->
|
||||
<div id="workEntryModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container large-modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="workEntryModalTitle">작업 관리</h2>
|
||||
<button class="modal-close-btn" onclick="closeWorkEntryModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="modal-tabs">
|
||||
<button class="tab-btn active" data-tab="existing" onclick="switchTab('existing')">
|
||||
📋 기존 작업 (0건)
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="new" onclick="switchTab('new')">
|
||||
➕ 새 작업 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기존 작업 목록 탭 -->
|
||||
<div id="existingWorkTab" class="tab-content active">
|
||||
<div class="existing-work-header">
|
||||
<h3>등록된 작업 목록</h3>
|
||||
<div class="work-summary" id="workSummary">
|
||||
총 <span id="totalWorkCount">0</span>건 | 총 <span id="totalWorkHours">0</span>시간
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="existingWorkList" class="existing-work-list">
|
||||
<!-- 기존 작업들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="noExistingWork" class="empty-state" style="display: none;">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h3>등록된 작업이 없습니다</h3>
|
||||
<p>"새 작업 추가" 탭에서 작업을 등록해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 작업 추가 탭 -->
|
||||
<div id="newWorkTab" class="tab-content">
|
||||
<form id="workEntryForm">
|
||||
<!-- 작업자 정보 -->
|
||||
<div class="form-section">
|
||||
<h3>작업자 정보</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업자</label>
|
||||
<input type="text" id="workerNameDisplay" class="form-control" readonly>
|
||||
<input type="hidden" id="workerId">
|
||||
<input type="hidden" id="editingWorkId">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 날짜</label>
|
||||
<input type="date" id="workDate" class="form-control" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 내용 -->
|
||||
<div class="form-section">
|
||||
<h3 id="workContentTitle">작업 내용</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">프로젝트 *</label>
|
||||
<select id="projectSelect" class="form-control" required>
|
||||
<option value="">프로젝트를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 유형 *</label>
|
||||
<select id="workTypeSelect" class="form-control" required>
|
||||
<option value="">작업 유형을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 시간 (시간) *</label>
|
||||
<input type="number" id="workHours" class="form-control" min="0" max="24" step="0.5" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 상태 *</label>
|
||||
<select id="workStatusSelect" class="form-control" required>
|
||||
<option value="">상태를 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">오류 유형</label>
|
||||
<select id="errorTypeSelect" class="form-control">
|
||||
<option value="">오류 유형 (선택사항)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업 설명</label>
|
||||
<textarea id="workDescription" class="form-control" rows="3" placeholder="작업 내용을 상세히 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 휴가 처리 -->
|
||||
<div class="form-section" id="vacationSection">
|
||||
<h3>휴가 처리</h3>
|
||||
<div class="vacation-buttons">
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('full')">연차 (8시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('half')">반차 (4시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('quarter')">반반차 (2시간)</button>
|
||||
<button type="button" class="btn-vacation" onclick="handleVacation('early')">조퇴 (6시간)</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeWorkEntryModal()">취소</button>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="btn btn-danger" id="deleteWorkBtn" onclick="deleteWork()" style="display: none;">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="saveWorkBtn" onclick="saveWorkEntry()">
|
||||
💾 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script type="module" src="/js/api-config.js?v=13"></script>
|
||||
<script type="module" src="/js/auth-check.js?v=13"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script src="/js/modules/calendar/CalendarState.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarAPI.js?v=1"></script>
|
||||
<script src="/js/modules/calendar/CalendarView.js?v=1"></script>
|
||||
<script src="/js/work-report-calendar.js?v=41"></script>
|
||||
</body>
|
||||
</html>
|
||||
650
web-ui/pages.backup.20260202/work/tbm.html
Normal file
650
web-ui/pages.backup.20260202/work/tbm.html
Normal file
@@ -0,0 +1,650 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TBM 관리 | (주)테크니컬코리아</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="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>
|
||||
.date-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.date-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
.date-group-header.today {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
.date-group-date {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.date-group-day {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.date-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.date-group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.date-group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</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">
|
||||
<span class="title-icon">🛠️</span>
|
||||
TBM (Tool Box Meeting)
|
||||
</h1>
|
||||
<p class="page-description">아침 안전 회의 및 팀 구성 관리</p>
|
||||
</div>
|
||||
|
||||
<div class="page-actions" id="headerActions">
|
||||
<!-- 탭에 따라 동적으로 변경됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 탭 -->
|
||||
<div class="code-tabs">
|
||||
<button class="tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
|
||||
<span class="tab-icon">➕</span>
|
||||
TBM 입력
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
|
||||
<span class="tab-icon">📋</span>
|
||||
TBM 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TBM 입력 탭 -->
|
||||
<div id="tbm-input-tab" class="code-tab-content active">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">🌅</span>
|
||||
오늘의 TBM
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
<span class="btn-icon">➕</span>
|
||||
새 TBM 시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
오늘 등록 <span id="todayTotalSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="todayCompletedSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">⏳</span>
|
||||
진행중 <span id="todayActiveSessions">0</span>개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="todayTbmGrid">
|
||||
<!-- 오늘의 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="todayEmptyState" style="display: none;">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>오늘 등록된 TBM이 없습니다</h3>
|
||||
<p>"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
|
||||
<button class="btn btn-primary" onclick="openNewTbmModal()">
|
||||
➕ 첫 TBM 시작하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 관리 탭 -->
|
||||
<div id="tbm-manage-tab" class="code-tab-content">
|
||||
<div class="code-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">📚</span>
|
||||
TBM 기록
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<button class="btn btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
|
||||
<span class="btn-icon">📅</span>
|
||||
더 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-stats">
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">📋</span>
|
||||
총 <span id="totalSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="completedSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span class="stat-icon">👤</span>
|
||||
<span id="viewModeText">내 TBM만</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 날짜별 그룹 컨테이너 -->
|
||||
<div id="tbmDateGroupsContainer">
|
||||
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<div class="empty-icon">🛠️</div>
|
||||
<h3>등록된 TBM 세션이 없습니다</h3>
|
||||
<p>TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<div id="tbmModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 1000px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">새 TBM 시작</h2>
|
||||
<button class="modal-close-btn" onclick="closeTbmModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<!-- 고정 정보 섹션 -->
|
||||
<div class="form-section" style="background: #f9fafb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1.5rem;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">TBM 날짜 *</label>
|
||||
<input type="date" id="sessionDate" class="form-control" required readonly style="background: #e5e7eb;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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-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 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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTbmModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTbmSession()">
|
||||
💾 저장하기
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 모달 -->
|
||||
<div id="teamModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>팀 구성</h2>
|
||||
<button class="modal-close-btn" onclick="closeTeamModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="section-header" style="margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600;">작업자 선택</h3>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-secondary" onclick="selectAllWorkers()">전체 선택</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="deselectAllWorkers()">전체 해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerSelectionGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem; max-height: 400px; overflow-y: auto; padding: 0.5rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<!-- 작업자 체크박스 목록이 여기에 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem;">선택된 팀원 <span id="selectedCount">0</span>명</h3>
|
||||
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem;">
|
||||
<p style="margin: 0; color: #9ca3af; font-size: 0.875rem;">작업자를 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTeamModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTeamComposition()">
|
||||
👥 팀 구성 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크리스트 모달 -->
|
||||
<div id="safetyModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h2>안전 체크리스트</h2>
|
||||
<button class="modal-close-btn" onclick="closeSafetyModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div id="safetyChecklistContainer" style="max-height: 500px; overflow-y: auto;">
|
||||
<!-- 안전 체크리스트가 여기에 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeSafetyModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSafetyChecklist()">
|
||||
✅ 안전 체크 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 완료 모달 -->
|
||||
<div id="completeModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 완료</h2>
|
||||
<button class="modal-close-btn" onclick="closeCompleteModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 1rem;">이 TBM 세션을 완료 처리하시겠습니까?</p>
|
||||
<p style="color: #6b7280; font-size: 0.875rem;">완료 후에는 수정할 수 없습니다.</p>
|
||||
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<label class="form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCompleteModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="completeTbmSession()">
|
||||
✅ 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 인계 모달 -->
|
||||
<div id="handoverModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h2>작업 인계</h2>
|
||||
<button class="modal-close-btn" onclick="closeHandoverModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="handoverForm">
|
||||
<input type="hidden" id="handoverSessionId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 사유 *</label>
|
||||
<select id="handoverReason" class="form-control" required>
|
||||
<option value="">사유 선택...</option>
|
||||
<option value="half_day">반차</option>
|
||||
<option value="early_leave">조퇴</option>
|
||||
<option value="emergency">긴급 상황</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인수자 (다음 팀장) *</label>
|
||||
<select id="toLeaderId" class="form-control" required>
|
||||
<option value="">인수자 선택...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 날짜 *</label>
|
||||
<input type="date" id="handoverDate" class="form-control" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 시간</label>
|
||||
<input type="time" id="handoverTime" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">인계 내용</label>
|
||||
<textarea id="handoverNotes" class="form-control" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="margin-bottom: 0.75rem; display: block;">인계할 팀원 선택</label>
|
||||
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
|
||||
<!-- 팀원 체크박스 목록 -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeHandoverModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveHandover()">
|
||||
📤 인계 요청
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TBM 상세보기 모달 -->
|
||||
<div id="detailModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-container" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>TBM 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||
<!-- 세션 기본 정보 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">기본 정보</h3>
|
||||
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 팀 구성 -->
|
||||
<div class="section" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">팀 구성</h3>
|
||||
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 안전 체크 -->
|
||||
<div class="section">
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: #374151;">안전 체크리스트</h3>
|
||||
<div id="detailSafetyChecks">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/tbm.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -102,7 +102,7 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디 *</label>
|
||||
<input type="text" id="userId" class="form-control" required>
|
||||
<small class="form-help">영문, 숫자만 사용 가능 (4-20자)</small>
|
||||
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
|
||||
493
web-ui/pages/admin/attendance-report.html
Normal file
493
web-ui/pages/admin/attendance-report.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>
|
||||
143
web-ui/pages/attendance/annual-overview.html
Normal file
143
web-ui/pages/attendance/annual-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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user