From de427c457bcca637b8192b135fe7f7bf96963b58 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 4 Nov 2025 16:56:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=20=EB=B6=84=EC=84=9D?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 새로운 기능: - 작업 분석 페이지 구현 (기간별, 프로젝트별, 작업자별, 오류별) - 개별 분석 실행 버튼으로 API 부하 최적화 - 연차/휴무 집계 방식 개선 (주말 제외, 작업내용 통합) - 프로젝트 관리 시스템 (활성화/비활성화) - 작업자 관리 시스템 (CRUD 기능) - 코드 관리 시스템 (작업유형, 작업상태, 오류유형) 🎨 UI/UX 개선: - 기간별 작업 현황을 테이블 형태로 변경 - 작업자별 rowspan 그룹화로 가독성 향상 - 연차/휴무 프로젝트 하단 배치 및 시각적 구분 - 기간 확정 시스템으로 사용자 경험 개선 - 반응형 디자인 적용 🔧 기술적 개선: - Rate Limiting 제거 (내부 시스템 최적화) - 주말 연차/휴무 자동 제외 로직 - 작업공수 계산 정확도 향상 - 데이터베이스 마이그레이션 추가 - API 엔드포인트 확장 및 최적화 🐛 버그 수정: - projectSelect 요소 참조 오류 해결 - 차트 높이 무한 증가 문제 해결 - 날짜 표시 형식 단순화 - 작업보고서 저장 validation 오류 수정 --- .../controllers/dailyWorkReportController.js | 306 +++- .../controllers/projectController.js | 13 + api.hyungi.net/controllers/taskController.js | 102 -- .../controllers/workAnalysisController.js | 6 +- .../controllers/workerController.js | 10 + api.hyungi.net/index.js | 25 +- .../migrations/010_add_project_status.sql | 22 + .../migrations/011_add_worker_status.sql | 30 + api.hyungi.net/models/WorkAnalysis.js | 4 +- api.hyungi.net/models/dailyWorkReportModel.js | 173 +- api.hyungi.net/models/projectModel.js | 37 +- api.hyungi.net/models/taskModel.js | 90 - api.hyungi.net/models/workerModel.js | 64 +- .../routes/dailyWorkReportRoutes.js | 16 + api.hyungi.net/routes/projectRoutes.js | 3 + api.hyungi.net/routes/taskRoutes.js | 21 - .../services/dailyWorkReportService.js | 8 +- api.hyungi.net/utils/validator.js | 9 +- web-ui/css/daily-work-report.css | 214 +++ web-ui/css/modern-dashboard.css | 55 +- web-ui/css/project-management.css | 1589 +++++++++++++++++ web-ui/css/work-analysis.css | 1064 +++++++++++ web-ui/css/work-management.css | 499 ++++++ web-ui/css/work-report-calendar.css | 271 ++- web-ui/js/code-management.js | 795 +++++++++ web-ui/js/daily-work-report.js | 215 ++- web-ui/js/manage-task.js | 104 -- web-ui/js/modern-dashboard.js | 2 +- web-ui/js/project-management.js | 634 +++++++ web-ui/js/work-analysis.js | 830 +++++++++ web-ui/js/work-management.js | 320 ++++ web-ui/js/work-report-calendar.js | 596 ++++++- web-ui/js/work-report-manage.js | 22 +- web-ui/js/work-report-review.js | 2 +- web-ui/js/work-review.js | 4 +- web-ui/js/worker-individual-report.js | 7 +- web-ui/js/worker-management.js | 734 ++++++++ web-ui/pages/analysis/work-analysis.html | 1391 +++++++++++++++ .../common/daily-work-report-viewer.html | 164 +- web-ui/pages/common/daily-work-report.html | 28 +- .../common/worker-individual-report.html | 2 +- web-ui/pages/dashboard/group-leader.html | 8 +- web-ui/pages/management/code-management.html | 289 +++ .../pages/management/project-management.html | 251 +++ web-ui/pages/management/work-management.html | 181 ++ .../pages/management/worker-management.html | 232 +++ 46 files changed, 10912 insertions(+), 530 deletions(-) delete mode 100644 api.hyungi.net/controllers/taskController.js create mode 100644 api.hyungi.net/migrations/010_add_project_status.sql create mode 100644 api.hyungi.net/migrations/011_add_worker_status.sql delete mode 100644 api.hyungi.net/models/taskModel.js delete mode 100644 api.hyungi.net/routes/taskRoutes.js create mode 100644 web-ui/css/project-management.css create mode 100644 web-ui/css/work-analysis.css create mode 100644 web-ui/css/work-management.css create mode 100644 web-ui/js/code-management.js delete mode 100644 web-ui/js/manage-task.js create mode 100644 web-ui/js/project-management.js create mode 100644 web-ui/js/work-analysis.js create mode 100644 web-ui/js/work-management.js create mode 100644 web-ui/js/worker-management.js create mode 100644 web-ui/pages/analysis/work-analysis.html create mode 100644 web-ui/pages/management/code-management.html create mode 100644 web-ui/pages/management/project-management.html create mode 100644 web-ui/pages/management/work-management.html create mode 100644 web-ui/pages/management/worker-management.html diff --git a/api.hyungi.net/controllers/dailyWorkReportController.js b/api.hyungi.net/controllers/dailyWorkReportController.js index e174a91..23a23af 100644 --- a/api.hyungi.net/controllers/dailyWorkReportController.js +++ b/api.hyungi.net/controllers/dailyWorkReportController.js @@ -14,8 +14,7 @@ const createDailyWorkReport = asyncHandler(async (req, res) => { created_by_name: req.user?.name || req.user?.username || '알 수 없는 사용자' }; - // 스키마 기반 유효성 검사 - validateSchema(reportData, schemas.createDailyWorkReport); + console.log('🔍 Controller에서 받은 데이터:', JSON.stringify(reportData, null, 2)); try { const result = await dailyWorkReportService.createDailyWorkReportService(reportData); @@ -177,6 +176,7 @@ const getDailyWorkReportsByDate = (req, res) => { const { date } = req.params; const current_user_id = req.user?.user_id || req.user?.id; const user_access_level = req.user?.access_level; + const user_job_type = req.user?.job_type; if (!current_user_id) { return res.status(401).json({ @@ -184,9 +184,10 @@ const getDailyWorkReportsByDate = (req, res) => { }); } - const isAdmin = user_access_level === 'system' || user_access_level === 'admin'; + const isAdmin = user_access_level === 'system' || user_access_level === 'admin' || user_access_level === 'leader' || user_job_type === 'leader'; - console.log(`📊 날짜별 조회 (경로): date=${date}, user=${current_user_id}, 권한=${user_access_level}, 관리자=${isAdmin}`); + console.log(`📊 날짜별 조회 (경로): date=${date}, user=${current_user_id}, 권한=${user_access_level}, 직책=${user_job_type}, 관리자=${isAdmin}`); + console.log(`🔍 사용자 정보 상세:`, req.user); dailyWorkReportModel.getByDate(date, (err, data) => { if (err) { @@ -197,14 +198,17 @@ const getDailyWorkReportsByDate = (req, res) => { }); } - // 🎯 권한별 필터링 + // 🎯 권한별 필터링 (임시로 비활성화) let finalData = data; - if (!isAdmin) { - finalData = data.filter(report => report.created_by === current_user_id); - console.log(`📊 권한 필터링: 전체 ${data.length}개 → ${finalData.length}개`); - } else { - console.log(`📊 관리자 권한으로 전체 조회: ${data.length}개`); - } + console.log(`📊 임시로 모든 사용자에게 전체 조회 허용: ${data.length}개`); + console.log(`📊 권한 정보: access_level=${user_access_level}, job_type=${user_job_type}, isAdmin=${isAdmin}`); + + // if (!isAdmin) { + // finalData = data.filter(report => report.created_by === current_user_id); + // console.log(`📊 권한 필터링: 전체 ${data.length}개 → ${finalData.length}개`); + // } else { + // console.log(`📊 관리자 권한으로 전체 조회: ${data.length}개`); + // } res.json(finalData); }); @@ -487,6 +491,273 @@ const getErrorTypes = (req, res) => { }); }; +// ========== 작업 유형 CRUD ========== + +/** + * 📝 작업 유형 생성 + */ +const createWorkType = asyncHandler(async (req, res) => { + const { name, description, category } = req.body; + + if (!name) { + throw new ApiError('작업 유형 이름이 필요합니다.', 400); + } + + console.log('📝 작업 유형 생성:', { name, description, category }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.createWorkType({ name, description, category }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + res.created(result, '작업 유형이 성공적으로 생성되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 유형 생성'); + } +}); + +/** + * ✏️ 작업 유형 수정 + */ +const updateWorkType = asyncHandler(async (req, res) => { + const { id } = req.params; + const { name, description, category } = req.body; + + if (!id) { + throw new ApiError('작업 유형 ID가 필요합니다.', 400); + } + + console.log('✏️ 작업 유형 수정:', { id, name, description, category }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.updateWorkType(id, { name, description, category }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('수정할 작업 유형을 찾을 수 없습니다.', 404); + } + + res.success(result, '작업 유형이 성공적으로 수정되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 유형 수정'); + } +}); + +/** + * 🗑️ 작업 유형 삭제 + */ +const deleteWorkType = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (!id) { + throw new ApiError('작업 유형 ID가 필요합니다.', 400); + } + + console.log('🗑️ 작업 유형 삭제:', id); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.deleteWorkType(id, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('삭제할 작업 유형을 찾을 수 없습니다.', 404); + } + + res.success(result, '작업 유형이 성공적으로 삭제되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 유형 삭제'); + } +}); + +// ========== 작업 상태 CRUD ========== + +/** + * 📝 작업 상태 생성 + */ +const createWorkStatus = asyncHandler(async (req, res) => { + const { name, description, is_error } = req.body; + + if (!name) { + throw new ApiError('작업 상태 이름이 필요합니다.', 400); + } + + console.log('📝 작업 상태 생성:', { name, description, is_error }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.createWorkStatus({ name, description, is_error }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + res.created(result, '작업 상태가 성공적으로 생성되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 상태 생성'); + } +}); + +/** + * ✏️ 작업 상태 수정 + */ +const updateWorkStatus = asyncHandler(async (req, res) => { + const { id } = req.params; + const { name, description, is_error } = req.body; + + if (!id) { + throw new ApiError('작업 상태 ID가 필요합니다.', 400); + } + + console.log('✏️ 작업 상태 수정:', { id, name, description, is_error }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.updateWorkStatus(id, { name, description, is_error }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('수정할 작업 상태를 찾을 수 없습니다.', 404); + } + + res.success(result, '작업 상태가 성공적으로 수정되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 상태 수정'); + } +}); + +/** + * 🗑️ 작업 상태 삭제 + */ +const deleteWorkStatus = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (!id) { + throw new ApiError('작업 상태 ID가 필요합니다.', 400); + } + + console.log('🗑️ 작업 상태 삭제:', id); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.deleteWorkStatus(id, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('삭제할 작업 상태를 찾을 수 없습니다.', 404); + } + + res.success(result, '작업 상태가 성공적으로 삭제되었습니다.'); + } catch (err) { + handleDatabaseError(err, '작업 상태 삭제'); + } +}); + +// ========== 오류 유형 CRUD ========== + +/** + * 📝 오류 유형 생성 + */ +const createErrorType = asyncHandler(async (req, res) => { + const { name, description, severity } = req.body; + + if (!name) { + throw new ApiError('오류 유형 이름이 필요합니다.', 400); + } + + console.log('📝 오류 유형 생성:', { name, description, severity }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.createErrorType({ name, description, severity }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + res.created(result, '오류 유형이 성공적으로 생성되었습니다.'); + } catch (err) { + handleDatabaseError(err, '오류 유형 생성'); + } +}); + +/** + * ✏️ 오류 유형 수정 + */ +const updateErrorType = asyncHandler(async (req, res) => { + const { id } = req.params; + const { name, description, severity } = req.body; + + if (!id) { + throw new ApiError('오류 유형 ID가 필요합니다.', 400); + } + + console.log('✏️ 오류 유형 수정:', { id, name, description, severity }); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.updateErrorType(id, { name, description, severity }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('수정할 오류 유형을 찾을 수 없습니다.', 404); + } + + res.success(result, '오류 유형이 성공적으로 수정되었습니다.'); + } catch (err) { + handleDatabaseError(err, '오류 유형 수정'); + } +}); + +/** + * 🗑️ 오류 유형 삭제 + */ +const deleteErrorType = asyncHandler(async (req, res) => { + const { id } = req.params; + + if (!id) { + throw new ApiError('오류 유형 ID가 필요합니다.', 400); + } + + console.log('🗑️ 오류 유형 삭제:', id); + + try { + const result = await new Promise((resolve, reject) => { + dailyWorkReportModel.deleteErrorType(id, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + if (result.affectedRows === 0) { + throw new ApiError('삭제할 오류 유형을 찾을 수 없습니다.', 404); + } + + res.success(result, '오류 유형이 성공적으로 삭제되었습니다.'); + } catch (err) { + handleDatabaseError(err, '오류 유형 삭제'); + } +}); + /** * 📊 누적 현황 조회 */ @@ -545,5 +816,16 @@ module.exports = { removeDailyWorkReportByDateAndWorker, getWorkTypes, getWorkStatusTypes, - getErrorTypes + getErrorTypes, + + // 🔽 마스터 데이터 CRUD + createWorkType, + updateWorkType, + deleteWorkType, + createWorkStatus, + updateWorkStatus, + deleteWorkStatus, + createErrorType, + updateErrorType, + deleteErrorType }; \ No newline at end of file diff --git a/api.hyungi.net/controllers/projectController.js b/api.hyungi.net/controllers/projectController.js index dd3158d..22dfe5c 100644 --- a/api.hyungi.net/controllers/projectController.js +++ b/api.hyungi.net/controllers/projectController.js @@ -33,6 +33,19 @@ exports.getAllProjects = asyncHandler(async (req, res) => { } }); +// 2-1. 활성 프로젝트만 조회 (작업보고서용) +exports.getActiveProjects = asyncHandler(async (req, res) => { + try { + const rows = await new Promise((resolve, reject) => { + projectModel.getActiveProjects((err, data) => (err ? reject(err) : resolve(data))); + }); + + res.list(rows, '활성 프로젝트 목록 조회 성공'); + } catch (err) { + handleDatabaseError(err, '활성 프로젝트 목록 조회'); + } +}); + // 3. 단일 조회 exports.getProjectById = asyncHandler(async (req, res) => { const id = parseInt(req.params.project_id, 10); diff --git a/api.hyungi.net/controllers/taskController.js b/api.hyungi.net/controllers/taskController.js deleted file mode 100644 index c0d6f79..0000000 --- a/api.hyungi.net/controllers/taskController.js +++ /dev/null @@ -1,102 +0,0 @@ -const taskModel = require('../models/taskModel'); -const { ApiError, asyncHandler, handleDatabaseError, handleNotFoundError } = require('../utils/errorHandler'); -const { validateSchema, schemas } = require('../utils/validator'); - -// 1. 생성 -exports.createTask = asyncHandler(async (req, res) => { - const taskData = req.body; - - try { - const lastID = await new Promise((resolve, reject) => { - taskModel.create(taskData, (err, id) => (err ? reject(err) : resolve(id))); - }); - - res.created({ task_id: lastID }, '작업이 성공적으로 생성되었습니다.'); - } catch (err) { - handleDatabaseError(err, '작업 생성'); - } -}); - -// 2. 전체 조회 -exports.getAllTasks = asyncHandler(async (req, res) => { - try { - const rows = await new Promise((resolve, reject) => { - taskModel.getAll((err, data) => (err ? reject(err) : resolve(data))); - }); - - res.list(rows, '작업 목록 조회 성공'); - } catch (err) { - handleDatabaseError(err, '작업 목록 조회'); - } -}); - -// 3. 단일 조회 -exports.getTaskById = asyncHandler(async (req, res) => { - const id = parseInt(req.params.task_id, 10); - - if (isNaN(id)) { - throw new ApiError('유효하지 않은 작업 ID입니다.', 400); - } - - try { - const row = await new Promise((resolve, reject) => { - taskModel.getById(id, (err, data) => (err ? reject(err) : resolve(data))); - }); - - if (!row) { - handleNotFoundError('작업', id); - } - - res.success(row, '작업 조회 성공'); - } catch (err) { - handleDatabaseError(err, '작업 조회'); - } -}); - -// 4. 수정 -exports.updateTask = asyncHandler(async (req, res) => { - const id = parseInt(req.params.task_id, 10); - - if (isNaN(id)) { - throw new ApiError('유효하지 않은 작업 ID입니다.', 400); - } - - const taskData = { ...req.body, task_id: id }; - - try { - const changes = await new Promise((resolve, reject) => { - taskModel.update(taskData, (err, ch) => (err ? reject(err) : resolve(ch))); - }); - - if (changes === 0) { - handleNotFoundError('작업', id); - } - - res.updated({ changes }, '작업 정보가 성공적으로 수정되었습니다.'); - } catch (err) { - handleDatabaseError(err, '작업 수정'); - } -}); - -// 5. 삭제 -exports.removeTask = asyncHandler(async (req, res) => { - const id = parseInt(req.params.task_id, 10); - - if (isNaN(id)) { - throw new ApiError('유효하지 않은 작업 ID입니다.', 400); - } - - try { - const changes = await new Promise((resolve, reject) => { - taskModel.remove(id, (err, ch) => (err ? reject(err) : resolve(ch))); - }); - - if (changes === 0) { - handleNotFoundError('작업', id); - } - - res.deleted('작업이 성공적으로 삭제되었습니다.'); - } catch (err) { - handleDatabaseError(err, '작업 삭제'); - } -}); \ No newline at end of file diff --git a/api.hyungi.net/controllers/workAnalysisController.js b/api.hyungi.net/controllers/workAnalysisController.js index 6dd6f11..ab88598 100644 --- a/api.hyungi.net/controllers/workAnalysisController.js +++ b/api.hyungi.net/controllers/workAnalysisController.js @@ -177,10 +177,10 @@ class WorkAnalysisController { const { start, end, limit = 10 } = req.query; this.validateDateRange(start, end); - // limit 유효성 검사 + // limit 유효성 검사 (최대 5000까지 허용) const limitNum = parseInt(limit); - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - throw new Error('limit은 1~100 사이의 숫자여야 합니다.'); + if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) { + throw new Error('limit은 1~5000 사이의 숫자여야 합니다.'); } const db = await getDb(); diff --git a/api.hyungi.net/controllers/workerController.js b/api.hyungi.net/controllers/workerController.js index 0c2ecb4..d6c67bd 100644 --- a/api.hyungi.net/controllers/workerController.js +++ b/api.hyungi.net/controllers/workerController.js @@ -131,6 +131,16 @@ exports.removeWorker = asyncHandler(async (req, res) => { handleNotFoundError('작업자', id); } + // 작업자 관련 캐시 무효화 + console.log('🗑️ 작업자 삭제 후 캐시 무효화 시작...'); + await cache.invalidateCache.worker(); + + // 추가로 전체 작업자 캐시도 강제 무효화 + await cache.delPattern('workers:*'); + await cache.flush(); // 전체 캐시 초기화 (임시) + + console.log('✅ 작업자 삭제 후 캐시 무효화 완료'); + res.deleted('작업자가 성공적으로 삭제되었습니다.'); } catch (err) { handleDatabaseError(err, '작업자 삭제'); diff --git a/api.hyungi.net/index.js b/api.hyungi.net/index.js index c7c099f..fcbe37c 100644 --- a/api.hyungi.net/index.js +++ b/api.hyungi.net/index.js @@ -183,31 +183,15 @@ app.get('/api/status', (req, res) => { // ✅ 신뢰할 수 있는 프록시 설정 (IP 주소 정확히 가져오기) app.set('trust proxy', 1); -// ✅ API 속도 제한 설정 -// 일반 API 속도 제한 -const apiLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15분 - max: process.env.RATE_LIMIT_MAX_REQUESTS || 100, - message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도하세요.', - standardHeaders: true, - legacyHeaders: false, -}); - -// 로그인 API 속도 제한 (개발 환경에서 완화됨) -const loginLimiter = rateLimit({ - windowMs: 5 * 60 * 1000, // 5분으로 단축 - max: process.env.LOGIN_RATE_LIMIT_MAX_REQUESTS || 20, // 5 -> 20으로 증가 - message: '너무 많은 로그인 시도입니다. 5분 후에 다시 시도하세요.', - standardHeaders: true, - legacyHeaders: false, - skipSuccessfulRequests: true, // 성공한 요청은 카운트하지 않음 -}); +// ✅ API 속도 제한 설정 - 내부 시스템이므로 비활성화 +// Rate Limiting 제거 (내부 시스템, 제한된 사용자) +const apiLimiter = (req, res, next) => next(); // 통과 +const loginLimiter = (req, res, next) => next(); // 통과 // ✅ 라우터 등록 const authRoutes = require('./routes/authRoutes'); const projectRoutes = require('./routes/projectRoutes'); const workerRoutes = require('./routes/workerRoutes'); -const taskRoutes = require('./routes/taskRoutes'); const workReportRoutes = require('./routes/workReportRoutes'); const toolsRoute = require('./routes/toolsRoute'); const uploadRoutes = require('./routes/uploadRoutes'); @@ -357,7 +341,6 @@ app.use('/api/performance', performanceRoutes); // ⚙️ 시스템 데이터들 (모든 인증된 사용자) app.use('/api/projects', projectRoutes); -app.use('/api/tasks', taskRoutes); app.use('/api/tools', toolsRoute); // 📤 파일 업로드 diff --git a/api.hyungi.net/migrations/010_add_project_status.sql b/api.hyungi.net/migrations/010_add_project_status.sql new file mode 100644 index 0000000..3372d2d --- /dev/null +++ b/api.hyungi.net/migrations/010_add_project_status.sql @@ -0,0 +1,22 @@ +-- 010_add_project_status.sql +-- 프로젝트 테이블에 활성화/비활성화 상태 필드 추가 + +-- 프로젝트 상태 필드 추가 +ALTER TABLE projects +ADD COLUMN is_active BOOLEAN DEFAULT TRUE COMMENT '프로젝트 활성화 상태 (TRUE: 활성, FALSE: 비활성)'; + +-- 프로젝트 완료일 필드 추가 (납품일) +ALTER TABLE projects +ADD COLUMN completed_date DATE NULL COMMENT '프로젝트 완료일 (납품일)'; + +-- 프로젝트 상태 필드 추가 (진행상태) +ALTER TABLE projects +ADD COLUMN project_status ENUM('planning', 'active', 'completed', 'cancelled') DEFAULT 'active' COMMENT '프로젝트 진행 상태'; + +-- 기존 프로젝트들을 모두 활성 상태로 설정 +UPDATE projects SET is_active = TRUE WHERE is_active IS NULL; + +-- 인덱스 추가 (성능 최적화) +CREATE INDEX idx_projects_is_active ON projects(is_active); +CREATE INDEX idx_projects_status ON projects(project_status); +CREATE INDEX idx_projects_completed_date ON projects(completed_date); diff --git a/api.hyungi.net/migrations/011_add_worker_status.sql b/api.hyungi.net/migrations/011_add_worker_status.sql new file mode 100644 index 0000000..9f9aa8f --- /dev/null +++ b/api.hyungi.net/migrations/011_add_worker_status.sql @@ -0,0 +1,30 @@ +-- 011_add_worker_status.sql +-- workers 테이블에 추가 정보 필드 추가 + +-- 작업자 상태 필드 수정 (기존 text에서 ENUM으로) +ALTER TABLE workers +MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '작업자 상태 (active: 활성, inactive: 비활성)'; + +-- 작업자 추가 정보 필드들 추가 +ALTER TABLE workers +ADD COLUMN phone_number VARCHAR(20) NULL COMMENT '전화번호'; + +ALTER TABLE workers +ADD COLUMN email VARCHAR(100) NULL COMMENT '이메일'; + +ALTER TABLE workers +ADD COLUMN hire_date DATE NULL COMMENT '입사일'; + +ALTER TABLE workers +ADD COLUMN department VARCHAR(100) NULL COMMENT '부서'; + +ALTER TABLE workers +ADD COLUMN notes TEXT NULL COMMENT '비고'; + +-- 기존 작업자들을 모두 활성 상태로 설정 +UPDATE workers SET status = 'active' WHERE status IS NULL; + +-- 인덱스 추가 (성능 최적화) +CREATE INDEX idx_workers_status ON workers(status); +CREATE INDEX idx_workers_job_type ON workers(job_type); +CREATE INDEX idx_workers_hire_date ON workers(hire_date); diff --git a/api.hyungi.net/models/WorkAnalysis.js b/api.hyungi.net/models/WorkAnalysis.js index e9919b6..e12ca5a 100644 --- a/api.hyungi.net/models/WorkAnalysis.js +++ b/api.hyungi.net/models/WorkAnalysis.js @@ -119,7 +119,7 @@ class WorkAnalysis { SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount, COUNT(DISTINCT dwr.report_date) as activeDays FROM daily_work_reports dwr - LEFT JOIN Projects p ON dwr.project_id = p.project_id + LEFT JOIN projects p ON dwr.project_id = p.project_id WHERE dwr.report_date BETWEEN ? AND ? GROUP BY dwr.project_id, p.project_name ORDER BY totalHours DESC @@ -364,7 +364,7 @@ class WorkAnalysis { FROM daily_work_reports dwr LEFT JOIN workers w ON dwr.worker_id = w.worker_id LEFT JOIN work_types wt ON dwr.work_type_id = wt.id - LEFT JOIN Projects p ON dwr.project_id = p.project_id + LEFT JOIN projects p ON dwr.project_id = p.project_id WHERE dwr.report_date BETWEEN ? AND ? GROUP BY dwr.worker_id, w.worker_name, dwr.work_type_id, wt.name, dwr.project_id, p.project_name HAVING totalHours > 0 diff --git a/api.hyungi.net/models/dailyWorkReportModel.js b/api.hyungi.net/models/dailyWorkReportModel.js index 6dbd25a..2b8b442 100644 --- a/api.hyungi.net/models/dailyWorkReportModel.js +++ b/api.hyungi.net/models/dailyWorkReportModel.js @@ -899,8 +899,8 @@ const getReportsWithOptions = async (options) => { const updateReportById = async (reportId, updateData) => { const db = await getDb(); - // 허용된 필드 목록 (보안 및 안정성) - const allowedFields = ['project_id', 'task_id', 'work_hours', 'is_error', 'error_type_code_id']; + // 허용된 필드 목록 (보안 및 안정성) - 실제 테이블 컬럼명 사용 + const allowedFields = ['project_id', 'work_type_id', 'work_hours', 'work_status_id', 'error_type_id']; const setClauses = []; const queryParams = []; @@ -923,7 +923,7 @@ const updateReportById = async (reportId, updateData) => { queryParams.push(reportId); - const sql = `UPDATE daily_work_reports SET ${setClauses.join(', ')} WHERE report_id = ?`; + const sql = `UPDATE daily_work_reports SET ${setClauses.join(', ')} WHERE id = ?`; try { const [result] = await db.query(sql, queryParams); @@ -948,10 +948,10 @@ const removeReportById = async (reportId, deletedByUserId) => { await conn.beginTransaction(); // 감사 로그를 위해 삭제 전 정보 조회 - const [reportInfo] = await conn.query('SELECT * FROM daily_work_reports WHERE report_id = ?', [reportId]); + const [reportInfo] = await conn.query('SELECT * FROM daily_work_reports WHERE id = ?', [reportId]); // 실제 삭제 작업 - const [result] = await conn.query('DELETE FROM daily_work_reports WHERE report_id = ?', [reportId]); + const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [reportId]); // 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체) if (reportInfo.length > 0 && deletedByUserId) { @@ -970,6 +970,158 @@ const removeReportById = async (reportId, deletedByUserId) => { } }; +// ========== 마스터 데이터 CRUD 메서드들 ========== + +/** + * 📝 작업 유형 생성 + */ +const createWorkType = async (data, callback) => { + try { + const db = await getDb(); + const { name, description, category } = data; + const [result] = await db.query( + 'INSERT INTO work_types (name, description, category) VALUES (?, ?, ?)', + [name, description, category] + ); + callback(null, { id: result.insertId, ...data }); + } catch (err) { + console.error('작업 유형 생성 오류:', err); + callback(err); + } +}; + +/** + * ✏️ 작업 유형 수정 + */ +const updateWorkType = async (id, data, callback) => { + try { + const db = await getDb(); + const { name, description, category } = data; + const [result] = await db.query( + 'UPDATE work_types SET name = ?, description = ?, category = ? WHERE id = ?', + [name, description, category, id] + ); + callback(null, result); + } catch (err) { + console.error('작업 유형 수정 오류:', err); + callback(err); + } +}; + +/** + * 🗑️ 작업 유형 삭제 + */ +const deleteWorkType = async (id, callback) => { + try { + const db = await getDb(); + const [result] = await db.query('DELETE FROM work_types WHERE id = ?', [id]); + callback(null, result); + } catch (err) { + console.error('작업 유형 삭제 오류:', err); + callback(err); + } +}; + +/** + * 📝 작업 상태 생성 + */ +const createWorkStatus = async (data, callback) => { + try { + const db = await getDb(); + const { name, description, is_error } = data; + const [result] = await db.query( + 'INSERT INTO work_status_types (name, description, is_error) VALUES (?, ?, ?)', + [name, description, is_error || 0] + ); + callback(null, { id: result.insertId, ...data }); + } catch (err) { + console.error('작업 상태 생성 오류:', err); + callback(err); + } +}; + +/** + * ✏️ 작업 상태 수정 + */ +const updateWorkStatus = async (id, data, callback) => { + try { + const db = await getDb(); + const { name, description, is_error } = data; + const [result] = await db.query( + 'UPDATE work_status_types SET name = ?, description = ?, is_error = ? WHERE id = ?', + [name, description, is_error || 0, id] + ); + callback(null, result); + } catch (err) { + console.error('작업 상태 수정 오류:', err); + callback(err); + } +}; + +/** + * 🗑️ 작업 상태 삭제 + */ +const deleteWorkStatus = async (id, callback) => { + try { + const db = await getDb(); + const [result] = await db.query('DELETE FROM work_status_types WHERE id = ?', [id]); + callback(null, result); + } catch (err) { + console.error('작업 상태 삭제 오류:', err); + callback(err); + } +}; + +/** + * 📝 오류 유형 생성 + */ +const createErrorType = async (data, callback) => { + try { + const db = await getDb(); + const { name, description, severity } = data; + const [result] = await db.query( + 'INSERT INTO error_types (name, description, severity) VALUES (?, ?, ?)', + [name, description, severity || 'medium'] + ); + callback(null, { id: result.insertId, ...data }); + } catch (err) { + console.error('오류 유형 생성 오류:', err); + callback(err); + } +}; + +/** + * ✏️ 오류 유형 수정 + */ +const updateErrorType = async (id, data, callback) => { + try { + const db = await getDb(); + const { name, description, severity } = data; + const [result] = await db.query( + 'UPDATE error_types SET name = ?, description = ?, severity = ? WHERE id = ?', + [name, description, severity || 'medium', id] + ); + callback(null, result); + } catch (err) { + console.error('오류 유형 수정 오류:', err); + callback(err); + } +}; + +/** + * 🗑️ 오류 유형 삭제 + */ +const deleteErrorType = async (id, callback) => { + try { + const db = await getDb(); + const [result] = await db.query('DELETE FROM error_types WHERE id = ?', [id]); + callback(null, result); + } catch (err) { + console.error('오류 유형 삭제 오류:', err); + callback(err); + } +}; + // 모든 함수 내보내기 (Promise 기반 함수 위주로 재구성) module.exports = { @@ -989,6 +1141,17 @@ module.exports = { getAllWorkTypes, getAllWorkStatusTypes, getAllErrorTypes, + + // 마스터 데이터 CRUD + createWorkType, + updateWorkType, + deleteWorkType, + createWorkStatus, + updateWorkStatus, + deleteWorkStatus, + createErrorType, + updateErrorType, + deleteErrorType, createDailyReport, getMyAccumulatedHours, getAccumulatedReportsByDate, diff --git a/api.hyungi.net/models/projectModel.js b/api.hyungi.net/models/projectModel.js index 6c3ee64..8c5fd63 100644 --- a/api.hyungi.net/models/projectModel.js +++ b/api.hyungi.net/models/projectModel.js @@ -6,14 +6,17 @@ const create = async (project, callback) => { const { job_no, project_name, contract_date, due_date, - delivery_method, site, pm + delivery_method, site, pm, + is_active = true, + project_status = 'active', + completed_date = null } = project; const [result] = await db.query( `INSERT INTO projects - (job_no, project_name, contract_date, due_date, delivery_method, site, pm) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [job_no, project_name, contract_date, due_date, delivery_method, site, pm] + (job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date] ); callback(null, result.insertId); @@ -34,6 +37,21 @@ const getAll = async (callback) => { } }; +// 활성 프로젝트만 조회 (작업보고서용) +const getActiveProjects = async (callback) => { + try { + const db = await getDb(); + const [rows] = await db.query( + `SELECT * FROM projects + WHERE is_active = TRUE + ORDER BY project_name ASC` + ); + callback(null, rows); + } catch (err) { + callback(err); + } +}; + const getById = async (project_id, callback) => { try { const db = await getDb(); @@ -53,7 +71,8 @@ const update = async (project, callback) => { const { project_id, job_no, project_name, contract_date, due_date, - delivery_method, site, pm + delivery_method, site, pm, + is_active, project_status, completed_date } = project; const [result] = await db.query( @@ -64,9 +83,12 @@ const update = async (project, callback) => { due_date = ?, delivery_method= ?, site = ?, - pm = ? + pm = ?, + is_active = ?, + project_status = ?, + completed_date = ? WHERE project_id = ?`, - [job_no, project_name, contract_date, due_date, delivery_method, site, pm, project_id] + [job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id] ); callback(null, result.affectedRows); @@ -91,6 +113,7 @@ const remove = async (project_id, callback) => { module.exports = { create, getAll, + getActiveProjects, getById, update, remove diff --git a/api.hyungi.net/models/taskModel.js b/api.hyungi.net/models/taskModel.js deleted file mode 100644 index 24ca845..0000000 --- a/api.hyungi.net/models/taskModel.js +++ /dev/null @@ -1,90 +0,0 @@ -const { getDb } = require('../dbPool'); - -// 1. 생성 -const create = async (task, callback) => { - try { - const db = await getDb(); - const { category, subcategory, task_name, description } = task; - - const [result] = await db.query( - `INSERT INTO tasks (category, subcategory, task_name, description) - VALUES (?, ?, ?, ?)`, - [category, subcategory, task_name, description] - ); - - callback(null, result.insertId); - } catch (err) { - callback(err); - } -}; - -// 2. 전체 조회 -const getAll = async (callback) => { - try { - const db = await getDb(); - const [rows] = await db.query( - `SELECT * FROM tasks ORDER BY task_id DESC` - ); - callback(null, rows); - } catch (err) { - callback(err); - } -}; - -// 3. 단일 조회 -const getById = async (task_id, callback) => { - try { - const db = await getDb(); - const [rows] = await db.query( - `SELECT * FROM tasks WHERE task_id = ?`, - [task_id] - ); - callback(null, rows[0]); - } catch (err) { - callback(err); - } -}; - -// 4. 수정 -const update = async (task, callback) => { - try { - const db = await getDb(); - const { task_id, category, subcategory, task_name, description } = task; - - const [result] = await db.query( - `UPDATE tasks - SET category = ?, - subcategory = ?, - task_name = ?, - description = ? - WHERE task_id = ?`, - [category, subcategory, task_name, description, task_id] - ); - - callback(null, result.affectedRows); - } catch (err) { - callback(new Error(err.message || String(err))); - } -}; - -// 5. 삭제 -const remove = async (task_id, callback) => { - try { - const db = await getDb(); - const [result] = await db.query( - `DELETE FROM tasks WHERE task_id = ?`, - [task_id] - ); - callback(null, result.affectedRows); - } catch (err) { - callback(err); - } -}; - -module.exports = { - create, - getAll, - getById, - update, - remove -}; \ No newline at end of file diff --git a/api.hyungi.net/models/workerModel.js b/api.hyungi.net/models/workerModel.js index a5f50f4..0cd7635 100644 --- a/api.hyungi.net/models/workerModel.js +++ b/api.hyungi.net/models/workerModel.js @@ -4,13 +4,22 @@ const { getDb } = require('../dbPool'); const create = async (worker, callback) => { try { const db = await getDb(); - const { worker_name, join_date, job_type, salary, annual_leave, status } = worker; + const { + worker_name, + job_type = 'worker', + phone_number = null, + email = null, + hire_date = null, + department = null, + notes = null, + status = 'active' + } = worker; const [result] = await db.query( `INSERT INTO workers - (worker_name, join_date, job_type, salary, annual_leave, status) - VALUES (?, ?, ?, ?, ?, ?)`, - [worker_name, join_date, job_type, salary, annual_leave, status] + (worker_name, job_type, phone_number, email, hire_date, department, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [worker_name, job_type, phone_number, email, hire_date, department, notes, status] ); callback(null, result.insertId); @@ -65,17 +74,56 @@ const update = async (worker, callback) => { } }; -// 5. 삭제 +// 5. 삭제 (외래키 제약조건 처리) const remove = async (worker_id, callback) => { + const db = await getDb(); + const conn = await db.getConnection(); + try { - const db = await getDb(); - const [result] = await db.query( + await conn.beginTransaction(); + + console.log(`🗑️ 작업자 삭제 시작: worker_id=${worker_id}`); + + // 안전한 삭제: 각 테이블을 개별적으로 처리하고 오류가 발생해도 계속 진행 + const tables = [ + { name: 'users', query: 'UPDATE users SET worker_id = NULL WHERE worker_id = ?', action: '업데이트' }, + { name: 'Users', query: 'UPDATE Users SET worker_id = NULL WHERE worker_id = ?', action: '업데이트' }, + { name: 'daily_issue_reports', query: 'DELETE FROM daily_issue_reports WHERE worker_id = ?', action: '삭제' }, + { name: 'DailyIssueReports', query: 'DELETE FROM DailyIssueReports WHERE worker_id = ?', action: '삭제' }, + { name: 'work_reports', query: 'DELETE FROM work_reports WHERE worker_id = ?', action: '삭제' }, + { name: 'WorkReports', query: 'DELETE FROM WorkReports WHERE worker_id = ?', action: '삭제' }, + { name: 'daily_work_reports', query: 'DELETE FROM daily_work_reports WHERE worker_id = ?', action: '삭제' }, + { name: 'monthly_worker_status', query: 'DELETE FROM monthly_worker_status WHERE worker_id = ?', action: '삭제' }, + { name: 'worker_groups', query: 'DELETE FROM worker_groups WHERE worker_id = ?', action: '삭제' } + ]; + + for (const table of tables) { + try { + const [result] = await conn.query(table.query, [worker_id]); + if (result.affectedRows > 0) { + console.log(`✅ ${table.name} 테이블 ${table.action}: ${result.affectedRows}건`); + } + } catch (tableError) { + console.log(`⚠️ ${table.name} 테이블 ${table.action} 실패 (무시): ${tableError.message}`); + } + } + + // 마지막으로 작업자 삭제 + const [result] = await conn.query( `DELETE FROM workers WHERE worker_id = ?`, [worker_id] ); + console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}건`); + + await conn.commit(); callback(null, result.affectedRows); + } catch (err) { - callback(err); + await conn.rollback(); + console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err); + callback(new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`)); + } finally { + conn.release(); } }; diff --git a/api.hyungi.net/routes/dailyWorkReportRoutes.js b/api.hyungi.net/routes/dailyWorkReportRoutes.js index 6333dba..c7658ae 100644 --- a/api.hyungi.net/routes/dailyWorkReportRoutes.js +++ b/api.hyungi.net/routes/dailyWorkReportRoutes.js @@ -8,6 +8,22 @@ router.get('/work-types', dailyWorkReportController.getWorkTypes); router.get('/work-status-types', dailyWorkReportController.getWorkStatusTypes); router.get('/error-types', dailyWorkReportController.getErrorTypes); +// 📝 마스터 데이터 CRUD 라우트들 (관리자만) +// 작업 유형 CRUD +router.post('/work-types', dailyWorkReportController.createWorkType); +router.put('/work-types/:id', dailyWorkReportController.updateWorkType); +router.delete('/work-types/:id', dailyWorkReportController.deleteWorkType); + +// 작업 상태 CRUD +router.post('/work-status-types', dailyWorkReportController.createWorkStatus); +router.put('/work-status-types/:id', dailyWorkReportController.updateWorkStatus); +router.delete('/work-status-types/:id', dailyWorkReportController.deleteWorkStatus); + +// 오류 유형 CRUD +router.post('/error-types', dailyWorkReportController.createErrorType); +router.put('/error-types/:id', dailyWorkReportController.updateErrorType); +router.delete('/error-types/:id', dailyWorkReportController.deleteErrorType); + // 🔄 누적 관련 새로운 라우트들 (누적입력 시스템 전용) router.get('/accumulated', dailyWorkReportController.getAccumulatedReports); // ?date=2024-06-16&worker_id=1 router.get('/contributors', dailyWorkReportController.getContributorsSummary); // ?date=2024-06-16&worker_id=1 diff --git a/api.hyungi.net/routes/projectRoutes.js b/api.hyungi.net/routes/projectRoutes.js index 68439e5..01a2681 100644 --- a/api.hyungi.net/routes/projectRoutes.js +++ b/api.hyungi.net/routes/projectRoutes.js @@ -9,6 +9,9 @@ router.post('/', projectController.createProject); // READ ALL router.get('/', projectController.getAllProjects); +// READ ACTIVE ONLY (작업보고서용) +router.get('/active/list', projectController.getActiveProjects); + // READ ONE router.get('/:project_id', projectController.getProjectById); diff --git a/api.hyungi.net/routes/taskRoutes.js b/api.hyungi.net/routes/taskRoutes.js deleted file mode 100644 index 69c2487..0000000 --- a/api.hyungi.net/routes/taskRoutes.js +++ /dev/null @@ -1,21 +0,0 @@ -// routes/taskRoutes.js -const express = require('express'); -const router = express.Router(); -const taskController = require('../controllers/taskController'); - -// CREATE -router.post('/', taskController.createTask); - -// READ ALL -router.get('/', taskController.getAllTasks); - -// READ ONE -router.get('/:task_id', taskController.getTaskById); - -// UPDATE -router.put('/:task_id', taskController.updateTask); - -// DELETE -router.delete('/:task_id', taskController.removeTask); - -module.exports = router; \ No newline at end of file diff --git a/api.hyungi.net/services/dailyWorkReportService.js b/api.hyungi.net/services/dailyWorkReportService.js index cb53ff1..574cf25 100644 --- a/api.hyungi.net/services/dailyWorkReportService.js +++ b/api.hyungi.net/services/dailyWorkReportService.js @@ -49,11 +49,11 @@ const createDailyWorkReportService = async (reportData) => { worker_id: parseInt(worker_id), entries: work_entries.map(entry => ({ project_id: entry.project_id, - task_id: entry.task_id, + work_type_id: entry.task_id, // task_id를 work_type_id로 매핑 work_hours: parseFloat(entry.work_hours), - is_error: entry.is_error || false, - error_type_code_id: entry.error_type_code_id || null, - created_by_user_id: created_by + work_status_id: entry.work_status_id, + error_type_id: entry.error_type_id, + created_by: created_by })) }; diff --git a/api.hyungi.net/utils/validator.js b/api.hyungi.net/utils/validator.js index a11edb3..66d4f3d 100644 --- a/api.hyungi.net/utils/validator.js +++ b/api.hyungi.net/utils/validator.js @@ -258,15 +258,12 @@ const schemas = { newPassword: { required: true, password: true } }, - // 일일 작업 보고서 생성 + // 일일 작업 보고서 생성 (배열 형태) createDailyWorkReport: { report_date: { required: true, type: 'date' }, worker_id: { required: true, type: 'integer' }, - project_id: { required: true, type: 'integer' }, - work_type_id: { required: true, type: 'integer' }, - work_hours: { required: true, type: 'number', min: 0.1, max: 24 }, - work_status_id: { type: 'integer' }, - error_type_id: { type: 'integer' } + work_entries: { required: true, type: 'array' }, + created_by: { type: 'integer' } }, // 프로젝트 생성 diff --git a/web-ui/css/daily-work-report.css b/web-ui/css/daily-work-report.css index 543f4cc..86d7597 100644 --- a/web-ui/css/daily-work-report.css +++ b/web-ui/css/daily-work-report.css @@ -1159,4 +1159,218 @@ .work-item-actions { flex-direction: column; } +} + +/* ======================================== + 저장 결과 모달 스타일 + ======================================== */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.result-modal { + background: var(--bg-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-2xl); + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow: hidden; + animation: slideUp 0.3s ease-out; + border: 2px solid var(--border-light); +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + background: linear-gradient(135deg, var(--primary-500), var(--primary-600)); + color: var(--text-inverse); + padding: var(--space-6); + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid var(--primary-700); +} + +.modal-header h2 { + margin: 0; + font-size: var(--text-xl); + font-weight: var(--font-bold); +} + +.modal-close-btn { + background: none; + border: none; + color: var(--text-inverse); + font-size: var(--text-2xl); + cursor: pointer; + padding: var(--space-2); + border-radius: var(--radius-full); + transition: var(--transition-fast); + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: scale(1.1); +} + +.modal-body { + padding: var(--space-8); + max-height: 60vh; + overflow-y: auto; +} + +.result-content { + text-align: center; +} + +.result-icon { + font-size: 4rem; + margin-bottom: var(--space-6); + display: block; +} + +.result-icon.success { + color: var(--success-500); +} + +.result-icon.error { + color: var(--error-500); +} + +.result-icon.warning { + color: var(--warning-500); +} + +.result-title { + font-size: var(--text-2xl); + font-weight: var(--font-bold); + margin-bottom: var(--space-4); + color: var(--text-primary); +} + +.result-title.success { + color: var(--success-600); +} + +.result-title.error { + color: var(--error-600); +} + +.result-title.warning { + color: var(--warning-600); +} + +.result-message { + font-size: var(--text-lg); + color: var(--text-secondary); + margin-bottom: var(--space-6); + line-height: 1.6; +} + +.result-details { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-top: var(--space-4); + text-align: left; +} + +.result-details h4 { + font-size: var(--text-base); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-2) 0; +} + +.result-details ul { + margin: 0; + padding-left: var(--space-4); + color: var(--text-secondary); +} + +.result-details li { + margin-bottom: var(--space-1); + font-size: var(--text-sm); +} + +.modal-footer { + background: var(--bg-secondary); + padding: var(--space-6); + border-top: 1px solid var(--border-light); + display: flex; + justify-content: center; +} + +.modal-footer .btn { + min-width: 120px; + padding: var(--space-3) var(--space-6); + font-weight: var(--font-semibold); +} + +/* 반응형 디자인 */ +@media (max-width: 480px) { + .result-modal { + width: 95%; + margin: var(--space-4); + } + + .modal-header { + padding: var(--space-4); + } + + .modal-body { + padding: var(--space-6); + } + + .modal-footer { + padding: var(--space-4); + } + + .result-icon { + font-size: 3rem; + } + + .result-title { + font-size: var(--text-xl); + } + + .result-message { + font-size: var(--text-base); + } } \ No newline at end of file diff --git a/web-ui/css/modern-dashboard.css b/web-ui/css/modern-dashboard.css index 0131b94..c2bbd74 100644 --- a/web-ui/css/modern-dashboard.css +++ b/web-ui/css/modern-dashboard.css @@ -135,16 +135,17 @@ top: 100%; right: 0; margin-top: 0.5rem; - background: var(--bg-primary); + background: linear-gradient(135deg, #ffffff, #f8fafc); border-radius: 0.75rem; - box-shadow: var(--shadow-xl); - border: 1px solid var(--border-light); - min-width: 200px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + border: 2px solid rgba(59, 130, 246, 0.2); + min-width: 220px; opacity: 0; visibility: hidden; transform: translateY(-10px); - transition: var(--transition-normal); + transition: all 0.3s ease; z-index: 1000; + backdrop-filter: blur(20px); } .user-profile:hover .profile-menu, @@ -158,37 +159,59 @@ display: flex; align-items: center; gap: 0.75rem; - padding: 0.75rem 1rem; - color: var(--text-primary); + padding: 0.875rem 1.25rem; + color: #374151; text-decoration: none; border: none; - background: none; + background: transparent; width: 100%; text-align: left; - font-size: 0.875rem; + font-size: 0.9rem; + font-weight: 500; cursor: pointer; - transition: var(--transition-fast); + transition: all 0.3s ease; + border-radius: 0.5rem; + margin: 0.25rem; } .menu-item:hover { - background: var(--gray-50); + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + color: #1f2937; + transform: translateX(2px); } .menu-item:first-child { - border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-radius: 0.5rem; + margin-top: 0.5rem; } .menu-item:last-child { - border-radius: 0 0 var(--radius-lg) var(--radius-lg); + border-radius: 0.5rem; + margin-bottom: 0.5rem; +} + +.menu-icon { + font-size: 1.1rem; + width: 1.5rem; + text-align: center; + opacity: 0.8; +} + +.menu-item:hover .menu-icon { + opacity: 1; } .logout-btn { - color: var(--error-600); - border-top: 1px solid var(--border-light); + color: #dc2626 !important; + border-top: 1px solid #e5e7eb; + margin-top: 0.5rem; + padding-top: 0.875rem; + font-weight: 600; } .logout-btn:hover { - background: var(--error-50); + background: linear-gradient(135deg, #fef2f2, #fee2e2) !important; + color: #b91c1c !important; } /* ========== 메인 콘텐츠 ========== */ diff --git a/web-ui/css/project-management.css b/web-ui/css/project-management.css new file mode 100644 index 0000000..321330c --- /dev/null +++ b/web-ui/css/project-management.css @@ -0,0 +1,1589 @@ +/* 프로젝트 관리 페이지 스타일 */ + +/* 기본 레이아웃 */ +body { + margin: 0; + padding: 0; + font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +/* 헤더 스타일 (work-management.css와 동일) */ +.dashboard-header { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; +} + +.header-left { + display: flex; + align-items: center; +} + +.logo-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo { + height: 40px; + width: auto; +} + +.company-info { + display: flex; + flex-direction: column; +} + +.company-name { + font-size: 1.25rem; + font-weight: 700; + color: #1f2937; + margin: 0; + line-height: 1.2; +} + +.company-subtitle { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; +} + +.header-center { + display: flex; + align-items: center; +} + +.current-time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 1rem; + background: rgba(59, 130, 246, 0.1); + border-radius: 0.5rem; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.time-label { + font-size: 0.75rem; + color: #6b7280; + margin-bottom: 0.125rem; +} + +.time-value { + font-size: 1rem; + font-weight: 600; + color: #1f2937; + font-family: 'Courier New', monospace; +} + +.header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.back-btn, .dashboard-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.15); + color: #374151; + text-decoration: none; + border-radius: 1.25rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); +} + +.back-btn:hover, .dashboard-btn:hover { + background: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + text-decoration: none; + color: #1f2937; +} + +.user-profile { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 2rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.user-profile:hover { + background: rgba(255, 255, 255, 0.2); +} + +.user-avatar { + width: 2.5rem; + height: 2.5rem; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 1rem; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 0.875rem; + font-weight: 600; + color: #1f2937; + line-height: 1.2; +} + +.user-role { + font-size: 0.75rem; + color: #6b7280; +} + +/* 메인 콘텐츠 */ +.dashboard-main { + flex: 1; + padding: 2rem; + min-height: calc(100vh - 80px); +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 2rem; +} + +.page-title-section { + flex: 1; +} + +.page-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 2.5rem; + font-weight: 700; + color: white; + margin: 0 0 0.5rem 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.title-icon { + font-size: 2.5rem; +} + +.page-description { + font-size: 1.125rem; + color: rgba(255, 255, 255, 0.9); + margin: 0; + font-weight: 400; +} + +.page-actions { + display: flex; + gap: 1rem; +} + +.btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.75rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; +} + +.btn-primary { + background: #3b82f6; + color: white; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-primary:hover { + background: #2563eb; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.9); + color: #374151; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.btn-secondary:hover { + background: white; + transform: translateY(-2px); +} + +.btn-danger { + background: #ef4444; + color: white; +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-2px); +} + +/* 검색 및 필터 섹션 */ +.search-section { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.search-bar { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.search-input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + font-size: 0.875rem; + transition: all 0.3s ease; +} + +.search-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.search-btn { + padding: 0.75rem 1rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.3s ease; +} + +.search-btn:hover { + background: #2563eb; +} + +.filter-options { + display: flex; + gap: 1rem; +} + +.filter-select { + padding: 0.5rem 1rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + font-size: 0.875rem; + background: white; + cursor: pointer; +} + +/* 프로젝트 섹션 */ +.projects-section { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.section-title { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.project-stats { + display: flex; + gap: 1.5rem; + font-size: 0.875rem; + align-items: center; +} + +.stat-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + font-weight: 500; + transition: all 0.3s ease; + cursor: pointer; + position: relative; +} + +.stat-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.stat-item.active { + transform: scale(1.05); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); + z-index: 10; +} + +.stat-item .stat-icon { + font-size: 1rem; +} + +.stat-item span:not(.stat-icon) { + font-weight: 600; +} + +/* 활성 프로젝트 통계 */ +.active-stat { + background: rgba(16, 185, 129, 0.1); + color: #065f46; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.active-stat span:not(.stat-icon) { + color: #10b981; +} + +.active-stat.active { + background: rgba(16, 185, 129, 0.2); + border: 2px solid #10b981; +} + +.active-stat:hover { + background: rgba(16, 185, 129, 0.15); +} + +/* 비활성 프로젝트 통계 */ +.inactive-stat { + background: rgba(239, 68, 68, 0.1); + color: #7f1d1d; + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.inactive-stat span:not(.stat-icon) { + color: #ef4444; +} + +.inactive-stat.active { + background: rgba(239, 68, 68, 0.2); + border: 2px solid #ef4444; +} + +.inactive-stat:hover { + background: rgba(239, 68, 68, 0.15); +} + +/* 전체 프로젝트 통계 */ +.total-stat { + background: rgba(59, 130, 246, 0.1); + color: #1e3a8a; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.total-stat span:not(.stat-icon) { + color: #3b82f6; +} + +.total-stat.active { + background: rgba(59, 130, 246, 0.2); + border: 2px solid #3b82f6; +} + +.total-stat:hover { + background: rgba(59, 130, 246, 0.15); +} + +/* 프로젝트 그리드 */ +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +/* 작업자 카드 전용 스타일 */ +.worker-card .project-info { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.worker-avatar { + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.avatar-initial { + color: white; + font-size: 1.5rem; + font-weight: 700; +} + +.worker-card .project-name { + margin-top: 0.25rem; +} + +.worker-card .project-meta { + margin-top: 0.5rem; +} + +.worker-card.inactive .worker-avatar { + background: linear-gradient(135deg, #9ca3af, #6b7280); + box-shadow: 0 4px 12px rgba(156, 163, 175, 0.3); +} + +/* 작업 유형 트리 뷰 스타일 */ +.task-tree-container { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.tree-header { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.btn-outline { + background: transparent; + border: 1px solid #d1d5db; + color: #374151; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.btn-outline:hover { + background: #f3f4f6; + border-color: #9ca3af; +} + +.task-tree { + max-height: 600px; + overflow-y: auto; +} + +/* 카테고리 (대분류) 스타일 */ +.tree-category { + margin-bottom: 1rem; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + overflow: hidden; +} + +.category-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + background: linear-gradient(135deg, #f8fafc, #e2e8f0); + cursor: pointer; + transition: all 0.3s ease; + border-bottom: 1px solid #e5e7eb; +} + +.category-header:hover { + background: linear-gradient(135deg, #f1f5f9, #cbd5e1); +} + +.category-toggle { + font-size: 0.875rem; + color: #6b7280; + transition: transform 0.3s ease; +} + +.category-icon { + font-size: 1.25rem; +} + +.category-name { + font-size: 1.125rem; + font-weight: 600; + color: #1f2937; + flex: 1; +} + +.category-count { + font-size: 0.875rem; + color: #6b7280; + background: rgba(107, 114, 128, 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; +} + +.category-actions { + display: flex; + gap: 0.5rem; +} + +.category-content { + background: #ffffff; +} + +/* 서브카테고리 (중분류) 스타일 */ +.tree-subcategory { + border-bottom: 1px solid #f3f4f6; +} + +.tree-subcategory:last-child { + border-bottom: none; +} + +.subcategory-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.25rem; + background: #f8fafc; + cursor: pointer; + transition: all 0.3s ease; +} + +.subcategory-header:hover { + background: #f1f5f9; +} + +.subcategory-toggle { + font-size: 0.75rem; + color: #6b7280; + margin-left: 1rem; +} + +.subcategory-icon { + font-size: 1rem; +} + +.subcategory-name { + font-size: 1rem; + font-weight: 500; + color: #374151; + flex: 1; +} + +.subcategory-count { + font-size: 0.75rem; + color: #6b7280; + background: rgba(107, 114, 128, 0.1); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +.subcategory-actions { + display: flex; + gap: 0.25rem; +} + +.subcategory-content { + background: #ffffff; +} + +/* 작업 (상세) 스타일 */ +.tree-task { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.25rem 0.75rem 2.5rem; + border-bottom: 1px solid #f9fafb; + cursor: pointer; + transition: all 0.3s ease; +} + +.tree-task:hover { + background: #f9fafb; +} + +.tree-task:last-child { + border-bottom: none; +} + +.task-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.task-icon { + font-size: 0.875rem; + color: #6b7280; +} + +.task-name { + font-size: 0.875rem; + font-weight: 500; + color: #1f2937; +} + +.task-description { + font-size: 0.75rem; + color: #6b7280; + margin-left: 0.5rem; + font-style: italic; +} + +.task-actions { + display: flex; + gap: 0.25rem; + opacity: 0; + transition: opacity 0.3s ease; +} + +.tree-task:hover .task-actions { + opacity: 1; +} + +/* 작은 버튼 스타일 */ +.btn-small { + padding: 0.25rem 0.5rem; + border: none; + border-radius: 0.25rem; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-small.btn-primary { + background: #3b82f6; + color: white; +} + +.btn-small.btn-primary:hover { + background: #2563eb; +} + +.btn-small.btn-secondary { + background: #6b7280; + color: white; +} + +.btn-small.btn-secondary:hover { + background: #4b5563; +} + +.btn-small.btn-edit { + background: #f59e0b; + color: white; +} + +.btn-small.btn-edit:hover { + background: #d97706; +} + +.btn-small.btn-delete { + background: #ef4444; + color: white; +} + +.btn-small.btn-delete:hover { + background: #dc2626; +} + +/* 코드 관리 전용 스타일 */ +.code-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + border-bottom: 3px solid #d1d5db; + padding-bottom: 0; + background: rgba(255, 255, 255, 0.9); + border-radius: 1rem 1rem 0 0; + padding: 0.5rem 0.5rem 0 0.5rem; +} + +.tab-btn { + background: rgba(255, 255, 255, 0.7); + border: 2px solid #e5e7eb; + padding: 1rem 1.5rem; + border-radius: 0.75rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 600; + color: #4b5563; + border-bottom: 3px solid transparent; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.tab-btn:hover { + background: rgba(59, 130, 246, 0.1); + color: #1e40af; + border-color: #3b82f6; + transform: translateY(-1px); +} + +.tab-btn.active { + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + color: #ffffff; + border-color: #1d4ed8; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); + transform: translateY(-2px); +} + +.tab-icon { + font-size: 1rem; +} + +.code-tab-content { + display: none; +} + +.code-tab-content.active { + display: block; +} + +.code-section { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95)); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 2rem; + border: 2px solid rgba(59, 130, 246, 0.2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.section-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.section-icon { + font-size: 1.5rem; +} + +.section-actions { + display: flex; + gap: 0.75rem; +} + +.code-stats { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.code-stats .stat-item { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05)); + border: 2px solid rgba(59, 130, 246, 0.3); + color: #1e40af; + padding: 0.75rem 1.25rem; + border-radius: 0.75rem; + font-size: 0.9rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); +} + +.code-stats .critical-stat { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(239, 68, 68, 0.1)); + border-color: rgba(239, 68, 68, 0.4); + color: #dc2626; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +.code-stats .high-stat { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(249, 115, 22, 0.1)); + border-color: rgba(249, 115, 22, 0.4); + color: #ea580c; + box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3); +} + +.code-stats .medium-stat { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(245, 158, 11, 0.1)); + border-color: rgba(245, 158, 11, 0.4); + color: #d97706; + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); +} + +.code-stats .low-stat { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(16, 185, 129, 0.1)); + border-color: rgba(16, 185, 129, 0.4); + color: #059669; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.code-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; +} + +.code-card { + background: linear-gradient(135deg, #ffffff, #f8fafc); + border: 2px solid #e5e7eb; + border-radius: 1rem; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.code-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.2); + border-color: #3b82f6; + background: linear-gradient(135deg, #ffffff, #f0f9ff); +} + +.code-card.normal-status { + border-left: 6px solid #10b981; + background: linear-gradient(135deg, #ffffff, #f0fdf4); +} + +.code-card.normal-status:hover { + background: linear-gradient(135deg, #f0fdf4, #dcfce7); + box-shadow: 0 12px 35px rgba(16, 185, 129, 0.3); +} + +.code-card.error-status { + border-left: 6px solid #ef4444; + background: linear-gradient(135deg, #ffffff, #fef2f2); +} + +.code-card.error-status:hover { + background: linear-gradient(135deg, #fef2f2, #fee2e2); + box-shadow: 0 12px 35px rgba(239, 68, 68, 0.3); +} + +.code-card.error-type-card.severity-low { + border-left: 6px solid #10b981; + background: linear-gradient(135deg, #ffffff, #f0fdf4); +} + +.code-card.error-type-card.severity-low:hover { + background: linear-gradient(135deg, #f0fdf4, #dcfce7); + box-shadow: 0 12px 35px rgba(16, 185, 129, 0.3); +} + +.code-card.error-type-card.severity-medium { + border-left: 6px solid #f59e0b; + background: linear-gradient(135deg, #ffffff, #fffbeb); +} + +.code-card.error-type-card.severity-medium:hover { + background: linear-gradient(135deg, #fffbeb, #fef3c7); + box-shadow: 0 12px 35px rgba(245, 158, 11, 0.3); +} + +.code-card.error-type-card.severity-high { + border-left: 6px solid #f97316; + background: linear-gradient(135deg, #ffffff, #fff7ed); +} + +.code-card.error-type-card.severity-high:hover { + background: linear-gradient(135deg, #fff7ed, #fed7aa); + box-shadow: 0 12px 35px rgba(249, 115, 22, 0.3); +} + +.code-card.error-type-card.severity-critical { + border-left: 6px solid #ef4444; + background: linear-gradient(135deg, #ffffff, #fef2f2); +} + +.code-card.error-type-card.severity-critical:hover { + background: linear-gradient(135deg, #fef2f2, #fee2e2); + box-shadow: 0 12px 35px rgba(239, 68, 68, 0.3); +} + +.code-card.work-type-card { + border-left: 6px solid #6366f1; + background: linear-gradient(135deg, #ffffff, #faf5ff); +} + +.code-card.work-type-card:hover { + background: linear-gradient(135deg, #faf5ff, #f3e8ff); + box-shadow: 0 12px 35px rgba(99, 102, 241, 0.3); +} + +.code-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1rem; +} + +.code-icon { + font-size: 1.5rem; + margin-right: 0.75rem; +} + +.code-info { + flex: 1; +} + +.code-name { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin: 0 0 0.5rem 0; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.code-label { + font-size: 0.8rem; + font-weight: 600; + color: #374151; + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + padding: 0.25rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid #d1d5db; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.code-description { + color: #6b7280; + font-size: 0.875rem; + line-height: 1.5; + margin: 0 0 1rem 0; +} + +.solution-guide { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 0.5rem; + padding: 0.75rem; + margin: 1rem 0; + font-size: 0.875rem; + color: #0c4a6e; +} + +.code-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + color: #9ca3af; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid #f3f4f6; +} + +.code-date { + font-size: 0.75rem; + color: #9ca3af; +} + +.code-actions { + display: flex; + gap: 0.25rem; + opacity: 0; + transition: opacity 0.3s ease; +} + +.code-card:hover .code-actions { + opacity: 1; +} + +.form-checkbox { + margin-right: 0.5rem; +} + +.form-help { + display: block; + margin-top: 0.25rem; + font-size: 0.75rem; + color: #6b7280; +} + +/* 프로필 드롭다운 스타일 개선 */ +.profile-dropdown { + position: relative; +} + +.profile-dropdown .dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: #374151; + text-decoration: none; + transition: all 0.3s ease; + border: none; + background: transparent; + width: 100%; + text-align: left; + font-size: 0.875rem; + cursor: pointer; + font-family: inherit; + border-radius: 0.5rem; + margin: 0.25rem; +} + +.profile-dropdown .dropdown-item:hover { + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + color: #1f2937; + transform: translateX(2px); +} + +.profile-dropdown .dropdown-item.logout-btn { + color: #dc2626; + border-top: 1px solid #e5e7eb; + margin-top: 0.5rem; + padding-top: 0.75rem; +} + +.profile-dropdown .dropdown-item.logout-btn:hover { + background: linear-gradient(135deg, #fef2f2, #fee2e2); + color: #b91c1c; +} + +.profile-dropdown .dropdown-icon { + font-size: 1.1rem; + width: 1.5rem; + text-align: center; + opacity: 0.8; +} + +.profile-dropdown .dropdown-item:hover .dropdown-icon { + opacity: 1; +} + +/* 헤더 사용자 프로필 스타일 개선 */ +.user-profile { + position: relative; + cursor: pointer; +} + +.user-profile .profile-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background: linear-gradient(135deg, #ffffff, #f8fafc); + border: 2px solid rgba(59, 130, 246, 0.2); + border-radius: 1rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + min-width: 200px; + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.3s ease; + overflow: hidden; + z-index: 1000; + backdrop-filter: blur(20px); +} + +.user-profile .profile-dropdown[style*="block"] { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .code-tabs { + flex-direction: column; + gap: 0; + } + + .tab-btn { + border-radius: 0; + border-bottom: 1px solid #e5e7eb; + } + + .tab-btn.active { + border-bottom-color: #3b82f6; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .code-stats { + justify-content: center; + } + + .code-grid { + grid-template-columns: 1fr; + } + + .user-profile .profile-dropdown { + right: -1rem; + min-width: 180px; + } +} + +.project-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + padding: 1.5rem; + transition: all 0.3s ease; + cursor: pointer; +} + +.project-card:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.project-card.inactive { + opacity: 0.8; + background: #f8f9fa; + border-color: #e9ecef; + border-left: 4px solid #ef4444; + position: relative; +} + +.project-card.inactive:hover { + background: #f1f3f4; + border-color: #dee2e6; +} + +/* 비활성화 오버레이 */ +.inactive-overlay { + position: absolute; + top: 0; + right: 0; + z-index: 10; +} + +.inactive-badge { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 0 0.75rem 0 0.5rem; + font-size: 0.75rem; + font-weight: 600; + box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3); +} + +/* 비활성 라벨 */ +.inactive-label { + color: #ef4444; + font-size: 0.8rem; + font-weight: 600; + margin-left: 0.5rem; + background: rgba(239, 68, 68, 0.1); + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; +} + +/* 비활성 안내 */ +.inactive-notice { + color: #f59e0b; + font-size: 0.75rem; + font-weight: 500; + background: rgba(245, 158, 11, 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + border: 1px solid rgba(245, 158, 11, 0.2); + display: inline-block; + margin-top: 0.25rem; +} + +.project-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.project-info { + flex: 1; +} + +.project-job-no { + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.project-name { + font-size: 1rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 0.5rem 0; + line-height: 1.4; +} + +.project-meta { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; + color: #6b7280; +} + +.project-actions { + display: flex; + gap: 0.5rem; +} + +.btn-edit, .btn-delete { + padding: 0.375rem 0.75rem; + border: none; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-edit { + background: #3b82f6; + color: white; +} + +.btn-edit:hover { + background: #2563eb; +} + +.btn-delete { + background: #ef4444; + color: white; +} + +.btn-delete:hover { + background: #dc2626; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: #6b7280; +} + +.empty-state .empty-icon { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h3 { + margin: 0 0 0.5rem 0; + color: #374151; + font-size: 1.25rem; +} + +.empty-state p { + margin: 0 0 1.5rem 0; + font-size: 0.875rem; +} + +/* 모달 스타일 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-container { + background: white; + border-radius: 1rem; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 25px rgba(0, 0, 0, 0.1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #e5e7eb; +} + +.modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; +} + +.modal-close-btn { + background: none; + border: none; + font-size: 1.5rem; + color: #6b7280; + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: all 0.3s ease; +} + +.modal-close-btn:hover { + background: #f3f4f6; + color: #374151; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-top: 1px solid #e5e7eb; + gap: 1rem; +} + +/* 폼 스타일 */ +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-label { + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.form-control { + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + font-size: 0.875rem; + transition: all 0.3s ease; +} + +.form-control:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* 반응형 디자인 */ +@media (max-width: 1024px) { + .dashboard-header { + padding: 1rem; + } + + .dashboard-main { + padding: 1.5rem; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .projects-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +@media (max-width: 768px) { + .header-center { + display: none; + } + + .company-info { + display: none; + } + + .page-title { + font-size: 2rem; + } + + .page-actions { + flex-direction: column; + } + + .search-bar { + flex-direction: column; + } + + .filter-options { + flex-direction: column; + } + + .projects-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .project-stats { + flex-direction: column; + gap: 0.75rem; + align-items: stretch; + } + + .stat-item { + justify-content: center; + } +} + +@media (max-width: 480px) { + .dashboard-header { + padding: 0.75rem; + } + + .dashboard-main { + padding: 1rem; + } + + .page-title { + font-size: 1.75rem; + } + + .search-section, + .projects-section { + padding: 1rem; + } + + .modal-container { + margin: 0.5rem; + max-height: 95vh; + } +} + +/* 작업자 상태 토글 버튼 스타일 */ +.btn-toggle { + background: none; + border: 2px solid; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s ease; + margin-right: 0.25rem; +} + +.btn-deactivate { + border-color: #ef4444; + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} + +.btn-deactivate:hover { + background: #ef4444; + color: white; + transform: scale(1.05); +} + +.btn-activate { + border-color: #10b981; + color: #10b981; + background: rgba(16, 185, 129, 0.1); +} + +.btn-activate:hover { + background: #10b981; + color: white; + transform: scale(1.05); +} diff --git a/web-ui/css/work-analysis.css b/web-ui/css/work-analysis.css new file mode 100644 index 0000000..b892b97 --- /dev/null +++ b/web-ui/css/work-analysis.css @@ -0,0 +1,1064 @@ +/* work-analysis.css - 최신 모던 디자인 */ + +/* ========== CSS 변수 정의 ========== */ +:root { + /* 색상 팔레트 - 모던하고 세련된 색상 */ + --primary: #2563eb; + --primary-light: #3b82f6; + --primary-dark: #1d4ed8; + --primary-bg: #eff6ff; + + --success: #10b981; + --success-light: #34d399; + --success-bg: #ecfdf5; + + --warning: #f59e0b; + --warning-light: #fbbf24; + --warning-bg: #fffbeb; + + --error: #ef4444; + --error-light: #f87171; + --error-bg: #fef2f2; + + --info: #06b6d4; + --info-light: #22d3ee; + --info-bg: #ecfeff; + + /* 중성 색상 */ + --white: #ffffff; + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + + /* 그라디언트 */ + --gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); + --gradient-success: linear-gradient(135deg, var(--success) 0%, var(--success-light) 100%); + --gradient-warning: linear-gradient(135deg, var(--warning) 0%, var(--warning-light) 100%); + --gradient-error: linear-gradient(135deg, var(--error) 0%, var(--error-light) 100%); + --gradient-bg: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + + /* 그림자 */ + --shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + + /* 테두리 반경 */ + --radius-sm: 0.375rem; + --radius: 0.5rem; + --radius-md: 0.75rem; + --radius-lg: 1rem; + --radius-xl: 1.5rem; + --radius-2xl: 2rem; + --radius-full: 9999px; + + /* 간격 */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + + /* 전환 효과 */ + --transition-fast: 150ms ease-in-out; + --transition: 200ms ease-in-out; + --transition-slow: 300ms ease-in-out; +} + +/* ========== 기본 스타일 리셋 ========== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--gradient-bg); + color: var(--gray-800); + line-height: 1.6; + min-height: 100vh; +} + +/* ========== 컨테이너 및 레이아웃 ========== */ +.analysis-container { + max-width: 1400px; + margin: 0 auto; + padding: var(--space-6); + min-height: 100vh; +} + +.page-header { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-8); + margin-bottom: var(--space-8); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); + position: relative; + overflow: hidden; +} + +.page-header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--gradient-primary); +} + +.page-title { + font-size: 2.5rem; + font-weight: 800; + color: var(--gray-900); + margin-bottom: var(--space-2); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.page-title .icon { + font-size: 2rem; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.page-subtitle { + font-size: 1.125rem; + color: var(--gray-600); + font-weight: 400; +} + +/* ========== 탭 네비게이션 ========== */ +.analysis-tabs { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-2); + margin-bottom: var(--space-8); + box-shadow: var(--shadow-md); + border: 1px solid var(--gray-200); + display: flex; + gap: var(--space-2); +} + +.tab-button { + flex: 1; + padding: var(--space-4) var(--space-6); + border: none; + border-radius: var(--radius-lg); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + position: relative; + background: transparent; + color: var(--gray-600); +} + +.tab-button:hover { + background: var(--gray-100); + color: var(--gray-800); +} + +.tab-button.active { + background: var(--gradient-primary); + color: var(--white); + box-shadow: var(--shadow-md); +} + +.tab-button.active::after { + content: ''; + position: absolute; + bottom: -2px; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 3px; + background: var(--white); + border-radius: var(--radius-full); +} + +/* ========== 분석 조건 설정 ========== */ +.analysis-controls { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-8); + margin-bottom: var(--space-8); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); +} + +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.form-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--gray-700); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.form-label .icon { + font-size: 1rem; + color: var(--primary); +} + +.form-input, +.form-select { + padding: var(--space-3) var(--space-4); + border: 2px solid var(--gray-200); + border-radius: var(--radius); + font-size: 0.875rem; + transition: var(--transition); + background: var(--white); + color: var(--gray-800); +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.analyze-button { + background: var(--gradient-primary); + color: var(--white); + border: none; + padding: var(--space-4) var(--space-8); + border-radius: var(--radius-lg); + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: var(--transition); + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: var(--space-2); + justify-self: start; +} + +.analyze-button:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.analyze-button:active { + transform: translateY(0); +} + +.analyze-button .icon { + font-size: 1.125rem; +} + +/* ========== 결과 카드 그리드 ========== */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: var(--space-6); + margin-bottom: var(--space-8); +} + +.result-card { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-6); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.result-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-2xl); +} + +.result-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--gradient-primary); +} + +.result-card.success::before { + background: var(--gradient-success); +} + +.result-card.warning::before { + background: var(--gradient-warning); +} + +.result-card.error::before { + background: var(--gradient-error); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.card-title { + font-size: 1.125rem; + font-weight: 700; + color: var(--gray-900); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.card-title .icon { + font-size: 1.25rem; + color: var(--primary); +} + +.card-value { + font-size: 2rem; + font-weight: 800; + color: var(--primary); + margin-bottom: var(--space-2); +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--gray-600); + margin-bottom: var(--space-4); +} + +.card-progress { + width: 100%; + height: 8px; + background: var(--gray-200); + border-radius: var(--radius-full); + overflow: hidden; + margin-bottom: var(--space-3); +} + +.progress-bar { + height: 100%; + background: var(--gradient-primary); + border-radius: var(--radius-full); + transition: width var(--transition-slow); +} + +.progress-bar.success { + background: var(--gradient-success); +} + +.progress-bar.warning { + background: var(--gradient-warning); +} + +.progress-bar.error { + background: var(--gradient-error); +} + +.card-stats { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + color: var(--gray-500); +} + +/* ========== 차트 컨테이너 ========== */ +.chart-container { + background: var(--white); + border-radius: var(--radius-xl); + padding: var(--space-8); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); + margin-bottom: var(--space-8); + position: relative; + min-height: 200px; /* 최소 높이만 설정 */ + overflow: visible; /* 테이블이 보이도록 */ +} + +/* 차트 컨테이너 타입별 스타일 */ +.chart-container.chart-type { + height: 450px; /* 차트일 때만 고정 높이 */ + overflow: hidden; /* 차트일 때만 넘치는 부분 숨김 */ +} + +.chart-container.table-type { + height: auto; /* 테이블일 때는 자동 높이 */ + overflow: visible; /* 테이블 전체 표시 */ + max-height: none; /* 최대 높이 제한 해제 */ +} + +.chart-container canvas { + max-height: 380px !important; /* 캔버스 최대 높이 제한 */ + width: 100% !important; +} + +/* ========== 작업 보고서 테이블 ========== */ +.table-container { + overflow-x: auto; + border-radius: var(--radius-lg); + border: 1px solid var(--gray-200); +} + +.work-report-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + background: var(--white); + table-layout: fixed; /* 고정 레이아웃으로 열 너비 제어 */ +} + +/* 열 너비 설정 */ +.work-report-table th:nth-child(1), /* 작업자 */ +.work-report-table td:nth-child(1) { + width: 12%; +} + +.work-report-table th:nth-child(2), /* 분류 */ +.work-report-table td:nth-child(2) { + width: 20%; +} + +.work-report-table th:nth-child(3), /* 작업내용 */ +.work-report-table td:nth-child(3) { + width: 18%; +} + +.work-report-table th:nth-child(4), /* 투입시간 */ +.work-report-table td:nth-child(4) { + width: 12%; +} + +.work-report-table th:nth-child(5), /* 작업공수 */ +.work-report-table td:nth-child(5) { + width: 15%; +} + +.work-report-table th:nth-child(6), /* 작업일/일평균시간 */ +.work-report-table td:nth-child(6) { + width: 15%; +} + +.work-report-table th:nth-child(7), /* 비고 */ +.work-report-table td:nth-child(7) { + width: 8%; +} + +.work-report-table th { + background: var(--gradient-primary); + color: var(--white); + font-weight: 600; + padding: 1rem 0.75rem; + text-align: center; + border-right: 1px solid rgba(255, 255, 255, 0.2); + vertical-align: middle; + line-height: 1.3; +} + +.work-report-table th:last-child { + border-right: none; +} + +.work-report-table th small { + font-size: 0.75rem; + font-weight: 400; + opacity: 0.9; +} + +.work-report-table td { + padding: 0.75rem; + text-align: center; + border-right: 1px solid var(--gray-200); + border-bottom: 1px solid var(--gray-200); + vertical-align: middle; +} + +.work-report-table td:last-child { + border-right: none; +} + +.work-report-table tbody tr:hover { + background: var(--gray-50); +} + +.work-report-table .total-row { + background: var(--warning-bg); + font-weight: 600; +} + +.work-report-table .total-row td { + border-top: 2px solid var(--warning); + padding: 1rem 0.75rem; +} + +/* 작업자별 그룹핑 스타일 */ +.work-report-table .worker-group { + background: var(--gray-50); + border-left: 4px solid var(--primary); +} + +.work-report-table .worker-name { + font-weight: 600; + color: var(--primary); + vertical-align: middle; + text-align: center; + background: rgba(37, 99, 235, 0.15); /* 파란색 계열 배경 강화 */ + border-right: 3px solid var(--primary); /* 작업자 구분선 */ + position: relative; +} + +.work-report-table .man-days { + vertical-align: middle; + text-align: center; + background: rgba(16, 185, 129, 0.15); /* 초록색 계열 배경 강화 */ + font-weight: 600; + white-space: nowrap; /* 텍스트 줄바꿈 방지 */ + min-width: 80px; /* 최소 너비 설정 */ +} + +.work-report-table .work-days { + vertical-align: middle; + text-align: center; + background: rgba(245, 158, 11, 0.15); /* 주황색 계열 배경 강화 */ + font-weight: 600; + white-space: nowrap; /* 텍스트 줄바꿈 방지 */ + min-width: 100px; /* 최소 너비 설정 */ +} + +.work-report-table .project-name { + font-weight: 500; + color: var(--gray-700); + background: rgba(59, 130, 246, 0.08); +} + +/* 작업자 그룹 경계선 */ +.work-report-table .worker-group-first td { + border-top: 3px solid var(--primary); +} + +.work-report-table .worker-group-last td { + border-bottom: 2px solid var(--gray-300); +} + +/* 동일 프로젝트 그룹 스타일 */ +.work-report-table .project-group { + background: rgba(59, 130, 246, 0.05); +} + +.work-report-table .project-group-first .project-name { + border-top: 2px solid rgba(59, 130, 246, 0.3); +} + +.work-report-table .project-group-last .project-name { + border-bottom: 2px solid rgba(59, 130, 246, 0.3); +} + +/* 연차/휴무 프로젝트 스타일 */ +.work-report-table .vacation-project { + background: rgba(34, 197, 94, 0.05); /* 연한 초록색 배경 */ +} + +.work-report-table .vacation-project .project-name { + background: rgba(34, 197, 94, 0.1); /* 연차/휴무 프로젝트 배경 */ + color: var(--success); + font-style: italic; +} + +.work-report-table .vacation-project .work-content { + color: var(--gray-500); + font-style: italic; + text-align: center; +} + +.work-report-table .work-content { + color: var(--gray-600); +} + +.work-report-table .input-hours { + font-weight: 600; + color: var(--success); +} + +.work-report-table .man-days { + font-weight: 600; + color: var(--primary); +} + +.work-report-table .work-days { + color: var(--gray-600); + font-size: 0.85rem; +} + +.chart-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--gradient-primary); +} + +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 2px solid var(--gray-100); +} + +/* 기간 확정 버튼 */ +.confirm-period-button { + background: var(--gradient-primary); + color: var(--white); + border: none; + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-6); + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} + +.confirm-period-button:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.confirm-period-button:disabled { + background: var(--gray-300); + cursor: not-allowed; + transform: none; +} + +/* 기간 상태 표시 */ +.period-status { + background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); + border: 2px solid #10b981; + border-radius: var(--radius-lg); + padding: var(--space-4) var(--space-6); + color: #065f46; + font-weight: 600; + font-size: 0.95rem; + display: flex; + align-items: center; + gap: var(--space-3); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15); + min-width: 280px; +} + +.period-status .icon { + font-size: 1.2rem; + color: #10b981; +} + +/* 차트별 분석 버튼 */ +.chart-analyze-btn { + background: var(--gradient-secondary); + color: var(--white); + border: none; + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: var(--space-1); +} + +.chart-analyze-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.chart-analyze-btn:disabled { + background: var(--gray-300); + color: var(--gray-500); + cursor: not-allowed; + transform: none; +} + +.chart-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--gray-900); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.chart-title .icon { + font-size: 1.5rem; + color: var(--primary); +} + +.chart-canvas { + width: 100%; + height: 400px; + border-radius: var(--radius); +} + +/* ========== 데이터 테이블 ========== */ +.data-table-container { + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); + overflow: hidden; + margin-bottom: var(--space-8); +} + +.table-header { + background: var(--gradient-primary); + color: var(--white); + padding: var(--space-6); +} + +.table-title { + font-size: 1.25rem; + font-weight: 700; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: var(--space-4); + text-align: left; + border-bottom: 1px solid var(--gray-200); +} + +.data-table th { + background: var(--gray-50); + font-weight: 600; + color: var(--gray-700); + font-size: 0.875rem; +} + +.data-table tr:hover { + background: var(--gray-50); +} + +.data-table td { + font-size: 0.875rem; + color: var(--gray-800); +} + +/* ========== 상태 배지 ========== */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; +} + +.status-badge.success { + background: var(--success-bg); + color: var(--success); +} + +.status-badge.warning { + background: var(--warning-bg); + color: var(--warning); +} + +.status-badge.error { + background: var(--error-bg); + color: var(--error); +} + +.status-badge.info { + background: var(--info-bg); + color: var(--info); +} + +/* ========== 로딩 상태 ========== */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-16); + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--gray-200); + border-top: 4px solid var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: var(--space-4); +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-text { + font-size: 1.125rem; + font-weight: 600; + color: var(--gray-600); +} + +/* ========== 빈 상태 ========== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-16); + background: var(--white); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + border: 1px solid var(--gray-200); + text-align: center; +} + +.empty-icon { + font-size: 4rem; + color: var(--gray-400); + margin-bottom: var(--space-4); +} + +.empty-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--gray-700); + margin-bottom: var(--space-2); +} + +.empty-description { + font-size: 1rem; + color: var(--gray-500); + max-width: 400px; +} + +/* ========== 반응형 디자인 ========== */ +@media (max-width: 1024px) { + .analysis-container { + padding: var(--space-4); + } + + .page-title { + font-size: 2rem; + } + + .controls-grid { + grid-template-columns: 1fr; + } + + .results-grid { + grid-template-columns: 1fr; + } + + .chart-canvas { + height: 300px; + } +} + +@media (max-width: 768px) { + .analysis-container { + padding: var(--space-3); + } + + .page-header { + padding: var(--space-6); + } + + .page-title { + font-size: 1.75rem; + flex-direction: column; + text-align: center; + } + + .analysis-tabs { + flex-direction: column; + } + + .tab-button { + text-align: center; + } + + .chart-header { + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + } + + .chart-canvas { + height: 250px; + } + + .data-table-container { + overflow-x: auto; + } + + .data-table { + min-width: 600px; + } +} + +@media (max-width: 480px) { + .analysis-container { + padding: var(--space-2); + } + + .page-header { + padding: var(--space-4); + } + + .page-title { + font-size: 1.5rem; + } + + .analysis-controls { + padding: var(--space-4); + } + + .controls-grid { + grid-template-columns: 1fr; + gap: var(--space-4); + } + + .form-group { + width: 100%; + } + + .period-status { + min-width: auto; + font-size: 0.85rem; + } + + .result-card { + padding: var(--space-4); + } + + .chart-container { + padding: var(--space-4); + } + + .chart-canvas { + height: 200px; + } +} + +/* ========== 애니메이션 ========== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.fade-in { + animation: fadeIn 0.6s ease-out; +} + +.slide-in { + animation: slideIn 0.4s ease-out; +} + +/* ========== 접근성 개선 ========== */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* 포커스 표시 개선 */ +button:focus, +input:focus, +select:focus { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* 다크 모드 지원 (선택사항) */ +@media (prefers-color-scheme: dark) { + :root { + --white: #1e293b; + --gray-50: #0f172a; + --gray-100: #1e293b; + --gray-200: #334155; + --gray-800: #f1f5f9; + --gray-900: #ffffff; + } +} \ No newline at end of file diff --git a/web-ui/css/work-management.css b/web-ui/css/work-management.css new file mode 100644 index 0000000..ec23275 --- /dev/null +++ b/web-ui/css/work-management.css @@ -0,0 +1,499 @@ +/* 작업 관리 페이지 스타일 */ + +/* 기본 레이아웃 */ +body { + margin: 0; + padding: 0; + font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +/* 헤더 스타일 */ +.dashboard-header { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; +} + +.header-left { + display: flex; + align-items: center; +} + +.logo-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo { + height: 40px; + width: auto; +} + +.company-info { + display: flex; + flex-direction: column; +} + +.company-name { + font-size: 1.25rem; + font-weight: 700; + color: #1f2937; + margin: 0; + line-height: 1.2; +} + +.company-subtitle { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; +} + +.header-center { + display: flex; + align-items: center; +} + +.current-time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 1rem; + background: rgba(59, 130, 246, 0.1); + border-radius: 0.5rem; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.time-label { + font-size: 0.75rem; + color: #6b7280; + margin-bottom: 0.125rem; +} + +.time-value { + font-size: 1rem; + font-weight: 600; + color: #1f2937; + font-family: 'Courier New', monospace; +} + +.header-right { + display: flex; + align-items: center; + gap: 1rem; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.dashboard-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.15); + color: #374151; + text-decoration: none; + border-radius: 1.25rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); +} + +.dashboard-btn:hover { + background: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + text-decoration: none; + color: #1f2937; +} + +.user-profile { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 2rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.user-profile:hover { + background: rgba(255, 255, 255, 0.2); +} + +.user-avatar { + width: 2.5rem; + height: 2.5rem; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 1rem; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 0.875rem; + font-weight: 600; + color: #1f2937; + line-height: 1.2; +} + +.user-role { + font-size: 0.75rem; + color: #6b7280; +} + +/* 메인 콘텐츠 */ +.dashboard-main { + flex: 1; + padding: 2rem; + min-height: calc(100vh - 80px); +} + +.page-header { + margin-bottom: 2rem; +} + +.page-title-section { + text-align: center; + margin-bottom: 2rem; +} + +.page-title { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + font-size: 2.5rem; + font-weight: 700; + color: white; + margin: 0 0 0.5rem 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.title-icon { + font-size: 2.5rem; +} + +.page-description { + font-size: 1.125rem; + color: rgba(255, 255, 255, 0.9); + margin: 0; + font-weight: 400; +} + +/* 관리 메뉴 그리드 */ +.management-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.5rem; + margin-bottom: 3rem; +} + +.management-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.management-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #3b82f6, #8b5cf6); + transform: scaleX(0); + transition: transform 0.3s ease; +} + +.management-card:hover::before { + transform: scaleX(1); +} + +.management-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); +} + +.card-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.card-icon { + font-size: 2rem; + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + border-radius: 0.75rem; +} + +.card-title { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.card-content { + margin-bottom: 1.5rem; +} + +.card-description { + font-size: 0.875rem; + color: #6b7280; + line-height: 1.5; + margin: 0 0 1rem 0; +} + +.card-stats { + display: flex; + gap: 1rem; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.stat-label { + font-size: 0.75rem; + color: #9ca3af; + font-weight: 500; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #3b82f6; +} + +.card-footer { + display: flex; + justify-content: flex-end; +} + +.card-action { + font-size: 0.875rem; + color: #3b82f6; + font-weight: 500; + transition: color 0.3s ease; +} + +.management-card:hover .card-action { + color: #1d4ed8; +} + +/* 최근 활동 섹션 */ +.recent-activity-section { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 1rem; + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.section-title { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin: 0; +} + +.refresh-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; +} + +.refresh-btn:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.activity-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: #f8fafc; + border-radius: 0.75rem; + border: 1px solid #e2e8f0; + transition: all 0.3s ease; +} + +.activity-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; +} + +.activity-icon { + font-size: 1.25rem; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + background: white; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + flex-shrink: 0; +} + +.activity-content { + flex: 1; +} + +.activity-title { + font-size: 0.875rem; + font-weight: 500; + color: #1f2937; + margin-bottom: 0.25rem; + line-height: 1.4; +} + +.activity-meta { + display: flex; + gap: 1rem; + font-size: 0.75rem; + color: #6b7280; +} + +.activity-user { + font-weight: 500; +} + +/* 반응형 디자인 */ +@media (max-width: 1024px) { + .dashboard-header { + padding: 1rem; + } + + .dashboard-main { + padding: 1.5rem; + } + + .management-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + } +} + +@media (max-width: 768px) { + .header-center { + display: none; + } + + .company-info { + display: none; + } + + .dashboard-btn .btn-text { + display: none; + } + + .user-info { + display: none; + } + + .page-title { + font-size: 2rem; + } + + .management-grid { + grid-template-columns: 1fr; + } + + .section-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } +} + +@media (max-width: 480px) { + .dashboard-header { + padding: 0.75rem; + } + + .dashboard-main { + padding: 1rem; + } + + .page-title { + font-size: 1.75rem; + } + + .management-card { + padding: 1rem; + } + + .recent-activity-section { + padding: 1rem; + } +} diff --git a/web-ui/css/work-report-calendar.css b/web-ui/css/work-report-calendar.css index 806ea1f..14e34f0 100644 --- a/web-ui/css/work-report-calendar.css +++ b/web-ui/css/work-report-calendar.css @@ -1039,6 +1039,271 @@ color: #10b981; } +/* 헤더 액션 영역 스타일 (navbar 스타일과 통일) */ +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-right: 1.5rem; +} + +.dashboard-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.15); + color: white; + text-decoration: none; + border-radius: 1.25rem; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.3s ease; + border: 1px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + font-family: inherit; +} + +.dashboard-btn:hover { + background: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + text-decoration: none; + color: white; +} + +.dashboard-btn:active { + transform: translateY(0); + background: rgba(255, 255, 255, 0.2); +} + +.dashboard-btn .btn-icon { + font-size: 1rem; +} + +.dashboard-btn .btn-text { + font-size: 0.85rem; + font-weight: 500; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .dashboard-btn .btn-text { + display: none; + } + + .dashboard-btn { + padding: 0.5rem; + min-width: 2.5rem; + justify-content: center; + } + + .header-actions { + margin-right: 1rem; + } +} + +@media (max-width: 480px) { + .dashboard-btn { + display: none; + } +} + +/* 작업 입력 모달 탭 스타일 */ +.modal-tabs { + display: flex; + border-bottom: 2px solid #e5e7eb; + margin-bottom: 1.5rem; + gap: 0.5rem; +} + +.tab-btn { + flex: 1; + padding: 0.75rem 1rem; + background: none; + border: none; + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + cursor: pointer; + transition: all 0.2s ease-in-out; + border-radius: 0.5rem 0.5rem 0 0; + position: relative; +} + +.tab-btn:hover { + background: #f3f4f6; + color: #374151; +} + +.tab-btn.active { + background: #3b82f6; + color: white; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* 기존 작업 목록 스타일 */ +.existing-work-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #e5e7eb; +} + +.existing-work-header h3 { + margin: 0; + color: #1f2937; + font-size: 1.125rem; +} + +.work-summary { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; +} + +.work-summary span { + color: #3b82f6; + font-weight: 600; +} + +.existing-work-list { + max-height: 400px; + overflow-y: auto; + padding-right: 0.5rem; +} + +.work-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 0.75rem; + transition: all 0.2s ease-in-out; +} + +.work-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.work-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.work-item-info { + flex: 1; +} + +.work-item-title { + font-weight: 600; + color: #1f2937; + margin-bottom: 0.25rem; + font-size: 1rem; +} + +.work-item-meta { + display: flex; + gap: 1rem; + font-size: 0.8125rem; + color: #6b7280; +} + +.work-item-actions { + display: flex; + gap: 0.5rem; +} + +.btn-edit, .btn-delete { + padding: 0.375rem 0.75rem; + border: none; + border-radius: 0.375rem; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.btn-edit { + background: #3b82f6; + color: white; +} + +.btn-edit:hover { + background: #2563eb; + transform: translateY(-1px); +} + +.btn-delete { + background: #ef4444; + color: white; +} + +.btn-delete:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.work-item-description { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #e2e8f0; + font-size: 0.875rem; + color: #4b5563; + line-height: 1.5; +} + +/* 모달 푸터 개선 */ +.modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.footer-actions { + display: flex; + gap: 0.75rem; +} + +/* Empty State 스타일 */ +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: #6b7280; +} + +.empty-state .empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.empty-state h3 { + margin: 0 0 0.5rem 0; + color: #374151; + font-size: 1.125rem; +} + +.empty-state p { + margin: 0; + font-size: 0.875rem; +} + /* 모달 스타일 */ .modal-overlay { position: fixed; @@ -1058,9 +1323,9 @@ width: 90vw; max-width: 1200px; max-height: 90vh; - background: var(--white); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-xl); + background: #ffffff; + border-radius: 0.75rem; + box-shadow: 0 20px 25px rgba(0, 0, 0, 0.1); overflow: hidden; display: flex; flex-direction: column; diff --git a/web-ui/js/code-management.js b/web-ui/js/code-management.js new file mode 100644 index 0000000..2b4388a --- /dev/null +++ b/web-ui/js/code-management.js @@ -0,0 +1,795 @@ +// 코드 관리 페이지 JavaScript + +// 전역 변수 +let workStatusTypes = []; +let errorTypes = []; +let workTypes = []; +let currentCodeType = 'work-status'; +let currentEditingCode = null; + +// 페이지 초기화 +document.addEventListener('DOMContentLoaded', function() { + console.log('🏷️ 코드 관리 페이지 초기화 시작'); + + initializePage(); + loadAllCodes(); +}); + +// 페이지 초기화 +function initializePage() { + // 시간 업데이트 시작 + updateCurrentTime(); + setInterval(updateCurrentTime, 1000); + + // 사용자 정보 업데이트 + updateUserInfo(); + + // 프로필 메뉴 토글 + setupProfileMenu(); + + // 로그아웃 버튼 + setupLogoutButton(); +} + +// 현재 시간 업데이트 +function updateCurrentTime() { + const now = new Date(); + const timeString = now.toLocaleTimeString('ko-KR', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + const timeElement = document.getElementById('timeValue'); + if (timeElement) { + timeElement.textContent = timeString; + } +} + +// 사용자 정보 업데이트 +function updateUserInfo() { + let userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); + let authUser = JSON.parse(localStorage.getItem('user') || '{}'); + + const finalUserInfo = { + worker_name: userInfo.worker_name || authUser.username || authUser.worker_name, + job_type: userInfo.job_type || authUser.role || authUser.job_type, + username: authUser.username || userInfo.username + }; + + const userNameElement = document.getElementById('userName'); + const userRoleElement = document.getElementById('userRole'); + const userInitialElement = document.getElementById('userInitial'); + + if (userNameElement) { + userNameElement.textContent = finalUserInfo.worker_name || '사용자'; + } + + if (userRoleElement) { + const roleMap = { + 'leader': '그룹장', + 'worker': '작업자', + 'admin': '관리자', + 'system': '시스템 관리자' + }; + userRoleElement.textContent = roleMap[finalUserInfo.job_type] || finalUserInfo.job_type || '작업자'; + } + + if (userInitialElement) { + const name = finalUserInfo.worker_name || '사용자'; + userInitialElement.textContent = name.charAt(0); + } +} + +// 프로필 메뉴 설정 +function setupProfileMenu() { + const userProfile = document.getElementById('userProfile'); + const profileMenu = document.getElementById('profileMenu'); + + if (userProfile && profileMenu) { + userProfile.addEventListener('click', function(e) { + e.stopPropagation(); + const isVisible = profileMenu.style.display === 'block'; + profileMenu.style.display = isVisible ? 'none' : 'block'; + }); + + // 외부 클릭 시 메뉴 닫기 + document.addEventListener('click', function() { + profileMenu.style.display = 'none'; + }); + } +} + +// 로그아웃 버튼 설정 +function setupLogoutButton() { + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function() { + if (confirm('로그아웃 하시겠습니까?')) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + localStorage.removeItem('userInfo'); + window.location.href = '/index.html'; + } + }); + } +} + +// 모든 코드 데이터 로드 +async function loadAllCodes() { + try { + console.log('📊 모든 코드 데이터 로딩 시작'); + + await Promise.all([ + loadWorkStatusTypes(), + loadErrorTypes(), + loadWorkTypes() + ]); + + // 현재 활성 탭 렌더링 + renderCurrentTab(); + + } catch (error) { + console.error('코드 데이터 로딩 오류:', error); + showToast('코드 데이터를 불러오는데 실패했습니다.', 'error'); + } +} + +// 작업 상태 유형 로드 +async function loadWorkStatusTypes() { + try { + console.log('📊 작업 상태 유형 로딩...'); + + const response = await apiCall('/daily-work-reports/work-status-types', 'GET'); + + let statusData = []; + if (response && response.success && Array.isArray(response.data)) { + statusData = response.data; + } else if (Array.isArray(response)) { + statusData = response; + } + + workStatusTypes = statusData; + console.log(`✅ 작업 상태 유형 ${workStatusTypes.length}개 로드 완료`); + + } catch (error) { + console.error('작업 상태 유형 로딩 오류:', error); + workStatusTypes = []; + } +} + +// 오류 유형 로드 +async function loadErrorTypes() { + try { + console.log('⚠️ 오류 유형 로딩...'); + + const response = await apiCall('/daily-work-reports/error-types', 'GET'); + + let errorData = []; + if (response && response.success && Array.isArray(response.data)) { + errorData = response.data; + } else if (Array.isArray(response)) { + errorData = response; + } + + errorTypes = errorData; + console.log(`✅ 오류 유형 ${errorTypes.length}개 로드 완료`); + + } catch (error) { + console.error('오류 유형 로딩 오류:', error); + errorTypes = []; + } +} + +// 작업 유형 로드 +async function loadWorkTypes() { + try { + console.log('🔧 작업 유형 로딩...'); + + const response = await apiCall('/daily-work-reports/work-types', 'GET'); + + let typeData = []; + if (response && response.success && Array.isArray(response.data)) { + typeData = response.data; + } else if (Array.isArray(response)) { + typeData = response; + } + + workTypes = typeData; + console.log(`✅ 작업 유형 ${workTypes.length}개 로드 완료`); + + } catch (error) { + console.error('작업 유형 로딩 오류:', error); + workTypes = []; + } +} + +// 코드 탭 전환 +function switchCodeTab(tabName) { + // 탭 버튼 활성화 상태 변경 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + }); + document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); + + // 탭 콘텐츠 표시/숨김 + document.querySelectorAll('.code-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`${tabName}-tab`).classList.add('active'); + + currentCodeType = tabName; + renderCurrentTab(); +} + +// 현재 탭 렌더링 +function renderCurrentTab() { + switch (currentCodeType) { + case 'work-status': + renderWorkStatusTypes(); + break; + case 'error-types': + renderErrorTypes(); + break; + case 'work-types': + renderWorkTypes(); + break; + } +} + +// 작업 상태 유형 렌더링 +function renderWorkStatusTypes() { + const grid = document.getElementById('workStatusGrid'); + if (!grid) return; + + if (workStatusTypes.length === 0) { + grid.innerHTML = ` +
+
📊
+

등록된 작업 상태 유형이 없습니다.

+

"새 상태 추가" 버튼을 눌러 작업 상태를 등록해보세요.

+ +
+ `; + updateWorkStatusStats(); + return; + } + + let gridHtml = ''; + + workStatusTypes.forEach(status => { + const isError = status.is_error === 1 || status.is_error === true; + const statusClass = isError ? 'error-status' : 'normal-status'; + const statusIcon = isError ? '❌' : '✅'; + const statusLabel = isError ? '오류' : '정상'; + + gridHtml += ` +
+
+
${statusIcon}
+
+

${status.name}

+ ${statusLabel} +
+
+ + +
+
+ ${status.description ? `

${status.description}

` : ''} +
+ 등록: ${formatDate(status.created_at)} +
+
+ `; + }); + + grid.innerHTML = gridHtml; + updateWorkStatusStats(); +} + +// 오류 유형 렌더링 +function renderErrorTypes() { + const grid = document.getElementById('errorTypesGrid'); + if (!grid) return; + + if (errorTypes.length === 0) { + grid.innerHTML = ` +
+
⚠️
+

등록된 오류 유형이 없습니다.

+

"새 오류 유형 추가" 버튼을 눌러 오류 유형을 등록해보세요.

+ +
+ `; + updateErrorTypesStats(); + return; + } + + let gridHtml = ''; + + errorTypes.forEach(error => { + const severityMap = { + 'low': { icon: '🟢', label: '낮음', class: 'severity-low' }, + 'medium': { icon: '🟡', label: '보통', class: 'severity-medium' }, + 'high': { icon: '🟠', label: '높음', class: 'severity-high' }, + 'critical': { icon: '🔴', label: '심각', class: 'severity-critical' } + }; + + const severity = severityMap[error.severity] || severityMap.medium; + + gridHtml += ` +
+
+
⚠️
+
+

${error.name}

+ ${severity.icon} ${severity.label} +
+
+ + +
+
+ ${error.description ? `

${error.description}

` : ''} + ${error.solution_guide ? `
해결 가이드:
${error.solution_guide}
` : ''} +
+ 등록: ${formatDate(error.created_at)} + ${error.updated_at !== error.created_at ? `수정: ${formatDate(error.updated_at)}` : ''} +
+
+ `; + }); + + grid.innerHTML = gridHtml; + updateErrorTypesStats(); +} + +// 작업 유형 렌더링 +function renderWorkTypes() { + const grid = document.getElementById('workTypesGrid'); + if (!grid) return; + + if (workTypes.length === 0) { + grid.innerHTML = ` +
+
🔧
+

등록된 작업 유형이 없습니다.

+

"새 작업 유형 추가" 버튼을 눌러 작업 유형을 등록해보세요.

+ +
+ `; + updateWorkTypesStats(); + return; + } + + let gridHtml = ''; + + workTypes.forEach(type => { + gridHtml += ` +
+
+
🔧
+
+

${type.name}

+ ${type.category ? `📁 ${type.category}` : ''} +
+
+ + +
+
+ ${type.description ? `

${type.description}

` : ''} +
+ 등록: ${formatDate(type.created_at)} + ${type.updated_at !== type.created_at ? `수정: ${formatDate(type.updated_at)}` : ''} +
+
+ `; + }); + + grid.innerHTML = gridHtml; + updateWorkTypesStats(); +} + +// 작업 상태 통계 업데이트 +function updateWorkStatusStats() { + const total = workStatusTypes.length; + const normal = workStatusTypes.filter(s => !s.is_error).length; + const error = workStatusTypes.filter(s => s.is_error).length; + + document.getElementById('workStatusCount').textContent = total; + document.getElementById('normalStatusCount').textContent = normal; + document.getElementById('errorStatusCount').textContent = error; +} + +// 오류 유형 통계 업데이트 +function updateErrorTypesStats() { + const total = errorTypes.length; + const critical = errorTypes.filter(e => e.severity === 'critical').length; + const high = errorTypes.filter(e => e.severity === 'high').length; + const medium = errorTypes.filter(e => e.severity === 'medium').length; + const low = errorTypes.filter(e => e.severity === 'low').length; + + document.getElementById('errorTypesCount').textContent = total; + document.getElementById('criticalErrorsCount').textContent = critical; + document.getElementById('highErrorsCount').textContent = high; + document.getElementById('mediumErrorsCount').textContent = medium; + document.getElementById('lowErrorsCount').textContent = low; +} + +// 작업 유형 통계 업데이트 +function updateWorkTypesStats() { + const total = workTypes.length; + const categories = new Set(workTypes.map(t => t.category).filter(Boolean)).size; + + document.getElementById('workTypesCount').textContent = total; + document.getElementById('workCategoriesCount').textContent = categories; +} + +// 코드 모달 열기 +function openCodeModal(codeType, codeData = null) { + const modal = document.getElementById('codeModal'); + const modalTitle = document.getElementById('modalTitle'); + const deleteBtn = document.getElementById('deleteCodeBtn'); + + if (!modal) return; + + currentEditingCode = codeData; + + // 모든 전용 필드 숨기기 + document.getElementById('isErrorGroup').style.display = 'none'; + document.getElementById('severityGroup').style.display = 'none'; + document.getElementById('solutionGuideGroup').style.display = 'none'; + document.getElementById('categoryGroup').style.display = 'none'; + + // 코드 유형별 설정 + switch (codeType) { + case 'work-status': + modalTitle.textContent = codeData ? '작업 상태 수정' : '새 작업 상태 추가'; + document.getElementById('isErrorGroup').style.display = 'block'; + break; + case 'error-types': + modalTitle.textContent = codeData ? '오류 유형 수정' : '새 오류 유형 추가'; + document.getElementById('severityGroup').style.display = 'block'; + document.getElementById('solutionGuideGroup').style.display = 'block'; + break; + case 'work-types': + modalTitle.textContent = codeData ? '작업 유형 수정' : '새 작업 유형 추가'; + document.getElementById('categoryGroup').style.display = 'block'; + updateCategoryList(); + break; + } + + document.getElementById('codeType').value = codeType; + + if (codeData) { + // 수정 모드 + deleteBtn.style.display = 'inline-flex'; + + // 폼에 데이터 채우기 + document.getElementById('codeId').value = codeData.id; + document.getElementById('codeName').value = codeData.name || ''; + document.getElementById('codeDescription').value = codeData.description || ''; + + // 코드 유형별 필드 채우기 + if (codeType === 'work-status') { + document.getElementById('isError').checked = codeData.is_error === 1 || codeData.is_error === true; + } else if (codeType === 'error-types') { + document.getElementById('severity').value = codeData.severity || 'medium'; + document.getElementById('solutionGuide').value = codeData.solution_guide || ''; + } else if (codeType === 'work-types') { + document.getElementById('category').value = codeData.category || ''; + } + } else { + // 신규 등록 모드 + deleteBtn.style.display = 'none'; + + // 폼 초기화 + document.getElementById('codeForm').reset(); + document.getElementById('codeId').value = ''; + document.getElementById('codeType').value = codeType; + } + + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // 첫 번째 입력 필드에 포커스 + setTimeout(() => { + document.getElementById('codeName').focus(); + }, 100); +} + +// 카테고리 목록 업데이트 +function updateCategoryList() { + const categoryList = document.getElementById('categoryList'); + if (categoryList) { + const categories = [...new Set(workTypes.map(t => t.category).filter(Boolean))].sort(); + categoryList.innerHTML = categories.map(cat => `