From 7637be33f35c6fad2f660f0c4dd0af7122512ec2 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 25 Feb 2026 07:46:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TBM=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20+=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0/=EC=9D=B4=EB=8F=99=20+=20=EA=B6=8C=ED=95=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TBM 시스템: - 4단계 워크플로우 (draft→세부편집→완료→작업보고) - 모바일 전용 TBM 페이지 (tbm-mobile.html) + 3단계 생성 위자드 - 작업자 작업 분할 (work_hours + split_seq) - 작업자 이동 보내기/빼오기 (tbm_transfers 테이블) - 생성 시 중복 배정 방지 (당일 배정 현황 조회) - 데스크탑 TBM 페이지 세부편집 기능 추가 작업보고서: - 모바일 전용 작업보고서 페이지 (report-create-mobile.html) - TBM에서 사전 등록된 work_hours 자동 반영 권한 시스템: - tkuser user_page_permissions 테이블과 system1 페이지 접근 연동 - pageAccessRoutes를 userRoutes보다 먼저 등록 (라우트 우선순위 수정) - TKUSER_DEFAULT_ACCESS 폴백 추가 (개인→부서→기본값 3단계) - 권한 캐시키 갱신 (userPageAccess_v2) 기타: - app-init.js 캐시 버스팅 (v=5) - iOS Safari touch-action: manipulation 적용 - KST 타임존 날짜 버그 수정 (toISOString UTC 이슈) Co-Authored-By: Claude Opus 4.6 --- .../controllers/authController.js | 3 + system1-factory/api/config/routes.js | 2 +- .../api/controllers/tbmController.js | 178 +- ..._add_attendance_type_to_tbm_assignments.js | 26 + .../20260225000001_add_work_hours.js | 21 + .../20260225000002_create_tbm_transfers.js | 28 + .../20260225000003_add_split_seq.js | 43 + system1-factory/api/models/tbmModel.js | 178 +- .../api/models/tbmTransferModel.js | 294 +++ .../api/routes/pageAccessRoutes.js | 166 +- system1-factory/api/routes/tbmRoutes.js | 17 + system1-factory/api/routes/userRoutes.js | 20 +- .../web/components/mobile-nav.html | 4 +- .../web/css/daily-work-report-mobile.css | 1318 ++++++++++ system1-factory/web/css/mobile.css | 286 ++- system1-factory/web/css/tbm.css | 109 + system1-factory/web/js/app-init.js | 18 +- system1-factory/web/js/auth-check.js | 10 +- .../web/js/daily-work-report-mobile.js | 1584 ++++++++++++ system1-factory/web/js/daily-work-report.js | 55 +- system1-factory/web/js/mobile-dashboard.js | 443 ++++ system1-factory/web/js/tbm-create.js | 573 +++++ system1-factory/web/js/tbm.js | 594 ++++- system1-factory/web/js/tbm/api.js | 7 +- system1-factory/web/js/tbm/state.js | 3 +- system1-factory/web/pages/admin/accounts.html | 2 +- .../web/pages/admin/attendance-report.html | 2 +- .../web/pages/admin/departments.html | 2 +- .../web/pages/admin/equipment-detail.html | 2 +- .../web/pages/admin/equipments.html | 2 +- .../web/pages/admin/issue-categories.html | 2 +- .../web/pages/admin/notifications.html | 2 +- system1-factory/web/pages/admin/projects.html | 2 +- .../web/pages/admin/repair-management.html | 2 +- system1-factory/web/pages/admin/tasks.html | 2 +- system1-factory/web/pages/admin/workers.html | 2 +- .../web/pages/admin/workplaces.html | 2 +- .../web/pages/attendance/annual-overview.html | 2 +- .../web/pages/attendance/checkin.html | 2 +- .../web/pages/attendance/daily.html | 2 +- .../web/pages/attendance/monthly.html | 2 +- .../pages/attendance/my-vacation-info.html | 2 +- .../pages/attendance/vacation-allocation.html | 2 +- .../pages/attendance/vacation-approval.html | 2 +- .../web/pages/attendance/vacation-input.html | 2 +- .../pages/attendance/vacation-management.html | 2 +- .../pages/attendance/vacation-request.html | 2 +- .../web/pages/attendance/work-status.html | 2 +- system1-factory/web/pages/dashboard.html | 23 +- .../web/pages/inspection/daily-patrol.html | 2 +- .../web/pages/inspection/zone-detail.html | 2 +- .../web/pages/safety/checklist-manage.html | 2 +- .../web/pages/safety/management.html | 2 +- system1-factory/web/pages/safety/report.html | 2 +- .../web/pages/safety/training-conduct.html | 2 +- .../web/pages/safety/visit-request.html | 2 +- system1-factory/web/pages/work/analysis.html | 2 +- .../web/pages/work/nonconformity.html | 2 +- .../web/pages/work/report-create-mobile.html | 194 ++ .../web/pages/work/report-create.html | 12 +- .../web/pages/work/tbm-create.html | 823 ++++++ .../web/pages/work/tbm-mobile.html | 2212 +++++++++++++++++ system1-factory/web/pages/work/tbm.html | 77 +- system2-report/web/js/issue-report.js | 213 ++ .../web/pages/safety/issue-report.html | 112 + 65 files changed, 9470 insertions(+), 240 deletions(-) create mode 100644 system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js create mode 100644 system1-factory/api/db/migrations/20260225000001_add_work_hours.js create mode 100644 system1-factory/api/db/migrations/20260225000002_create_tbm_transfers.js create mode 100644 system1-factory/api/db/migrations/20260225000003_add_split_seq.js create mode 100644 system1-factory/api/models/tbmTransferModel.js create mode 100644 system1-factory/web/css/daily-work-report-mobile.css create mode 100644 system1-factory/web/js/daily-work-report-mobile.js create mode 100644 system1-factory/web/js/mobile-dashboard.js create mode 100644 system1-factory/web/js/tbm-create.js create mode 100644 system1-factory/web/pages/work/report-create-mobile.html create mode 100644 system1-factory/web/pages/work/tbm-create.html create mode 100644 system1-factory/web/pages/work/tbm-mobile.html diff --git a/sso-auth-service/controllers/authController.js b/sso-auth-service/controllers/authController.js index c7a5941..d88b468 100644 --- a/sso-auth-service/controllers/authController.js +++ b/sso-auth-service/controllers/authController.js @@ -21,6 +21,7 @@ function createTokenPayload(user) { id: user.user_id, username: user.username, name: user.name, + worker_id: user.worker_id || null, department: user.department, role: user.role, access_level: user.role, @@ -84,6 +85,7 @@ async function login(req, res, next) { name: user.name, department: user.department, role: user.role, + worker_id: user.worker_id || null, system_access: payload.system_access } }); @@ -159,6 +161,7 @@ async function validate(req, res, next) { name: user.name, department: user.department, role: user.role, + worker_id: user.worker_id || null, system_access: { system1: user.system1_access, system2: user.system2_access, diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index faef062..a03b560 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -149,6 +149,7 @@ function setupRoutes(app) { app.use('/api/performance', performanceRoutes); app.use('/api/projects', projectRoutes); app.use('/api/tools', toolsRoute); + app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 (userRoutes보다 먼저 등록 - /users/:id/page-access 매칭 우선) app.use('/api/users', userRoutes); app.use('/api/workplaces', workplaceRoutes); app.use('/api/equipments', equipmentRoutes); @@ -157,7 +158,6 @@ function setupRoutes(app) { app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리 app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리 - app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/departments', departmentRoutes); // 부서 관리 diff --git a/system1-factory/api/controllers/tbmController.js b/system1-factory/api/controllers/tbmController.js index 5ab7dc6..8baba23 100644 --- a/system1-factory/api/controllers/tbmController.js +++ b/system1-factory/api/controllers/tbmController.js @@ -1,5 +1,6 @@ // controllers/tbmController.js - TBM 시스템 컨트롤러 const TbmModel = require('../models/tbmModel'); +const TbmTransferModel = require('../models/tbmTransferModel'); const TbmController = { // ==================== TBM 세션 관련 ==================== @@ -151,7 +152,37 @@ const TbmController = { completeSession: (req, res) => { const { sessionId } = req.params; const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8); + const attendanceData = req.body.attendance_data; + // 근태 데이터가 있으면 새 메서드 사용 + if (attendanceData && Array.isArray(attendanceData) && attendanceData.length > 0) { + const createdBy = req.user.user_id; + TbmModel.completeSessionWithAttendance(sessionId, endTime, attendanceData, createdBy, (err, result) => { + if (err) { + console.error('TBM 세션 완료 처리 오류:', err); + return res.status(500).json({ + success: false, + message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (result.affectedRows === 0) { + return res.status(404).json({ + success: false, + message: 'TBM 세션을 찾을 수 없습니다.' + }); + } + + res.json({ + success: true, + message: 'TBM 세션이 완료되었습니다.' + }); + }); + return; + } + + // 기존 방식 (하위 호환) TbmModel.completeSession(sessionId, endTime, (err, result) => { if (err) { console.error('TBM 세션 완료 처리 오류:', err); @@ -223,7 +254,8 @@ const TbmController = { work_type_id: req.body.work_type_id || null, task_id: req.body.task_id || null, workplace_category_id: req.body.workplace_category_id || null, - workplace_id: req.body.workplace_id || null + workplace_id: req.body.workplace_id || null, + work_hours: req.body.work_hours !== undefined ? req.body.work_hours : undefined }; if (!assignmentData.worker_id) { @@ -250,6 +282,34 @@ const TbmController = { }); }, + /** + * 분할 항목 추가 (같은 작업자의 추가 배정) + */ + addSplitAssignment: (req, res) => { + const assignmentData = { + session_id: req.params.sessionId, + worker_id: req.body.worker_id, + work_hours: req.body.work_hours, + project_id: req.body.project_id || null, + work_type_id: req.body.work_type_id || null, + task_id: req.body.task_id || null, + workplace_category_id: req.body.workplace_category_id || null, + workplace_id: req.body.workplace_id || null + }; + + if (!assignmentData.worker_id || !assignmentData.work_hours) { + return res.status(400).json({ success: false, message: '작업자 ID와 작업시간이 필요합니다.' }); + } + + TbmModel.addSplitAssignment(assignmentData, (err, result) => { + if (err) { + console.error('분할 항목 추가 오류:', err); + return res.status(500).json({ success: false, message: '분할 항목 추가 중 오류가 발생했습니다.' }); + } + res.json({ success: true, data: result }); + }); + }, + /** * 팀 구성 일괄 추가 */ @@ -892,6 +952,122 @@ const TbmController = { }); }, + // ==================== 작업자 이동 관련 ==================== + + /** + * 이동 실행 (보내기/빼오기) + */ + createTransfer: (req, res) => { + const { transfer_type, worker_id, source_session_id, dest_session_id, hours, + project_id, work_type_id, task_id, workplace_category_id, workplace_id } = req.body; + + if (!transfer_type || !worker_id || !source_session_id || !dest_session_id || !hours) { + return res.status(400).json({ + success: false, + message: '필수 정보가 누락되었습니다.' + }); + } + + const today = new Date(); + const transferDate = today.getFullYear() + '-' + + String(today.getMonth() + 1).padStart(2, '0') + '-' + + String(today.getDate()).padStart(2, '0'); + + const transferData = { + transfer_type, worker_id, source_session_id, dest_session_id, + hours, initiated_by: req.user.user_id, transfer_date: transferDate, + project_id, work_type_id, task_id, workplace_category_id, workplace_id + }; + + TbmTransferModel.createTransfer(transferData, (err, result) => { + if (err) { + console.error('이동 실행 오류:', err); + return res.status(500).json({ + success: false, + message: '이동 실행 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (!result.success) { + return res.status(400).json(result); + } + + res.json({ + success: true, + message: '이동이 완료되었습니다.', + data: result + }); + }); + }, + + /** + * 당일 이동 내역 조회 + */ + getTransfersByDate: (req, res) => { + const { date } = req.params; + + TbmTransferModel.getTransfersByDate(date, (err, results) => { + if (err) { + console.error('이동 내역 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '이동 내역 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ success: true, data: results }); + }); + }, + + /** + * 이동 취소 (원복) + */ + cancelTransfer: (req, res) => { + const { transferId } = req.params; + + TbmTransferModel.cancelTransfer(transferId, (err, result) => { + if (err) { + console.error('이동 취소 오류:', err); + return res.status(500).json({ + success: false, + message: '이동 취소 중 오류가 발생했습니다.', + error: err.message + }); + } + + if (!result.success) { + return res.status(400).json(result); + } + + res.json({ + success: true, + message: '이동이 취소되었습니다.' + }); + }); + }, + + /** + * 당일 전 작업자 배정 현황 + */ + getWorkerAssignmentsByDate: (req, res) => { + const { date } = req.params; + + TbmTransferModel.getWorkerAssignmentsByDate(date, (err, results) => { + if (err) { + console.error('배정 현황 조회 오류:', err); + return res.status(500).json({ + success: false, + message: '배정 현황 조회 중 오류가 발생했습니다.', + error: err.message + }); + } + + res.json({ success: true, data: results }); + }); + }, + /** * 작업보고서가 작성되지 않은 TBM 팀 배정 조회 */ diff --git a/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js b/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js new file mode 100644 index 0000000..153ef7f --- /dev/null +++ b/system1-factory/api/db/migrations/20260224000001_add_attendance_type_to_tbm_assignments.js @@ -0,0 +1,26 @@ +/** + * TBM 팀 배정에 근태 유형 컬럼 추가 + * - attendance_type: 근태유형 (overtime/regular/annual/half/quarter/early) + * - attendance_hours: 추가시간(overtime) 또는 실제근무시간(early) + * + * @since 2026-02-24 + */ + +exports.up = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + ADD COLUMN attendance_type ENUM('overtime','regular','annual','half','quarter','early') + DEFAULT NULL + COMMENT '근태유형', + ADD COLUMN attendance_hours DECIMAL(4,1) DEFAULT NULL + COMMENT '추가시간(overtime) 또는 실제근무시간(early)' + `); +}; + +exports.down = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + DROP COLUMN attendance_type, + DROP COLUMN attendance_hours + `); +}; diff --git a/system1-factory/api/db/migrations/20260225000001_add_work_hours.js b/system1-factory/api/db/migrations/20260225000001_add_work_hours.js new file mode 100644 index 0000000..960391c --- /dev/null +++ b/system1-factory/api/db/migrations/20260225000001_add_work_hours.js @@ -0,0 +1,21 @@ +/** + * TBM 팀 배정에 작업시간 컬럼 추가 + * - work_hours: 작업시간 (NULL=종일 8h, 값=해당 시간만) + * + * @since 2026-02-25 + */ + +exports.up = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + ADD COLUMN work_hours DECIMAL(4,1) DEFAULT NULL + COMMENT 'NULL=종일(8h), 값 있으면 해당 시간만' + `); +}; + +exports.down = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + DROP COLUMN work_hours + `); +}; diff --git a/system1-factory/api/db/migrations/20260225000002_create_tbm_transfers.js b/system1-factory/api/db/migrations/20260225000002_create_tbm_transfers.js new file mode 100644 index 0000000..7830f94 --- /dev/null +++ b/system1-factory/api/db/migrations/20260225000002_create_tbm_transfers.js @@ -0,0 +1,28 @@ +/** + * TBM 작업자 이동 로그 테이블 생성 + * - 작업자를 다른 TBM 세션으로 보내기/빼오기 기록 + * + * @since 2026-02-25 + */ + +exports.up = function(knex) { + return knex.raw(` + CREATE TABLE tbm_transfers ( + transfer_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + transfer_date DATE NOT NULL, + worker_id INT NOT NULL, + source_session_id INT UNSIGNED NOT NULL, + dest_session_id INT UNSIGNED NOT NULL, + hours DECIMAL(4,1) NOT NULL, + transfer_type ENUM('send','pull') NOT NULL, + initiated_by INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_date (transfer_date), + INDEX idx_worker (worker_id, transfer_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +}; + +exports.down = function(knex) { + return knex.raw(`DROP TABLE IF EXISTS tbm_transfers`); +}; diff --git a/system1-factory/api/db/migrations/20260225000003_add_split_seq.js b/system1-factory/api/db/migrations/20260225000003_add_split_seq.js new file mode 100644 index 0000000..7f6643e --- /dev/null +++ b/system1-factory/api/db/migrations/20260225000003_add_split_seq.js @@ -0,0 +1,43 @@ +// 20260225000003_add_split_seq.js +// 같은 작업자가 같은 세션에서 여러 분할 항목을 가질 수 있도록 split_seq 추가 + +exports.up = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + ADD COLUMN split_seq INT UNSIGNED DEFAULT 0 AFTER work_hours + `).then(() => { + return knex.raw(` + ALTER TABLE tbm_team_assignments + DROP INDEX tbm_team_assignments_session_id_worker_id_unique + `); + }).then(() => { + return knex.raw(` + ALTER TABLE tbm_team_assignments + ADD UNIQUE INDEX uniq_session_worker_seq (session_id, worker_id, split_seq) + `); + }); +}; + +exports.down = function(knex) { + return knex.raw(` + ALTER TABLE tbm_team_assignments + DROP INDEX uniq_session_worker_seq + `).then(() => { + return knex.raw(` + DELETE t1 FROM tbm_team_assignments t1 + INNER JOIN tbm_team_assignments t2 + WHERE t1.assignment_id > t2.assignment_id + AND t1.session_id = t2.session_id + AND t1.worker_id = t2.worker_id + `); + }).then(() => { + return knex.raw(` + ALTER TABLE tbm_team_assignments + ADD UNIQUE INDEX tbm_team_assignments_session_id_worker_id_unique (session_id, worker_id) + `); + }).then(() => { + return knex.raw(` + ALTER TABLE tbm_team_assignments DROP COLUMN split_seq + `); + }); +}; diff --git a/system1-factory/api/models/tbmModel.js b/system1-factory/api/models/tbmModel.js index 6100b4b..b459dfe 100644 --- a/system1-factory/api/models/tbmModel.js +++ b/system1-factory/api/models/tbmModel.js @@ -47,6 +47,11 @@ const TbmModel = { u.username as created_by_username, u.name as created_by_name, COUNT(DISTINCT ta.worker_id) as team_member_count, + GROUP_CONCAT(DISTINCT w2.worker_name ORDER BY ta.assignment_id SEPARATOR ', ') as team_member_names, + -- 이동 수 (이 세션이 source 또는 dest인 이동 건수) + (SELECT COUNT(*) FROM tbm_transfers tf + WHERE (tf.source_session_id = s.session_id OR tf.dest_session_id = s.session_id) + AND tf.transfer_date = s.session_date) as transfer_count, -- 첫 번째 팀원의 작업 정보 가져오기 first_ta.project_id, first_ta.work_type_id, @@ -58,8 +63,9 @@ const TbmModel = { first_wp.workplace_name as work_location FROM tbm_sessions s LEFT JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN users u ON s.created_by = u.user_id + LEFT JOIN sso_users u ON s.created_by = u.user_id LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id + LEFT JOIN workers w2 ON ta.worker_id = w2.worker_id -- 첫 번째 팀원 정보 (가장 먼저 등록된 작업) LEFT JOIN ( SELECT * FROM tbm_team_assignments @@ -110,7 +116,7 @@ const TbmModel = { first_wc.category_name as workplace_category_name FROM tbm_sessions s LEFT JOIN workers w ON s.leader_id = w.worker_id - LEFT JOIN users u ON s.created_by = u.user_id + LEFT JOIN sso_users u ON s.created_by = u.user_id LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id LEFT JOIN ( SELECT * FROM tbm_team_assignments @@ -188,6 +194,107 @@ const TbmModel = { } }, + /** + * TBM 세션 완료 처리 (근태 유형 포함) + * @param {number} sessionId - TBM 세션 ID + * @param {string} endTime - 종료 시간 + * @param {Array} attendanceData - [{worker_id, attendance_type, attendance_hours}] + * @param {number} createdBy - 처리자 user_id + */ + completeSessionWithAttendance: async (sessionId, endTime, attendanceData, createdBy, callback) => { + let conn; + try { + const db = await getDb(); + conn = await db.getConnection(); + await conn.beginTransaction(); + + // 1. 세션 정보 조회 (날짜 확인용) + const [sessionRows] = await conn.query( + 'SELECT session_date FROM tbm_sessions WHERE session_id = ?', + [sessionId] + ); + if (sessionRows.length === 0) { + await conn.rollback(); + conn.release(); + return callback(null, { affectedRows: 0 }); + } + const sessionDate = sessionRows[0].session_date; + // sessionDate를 YYYY-MM-DD 형식으로 변환 + let reportDate; + if (sessionDate instanceof Date) { + reportDate = sessionDate.toISOString().split('T')[0]; + } else if (typeof sessionDate === 'string') { + reportDate = sessionDate.split('T')[0]; + } else { + reportDate = new Date(sessionDate).toISOString().split('T')[0]; + } + + // 2. 각 작업자의 근태 유형 업데이트 + for (const item of attendanceData) { + await conn.query( + `UPDATE tbm_team_assignments + SET attendance_type = ?, attendance_hours = ? + WHERE session_id = ? AND worker_id = ?`, + [item.attendance_type, item.attendance_hours || null, sessionId, item.worker_id] + ); + } + + // 3. 연차(annual) 작업자 → 작업보고서 자동 생성 (project_id=13, 8h) + const annualWorkers = attendanceData.filter(a => a.attendance_type === 'annual'); + for (const aw of annualWorkers) { + // 해당 작업자의 assignment_id 조회 + const [assignRows] = await conn.query( + 'SELECT assignment_id FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [sessionId, aw.worker_id] + ); + if (assignRows.length > 0) { + // 이미 보고서가 있는지 확인 + const [existingReport] = await conn.query( + 'SELECT id FROM daily_work_reports WHERE tbm_assignment_id = ?', + [assignRows[0].assignment_id] + ); + if (existingReport.length === 0) { + await conn.query( + `INSERT INTO daily_work_reports + (report_date, worker_id, project_id, work_hours, work_status_id, created_by, tbm_assignment_id, created_at) + VALUES (?, ?, 13, 8, 1, ?, ?, NOW())`, + [reportDate, aw.worker_id, createdBy, assignRows[0].assignment_id] + ); + } + } + } + + // 4. 세션 완료 처리 + await conn.query( + `UPDATE tbm_sessions + SET status = 'completed', end_time = ?, updated_at = NOW() + WHERE session_id = ?`, + [endTime, sessionId] + ); + + await conn.commit(); + conn.release(); + + // 5. 연차 작업자 근태 동기화 + for (const aw of annualWorkers) { + try { + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(aw.worker_id, reportDate); + } catch (syncErr) { + console.error('근태 동기화 오류 (무시됨):', syncErr); + } + } + + callback(null, { affectedRows: 1 }); + } catch (err) { + if (conn) { + try { await conn.rollback(); } catch (e) {} + conn.release(); + } + callback(err); + } + }, + /** * TBM 세션 삭제 (draft 상태만 가능) */ @@ -215,9 +322,9 @@ const TbmModel = { const db = await getDb(); const sql = ` INSERT INTO tbm_team_assignments - (session_id, worker_id, assigned_role, work_detail, is_present, absence_reason, - project_id, work_type_id, task_id, workplace_category_id, workplace_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (session_id, worker_id, split_seq, assigned_role, work_detail, is_present, absence_reason, + project_id, work_type_id, task_id, workplace_category_id, workplace_id, work_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE assigned_role = VALUES(assigned_role), work_detail = VALUES(work_detail), @@ -227,12 +334,14 @@ const TbmModel = { work_type_id = VALUES(work_type_id), task_id = VALUES(task_id), workplace_category_id = VALUES(workplace_category_id), - workplace_id = VALUES(workplace_id) + workplace_id = VALUES(workplace_id), + work_hours = COALESCE(VALUES(work_hours), work_hours) `; const values = [ assignmentData.session_id, assignmentData.worker_id, + assignmentData.split_seq || 0, assignmentData.assigned_role, assignmentData.work_detail, assignmentData.is_present !== undefined ? assignmentData.is_present : true, @@ -241,7 +350,8 @@ const TbmModel = { assignmentData.work_type_id || null, assignmentData.task_id || null, assignmentData.workplace_category_id || null, - assignmentData.workplace_id || null + assignmentData.workplace_id || null, + assignmentData.work_hours !== undefined ? assignmentData.work_hours : null ]; const [result] = await db.query(sql, values); @@ -251,6 +361,42 @@ const TbmModel = { } }, + /** + * 분할 항목 추가 (같은 세션+작업자에 split_seq 자동 증가) + */ + addSplitAssignment: async (assignmentData, callback) => { + try { + const db = await getDb(); + // 현재 최대 split_seq 조회 + const [maxRows] = await db.query( + 'SELECT COALESCE(MAX(split_seq), -1) as max_seq FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [assignmentData.session_id, assignmentData.worker_id] + ); + const nextSeq = (maxRows[0].max_seq || 0) + 1; + + const sql = ` + INSERT INTO tbm_team_assignments + (session_id, worker_id, split_seq, work_hours, project_id, work_type_id, + task_id, workplace_category_id, workplace_id, is_present) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `; + const [result] = await db.query(sql, [ + assignmentData.session_id, + assignmentData.worker_id, + nextSeq, + assignmentData.work_hours, + assignmentData.project_id || null, + assignmentData.work_type_id || null, + assignmentData.task_id || null, + assignmentData.workplace_category_id || null, + assignmentData.workplace_id || null + ]); + callback(null, { assignment_id: result.insertId, split_seq: nextSeq }); + } catch (err) { + callback(err); + } + }, + /** * 팀 구성 일괄 추가 (작업자별 상세 정보 포함) */ @@ -420,7 +566,7 @@ const TbmModel = { u.name as checked_by_name FROM tbm_safety_records sr INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id - LEFT JOIN users u ON sr.checked_by = u.user_id + LEFT JOIN sso_users u ON sr.checked_by = u.user_id WHERE sr.session_id = ? ORDER BY sc.check_category, sc.display_order `; @@ -571,7 +717,7 @@ const TbmModel = { FROM team_handovers h INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id - LEFT JOIN users u ON h.confirmed_by = u.user_id + LEFT JOIN sso_users u ON h.confirmed_by = u.user_id WHERE h.handover_date = ? ORDER BY h.handover_time DESC `; @@ -673,9 +819,13 @@ const TbmModel = { const db = await getDb(); // WHERE 조건 동적 생성 + // TBM 완료(근태 입력) 후에만 작업보고서 작성 가능 let whereClause = ` WHERE dwr.id IS NULL - AND s.status = 'draft' + AND s.status = 'completed' + AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual') + AND ta.task_id IS NOT NULL + AND ta.workplace_id IS NOT NULL `; const params = []; @@ -684,7 +834,10 @@ const TbmModel = { whereClause = ` WHERE s.created_by = ? AND dwr.id IS NULL - AND s.status = 'draft' + AND s.status = 'completed' + AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual') + AND ta.task_id IS NOT NULL + AND ta.workplace_id IS NOT NULL `; params.push(userId); } @@ -699,6 +852,9 @@ const TbmModel = { ta.task_id, ta.workplace_category_id, ta.workplace_id, + ta.attendance_type, + ta.attendance_hours, + ta.work_hours, s.session_date, s.status as session_status, s.created_by, diff --git a/system1-factory/api/models/tbmTransferModel.js b/system1-factory/api/models/tbmTransferModel.js new file mode 100644 index 0000000..5935443 --- /dev/null +++ b/system1-factory/api/models/tbmTransferModel.js @@ -0,0 +1,294 @@ +// models/tbmTransferModel.js - TBM 작업자 이동 모델 +const { getDb } = require('../dbPool'); + +const TbmTransferModel = { + /** + * 작업자 이동 실행 (보내기/빼오기) + * 트랜잭션: source work_hours 업데이트 + dest INSERT + 로그 INSERT + */ + createTransfer: async (transferData, callback) => { + let conn; + try { + const db = await getDb(); + conn = await db.getConnection(); + await conn.beginTransaction(); + + const { + transfer_type, worker_id, source_session_id, dest_session_id, + hours, initiated_by, transfer_date, + project_id, work_type_id, task_id, workplace_category_id, workplace_id + } = transferData; + + // 1. source 세션에서 해당 작업자의 work_hours 업데이트 + const [sourceRows] = await conn.query( + 'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [source_session_id, worker_id] + ); + + if (sourceRows.length === 0) { + await conn.rollback(); + conn.release(); + return callback(null, { success: false, message: '원본 세션에서 해당 작업자를 찾을 수 없습니다.' }); + } + + const currentHours = sourceRows[0].work_hours === null ? 8 : parseFloat(sourceRows[0].work_hours); + const newSourceHours = currentHours - parseFloat(hours); + + if (newSourceHours < 0) { + await conn.rollback(); + conn.release(); + return callback(null, { success: false, message: '이동 시간이 현재 배정 시간보다 큽니다.' }); + } + + await conn.query( + 'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?', + [newSourceHours, source_session_id, worker_id] + ); + + // 2. dest 세션에 작업자 INSERT (이미 있으면 work_hours 증가) + const [destRows] = await conn.query( + 'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [dest_session_id, worker_id] + ); + + if (destRows.length > 0) { + // 이미 있으면 시간만 추가 + const existingHours = destRows[0].work_hours === null ? 8 : parseFloat(destRows[0].work_hours); + await conn.query( + 'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?', + [existingHours + parseFloat(hours), dest_session_id, worker_id] + ); + } else { + // 새로 INSERT + await conn.query( + `INSERT INTO tbm_team_assignments + (session_id, worker_id, work_hours, project_id, work_type_id, task_id, workplace_category_id, workplace_id, is_present) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`, + [dest_session_id, worker_id, parseFloat(hours), + project_id || null, work_type_id || null, task_id || null, + workplace_category_id || null, workplace_id || null] + ); + } + + // 3. tbm_transfers에 로그 INSERT + const [logResult] = await conn.query( + `INSERT INTO tbm_transfers + (transfer_date, worker_id, source_session_id, dest_session_id, hours, transfer_type, initiated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [transfer_date, worker_id, source_session_id, dest_session_id, parseFloat(hours), transfer_type, initiated_by] + ); + + // 4. 합계 시간 검증 (경고만, 차단 안함) + const [totalRows] = await conn.query( + `SELECT SUM(COALESCE(work_hours, 8)) as total_hours + FROM tbm_team_assignments + WHERE worker_id = ? + AND session_id IN (SELECT session_id FROM tbm_sessions WHERE session_date = ?)`, + [worker_id, transfer_date] + ); + const totalHours = totalRows[0] ? parseFloat(totalRows[0].total_hours) : 0; + + await conn.commit(); + conn.release(); + + const result = { + success: true, + transfer_id: logResult.insertId, + total_hours: totalHours + }; + + if (totalHours > 8) { + result.warning = `해당 작업자의 당일 합계가 ${totalHours}h입니다 (8h 초과).`; + } + + callback(null, result); + } catch (err) { + if (conn) { + try { await conn.rollback(); } catch (e) {} + conn.release(); + } + callback(err); + } + }, + + /** + * 이동 취소 (원복) + */ + cancelTransfer: async (transferId, callback) => { + let conn; + try { + const db = await getDb(); + conn = await db.getConnection(); + await conn.beginTransaction(); + + // 1. 이동 로그 조회 + const [transfers] = await conn.query( + 'SELECT * FROM tbm_transfers WHERE transfer_id = ?', + [transferId] + ); + + if (transfers.length === 0) { + await conn.rollback(); + conn.release(); + return callback(null, { success: false, message: '이동 기록을 찾을 수 없습니다.' }); + } + + const t = transfers[0]; + + // 2. dest 세션에서 작업자 work_hours 감소 (또는 삭제) + const [destRows] = await conn.query( + 'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [t.dest_session_id, t.worker_id] + ); + + if (destRows.length > 0) { + const destHours = destRows[0].work_hours === null ? 8 : parseFloat(destRows[0].work_hours); + const newDestHours = destHours - parseFloat(t.hours); + + if (newDestHours <= 0) { + // 삭제 + await conn.query( + 'DELETE FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [t.dest_session_id, t.worker_id] + ); + } else { + await conn.query( + 'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?', + [newDestHours, t.dest_session_id, t.worker_id] + ); + } + } + + // 3. source 세션에서 작업자 work_hours 복원 + const [sourceRows] = await conn.query( + 'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?', + [t.source_session_id, t.worker_id] + ); + + if (sourceRows.length > 0) { + const sourceHours = sourceRows[0].work_hours === null ? 8 : parseFloat(sourceRows[0].work_hours); + const restoredHours = sourceHours + parseFloat(t.hours); + // 8이면 NULL로 복원 (종일) + await conn.query( + 'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?', + [restoredHours >= 8 ? null : restoredHours, t.source_session_id, t.worker_id] + ); + } + + // 4. 이동 로그 삭제 + await conn.query('DELETE FROM tbm_transfers WHERE transfer_id = ?', [transferId]); + + await conn.commit(); + conn.release(); + + callback(null, { success: true }); + } catch (err) { + if (conn) { + try { await conn.rollback(); } catch (e) {} + conn.release(); + } + callback(err); + } + }, + + /** + * 당일 이동 내역 조회 + */ + getTransfersByDate: async (date, callback) => { + try { + const db = await getDb(); + const sql = ` + SELECT + t.*, + w.worker_name, + w.job_type, + sl.worker_name as source_leader_name, + dl.worker_name as dest_leader_name, + u.name as initiated_by_name + FROM tbm_transfers t + INNER JOIN workers w ON t.worker_id = w.worker_id + LEFT JOIN tbm_sessions ss ON t.source_session_id = ss.session_id + LEFT JOIN workers sl ON ss.leader_id = sl.worker_id + LEFT JOIN tbm_sessions ds ON t.dest_session_id = ds.session_id + LEFT JOIN workers dl ON ds.leader_id = dl.worker_id + LEFT JOIN sso_users u ON t.initiated_by = u.user_id + WHERE t.transfer_date = ? + ORDER BY t.created_at DESC + `; + const [rows] = await db.query(sql, [date]); + callback(null, rows); + } catch (err) { + callback(err); + } + }, + + /** + * 당일 전 작업자 배정 현황 조회 + */ + getWorkerAssignmentsByDate: async (date, callback) => { + try { + const db = await getDb(); + + // 1. 해당 날짜의 모든 배정 가져오기 + const [assignments] = await db.query(` + SELECT + ta.worker_id, + ta.session_id, + ta.work_hours, + w.worker_name, + w.job_type, + s.leader_id, + lw.worker_name as leader_name, + s.status as session_status + FROM tbm_team_assignments ta + INNER JOIN tbm_sessions s ON ta.session_id = s.session_id + INNER JOIN workers w ON ta.worker_id = w.worker_id + LEFT JOIN workers lw ON s.leader_id = lw.worker_id + WHERE s.session_date = ? + ORDER BY w.worker_name + `, [date]); + + // 2. 모든 작업자 가져오기 (배정 안 된 사람도 포함) + const [allWorkers] = await db.query( + "SELECT worker_id, worker_name, job_type FROM workers WHERE status = 'active' AND department = '생산' ORDER BY worker_name" + ); + + // 3. 작업자별 배정 현황 구성 + const workerMap = {}; + allWorkers.forEach(w => { + workerMap[w.worker_id] = { + worker_id: w.worker_id, + worker_name: w.worker_name, + job_type: w.job_type, + sessions: [], + total_hours: 0, + available: true + }; + }); + + assignments.forEach(a => { + const hours = a.work_hours === null ? 8 : parseFloat(a.work_hours); + if (workerMap[a.worker_id]) { + workerMap[a.worker_id].sessions.push({ + session_id: a.session_id, + leader_name: a.leader_name, + work_hours: hours, + session_status: a.session_status + }); + workerMap[a.worker_id].total_hours += hours; + } + }); + + // available 판단 + Object.values(workerMap).forEach(w => { + w.available = w.total_hours < 8; + }); + + callback(null, Object.values(workerMap)); + } catch (err) { + callback(err); + } + } +}; + +module.exports = TbmTransferModel; diff --git a/system1-factory/api/routes/pageAccessRoutes.js b/system1-factory/api/routes/pageAccessRoutes.js index 9c46223..14779b4 100644 --- a/system1-factory/api/routes/pageAccessRoutes.js +++ b/system1-factory/api/routes/pageAccessRoutes.js @@ -3,6 +3,71 @@ const router = express.Router(); const { getDb } = require('../dbPool'); const { requireAuth, requireAdmin } = require('../middlewares/auth'); +// tkuser page_name → default_access 매핑 (permissionModel.js의 DEFAULT_PAGES와 동기화) +const TKUSER_DEFAULT_ACCESS = { + 's1.dashboard': true, + 's1.work.tbm': true, + 's1.work.report_create': true, + 's1.work.analysis': false, + 's1.work.nonconformity': true, + 's1.factory.repair_management': false, + 's1.inspection.daily_patrol': false, + 's1.inspection.checkin': true, + 's1.inspection.work_status': false, + 's1.safety.visit_request': true, + 's1.safety.management': false, + 's1.safety.checklist_manage': false, + 's1.attendance.my_vacation_info': true, + 's1.attendance.monthly': true, + 's1.attendance.vacation_request': true, + 's1.attendance.vacation_management': false, + 's1.attendance.vacation_allocation': false, + 's1.attendance.annual_overview': false, + 's1.admin.workers': false, + 's1.admin.projects': false, + 's1.admin.tasks': false, + 's1.admin.workplaces': false, + 's1.admin.equipments': false, + 's1.admin.issue_categories': false, + 's1.admin.attendance_report': false, +}; + +// system1 page_key → tkuser page_name 매핑 +const PAGEKEY_TO_TKUSER = { + 'dashboard': 's1.dashboard', + 'work.tbm': 's1.work.tbm', + 'work.report-create': 's1.work.report_create', + 'work.report-view': 's1.work.report_create', + 'work.analysis': 's1.work.analysis', + 'work.visit-request': 's1.safety.visit_request', + 'work.issue-report': 's1.work.nonconformity', + 'work.issue-list': 's1.work.nonconformity', + 'work.issue-detail': 's1.work.nonconformity', + 'safety.issue_report': 's1.work.nonconformity', + 'safety.issue_list': 's1.work.nonconformity', + 'safety.issue_detail': 's1.work.nonconformity', + 'safety.checklist_manage': 's1.safety.checklist_manage', + 'admin.workers': 's1.admin.workers', + 'admin.projects': 's1.admin.projects', + 'admin.tasks': 's1.admin.tasks', + 'admin.workplaces': 's1.admin.workplaces', + 'admin.equipments': 's1.admin.equipments', + 'admin.codes': 's1.admin.tasks', + 'admin.safety-management': 's1.safety.management', + 'admin.safety-training-conduct': 's1.safety.management', + 'admin.attendance-report-comparison': 's1.admin.attendance_report', + 'admin.departments': 's1.admin.workers', + 'common.daily-attendance': 's1.inspection.checkin', + 'common.monthly-attendance': 's1.attendance.monthly', + 'common.vacation-request': 's1.attendance.vacation_request', + 'common.vacation-management': 's1.attendance.vacation_management', + 'common.annual-vacation-overview': 's1.attendance.annual_overview', + 'common.vacation-allocation': 's1.attendance.vacation_allocation', + 'inspection.daily_patrol': 's1.inspection.daily_patrol', + 'attendance.vacation_approval': 's1.attendance.vacation_management', + 'attendance.vacation_input': 's1.attendance.vacation_allocation', +}; + /** * 모든 페이지 목록 조회 * GET /api/pages @@ -29,25 +94,22 @@ router.get('/pages', requireAuth, async (req, res) => { */ router.get('/users/:userId/page-access', requireAuth, async (req, res) => { try { - const { userId } = req.params; + const ssoUserId = req.params.userId; const db = await getDb(); - // 사용자의 역할 확인 - const [userRows] = await db.query(` - SELECT u.user_id, u.username, u.role_id, r.name as role_name - FROM users u - LEFT JOIN roles r ON u.role_id = r.id - WHERE u.user_id = ? - `, [userId]); - - if (userRows.length === 0) { + // SSO 사용자 조회 (department_id 포함) + const [ssoRows] = await db.query( + 'SELECT user_id, username, name, role, department_id FROM sso_users WHERE user_id = ?', + [ssoUserId] + ); + if (ssoRows.length === 0) { return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' }); } + const ssoUser = ssoRows[0]; - const user = userRows[0]; - - // Admin/System Admin인 경우 모든 페이지 접근 가능 - if (user.role_name === 'Admin' || user.role_name === 'System Admin') { + // SSO role로 Admin 체크 + const ssoRole = (ssoUser.role || '').toLowerCase(); + if (ssoRole === 'admin' || ssoRole === 'system') { const [allPages] = await db.query(` SELECT id, page_key, page_name, page_path, category, is_admin_only FROM pages @@ -62,32 +124,66 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => { category: page.category, is_admin_only: page.is_admin_only, can_access: true, - is_default: true // Admin은 기본적으로 모든 권한 보유 + is_default: true })); - return res.json({ success: true, data: { user, pageAccess } }); + return res.json({ success: true, data: { user: ssoUser, pageAccess } }); } - // 일반 사용자의 페이지 접근 권한 조회 - const [pageAccess] = await db.query(` - SELECT - p.id as page_id, - p.page_key, - p.page_name, - p.page_path, - p.category, - p.is_admin_only, - COALESCE(upa.can_access, 0) as can_access, - upa.granted_at, - u2.username as granted_by_username - FROM pages p - LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ? - LEFT JOIN users u2 ON upa.granted_by = u2.user_id - WHERE p.is_admin_only = 0 - ORDER BY p.display_order, p.page_name - `, [userId]); + // 일반 사용자: tkuser 권한 테이블에서 조회 + // 1) 개인 권한 (user_page_permissions) + const [userPerms] = await db.query( + 'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?', + [ssoUserId] + ); + const userPermMap = {}; + userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; }); - res.json({ success: true, data: { user, pageAccess } }); + // 2) 부서 권한 (department_page_permissions) + const deptPermMap = {}; + if (ssoUser.department_id) { + const [deptPerms] = await db.query( + 'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?', + [ssoUser.department_id] + ); + deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; }); + } + + // 3) 페이지 목록 조회 + 권한 매핑 + const [pages] = await db.query(` + SELECT id, page_key, page_name, page_path, category, is_admin_only + FROM pages + WHERE is_admin_only = 0 + ORDER BY display_order, page_name + `); + + const pageAccess = pages.map(page => { + const tkuserKey = PAGEKEY_TO_TKUSER[page.page_key]; + let canAccess = false; + + if (tkuserKey) { + // 우선순위: 개인 권한 > 부서 권한 > default_access + if (tkuserKey in userPermMap) { + canAccess = userPermMap[tkuserKey]; + } else if (tkuserKey in deptPermMap) { + canAccess = deptPermMap[tkuserKey]; + } else if (tkuserKey in TKUSER_DEFAULT_ACCESS) { + canAccess = TKUSER_DEFAULT_ACCESS[tkuserKey]; + } + } + + return { + page_id: page.id, + page_key: page.page_key, + page_name: page.page_name, + page_path: page.page_path, + category: page.category, + is_admin_only: page.is_admin_only, + can_access: canAccess ? 1 : 0 + }; + }); + + res.json({ success: true, data: { user: ssoUser, pageAccess } }); } catch (error) { console.error('페이지 접근 권한 조회 오류:', error); res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' }); diff --git a/system1-factory/api/routes/tbmRoutes.js b/system1-factory/api/routes/tbmRoutes.js index aa7e8eb..0db3eb2 100644 --- a/system1-factory/api/routes/tbmRoutes.js +++ b/system1-factory/api/routes/tbmRoutes.js @@ -12,6 +12,9 @@ router.post('/sessions', requireAuth, TbmController.createSession); // 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의) router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports); +// 당일 전 작업자 배정 현황 (더 구체적인 경로이므로 먼저 정의) +router.get('/sessions/date/:date/assignments', requireAuth, TbmController.getWorkerAssignmentsByDate); + // 특정 날짜의 TBM 세션 목록 조회 router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate); @@ -32,6 +35,9 @@ router.delete('/sessions/:sessionId', requireAuth, TbmController.deleteSession); // 팀원 추가 (단일) router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember); +// 분할 항목 추가 (같은 작업자의 추가 배정) +router.post('/sessions/:sessionId/team/split', requireAuth, TbmController.addSplitAssignment); + // 팀 구성 일괄 추가 router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers); @@ -81,6 +87,17 @@ router.get('/sessions/:sessionId/weather', requireAuth, TbmController.getSession // 세션 날씨 정보 저장 router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather); +// ==================== 작업자 이동 관련 ==================== + +// 이동 실행 (보내기/빼오기) +router.post('/transfers', requireAuth, TbmController.createTransfer); + +// 당일 이동 내역 조회 +router.get('/transfers/date/:date', requireAuth, TbmController.getTransfersByDate); + +// 이동 취소 (원복) +router.delete('/transfers/:transferId', requireAuth, TbmController.cancelTransfer); + // ==================== 작업 인계 관련 ==================== // 작업 인계 생성 diff --git a/system1-factory/api/routes/userRoutes.js b/system1-factory/api/routes/userRoutes.js index 9de69f9..610fefe 100644 --- a/system1-factory/api/routes/userRoutes.js +++ b/system1-factory/api/routes/userRoutes.js @@ -113,23 +113,9 @@ router.get('/me/monthly-stats', async (req, res) => { } }); -// ========== 자신의 페이지 권한 조회 (Admin 불필요) ========== -// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin) -router.get('/:id/page-access', (req, res, next) => { - const requestedId = parseInt(req.params.id); - const currentUserId = req.user?.user_id; - const userRole = req.user?.role?.toLowerCase(); - - // 자신의 권한 조회이거나 Admin인 경우 허용 - if (requestedId === currentUserId || userRole === 'admin' || userRole === 'system admin') { - return userController.getUserPageAccess(req, res, next); - } - - return res.status(403).json({ - success: false, - message: '자신의 페이지 권한만 조회할 수 있습니다' - }); -}); +// ========== 페이지 권한 조회는 pageAccessRoutes.js에서 처리 ========== +// GET /:id/page-access → /api/users/:userId/page-access (pageAccessRoutes.js) +// tkuser의 user_page_permissions 테이블을 조회하는 통합 핸들러 사용 // ========== 관리자 전용 API ========== /** diff --git a/system1-factory/web/components/mobile-nav.html b/system1-factory/web/components/mobile-nav.html index a525486..7d72119 100644 --- a/system1-factory/web/components/mobile-nav.html +++ b/system1-factory/web/components/mobile-nav.html @@ -8,14 +8,14 @@ - + TBM - + diff --git a/system1-factory/web/css/daily-work-report-mobile.css b/system1-factory/web/css/daily-work-report-mobile.css new file mode 100644 index 0000000..71ec89a --- /dev/null +++ b/system1-factory/web/css/daily-work-report-mobile.css @@ -0,0 +1,1318 @@ +/* =================================== + Daily Work Report - Mobile CSS + 모바일 전용 작업보고서 스타일 + =================================== */ + +/* 기본 레이아웃 */ +.m-container { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + background: #f9fafb; + padding-bottom: calc(68px + env(safe-area-inset-bottom)); +} + +/* Sticky 헤더 */ +.m-header { + position: sticky; + top: 0; + z-index: 100; + background: #fff; + padding: 0.75rem 1rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; +} + +.m-header-title { + font-size: 1.125rem; + font-weight: 700; + color: #111827; + margin: 0; +} + +.m-header-action { + display: flex; + gap: 0.5rem; +} + +.m-btn-add { + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + padding: 0.4rem 0.75rem; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.m-btn-add:active { + background: #2563eb; +} + +/* 탭바 */ +.m-tab-bar { + display: flex; + background: #fff; + border-bottom: 1px solid #e5e7eb; + position: sticky; + top: 52px; + z-index: 99; +} + +.m-tab-btn { + flex: 1; + padding: 0.75rem 0.5rem; + border: none; + background: none; + font-size: 0.8125rem; + font-weight: 500; + color: #6b7280; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + white-space: nowrap; +} + +.m-tab-btn.active { + color: #3b82f6; + font-weight: 700; + border-bottom-color: #3b82f6; +} + +.m-tab-btn:active { + background: #f3f4f6; +} + +.m-tab-count { + display: inline-block; + background: #e5e7eb; + color: #374151; + border-radius: 10px; + padding: 0.05rem 0.4rem; + font-size: 0.6875rem; + font-weight: 600; + margin-left: 0.25rem; + min-width: 1.25rem; + text-align: center; +} + +.m-tab-btn.active .m-tab-count { + background: #dbeafe; + color: #2563eb; +} + +/* 탭 콘텐츠 */ +.m-tab-content { + display: none; + padding: 0.75rem; +} + +.m-tab-content.active { + display: block; +} + +/* 메시지 영역 */ +.m-message { + margin: 0.5rem 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 8px; + font-size: 0.8125rem; + display: none; +} + +.m-message.show { + display: block; +} + +.m-message.info { + background: #dbeafe; + color: #1e40af; +} + +.m-message.success { + background: #dcfce7; + color: #166534; +} + +.m-message.error { + background: #fee2e2; + color: #991b1b; +} + +.m-message.loading { + background: #fef3c7; + color: #92400e; +} + +/* 날짜 그룹 */ +.m-date-group { + margin-bottom: 0.75rem; +} + +.m-date-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.625rem 0.75rem; + background: #fff; + border-radius: 10px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.m-date-header:active { + background: #f3f4f6; +} + +.m-date-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.m-date-toggle { + font-size: 0.625rem; + color: #9ca3af; + transition: transform 0.2s; +} + +.m-date-group.expanded .m-date-toggle { + transform: rotate(90deg); +} + +.m-date-title { + font-size: 0.875rem; + font-weight: 600; + color: #111827; +} + +.m-today-badge { + background: #dbeafe; + color: #2563eb; + font-size: 0.6875rem; + font-weight: 700; + padding: 0.1rem 0.4rem; + border-radius: 4px; +} + +.m-date-right { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: #6b7280; +} + +.m-date-content { + overflow: hidden; + transition: max-height 0.3s ease; +} + +.m-date-group:not(.expanded) .m-date-content { + display: none; +} + +/* 이슈 리마인더 */ +.m-issue-reminder { + margin: 0.5rem 0; + padding: 0.625rem 0.75rem; + background: #fffbeb; + border: 1px solid #fde68a; + border-radius: 10px; +} + +.m-issue-reminder-header { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + color: #92400e; + margin-bottom: 0.375rem; +} + +.m-issue-reminder-item { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + padding: 0.25rem 0; + color: #78350f; +} + +.m-issue-type-badge { + display: inline-block; + padding: 0.05rem 0.375rem; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 600; + flex-shrink: 0; +} + +.m-issue-type-badge.nonconformity { + background: #fee2e2; + color: #dc2626; +} + +.m-issue-type-badge.safety { + background: #fef3c7; + color: #d97706; +} + +/* 세션 헤더 */ +.m-session-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + margin-top: 0.5rem; +} + +.m-session-badge { + background: #3b82f6; + color: #fff; + font-size: 0.625rem; + font-weight: 700; + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +.m-session-info { + font-size: 0.75rem; + color: #6b7280; +} + +/* 작업자 카드 */ +.m-worker-card { + background: #fff; + border-radius: 12px; + padding: 0.875rem; + margin-bottom: 0.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + border: 1px solid #f3f4f6; +} + +.m-worker-card.submitted { + opacity: 0.5; + pointer-events: none; +} + +.m-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.m-worker-name { + font-size: 0.9375rem; + font-weight: 700; + color: #111827; +} + +.m-worker-job { + font-size: 0.75rem; + color: #6b7280; + margin-top: 0.1rem; +} + +.m-card-status { + font-size: 0.6875rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-weight: 600; +} + +.m-card-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.625rem; +} + +.m-info-row { + display: flex; + font-size: 0.8125rem; +} + +.m-info-label { + color: #9ca3af; + width: 4.5rem; + flex-shrink: 0; +} + +.m-info-value { + color: #374151; + flex: 1; +} + +/* 카드 하단 버튼 영역 */ +.m-card-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.625rem; +} + +.m-action-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0.5rem; + border-radius: 8px; + border: 1.5px solid #e5e7eb; + background: #f9fafb; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + min-height: 44px; + transition: border-color 0.15s, background 0.15s; +} + +.m-action-btn:active { + background: #f3f4f6; +} + +.m-action-btn.has-value { + border-color: #3b82f6; + background: #eff6ff; +} + +.m-action-btn.has-related-issue { + border-color: #f59e0b; + background: #fffbeb; +} + +.m-action-label { + font-size: 0.6875rem; + color: #9ca3af; + margin-bottom: 0.15rem; +} + +.m-action-value { + font-size: 0.8125rem; + font-weight: 600; + color: #374151; +} + +.m-action-btn.has-value .m-action-value { + color: #2563eb; +} + +/* 제출 버튼 */ +.m-submit-btn { + width: 100%; + min-height: 44px; + border: none; + border-radius: 10px; + font-size: 0.875rem; + font-weight: 700; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + margin-top: 0.625rem; + transition: background 0.15s; +} + +.m-submit-btn.primary { + background: #3b82f6; + color: #fff; +} + +.m-submit-btn.primary:active { + background: #2563eb; +} + +.m-submit-btn.primary:disabled { + background: #93c5fd; + cursor: not-allowed; +} + +/* 일괄 제출 버튼 */ +.m-batch-btn { + width: 100%; + min-height: 48px; + border: none; + border-radius: 10px; + font-size: 0.875rem; + font-weight: 700; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + margin: 0.5rem 0 0.75rem; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: #fff; + box-shadow: 0 2px 6px rgba(59,130,246,0.3); +} + +.m-batch-btn:active { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); +} + +.m-batch-btn:disabled { + background: #93c5fd; + box-shadow: none; + cursor: not-allowed; +} + +/* 수동 입력 카드 */ +.m-manual-card { + background: #fff; + border-radius: 12px; + padding: 0.875rem; + margin-bottom: 0.75rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + border: 2px solid #fde68a; +} + +.m-manual-card .m-form-group { + margin-bottom: 0.625rem; +} + +.m-form-label { + display: block; + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + margin-bottom: 0.25rem; +} + +.m-form-select, +.m-form-input { + width: 100%; + padding: 0.5rem 0.625rem; + border: 1.5px solid #e5e7eb; + border-radius: 8px; + font-size: 16px; /* iOS 줌 방지 */ + color: #111827; + background: #fff; + -webkit-appearance: none; + appearance: none; +} + +.m-form-select { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.25rem; + padding-right: 2rem; +} + +.m-form-select:focus, +.m-form-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59,130,246,0.1); +} + +.m-form-row { + display: flex; + gap: 0.5rem; +} + +.m-form-row > .m-form-group { + flex: 1; +} + +.m-manual-delete { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: none; + border: none; + color: #9ca3af; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; +} + +/* 작업장소 선택 박스 */ +.m-workplace-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + border: 1.5px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; + cursor: pointer; + min-height: 42px; + -webkit-tap-highlight-color: transparent; +} + +.m-workplace-box.selected { + border-color: #10b981; + background: #ecfdf5; +} + +.m-workplace-box .m-wp-icon { + font-size: 1rem; + flex-shrink: 0; +} + +.m-workplace-box .m-wp-text { + font-size: 0.8125rem; + color: #9ca3af; +} + +.m-workplace-box.selected .m-wp-text { + color: #065f46; + font-weight: 500; +} + +/* 바텀시트 */ +.m-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.4); + z-index: 1001; + opacity: 0; + visibility: hidden; + transition: opacity 0.25s, visibility 0.25s; +} + +.m-overlay.show { + opacity: 1; + visibility: visible; +} + +.m-bottom-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-radius: 16px 16px 0 0; + z-index: 1002; + max-height: 85vh; + overflow-y: auto; + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); + padding-bottom: env(safe-area-inset-bottom); + -webkit-overflow-scrolling: touch; +} + +.m-bottom-sheet.show { + transform: translateY(0); +} + +.m-sheet-handle { + width: 36px; + height: 4px; + background: #d1d5db; + border-radius: 2px; + margin: 8px auto 0; +} + +.m-sheet-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid #f3f4f6; +} + +.m-sheet-title { + font-size: 1rem; + font-weight: 700; + color: #111827; + margin: 0; +} + +.m-sheet-close { + background: none; + border: none; + font-size: 1.5rem; + color: #9ca3af; + cursor: pointer; + padding: 0.25rem; + line-height: 1; + -webkit-tap-highlight-color: transparent; +} + +.m-sheet-body { + padding: 0.75rem 1rem; +} + +.m-sheet-footer { + padding: 0.75rem 1rem; + border-top: 1px solid #f3f4f6; +} + +/* 부적합 바텀시트 */ +.m-defect-issue-item { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.625rem; + border: 1.5px solid #e5e7eb; + border-radius: 10px; + margin-bottom: 0.5rem; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + transition: border-color 0.15s, background 0.15s; +} + +.m-defect-issue-item:active { + background: #f9fafb; +} + +.m-defect-issue-item.selected { + border-color: #3b82f6; + background: #eff6ff; +} + +.m-defect-checkbox { + width: 20px; + height: 20px; + border-radius: 4px; + border: 2px solid #d1d5db; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + transition: all 0.15s; +} + +.m-defect-issue-item.selected .m-defect-checkbox { + background: #3b82f6; + border-color: #3b82f6; +} + +.m-defect-issue-item.selected .m-defect-checkbox::after { + content: ''; + width: 6px; + height: 10px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-top: -2px; +} + +.m-defect-issue-info { + flex: 1; +} + +.m-defect-issue-name { + font-size: 0.8125rem; + font-weight: 600; + color: #111827; + margin-bottom: 0.15rem; +} + +.m-defect-issue-detail { + font-size: 0.75rem; + color: #6b7280; +} + +.m-defect-time-input { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.375rem; +} + +.m-defect-time-input label { + font-size: 0.75rem; + color: #6b7280; +} + +.m-defect-manual-section { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; +} + +.m-defect-manual-title { + font-size: 0.8125rem; + font-weight: 600; + color: #374151; + margin-bottom: 0.5rem; +} + +/* 시간 선택 오버레이 */ +.m-time-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.4); + z-index: 1003; + display: none; + align-items: center; + justify-content: center; +} + +.m-time-overlay.show { + display: flex; +} + +.m-time-popup { + background: #fff; + border-radius: 16px; + padding: 1.25rem; + width: calc(100% - 2rem); + max-width: 340px; + box-shadow: 0 10px 40px rgba(0,0,0,0.15); +} + +.m-time-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.m-time-title { + font-size: 1rem; + font-weight: 700; + color: #111827; + margin: 0; +} + +.m-time-close { + background: none; + border: none; + font-size: 1.5rem; + color: #9ca3af; + cursor: pointer; + padding: 0.25rem; + line-height: 1; +} + +.m-quick-time-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + margin-bottom: 1rem; +} + +.m-time-btn { + padding: 0.625rem 0; + border: 1.5px solid #e5e7eb; + border-radius: 10px; + background: #fff; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + transition: all 0.15s; +} + +.m-time-btn:active { + background: #f3f4f6; +} + +.m-time-btn.selected { + border-color: #3b82f6; + background: #eff6ff; + color: #2563eb; +} + +.m-time-adjust { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.625rem; + background: #f9fafb; + border-radius: 10px; +} + +.m-time-current { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + min-width: 5rem; + text-align: center; +} + +.m-time-adjust-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1.5px solid #e5e7eb; + background: #fff; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.m-time-adjust-btn:active { + background: #f3f4f6; +} + +.m-time-confirm { + width: 100%; + padding: 0.75rem; + border: none; + border-radius: 10px; + background: #3b82f6; + color: #fff; + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + min-height: 44px; +} + +.m-time-confirm:active { + background: #2563eb; +} + +/* 작업장소 바텀시트 */ +.m-wp-category-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.m-wp-category-btn { + padding: 0.75rem; + border: 1.5px solid #e5e7eb; + border-radius: 10px; + background: #fff; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + cursor: pointer; + text-align: left; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + transition: all 0.15s; +} + +.m-wp-category-btn:active { + background: #f3f4f6; +} + +.m-wp-category-btn.selected { + border-color: #3b82f6; + background: #eff6ff; + color: #2563eb; + font-weight: 600; +} + +.m-wp-list { + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.m-wp-item { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem; + border-bottom: 1px solid #f3f4f6; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} + +.m-wp-item:active { + background: #f9fafb; +} + +.m-wp-item.selected { + background: #eff6ff; +} + +.m-wp-item-icon { + font-size: 1.125rem; +} + +.m-wp-item-name { + font-size: 0.875rem; + color: #111827; + font-weight: 500; +} + +.m-wp-item.selected .m-wp-item-name { + color: #2563eb; + font-weight: 600; +} + +/* 완료 보고서 */ +.m-completed-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.m-date-input { + flex: 1; + padding: 0.5rem 0.625rem; + border: 1.5px solid #e5e7eb; + border-radius: 8px; + font-size: 16px; + color: #111827; +} + +.m-completed-card { + background: #fff; + border-radius: 12px; + padding: 0.875rem; + margin-bottom: 0.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + border: 1px solid #f3f4f6; +} + +.m-completed-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.m-completed-worker { + font-size: 0.9375rem; + font-weight: 700; + color: #111827; +} + +.m-completed-badge { + font-size: 0.625rem; + font-weight: 700; + padding: 0.1rem 0.375rem; + border-radius: 4px; +} + +.m-completed-badge.tbm { + background: #dbeafe; + color: #2563eb; +} + +.m-completed-badge.manual { + background: #fef3c7; + color: #d97706; +} + +.m-completed-info { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-bottom: 0.5rem; +} + +.m-completed-actions { + display: flex; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #f3f4f6; +} + +.m-completed-actions button { + flex: 1; + padding: 0.4rem 0; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + min-height: 36px; + border: 1px solid; +} + +.m-btn-edit { + background: #f9fafb; + color: #374151; + border-color: #e5e7eb !important; +} + +.m-btn-edit:active { + background: #f3f4f6; +} + +.m-btn-delete { + background: #fee2e2; + color: #dc2626; + border-color: #fecaca !important; +} + +.m-btn-delete:active { + background: #fecdd3; +} + +/* 비어있는 상태 */ +.m-empty { + text-align: center; + padding: 2rem 1rem; + color: #9ca3af; + font-size: 0.875rem; +} + +.m-empty-icon { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +/* 로딩 스피너 */ +.m-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.m-loading::after { + content: ''; + width: 24px; + height: 24px; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: m-spin 0.6s linear infinite; +} + +@keyframes m-spin { + to { transform: rotate(360deg); } +} + +/* 토스트 */ +.m-toast { + position: fixed; + bottom: calc(80px + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%) translateY(100px); + background: #1f2937; + color: #fff; + padding: 0.625rem 1.25rem; + border-radius: 10px; + font-size: 0.8125rem; + font-weight: 500; + z-index: 2000; + opacity: 0; + transition: transform 0.3s, opacity 0.3s; + max-width: calc(100% - 2rem); + text-align: center; +} + +.m-toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.m-toast.success { + background: #059669; +} + +.m-toast.error { + background: #dc2626; +} + +/* 수정 바텀시트 폼 */ +.m-edit-form .m-form-group { + margin-bottom: 0.75rem; +} + +/* 부적합 요약 뱃지 */ +.m-defect-summary { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.m-defect-count { + background: #fee2e2; + color: #dc2626; + font-size: 0.625rem; + font-weight: 700; + padding: 0.1rem 0.3rem; + border-radius: 4px; +} + +/* 부적합 저장된 항목 */ +.m-saved-defects { + margin-top: 0.375rem; + padding: 0.375rem 0.5rem; + background: #ecfdf5; + border-radius: 6px; +} + +.m-saved-defect-item { + font-size: 0.6875rem; + color: #065f46; + padding: 0.1rem 0; +} + +/* iOS 안전 영역 */ +@supports (padding-bottom: env(safe-area-inset-bottom)) { + .m-bottom-sheet { + padding-bottom: env(safe-area-inset-bottom); + } +} + +/* 스크롤바 숨기기 (모바일) */ +.m-bottom-sheet::-webkit-scrollbar, +.m-wp-list::-webkit-scrollbar { + display: none; +} + +/* 확인 다이얼로그 */ +.m-confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 2001; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.m-confirm-box { + background: #fff; + border-radius: 14px; + padding: 1.25rem; + max-width: 300px; + width: 100%; + text-align: center; +} + +.m-confirm-message { + font-size: 0.9375rem; + color: #111827; + margin-bottom: 1rem; + line-height: 1.5; +} + +.m-confirm-actions { + display: flex; + gap: 0.5rem; +} + +.m-confirm-actions button { + flex: 1; + padding: 0.625rem; + border-radius: 10px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + border: none; + min-height: 44px; + -webkit-tap-highlight-color: transparent; +} + +.m-confirm-cancel { + background: #f3f4f6; + color: #374151; +} + +.m-confirm-ok { + background: #3b82f6; + color: #fff; +} + +.m-confirm-ok.danger { + background: #dc2626; +} + +/* 결과 모달 */ +.m-result-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 2002; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.m-result-overlay.show { + display: flex; +} + +.m-result-box { + background: #fff; + border-radius: 14px; + padding: 1.5rem; + max-width: 320px; + width: 100%; + text-align: center; +} + +.m-result-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.m-result-title { + font-size: 1rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.375rem; +} + +.m-result-message { + font-size: 0.8125rem; + color: #6b7280; + margin-bottom: 0.75rem; + line-height: 1.4; +} + +.m-result-details { + text-align: left; + background: #f9fafb; + border-radius: 8px; + padding: 0.625rem; + margin-bottom: 0.75rem; + font-size: 0.75rem; + color: #374151; + max-height: 200px; + overflow-y: auto; +} + +.m-result-details li { + padding: 0.15rem 0; +} + +.m-result-close { + width: 100%; + padding: 0.625rem; + border: none; + border-radius: 10px; + background: #3b82f6; + color: #fff; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + min-height: 44px; +} diff --git a/system1-factory/web/css/mobile.css b/system1-factory/web/css/mobile.css index 40417bf..593a557 100644 --- a/system1-factory/web/css/mobile.css +++ b/system1-factory/web/css/mobile.css @@ -218,16 +218,36 @@ width: 100%; } - /* 지도 영역 */ + /* 작업장 카드: 엣지투엣지 */ + .workplace-status-section .card { + border-radius: 0; + margin-left: -0.75rem; + margin-right: -0.75rem; + border-left: none; + border-right: none; + } + + .workplace-status-section .card-body { + padding: 0 !important; + } + #workplaceMapContainer { - min-height: 300px !important; + min-height: auto !important; } + /* 캔버스: 높이 제한 해제, 풀와이드 */ #workplaceMapCanvas { - max-height: 350px; + max-height: none; + border-radius: 0 !important; + border-left: none !important; + border-right: none !important; } - /* 범례 숨기기 또는 축소 */ + .workplace-status-section .card-header { + padding: 0.75rem !important; + } + + /* 범례 숨기기 */ #mapLegend { display: none; } @@ -756,6 +776,264 @@ } } +/* ========== 모바일 대시보드 (작업장 리스트 뷰) ========== */ +@media (max-width: 768px) { + /* 모바일 뷰가 활성화되면 데스크톱 섹션 숨김 */ + .mobile-dashboard-view[style*="block"] ~ .workplace-status-section, + .mobile-dashboard-view[style*="block"] ~ .moved-equipment-section, + .mobile-dashboard-view[style*="block"] ~ .dashboard-footer { + display: none !important; + } + + .mobile-dashboard-view { + padding-bottom: 8px; + } + + /* --- 날짜 헤더 --- */ + .md-date-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 14px; + padding: 0 2px; + } + + .md-date-label { + font-size: var(--text-lg, 18px); + font-weight: 700; + color: var(--text-primary, #1a202c); + } + + .md-date-value { + font-size: var(--text-sm, 14px); + color: var(--text-tertiary, #718096); + font-weight: 400; + } + + /* --- 카테고리 탭 --- */ + .md-category-tabs { + display: flex; + gap: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0 2px 12px; + margin-bottom: 4px; + scrollbar-width: none; + } + + .md-category-tabs::-webkit-scrollbar { + display: none; + } + + .md-cat-tab { + flex-shrink: 0; + padding: 7px 16px; + border-radius: var(--radius-full, 9999px); + font-size: var(--text-sm, 14px); + font-weight: 500; + white-space: nowrap; + border: 1px solid var(--border-light, #e2e8f0); + background: var(--bg-primary, #fff); + color: var(--text-secondary, #4a5568); + cursor: pointer; + transition: all 0.15s; + min-height: 36px; + -webkit-tap-highlight-color: transparent; + } + + .md-cat-tab:active { + transform: scale(0.97); + } + + .md-cat-tab.active { + background: var(--primary-500, #3b82f6); + color: #fff; + border-color: var(--primary-500, #3b82f6); + } + + /* --- 작업장 리스트 --- */ + .md-workplace-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + /* --- 작업장 카드 --- */ + .md-wp-card { + background: var(--bg-primary, #fff); + border: 1px solid var(--border-light, #e2e8f0); + border-radius: var(--radius-lg, 12px); + padding: 14px 16px; + } + + .md-wp-name { + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #1a202c); + margin: 0 0 8px; + } + + .md-wp-stats { + display: flex; + flex-direction: column; + gap: 4px; + } + + .md-wp-stat-row { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-sm, 14px); + color: var(--text-secondary, #4a5568); + line-height: 1.6; + } + + .md-wp-stat-icon { + flex-shrink: 0; + width: 18px; + text-align: center; + font-size: 13px; + } + + .md-wp-stat-text { + font-variant-numeric: tabular-nums; + } + + .md-wp-stat--warning { + color: var(--status-warning-text, #ca8a04); + } + + /* --- 빈 상태 --- */ + .md-wp-no-activity { + margin: 0; + color: var(--text-tertiary, #718096); + font-size: var(--text-sm, 14px); + } + + .md-wp-empty-all { + text-align: center; + padding: 32px 0; + color: var(--text-tertiary, #718096); + font-size: var(--text-sm, 14px); + } + + /* --- 카드 확장 상태 --- */ + .md-wp-card.expanded { + border-color: var(--primary-300, #93c5fd); + box-shadow: 0 2px 8px rgba(59,130,246,0.1); + } + + .md-wp-header { + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .md-wp-toggle { + float: right; + color: var(--text-tertiary, #718096); + font-size: 12px; + transition: transform 0.2s; + } + + .md-wp-card.expanded .md-wp-toggle { + transform: rotate(180deg); + } + + /* --- 상세 영역 --- */ + .md-wp-detail { + display: none; + border-top: 1px solid var(--border-light, #e2e8f0); + margin-top: 10px; + padding-top: 10px; + } + + .md-wp-card.expanded .md-wp-detail { + display: block; + } + + .md-wp-detail-section { + margin-bottom: 10px; + } + + .md-wp-detail-section:last-child { + margin-bottom: 0; + } + + .md-wp-detail-title { + font-size: 12px; + font-weight: 600; + color: var(--text-tertiary, #718096); + letter-spacing: 0.03em; + margin-bottom: 6px; + } + + .md-wp-detail-item { + padding: 6px 0; + border-bottom: 1px solid var(--gray-100, #f5f5f5); + font-size: 14px; + } + + .md-wp-detail-item:last-child { + border-bottom: none; + } + + .md-wp-detail-main { + font-weight: 500; + color: var(--text-primary, #1a202c); + } + + .md-wp-detail-sub { + font-size: 13px; + color: var(--text-tertiary, #718096); + margin-top: 1px; + } + + /* 신고 상태 배지 */ + .md-wp-issue-status { + display: inline-block; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + font-weight: 500; + } + + .md-wp-issue-status--reported { + background: var(--status-warning-bg, #fef3c7); + color: var(--status-warning-text, #ca8a04); + } + + .md-wp-issue-status--received { + background: var(--status-info-bg, #dbeafe); + color: var(--status-info-text, #2563eb); + } + + .md-wp-issue-status--in_progress { + background: var(--status-error-bg, #fee2e2); + color: var(--status-error-text, #dc2626); + } + + /* --- 로딩 스켈레톤 --- */ + .md-skeleton { + height: 48px; + border-radius: var(--radius-md, 8px); + background: linear-gradient(90deg, var(--gray-100, #f5f5f5) 25%, var(--gray-200, #eee) 50%, var(--gray-100, #f5f5f5) 75%); + background-size: 200% 100%; + animation: md-shimmer 1.5s infinite; + } + + @keyframes md-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } +} + +/* 데스크톱에서는 모바일 대시보드 절대 표시 안 함 */ +@media (min-width: 769px) { + .mobile-dashboard-view { + display: none !important; + } +} + /* ========== 다크모드 지원 (선택적) ========== */ @media (max-width: 768px) and (prefers-color-scheme: dark) { /* 다크모드 색상 조정 필요시 여기에 추가 */ diff --git a/system1-factory/web/css/tbm.css b/system1-factory/web/css/tbm.css index 2a4753e..104e774 100644 --- a/system1-factory/web/css/tbm.css +++ b/system1-factory/web/css/tbm.css @@ -1237,6 +1237,115 @@ body.tbm-modal-open .mobile-bottom-nav { } } +/* ===== 가로모드 전체화면 지도 오버레이 ===== */ +.landscape-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.95); + display: flex; + align-items: center; + justify-content: center; +} + +.landscape-inner { + display: flex; + flex-direction: column; + background: #fff; + overflow: hidden; +} + +/* 세로모드(portrait)일 때 CSS 회전으로 가로 효과 */ +.landscape-inner.rotated { + width: 100vh; + height: 100vw; + transform: translate(-50%, -50%) rotate(90deg); + position: absolute; + top: 50%; + left: 50%; +} + +/* 물리적 가로모드일 때 회전 없이 전체화면 */ +.landscape-inner.no-rotate { + width: 100vw; + height: 100vh; +} + +.landscape-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + flex-shrink: 0; +} + +.landscape-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.landscape-close-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; +} + +.landscape-canvas-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #f1f5f9; + padding: 0.5rem; +} + +.landscape-canvas-wrap canvas { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); +} + +/* 전체화면 지도 트리거 버튼 (모바일만) */ +.landscape-trigger-btn { + display: none; +} + +@media (max-width: 768px) { + .landscape-trigger-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + color: white; + border: none; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; + -webkit-tap-highlight-color: transparent; + } + .landscape-trigger-btn:active { + transform: scale(0.97); + opacity: 0.85; + } +} + /* ===== 작업장 선택 지도 ===== */ .tbm-workplace-map-container { text-align: center; diff --git a/system1-factory/web/js/app-init.js b/system1-factory/web/js/app-init.js index 4c10013..25a3641 100644 --- a/system1-factory/web/js/app-init.js +++ b/system1-factory/web/js/app-init.js @@ -26,7 +26,7 @@ if (window.clearSSOAuth) { window.clearSSOAuth(); return; } localStorage.removeItem('sso_token'); localStorage.removeItem('sso_user'); - localStorage.removeItem('userPageAccess'); + localStorage.removeItem('userPageAccess_v2'); } // ===== 페이지 권한 캐시 ===== @@ -36,7 +36,7 @@ if (!currentUser || !currentUser.user_id) return null; // 캐시 확인 - const cached = localStorage.getItem('userPageAccess'); + const cached = localStorage.getItem('userPageAccess_v2_v2'); if (cached) { try { const cacheData = JSON.parse(cached); @@ -44,7 +44,7 @@ return cacheData.pages; } } catch (e) { - localStorage.removeItem('userPageAccess'); + localStorage.removeItem('userPageAccess_v2'); } } @@ -67,7 +67,7 @@ const data = await response.json(); const pages = data.data.pageAccess || []; - localStorage.setItem('userPageAccess', JSON.stringify({ + localStorage.setItem('userPageAccess_v2', JSON.stringify({ pages: pages, timestamp: Date.now() })); @@ -91,11 +91,19 @@ } // ===== 현재 페이지 키 추출 ===== + // 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유) + var PAGE_KEY_ALIASES = { + 'work.tbm-create': 'work.tbm', + 'work.tbm-mobile': 'work.tbm', + 'work.report-create-mobile': 'work.report-create' + }; + function getCurrentPageKey() { const path = window.location.pathname; if (!path.startsWith('/pages/')) return null; const pagePath = path.substring(7).replace('.html', ''); - return pagePath.replace(/\//g, '.'); + const rawKey = pagePath.replace(/\//g, '.'); + return PAGE_KEY_ALIASES[rawKey] || rawKey; } // ===== 컴포넌트 로더 ===== diff --git a/system1-factory/web/js/auth-check.js b/system1-factory/web/js/auth-check.js index 68b958e..85b64b0 100644 --- a/system1-factory/web/js/auth-check.js +++ b/system1-factory/web/js/auth-check.js @@ -26,6 +26,12 @@ function clearAuthData() { * /pages/admin/accounts.html -> admin.accounts * /pages/dashboard.html -> dashboard */ +// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유) +var PAGE_KEY_ALIASES = { + 'work.tbm-create': 'work.tbm', + 'work.tbm-mobile': 'work.tbm' +}; + function getCurrentPageKey() { const path = window.location.pathname; @@ -41,9 +47,9 @@ function getCurrentPageKey() { const withoutExt = pagePath.replace('.html', ''); // 슬래시를 점으로 변환 - const pageKey = withoutExt.replace(/\//g, '.'); + const rawKey = withoutExt.replace(/\//g, '.'); - return pageKey; + return PAGE_KEY_ALIASES[rawKey] || rawKey; } /** diff --git a/system1-factory/web/js/daily-work-report-mobile.js b/system1-factory/web/js/daily-work-report-mobile.js new file mode 100644 index 0000000..8eac46f --- /dev/null +++ b/system1-factory/web/js/daily-work-report-mobile.js @@ -0,0 +1,1584 @@ +/** + * Daily Work Report - Mobile UI Logic + * 모바일 전용 작업보고서 UI 로직 + * + * 재사용 모듈: state.js, api.js, utils.js, api-base.js + */ + +const MobileReport = (function() { + 'use strict'; + + // ===== 내부 상태 ===== + let currentTab = 'tbm'; + let manualCounter = 0; + let manualCards = {}; // { id: { data } } + + // 시간 선택 상태 + let timePickerCallback = null; + let timePickerValue = 0; + + // 부적합 시트 상태 + let defectSheetIndex = null; + let defectSheetType = null; // 'tbm' | 'manual' + let defectSheetTempData = []; // 현재 편집중인 부적합 데이터 + + // 작업장소 시트 상태 + let wpSheetManualId = null; + let wpSheetStep = 'category'; // 'category' | 'list' + let wpSelectedCategory = null; + let wpSelectedCategoryName = null; + let wpSelectedId = null; + let wpSelectedName = null; + + // 수정 시트 상태 + let editReportId = null; + + // ===== 초기화 ===== + async function init() { + console.log('[Mobile] 초기화 시작'); + showMessage('데이터를 불러오는 중...', 'loading'); + + try { + const api = window.DailyWorkReportAPI; + const state = window.DailyWorkReportState; + + await api.loadAllData(); + + // 부적합 카테고리/아이템 로드 확인 + console.log('[Mobile] issueCategories:', (state.issueCategories || []).length, '개'); + console.log('[Mobile] issueItems:', (state.issueItems || []).length, '개'); + + // TBM 데이터 로드 + await api.loadIncompleteTbms(); + await api.loadDailyIssuesForTbms(); + + console.log('[Mobile] incompleteTbms:', (state.incompleteTbms || []).length, '개'); + console.log('[Mobile] dailyIssuesCache:', Object.keys(state.dailyIssuesCache || {}).length, '날짜'); + + hideMessage(); + renderTbmCards(); + + // 완료 탭 날짜 초기화 + const today = getKoreaToday(); + const dateInput = document.getElementById('completedDate'); + if (dateInput) dateInput.value = today; + + console.log('[Mobile] 초기화 완료'); + } catch (error) { + console.error('[Mobile] 초기화 오류:', error); + showMessage('데이터 로드 중 오류가 발생했습니다.', 'error'); + } + } + + // ===== 유틸리티 ===== + function getKoreaToday() { + const today = new Date(); + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + } + + function formatDateForApi(date) { + if (!date) return null; + const d = date instanceof Date ? date : new Date(date); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + function formatDate(dateString) { + if (!dateString) return ''; + const d = new Date(dateString); + return `${d.getMonth() + 1}/${d.getDate()}`; + } + + function getDayOfWeek(dateString) { + const days = ['일', '월', '화', '수', '목', '금', '토']; + return days[new Date(dateString).getDay()]; + } + + function formatHours(val) { + if (!val || val <= 0) return '선택'; + if (val === Math.floor(val)) return val + '시간'; + const hours = Math.floor(val); + const mins = Math.round((val - hours) * 60); + if (hours === 0) return mins + '분'; + return hours + '시간 ' + mins + '분'; + } + + function esc(str) { + return window.escapeHtml ? window.escapeHtml(String(str || '')) : String(str || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); + } + + // ===== 메시지 / 토스트 ===== + function showMessage(msg, type) { + const el = document.getElementById('mMessage'); + if (!el) return; + el.textContent = msg; + el.className = 'm-message show ' + (type || 'info'); + if (type === 'success') setTimeout(hideMessage, 3000); + } + + function hideMessage() { + const el = document.getElementById('mMessage'); + if (el) el.className = 'm-message'; + } + + function showToast(msg, type, duration) { + const el = document.getElementById('mToast'); + if (!el) return; + el.textContent = msg; + el.className = 'm-toast show ' + (type || ''); + clearTimeout(el._timer); + el._timer = setTimeout(() => { el.className = 'm-toast'; }, duration || 2500); + } + + function showResult(type, title, message, details) { + const overlay = document.getElementById('mResultOverlay'); + const icons = { success: '✅', error: '❌', warning: '⚠️' }; + document.getElementById('mResultIcon').textContent = icons[type] || 'ℹ️'; + document.getElementById('mResultTitle').textContent = title; + document.getElementById('mResultMessage').textContent = message; + + const detailsEl = document.getElementById('mResultDetails'); + if (details && Array.isArray(details) && details.length > 0) { + detailsEl.innerHTML = '
    ' + details.map(d => '
  • ' + esc(d) + '
  • ').join('') + '
'; + detailsEl.style.display = 'block'; + } else if (typeof details === 'string' && details) { + detailsEl.innerHTML = '

' + esc(details) + '

'; + detailsEl.style.display = 'block'; + } else { + detailsEl.style.display = 'none'; + } + + overlay.classList.add('show'); + } + + function closeResult() { + document.getElementById('mResultOverlay').classList.remove('show'); + } + + function showConfirm(message, onConfirm, isDanger) { + const overlay = document.createElement('div'); + overlay.className = 'm-confirm-overlay'; + overlay.innerHTML = ` +
+
${esc(message)}
+
+ + +
+
+ `; + document.body.appendChild(overlay); + + overlay.querySelector('#mConfirmCancel').onclick = () => { overlay.remove(); }; + overlay.querySelector('#mConfirmOk').onclick = () => { overlay.remove(); onConfirm(); }; + } + + // ===== 탭 관리 ===== + function switchTab(tab) { + currentTab = tab; + + // 탭 버튼 상태 + document.querySelectorAll('.m-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); + + // 탭 콘텐츠 + document.querySelectorAll('.m-tab-content').forEach(el => { + el.classList.remove('active'); + }); + + const tabMap = { tbm: 'tabTbm', manual: 'tabManual', completed: 'tabCompleted' }; + const targetEl = document.getElementById(tabMap[tab]); + if (targetEl) targetEl.classList.add('active'); + + // 수동추가 버튼 표시/숨기기 + const addBtn = document.getElementById('btnAddManual'); + if (addBtn) addBtn.style.display = (tab === 'manual') ? 'block' : 'none'; + + // 완료 탭 진입 시 자동 조회 + if (tab === 'completed') { + loadCompletedReports(); + } + } + + // ===== TBM 카드 렌더링 ===== + function renderTbmCards() { + const state = window.DailyWorkReportState; + const container = document.getElementById('tbmCardList'); + const tbms = state.incompleteTbms || []; + + // 탭 카운트 업데이트 + const countEl = document.getElementById('tbmCount'); + if (countEl) countEl.textContent = tbms.length; + + if (tbms.length === 0) { + container.innerHTML = '
미완료 TBM 작업이 없습니다
'; + return; + } + + // 날짜별 그룹화 + const byDate = {}; + tbms.forEach((tbm, index) => { + const dateStr = formatDateForApi(tbm.session_date); + if (!byDate[dateStr]) { + byDate[dateStr] = { date: tbm.session_date, sessions: {} }; + } + const sessionKey = `${tbm.session_id}_${dateStr}`; + if (!byDate[dateStr].sessions[sessionKey]) { + byDate[dateStr].sessions[sessionKey] = { + session_id: tbm.session_id, + created_by_name: tbm.created_by_name, + items: [] + }; + } + byDate[dateStr].sessions[sessionKey].items.push({ ...tbm, originalIndex: index }); + }); + + const sortedDates = Object.keys(byDate).sort((a, b) => new Date(b) - new Date(a)); + const today = getKoreaToday(); + let html = ''; + + sortedDates.forEach((dateStr, dateIndex) => { + const dateData = byDate[dateStr]; + const sessions = Object.values(dateData.sessions); + const totalWorkers = sessions.reduce((sum, s) => sum + s.items.length, 0); + const isToday = dateStr === today; + const isExpanded = isToday || dateIndex === 0; + const dayOfWeek = getDayOfWeek(dateStr); + + // 당일 신고 + const issues = state.dailyIssuesCache[dateStr] || []; + const nonconfCount = issues.filter(i => i.category_type === 'nonconformity').length; + + html += ` +
+
+
+ + ${formatDate(dateData.date)} (${dayOfWeek}) + ${isToday ? '오늘' : ''} +
+
+ ${nonconfCount > 0 ? '부적합 ' + nonconfCount + '' : ''} + 세션${sessions.length} · ${totalWorkers}명 +
+
+
+ `; + + // 이슈 리마인더 + if (issues.length > 0) { + html += ` +
+
+ ⚠️ + 당일 신고 ${issues.length}건 +
+ ${issues.slice(0, 3).map(issue => { + const itemText = issue.issue_item_name || ''; + const desc = issue.additional_description ? (itemText ? itemText + ' - ' + issue.additional_description : issue.additional_description) : itemText; + return ` +
+ ${issue.category_type === 'safety' ? '안전' : '부적합'} + ${esc(issue.issue_category_name || '')} ${esc(desc || '-')} +
+ `; + }).join('')} + ${issues.length > 3 ? '
외 ' + (issues.length - 3) + '건
' : ''} +
+ `; + } + + // 세션별 카드 + sessions.forEach(group => { + const sessionKey = `${group.session_id}_${dateStr}`; + + html += ` +
+ TBM + 작성자: ${esc(group.created_by_name)} · ${group.items.length}명 +
+ `; + + group.items.forEach(tbm => { + const idx = tbm.originalIndex; + const defects = state.tempDefects[idx] || []; + const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0); + const totalHoursVal = document.getElementById('m_totalHours_' + idx)?.value || ''; + const hasRelatedIssue = issues.some(i => { + if (i.category_type !== 'nonconformity') return false; + if (tbm.workplace_id && i.workplace_id) return tbm.workplace_id === i.workplace_id; + return false; + }); + + html += renderWorkerCard(tbm, idx, totalHoursVal, defects, totalDefectHours, hasRelatedIssue); + }); + + // 일괄 제출 버튼 + if (group.items.length > 1) { + html += ` + + `; + } + }); + + html += '
'; + }); + + container.innerHTML = html; + } + + function getDefaultHours(tbm) { + // work_hours가 있으면 (분할 배정) 해당 값 우선 사용 + if (tbm.work_hours != null && parseFloat(tbm.work_hours) > 0) { + return parseFloat(tbm.work_hours); + } + switch (tbm.attendance_type) { + case 'overtime': return 8 + (parseFloat(tbm.attendance_hours) || 0); + case 'regular': return 8; + case 'half': return 4; + case 'quarter': return 6; + case 'early': return parseFloat(tbm.attendance_hours) || 0; + default: return 0; + } + } + + function getAttendanceLabel(type) { + const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' }; + return labels[type] || ''; + } + + function getAttendanceBadgeColor(type) { + const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' }; + return colors[type] || '#6b7280'; + } + + function renderWorkerCard(tbm, idx, totalHoursVal, defects, totalDefectHours, hasRelatedIssue) { + const state = window.DailyWorkReportState; + const defectCount = defects.filter(d => d.defect_hours > 0).length; + const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음'; + + // 근태 기반 자동 시간 채움 + const defaultHours = tbm.attendance_type ? getDefaultHours(tbm) : 0; + const effectiveHours = totalHoursVal || (defaultHours > 0 ? String(defaultHours) : ''); + const timeDisplay = effectiveHours ? formatHours(parseFloat(effectiveHours)) : '선택'; + const attendanceBadge = tbm.attendance_type ? + `${getAttendanceLabel(tbm.attendance_type)}` : ''; + + return ` +
+ +
+
+
${esc(tbm.worker_name || '작업자')}${attendanceBadge}
+
${esc(tbm.job_type || '-')}
+
+
+
+
프로젝트${esc(tbm.project_name || '-')}
+
공정/작업${esc(tbm.work_type_name || '-')} / ${esc(tbm.task_name || '-')}
+
작업장소${esc(tbm.category_name || '')} > ${esc(tbm.workplace_name || '-')}
+
+
+
+ 작업시간 + ${timeDisplay} +
+ +
+ +
+ `; + } + + function toggleDateGroup(dateStr) { + const group = document.querySelector(`.m-date-group[data-date="${dateStr}"]`); + if (!group) return; + group.classList.toggle('expanded'); + } + + // ===== 시간 선택 ===== + function openTimePicker(index, type) { + timePickerValue = 0; + const overlay = document.getElementById('mTimeOverlay'); + + if (type === 'tbm') { + const existing = document.getElementById('m_totalHours_' + index); + if (existing && existing.value) timePickerValue = parseFloat(existing.value) || 0; + document.getElementById('mTimeTitle').textContent = '작업시간 선택'; + timePickerCallback = function(val) { + const inp = document.getElementById('m_totalHours_' + index); + if (inp) inp.value = val; + const disp = document.getElementById('m_timeDisplay_' + index); + if (disp) { + disp.textContent = formatHours(val); + disp.closest('.m-action-btn').classList.toggle('has-value', val > 0); + } + }; + } else if (type === 'manual') { + const existing = document.getElementById('m_manual_hours_' + index); + if (existing && existing.value) timePickerValue = parseFloat(existing.value) || 0; + document.getElementById('mTimeTitle').textContent = '작업시간 선택'; + timePickerCallback = function(val) { + const inp = document.getElementById('m_manual_hours_' + index); + if (inp) inp.value = val; + const disp = document.getElementById('m_manual_timeDisplay_' + index); + if (disp) { + disp.textContent = formatHours(val); + disp.closest('.m-action-btn').classList.toggle('has-value', val > 0); + } + }; + } else if (type === 'defect') { + // 부적합 시트 내 시간 선택 + timePickerValue = 0; + document.getElementById('mTimeTitle').textContent = '부적합 시간 선택'; + timePickerCallback = function(val) { + // index is defect index within defectSheetTempData + if (defectSheetTempData[index]) { + defectSheetTempData[index].defect_hours = val; + const timeEl = document.getElementById('m_defect_time_' + index); + if (timeEl) timeEl.textContent = formatHours(val); + } + }; + } + + updateTimeDisplay(); + highlightTimeButtons(); + overlay.classList.add('show'); + } + + function setTime(val) { + timePickerValue = val; + updateTimeDisplay(); + highlightTimeButtons(); + } + + function adjustTime(delta) { + timePickerValue = Math.max(0, Math.round((timePickerValue + delta) * 10) / 10); + updateTimeDisplay(); + highlightTimeButtons(); + } + + function updateTimeDisplay() { + document.getElementById('mTimeCurrent').textContent = formatHours(timePickerValue) || '0시간'; + } + + function highlightTimeButtons() { + document.querySelectorAll('.m-time-btn').forEach(btn => { + btn.classList.remove('selected'); + }); + const quickValues = [0.5, 1, 2, 4, 8]; + const btns = document.querySelectorAll('.m-quick-time-grid .m-time-btn'); + quickValues.forEach((v, i) => { + if (btns[i] && timePickerValue === v) btns[i].classList.add('selected'); + }); + } + + function confirmTime() { + if (timePickerCallback) { + timePickerCallback(timePickerValue); + } + closeTimePicker(); + } + + function closeTimePicker() { + document.getElementById('mTimeOverlay').classList.remove('show'); + timePickerCallback = null; + } + + // ===== 부적합 바텀시트 ===== + function openDefectSheet(index, type) { + console.log('[Mobile] openDefectSheet:', index, type); + defectSheetIndex = index; + defectSheetType = type; + const state = window.DailyWorkReportState; + + // 기존 부적합 데이터 복사 + if (type === 'tbm') { + if (!state.tempDefects[index]) { + state.tempDefects[index] = []; + } + defectSheetTempData = JSON.parse(JSON.stringify(state.tempDefects[index])); + } else { + const mcData = manualCards[index]; + if (mcData && mcData.defects) { + defectSheetTempData = JSON.parse(JSON.stringify(mcData.defects)); + } else { + defectSheetTempData = []; + } + } + + console.log('[Mobile] defectSheetTempData:', defectSheetTempData); + renderDefectSheetContent(); + showBottomSheet('defect'); + } + + function renderDefectSheetContent() { + const state = window.DailyWorkReportState; + const body = document.getElementById('defectSheetBody'); + console.log('[Mobile] renderDefectSheetContent, body:', !!body); + console.log('[Mobile] issueCategories:', (state.issueCategories || []).length); + console.log('[Mobile] issueItems:', (state.issueItems || []).length); + + // 당일 신고된 이슈 가져오기 + let relatedIssues = []; + if (defectSheetType === 'tbm') { + const tbm = state.incompleteTbms[defectSheetIndex]; + console.log('[Mobile] tbm:', tbm?.worker_name, 'session_date:', tbm?.session_date, 'workplace_id:', tbm?.workplace_id); + if (tbm) { + const dateStr = formatDateForApi(tbm.session_date); + const allIssues = state.dailyIssuesCache[dateStr] || []; + console.log('[Mobile] allIssues for', dateStr, ':', allIssues.length); + relatedIssues = allIssues.filter(i => { + if (i.category_type !== 'nonconformity') return false; + if (tbm.workplace_id && i.workplace_id) return tbm.workplace_id === i.workplace_id; + if (tbm.workplace_name && (i.workplace_name || i.custom_location)) { + const loc = i.workplace_name || i.custom_location || ''; + return loc.includes(tbm.workplace_name) || tbm.workplace_name.includes(loc); + } + return false; + }); + } + } + + console.log('[Mobile] relatedIssues:', relatedIssues.length); + + let html = ''; + + // 신고 기반 이슈 체크 + if (relatedIssues.length > 0) { + html += '
📋 관련 신고
'; + relatedIssues.forEach(issue => { + const existingIdx = defectSheetTempData.findIndex(d => d.issue_report_id == issue.report_id); + const isSelected = existingIdx >= 0; + const defectHours = isSelected ? defectSheetTempData[existingIdx].defect_hours : 0; + const itemText = issue.issue_item_name || ''; + const desc = issue.additional_description ? (itemText ? itemText + ' - ' + issue.additional_description : issue.additional_description) : itemText; + + html += ` +
+
+
+
${esc(issue.issue_category_name || '부적합')}
+
${esc(desc || '-')} · ${esc(issue.workplace_name || issue.custom_location || '')}
+ ${isSelected ? ` +
+ + ${formatHours(defectHours) || '선택'} +
+ ` : ''} +
+
+ `; + }); + } + + // 수동 부적합 추가 섹션 + html += '
'; + html += '
수동 부적합 추가
'; + + // 기존 수동 부적합 항목 렌더링 + const manualDefects = defectSheetTempData.filter(d => !d.issue_report_id); + manualDefects.forEach((defect, i) => { + const realIdx = defectSheetTempData.indexOf(defect); + html += renderManualDefectRow(defect, realIdx); + }); + + html += ` + +
`; + + body.innerHTML = html; + console.log('[Mobile] defectSheet rendered, html length:', html.length); + } + + function renderManualDefectRow(defect, idx) { + const state = window.DailyWorkReportState; + const categories = state.issueCategories || []; + const items = state.issueItems || []; + const filteredItems = defect.category_id ? items.filter(it => it.category_id == defect.category_id) : []; + + return ` +
+ +
+ + +
+
+ + +
+
+ 시간: + ${formatHours(defect.defect_hours) || '선택'} +
+
+ +
+
+ `; + } + + function toggleDefectIssue(reportId) { + const existingIdx = defectSheetTempData.findIndex(d => d.issue_report_id == reportId); + + if (existingIdx >= 0) { + // 이미 선택됨 → 해제 + defectSheetTempData.splice(existingIdx, 1); + } else { + // 선택 → 추가 (데스크탑과 동일한 구조: 이슈 기반이므로 error_type_id는 null) + defectSheetTempData.push({ + issue_report_id: reportId, + error_type_id: null, + defect_hours: 0, + note: '' + }); + } + renderDefectSheetContent(); + } + + function addManualDefect() { + defectSheetTempData.push({ + issue_report_id: null, + category_id: null, + item_id: null, + error_type_id: '', // 레거시 호환 (데스크탑과 동일) + defect_hours: 0, + note: '' + }); + renderDefectSheetContent(); + } + + function removeManualDefect(idx) { + defectSheetTempData.splice(idx, 1); + renderDefectSheetContent(); + } + + function updateDefectCategory(idx, categoryId) { + if (defectSheetTempData[idx]) { + defectSheetTempData[idx].category_id = categoryId ? parseInt(categoryId) : null; + defectSheetTempData[idx].item_id = null; + defectSheetTempData[idx].error_type_id = null; + // 항목 드롭다운 재렌더링 + renderDefectSheetContent(); + } + } + + function updateDefectItem(idx, itemId) { + if (defectSheetTempData[idx]) { + defectSheetTempData[idx].item_id = itemId ? parseInt(itemId) : null; + defectSheetTempData[idx].error_type_id = itemId ? parseInt(itemId) : null; + } + } + + function updateDefectNote(idx, note) { + if (defectSheetTempData[idx]) { + defectSheetTempData[idx].note = note; + } + } + + function openDefectTimePicker(id, type) { + if (type === 'issue') { + // issue_report_id 기반 + const idx = defectSheetTempData.findIndex(d => d.issue_report_id == id); + if (idx < 0) return; + timePickerValue = defectSheetTempData[idx].defect_hours || 0; + timePickerCallback = function(val) { + defectSheetTempData[idx].defect_hours = val; + const el = document.getElementById('m_defect_issue_time_' + id); + if (el) el.textContent = formatHours(val); + }; + } else { + // manual index + timePickerValue = defectSheetTempData[id]?.defect_hours || 0; + timePickerCallback = function(val) { + if (defectSheetTempData[id]) { + defectSheetTempData[id].defect_hours = val; + const el = document.getElementById('m_defect_time_' + id); + if (el) el.textContent = formatHours(val); + } + }; + } + updateTimeDisplay(); + highlightTimeButtons(); + document.getElementById('mTimeTitle').textContent = '부적합 시간 선택'; + document.getElementById('mTimeOverlay').classList.add('show'); + } + + function saveDefects() { + const state = window.DailyWorkReportState; + + // 수동 부적합 유효성 검사: category_id 또는 item_id가 있어야 저장 + const manualDefects = defectSheetTempData.filter(d => !d.issue_report_id); + const invalidManual = manualDefects.filter(d => d.defect_hours > 0 && !d.category_id && !d.item_id); + if (invalidManual.length > 0) { + showToast('부적합 카테고리/항목을 선택해주세요', 'error'); + return; + } + + // _saved 플래그 설정 (데스크탑 호환) + defectSheetTempData.forEach(d => { d._saved = true; }); + + if (defectSheetType === 'tbm') { + state.tempDefects[defectSheetIndex] = defectSheetTempData; + // 카드 UI 업데이트 + const defects = defectSheetTempData; + const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0); + const defectCount = defects.filter(d => d.defect_hours > 0).length; + const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음'; + const disp = document.getElementById('m_defectDisplay_' + defectSheetIndex); + if (disp) { + disp.textContent = defectText; + disp.closest('.m-action-btn').classList.toggle('has-value', defectCount > 0); + } + } else if (defectSheetType === 'manual') { + if (manualCards[defectSheetIndex]) { + manualCards[defectSheetIndex].defects = defectSheetTempData; + // UI 업데이트 + const defects = defectSheetTempData; + const totalDefectHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0); + const defectCount = defects.filter(d => d.defect_hours > 0).length; + const defectText = defectCount > 0 ? totalDefectHours + 'h / ' + defectCount + '건' : '없음'; + const disp = document.getElementById('m_manual_defectDisplay_' + defectSheetIndex); + if (disp) { + disp.textContent = defectText; + disp.closest('.m-action-btn').classList.toggle('has-value', defectCount > 0); + } + } + } + + hideDefectSheet(); + showToast('부적합 정보가 저장되었습니다', 'success'); + } + + function hideDefectSheet() { + hideBottomSheet('defect'); + defectSheetIndex = null; + defectSheetType = null; + } + + // ===== 제출 ===== + async function submitTbmReport(index) { + const state = window.DailyWorkReportState; + const tbm = state.incompleteTbms[index]; + if (!tbm) return; + + const totalHours = parseFloat(document.getElementById('m_totalHours_' + index)?.value); + const defects = state.tempDefects[index] || []; + const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0); + const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null; + + // 검증 + if (!totalHours || totalHours <= 0) { + showToast('작업시간을 선택해주세요', 'error'); + return; + } + if (errorHours > totalHours) { + showToast('부적합 시간이 총 작업시간을 초과합니다', 'error'); + return; + } + + const invalidDefects = defects.filter(d => d.defect_hours > 0 && !d.error_type_id && !d.issue_report_id && !d.category_id && !d.item_id); + if (invalidDefects.length > 0) { + showToast('부적합 원인을 선택해주세요', 'error'); + return; + } + + const reportDate = typeof tbm.session_date === 'string' && tbm.session_date.includes('T') + ? tbm.session_date.split('T')[0] + : (tbm.session_date instanceof Date ? formatDateForApi(tbm.session_date) : tbm.session_date); + + const reportData = { + tbm_assignment_id: tbm.assignment_id, + tbm_session_id: tbm.session_id, + worker_id: tbm.worker_id, + project_id: tbm.project_id, + work_type_id: tbm.task_id, + report_date: reportDate, + start_time: null, + end_time: null, + total_hours: totalHours, + error_hours: errorHours, + error_type_id: errorTypeId, + work_status_id: errorHours > 0 ? 2 : 1 + }; + + // 제출 버튼 비활성화 + const card = document.querySelector(`.m-worker-card[data-index="${index}"]`); + const btn = card?.querySelector('.m-submit-btn'); + if (btn) { btn.disabled = true; btn.textContent = '제출 중...'; } + + try { + const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', reportData); + if (!response.success) throw new Error(response.message || '제출 실패'); + + // 부적합 원인 저장 + if (defects.length > 0 && response.data?.report_id) { + const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0); + if (validDefects.length > 0) { + const defectsToSend = validDefects.map(d => ({ + issue_report_id: d.issue_report_id || null, + category_id: d.category_id || null, + item_id: d.item_id || null, + error_type_id: d.error_type_id || null, + defect_hours: d.defect_hours, + note: d.note || '' + })); + await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', { defects: defectsToSend }); + } + } + + // 임시 데이터 삭제 + delete state.tempDefects[index]; + + showResult('success', '제출 완료', `${tbm.worker_name}의 작업보고서가 제출되었습니다.`, + response.data.tbm_completed ? '모든 팀원의 작업보고서가 제출되어 TBM이 완료되었습니다.' : response.data.completion_status + ); + + // 목록 새로고침 + await refreshTbmList(); + } catch (error) { + console.error('[Mobile] TBM 제출 오류:', error); + showResult('error', '제출 실패', error.message); + if (btn) { btn.disabled = false; btn.textContent = '제출'; } + } + } + + async function batchSubmitSession(sessionKey) { + const state = window.DailyWorkReportState; + const cards = document.querySelectorAll(`.m-worker-card[data-type="tbm"]`); + + // 세션에 해당하는 카드 찾기 + const tbms = state.incompleteTbms || []; + const byDate = {}; + tbms.forEach((tbm, index) => { + const dateStr = formatDateForApi(tbm.session_date); + const key = `${tbm.session_id}_${dateStr}`; + if (!byDate[key]) byDate[key] = []; + byDate[key].push({ tbm, index }); + }); + + const sessionItems = byDate[sessionKey]; + if (!sessionItems || sessionItems.length === 0) { + showToast('제출할 항목이 없습니다', 'error'); + return; + } + + // 검증 + const errors = []; + const itemsToSubmit = []; + + sessionItems.forEach(({ tbm, index }) => { + const totalHours = parseFloat(document.getElementById('m_totalHours_' + index)?.value); + const defects = state.tempDefects[index] || []; + const errorHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0); + const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null; + + if (!totalHours || totalHours <= 0) { + errors.push(`${tbm.worker_name}: 작업시간 미입력`); + return; + } + if (errorHours > totalHours) { + errors.push(`${tbm.worker_name}: 부적합 시간 초과`); + return; + } + + const reportDate = typeof tbm.session_date === 'string' && tbm.session_date.includes('T') + ? tbm.session_date.split('T')[0] + : (tbm.session_date instanceof Date ? formatDateForApi(tbm.session_date) : tbm.session_date); + + itemsToSubmit.push({ + index, + tbm, + defects, + data: { + tbm_assignment_id: tbm.assignment_id, + tbm_session_id: tbm.session_id, + worker_id: tbm.worker_id, + project_id: tbm.project_id, + work_type_id: tbm.task_id, + report_date: reportDate, + start_time: null, + end_time: null, + total_hours: totalHours, + error_hours: errorHours, + error_type_id: errorTypeId, + work_status_id: errorHours > 0 ? 2 : 1 + } + }); + }); + + if (errors.length > 0) { + showResult('error', '일괄제출 검증 실패', '모든 항목이 유효해야 제출할 수 있습니다.', errors); + return; + } + + showMessage('일괄 제출 중...', 'loading'); + const results = { success: [], failed: [] }; + + for (const item of itemsToSubmit) { + try { + const response = await window.apiCall('/daily-work-reports/from-tbm', 'POST', item.data); + if (response.success) { + // 부적합 저장 + if (item.defects.length > 0 && response.data?.report_id) { + const validDefects = item.defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0); + if (validDefects.length > 0) { + const defectsToSend = validDefects.map(d => ({ + issue_report_id: d.issue_report_id || null, + category_id: d.category_id || null, + item_id: d.item_id || null, + error_type_id: d.error_type_id || null, + defect_hours: d.defect_hours, + note: d.note || '' + })); + await window.apiCall(`/daily-work-reports/${response.data.report_id}/defects`, 'PUT', { defects: defectsToSend }); + } + } + delete state.tempDefects[item.index]; + results.success.push(item.tbm.worker_name); + } else { + results.failed.push(`${item.tbm.worker_name}: ${response.message}`); + } + } catch (error) { + results.failed.push(`${item.tbm.worker_name}: ${error.message}`); + } + } + + hideMessage(); + + if (results.failed.length === 0) { + showResult('success', '일괄제출 완료', `${itemsToSubmit.length}건 모두 성공`, results.success.map(n => '✓ ' + n)); + } else if (results.success.length === 0) { + showResult('error', '일괄제출 실패', `${itemsToSubmit.length}건 모두 실패`, results.failed); + } else { + showResult('warning', '일괄제출 부분 완료', `성공 ${results.success.length}건 / 실패 ${results.failed.length}건`, + [...results.success.map(n => '✓ ' + n), ...results.failed.map(m => '✗ ' + m)] + ); + } + + await refreshTbmList(); + } + + async function refreshTbmList() { + const api = window.DailyWorkReportAPI; + await api.loadIncompleteTbms(); + await api.loadDailyIssuesForTbms(); + renderTbmCards(); + } + + // ===== 수동 입력 ===== + function addManualCard() { + const id = 'mc_' + (manualCounter++); + const state = window.DailyWorkReportState; + + manualCards[id] = { + worker_id: null, + report_date: getKoreaToday(), + project_id: null, + work_type_id: null, + task_id: null, + workplace_category_id: null, + workplace_id: null, + workplace_name: null, + workplace_category_name: null, + total_hours: 0, + defects: [] + }; + + const container = document.getElementById('manualCardList'); + // 빈 상태 메시지 제거 + const empty = container.querySelector('.m-empty'); + if (empty) empty.remove(); + + const cardEl = document.createElement('div'); + cardEl.className = 'm-manual-card'; + cardEl.id = 'm_manual_card_' + id; + cardEl.setAttribute('data-manual-id', id); + cardEl.style.position = 'relative'; + + const workers = state.workers || []; + const projects = state.projects || []; + const workTypes = state.workTypes || []; + + cardEl.innerHTML = ` + +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ 📍 + 클릭하여 선택 +
+
+
+
+ 작업시간 + 선택 + +
+
+ 부적합 + 없음 +
+
+ + `; + + container.appendChild(cardEl); + + // 수동 입력 탭으로 이동 + if (currentTab !== 'manual') { + switchTab('manual'); + } + } + + function removeManualCard(id) { + const card = document.getElementById('m_manual_card_' + id); + if (card) card.remove(); + delete manualCards[id]; + + // 카드가 모두 없으면 빈 상태 표시 + if (Object.keys(manualCards).length === 0) { + document.getElementById('manualCardList').innerHTML = ` +
+
📝
+
수동으로 작업보고서를 추가하세요
+ +
+ `; + } + } + + function updateManualField(id, field, value) { + if (manualCards[id]) { + manualCards[id][field] = value; + } + } + + async function onWorkTypeChange(id, workTypeId) { + updateManualField(id, 'work_type_id', workTypeId); + updateManualField(id, 'task_id', null); + + const taskSelect = document.getElementById('m_manual_task_' + id); + if (!workTypeId) { + taskSelect.disabled = true; + taskSelect.innerHTML = ''; + return; + } + + try { + const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`); + const tasks = response.success ? response.data : (Array.isArray(response) ? response : []); + + if (tasks && tasks.length > 0) { + taskSelect.disabled = false; + taskSelect.innerHTML = '' + + tasks.map(t => ``).join(''); + taskSelect.onchange = function() { + updateManualField(id, 'task_id', this.value); + }; + } else { + taskSelect.disabled = true; + taskSelect.innerHTML = ''; + } + } catch (error) { + taskSelect.disabled = true; + taskSelect.innerHTML = ''; + } + } + + async function submitManualReport(id) { + const mc = manualCards[id]; + if (!mc) return; + + const workerId = mc.worker_id || document.getElementById('m_manual_worker_' + id)?.value; + const reportDate = mc.report_date || document.getElementById('m_manual_date_' + id)?.value; + const projectId = mc.project_id || document.getElementById('m_manual_project_' + id)?.value; + const taskId = mc.task_id || document.getElementById('m_manual_task_' + id)?.value; + const totalHours = parseFloat(document.getElementById('m_manual_hours_' + id)?.value); + const workplaceId = mc.workplace_id; + const defects = mc.defects || []; + const errorHours = defects.reduce((s, d) => s + (parseFloat(d.defect_hours) || 0), 0); + const errorTypeId = defects.length > 0 ? (defects[0].error_type_id || defects[0].item_id || null) : null; + + // 검증 + if (!workerId) { showToast('작업자를 선택해주세요', 'error'); return; } + if (!reportDate) { showToast('날짜를 입력해주세요', 'error'); return; } + if (!projectId) { showToast('프로젝트를 선택해주세요', 'error'); return; } + if (!taskId) { showToast('작업을 선택해주세요', 'error'); return; } + if (workplaceId === null || workplaceId === undefined) { showToast('작업장소를 선택해주세요', 'error'); return; } + if (!totalHours || totalHours <= 0) { showToast('작업시간을 선택해주세요', 'error'); return; } + if (errorHours > totalHours) { showToast('부적합 시간이 초과합니다', 'error'); return; } + + const reportData = { + report_date: reportDate, + worker_id: parseInt(workerId), + work_entries: [{ + project_id: parseInt(projectId), + task_id: parseInt(taskId), + work_hours: totalHours, + work_status_id: errorHours > 0 ? 2 : 1, + error_type_id: errorTypeId ? parseInt(errorTypeId) : null + }] + }; + + const btn = document.querySelector(`#m_manual_card_${id} .m-submit-btn`); + if (btn) { btn.disabled = true; btn.textContent = '제출 중...'; } + + try { + let response; + let retries = 3; + for (let i = 0; i < retries; i++) { + try { + response = await window.apiCall('/daily-work-reports', 'POST', reportData); + break; + } catch (err) { + if ((err.message?.includes('429') || err.message?.includes('너무 많은 요청')) && i < retries - 1) { + await new Promise(r => setTimeout(r, (i + 1) * 2000)); + continue; + } + throw err; + } + } + + if (!response.success) throw new Error(response.message || '제출 실패'); + + // 부적합 저장 + const reportId = response.data?.inserted_ids?.[0] || response.data?.workReport_ids?.[0]; + if (defects.length > 0 && reportId) { + const validDefects = defects.filter(d => (d.issue_report_id || d.category_id || d.item_id || d.error_type_id) && d.defect_hours > 0); + if (validDefects.length > 0) { + await window.apiCall(`/daily-work-reports/${reportId}/defects`, 'PUT', { defects: validDefects }); + } + } + + removeManualCard(id); + showToast('작업보고서가 제출되었습니다', 'success'); + } catch (error) { + console.error('[Mobile] 수동 제출 오류:', error); + showToast('제출 실패: ' + error.message, 'error'); + if (btn) { btn.disabled = false; btn.textContent = '제출'; } + } + } + + // ===== 작업장소 바텀시트 ===== + function openWorkplaceSheet(manualId) { + wpSheetManualId = manualId; + wpSheetStep = 'category'; + wpSelectedCategory = null; + wpSelectedCategoryName = null; + wpSelectedId = null; + wpSelectedName = null; + + renderWorkplaceCategories(); + showBottomSheet('wp'); + } + + async function renderWorkplaceCategories() { + const body = document.getElementById('wpSheetBody'); + document.getElementById('wpSheetTitle').textContent = '작업장소 선택'; + body.innerHTML = '
'; + + try { + const response = await window.apiCall('/workplaces/categories'); + const categories = response.success ? response.data : response; + + let html = '
'; + categories.forEach(cat => { + html += ``; + }); + html += ``; + html += '
'; + + body.innerHTML = html; + } catch (error) { + body.innerHTML = '
작업장소 로드 실패
'; + } + } + + async function selectWpCategory(categoryId, categoryName) { + wpSelectedCategory = categoryId; + wpSelectedCategoryName = categoryName; + wpSheetStep = 'list'; + + document.getElementById('wpSheetTitle').textContent = categoryName; + const body = document.getElementById('wpSheetBody'); + body.innerHTML = '
'; + + try { + const response = await window.apiCall(`/workplaces?category_id=${categoryId}`); + const workplaces = response.success ? response.data : response; + + let html = ``; + html += '
'; + workplaces.forEach(wp => { + html += ` +
+ 📍 + ${esc(wp.workplace_name)} +
+ `; + }); + html += '
'; + + body.innerHTML = html; + } catch (error) { + body.innerHTML = '
작업장소 로드 실패
'; + } + } + + function selectWpItem(workplaceId, workplaceName) { + wpSelectedId = workplaceId; + wpSelectedName = workplaceName; + + // 수동 카드에 반영 + if (manualCards[wpSheetManualId]) { + manualCards[wpSheetManualId].workplace_id = workplaceId; + manualCards[wpSheetManualId].workplace_name = workplaceName; + manualCards[wpSheetManualId].workplace_category_id = wpSelectedCategory; + manualCards[wpSheetManualId].workplace_category_name = wpSelectedCategoryName; + } + + const textEl = document.getElementById('m_manual_wpText_' + wpSheetManualId); + const boxEl = document.getElementById('m_manual_wpBox_' + wpSheetManualId); + if (textEl) textEl.textContent = `${wpSelectedCategoryName} > ${workplaceName}`; + if (boxEl) boxEl.classList.add('selected'); + + hideWorkplaceSheet(); + showToast('작업장소 선택됨', 'success'); + } + + function selectExternalWp() { + if (manualCards[wpSheetManualId]) { + manualCards[wpSheetManualId].workplace_id = 0; + manualCards[wpSheetManualId].workplace_name = '외부 (외근/연차/휴무)'; + manualCards[wpSheetManualId].workplace_category_id = 0; + manualCards[wpSheetManualId].workplace_category_name = '외부'; + } + + const textEl = document.getElementById('m_manual_wpText_' + wpSheetManualId); + const boxEl = document.getElementById('m_manual_wpBox_' + wpSheetManualId); + if (textEl) textEl.textContent = '🌐 외부 (외근/연차/휴무)'; + if (boxEl) boxEl.classList.add('selected'); + + hideWorkplaceSheet(); + showToast('외부 작업장소 선택됨', 'success'); + } + + function hideWorkplaceSheet() { + hideBottomSheet('wp'); + } + + // ===== 완료 보고서 ===== + async function loadCompletedReports() { + const dateInput = document.getElementById('completedDate'); + const selectedDate = dateInput?.value; + if (!selectedDate) return; + + const container = document.getElementById('completedCardList'); + container.innerHTML = '
'; + + try { + const response = await window.apiCall(`/daily-work-reports?date=${selectedDate}`); + let reports = []; + if (Array.isArray(response)) { + reports = response; + } else if (response.success && response.data) { + reports = Array.isArray(response.data) ? response.data : []; + } else if (response.data) { + reports = Array.isArray(response.data) ? response.data : []; + } + + renderCompletedCards(reports); + } catch (error) { + console.error('[Mobile] 완료 보고서 로드 오류:', error); + container.innerHTML = '
⚠️
보고서를 불러올 수 없습니다
'; + } + } + + function renderCompletedCards(reports) { + const container = document.getElementById('completedCardList'); + + if (!reports || reports.length === 0) { + container.innerHTML = '
📋
작성된 보고서가 없습니다
'; + return; + } + + let html = ''; + reports.forEach(report => { + const totalHours = parseFloat(report.total_hours || report.work_hours || 0); + const errorHours = parseFloat(report.error_hours || 0); + + html += ` +
+
+
+
${esc(report.worker_name || '작업자')}
+ ${report.tbm_session_id ? 'TBM' : '수동'} +
+
+
+
프로젝트${esc(report.project_name || '-')}
+
공정/작업${esc(report.work_type_name || '-')} / ${esc(report.task_name || '-')}
+
작업시간${totalHours}시간
+ ${errorHours > 0 ? `
부적합${errorHours}시간 (${esc(report.error_type_name || '-')})
` : ''} +
작성자${esc(report.created_by_name || '-')}
+
+
+ + +
+
+ `; + }); + + container.innerHTML = html; + } + + // ===== 수정 바텀시트 ===== + function openEditSheet(report) { + editReportId = report.id; + const state = window.DailyWorkReportState; + const projects = state.projects || []; + const workTypes = state.workTypes || []; + const workStatusTypes = state.workStatusTypes || []; + + const body = document.getElementById('editSheetBody'); + body.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + // 작업 목록 로드 후 현재 값 설정 + loadEditTasks().then(() => { + const taskSelect = document.getElementById('m_edit_task'); + if (report.work_type_id && taskSelect) { + taskSelect.value = report.work_type_id; + } + }); + + showBottomSheet('edit'); + } + + async function loadEditTasks() { + const workTypeId = document.getElementById('m_edit_workType')?.value; + const taskSelect = document.getElementById('m_edit_task'); + if (!workTypeId || !taskSelect) return; + + try { + const response = await window.apiCall(`/tasks?work_type_id=${workTypeId}`); + const tasks = response.data || response || []; + taskSelect.innerHTML = '' + + tasks.map(t => ``).join(''); + } catch (error) { + taskSelect.innerHTML = ''; + } + } + + async function saveEditedReport() { + const projectId = document.getElementById('m_edit_project')?.value; + const taskId = document.getElementById('m_edit_task')?.value; + const workHours = parseFloat(document.getElementById('m_edit_hours')?.value); + const workStatusId = document.getElementById('m_edit_status')?.value; + + if (!projectId || !taskId || !workHours) { + showToast('필수 항목을 입력해주세요', 'error'); + return; + } + + try { + const response = await window.apiCall(`/daily-work-reports/${editReportId}`, 'PUT', { + project_id: parseInt(projectId), + work_type_id: parseInt(taskId), + work_hours: workHours, + work_status_id: parseInt(workStatusId) + }); + + if (response.success) { + hideEditSheet(); + showToast('수정 완료', 'success'); + loadCompletedReports(); + } else { + throw new Error(response.message || '수정 실패'); + } + } catch (error) { + showToast('수정 실패: ' + error.message, 'error'); + } + } + + function hideEditSheet() { + hideBottomSheet('edit'); + editReportId = null; + } + + // ===== 삭제 ===== + function deleteReport(reportId) { + showConfirm('이 작업보고서를 삭제하시겠습니까?', async () => { + try { + const response = await window.apiCall(`/daily-work-reports/${reportId}`, 'DELETE'); + if (response.success) { + showToast('삭제 완료', 'success'); + loadCompletedReports(); + } else { + throw new Error(response.message || '삭제 실패'); + } + } catch (error) { + showToast('삭제 실패: ' + error.message, 'error'); + } + }, true); + } + + // ===== 바텀시트 공통 ===== + function showBottomSheet(id) { + const sheetMap = { defect: 'defectSheet', wp: 'wpSheet', edit: 'editSheet' }; + const overlayMap = { defect: 'defectOverlay', wp: 'wpOverlay', edit: 'editOverlay' }; + + const sheet = document.getElementById(sheetMap[id]); + const overlay = document.getElementById(overlayMap[id]); + + if (overlay) overlay.classList.add('show'); + if (sheet) { + sheet.classList.add('show'); + // body 스크롤 방지 + document.body.style.overflow = 'hidden'; + } + } + + function hideBottomSheet(id) { + const sheetMap = { defect: 'defectSheet', wp: 'wpSheet', edit: 'editSheet' }; + const overlayMap = { defect: 'defectOverlay', wp: 'wpOverlay', edit: 'editOverlay' }; + + const sheet = document.getElementById(sheetMap[id]); + const overlay = document.getElementById(overlayMap[id]); + + if (overlay) overlay.classList.remove('show'); + if (sheet) sheet.classList.remove('show'); + document.body.style.overflow = ''; + } + + // ===== DOM Ready ===== + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { setTimeout(init, 100); }); + } else { + setTimeout(init, 100); + } + + // ===== Public API ===== + return { + switchTab, + toggleDateGroup, + openTimePicker, + setTime, + adjustTime, + confirmTime, + closeTimePicker, + openDefectSheet, + toggleDefectIssue, + addManualDefect, + removeManualDefect, + updateDefectCategory, + updateDefectItem, + updateDefectNote, + openDefectTimePicker, + saveDefects, + hideDefectSheet, + submitTbmReport, + batchSubmitSession, + addManualCard, + removeManualCard, + updateManualField, + onWorkTypeChange, + submitManualReport, + openWorkplaceSheet, + renderWorkplaceCategories, + selectWpCategory, + selectWpItem, + selectExternalWp, + hideWorkplaceSheet, + loadCompletedReports, + openEditSheet, + loadEditTasks, + saveEditedReport, + hideEditSheet, + deleteReport, + closeResult, + showToast + }; + +})(); diff --git a/system1-factory/web/js/daily-work-report.js b/system1-factory/web/js/daily-work-report.js index 1abac42..0d191a7 100644 --- a/system1-factory/web/js/daily-work-report.js +++ b/system1-factory/web/js/daily-work-report.js @@ -213,6 +213,46 @@ function getUser() { return user ? JSON.parse(user) : null; } +/** + * 근태 유형에 따른 기본 작업시간 반환 + */ +function getDefaultHoursFromAttendance(tbm) { + // work_hours가 있으면 (분할 배정) 해당 값 우선 사용 + if (tbm.work_hours != null && parseFloat(tbm.work_hours) > 0) { + return parseFloat(tbm.work_hours); + } + switch (tbm.attendance_type) { + case 'overtime': return 8 + (parseFloat(tbm.attendance_hours) || 0); + case 'regular': return 8; + case 'half': return 4; + case 'quarter': return 6; + case 'early': return parseFloat(tbm.attendance_hours) || 0; + default: return 0; + } +} + +/** + * 근태 유형 뱃지 HTML 반환 + */ +function getAttendanceBadgeHtml(type) { + const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' }; + const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' }; + if (!type || !labels[type]) return ''; + return ` ${labels[type]}`; +} + +/** + * 시간 표시 포맷 + */ +function formatHoursDisplay(val) { + if (!val || val <= 0) return '시간 선택'; + val = parseFloat(val); + if (val === Math.floor(val)) return val + '시간'; + const hours = Math.floor(val); + const mins = Math.round((val - hours) * 60); + return hours > 0 ? hours + '시간 ' + mins + '분' : mins + '분'; +} + /** * TBM 작업 목록 렌더링 (날짜별 > 세션별 그룹화) * - 날짜별로 접기/펼치기 가능 @@ -422,11 +462,15 @@ function renderTbmWorkList() { } return false; }); + // 근태 기반 자동 시간 채움 + const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0; + const hasDefaultHours = defaultHours > 0; + const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : ''; return `
- ${tbm.worker_name || '작업자'} + ${tbm.worker_name || '작업자'}${attendanceBadgeHtml}
${tbm.job_type || '-'}
@@ -440,11 +484,12 @@ function renderTbmWorkList() { - -
+
- 시간 선택 + onclick="openTimePicker(${index}, 'total')" + style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}"> + ${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
diff --git a/system1-factory/web/js/mobile-dashboard.js b/system1-factory/web/js/mobile-dashboard.js new file mode 100644 index 0000000..47489ca --- /dev/null +++ b/system1-factory/web/js/mobile-dashboard.js @@ -0,0 +1,443 @@ +// mobile-dashboard.js - 모바일 대시보드 v2 +// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약 + +(function() { + 'use strict'; + + if (window.innerWidth > 768) return; + + var today = new Date().toISOString().slice(0, 10); + + // ==================== 캐시 변수 ==================== + var categories = []; + var allWorkplaces = []; + var tbmByWorkplace = {}; + var visitorsByWorkplace = {}; + var movedByWorkplace = {}; + var issuesByWorkplace = {}; + var workplacesByCategory = {}; + + // ==================== 유틸리티 ==================== + + function escapeHtml(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function waitForApi(timeout) { + timeout = timeout || 5000; + return new Promise(function(resolve, reject) { + if (window.apiCall) return resolve(); + var elapsed = 0; + var interval = setInterval(function() { + elapsed += 50; + if (window.apiCall) { clearInterval(interval); resolve(); } + else if (elapsed >= timeout) { clearInterval(interval); reject(new Error('apiCall timeout')); } + }, 50); + }); + } + + // ==================== 데이터 그룹핑 ==================== + + function groupTbmByWorkplace(sessions) { + tbmByWorkplace = {}; + if (!Array.isArray(sessions)) return; + sessions.forEach(function(s) { + var wpId = s.workplace_id; + if (!wpId) return; + if (!tbmByWorkplace[wpId]) { + tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] }; + } + tbmByWorkplace[wpId].taskCount++; + // team_member_count + leader 1 + tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0) + 1; + tbmByWorkplace[wpId].sessions.push(s); + }); + } + + function groupVisitorsByWorkplace(requests) { + visitorsByWorkplace = {}; + if (!Array.isArray(requests)) return; + requests.forEach(function(r) { + // 오늘 날짜 + 승인된 건만 + if (r.visit_date !== today) return; + if (r.status !== 'approved') return; + var wpId = r.workplace_id; + if (!wpId) return; + if (!visitorsByWorkplace[wpId]) { + visitorsByWorkplace[wpId] = { visitCount: 0, totalVisitors: 0, requests: [] }; + } + visitorsByWorkplace[wpId].visitCount++; + visitorsByWorkplace[wpId].totalVisitors += parseInt(r.visitor_count) || 0; + visitorsByWorkplace[wpId].requests.push(r); + }); + } + + function groupMovedByWorkplace(items) { + movedByWorkplace = {}; + if (!Array.isArray(items)) return; + items.forEach(function(eq) { + var wpId = eq.current_workplace_id; + if (!wpId) return; + if (!movedByWorkplace[wpId]) { + movedByWorkplace[wpId] = { movedCount: 0, items: [] }; + } + movedByWorkplace[wpId].movedCount++; + movedByWorkplace[wpId].items.push(eq); + }); + } + + function groupIssuesByWorkplace(issues) { + issuesByWorkplace = {}; + if (!Array.isArray(issues)) return; + var activeStatuses = ['reported', 'received', 'in_progress']; + issues.forEach(function(issue) { + var wpId = issue.workplace_id; + if (!wpId) return; + if (activeStatuses.indexOf(issue.status) === -1) return; + if (!issuesByWorkplace[wpId]) { + issuesByWorkplace[wpId] = { activeCount: 0, items: [] }; + } + issuesByWorkplace[wpId].activeCount++; + issuesByWorkplace[wpId].items.push(issue); + }); + } + + function groupWorkplacesByCategory(workplaces) { + workplacesByCategory = {}; + if (!Array.isArray(workplaces)) return; + workplaces.forEach(function(wp) { + var catId = wp.category_id; + if (!catId) return; + if (!workplacesByCategory[catId]) { + workplacesByCategory[catId] = []; + } + workplacesByCategory[catId].push(wp); + }); + } + + // ==================== 렌더링 ==================== + + function renderCategoryTabs() { + var container = document.getElementById('mCategoryTabs'); + if (!container || !categories.length) return; + + var html = ''; + categories.forEach(function(cat, idx) { + html += ''; + }); + + // 전체 탭 + html += ''; + + container.innerHTML = html; + + // 이벤트 바인딩 + var tabs = container.querySelectorAll('.md-cat-tab'); + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + tabs.forEach(function(t) { t.classList.remove('active'); }); + tab.classList.add('active'); + var catId = tab.getAttribute('data-id'); + selectCategory(catId); + }); + }); + + // 첫 번째 카테고리 자동 선택 + if (categories.length > 0) { + selectCategory(String(categories[0].category_id)); + } + } + + function selectCategory(categoryId) { + var workplaces; + if (categoryId === 'all') { + workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; }); + } else { + workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) { + return wp.is_active !== false; + }); + } + renderWorkplaceList(workplaces); + } + + function renderWorkplaceList(workplaces) { + var container = document.getElementById('mWorkplaceList'); + if (!container) return; + + if (!workplaces || workplaces.length === 0) { + container.innerHTML = '
등록된 작업장이 없습니다.
'; + return; + } + + var html = ''; + workplaces.forEach(function(wp) { + var wpId = wp.workplace_id; + var tbm = tbmByWorkplace[wpId]; + var visitors = visitorsByWorkplace[wpId]; + var moved = movedByWorkplace[wpId]; + var issues = issuesByWorkplace[wpId]; + + var hasAny = tbm || visitors || moved || issues; + + html += '
'; + + // 헤더 (클릭 영역) + html += '
'; + html += '

' + escapeHtml(wp.workplace_name); + if (hasAny) { + html += ''; + } + html += '

'; + + if (!hasAny) { + html += '

오늘 활동이 없습니다

'; + } else { + html += '
'; + + // TBM 작업 + if (tbm) { + html += '
' + + '🛠' + + '작업 ' + tbm.taskCount + '건 · ' + tbm.totalWorkers + '명' + + '
'; + } + + // 방문 + if (visitors) { + html += '
' + + '🚪' + + '방문 ' + visitors.visitCount + '건 · ' + visitors.totalVisitors + '명' + + '
'; + } + + // 신고 (미완료만) + if (issues && issues.activeCount > 0) { + html += '
' + + '' + + '신고 ' + issues.activeCount + '건' + + '
'; + } + + // 이동설비 + if (moved && moved.movedCount > 0) { + html += '
' + + '' + + '이동설비 ' + moved.movedCount + '건' + + '
'; + } + + html += '
'; + } + + html += '
'; // .md-wp-header + + // 상세 영역 (활동 있는 카드만) + if (hasAny) { + html += '
' + renderCardDetail(wpId) + '
'; + } + + html += '
'; // .md-wp-card + }); + + container.innerHTML = html; + + // 클릭 이벤트 바인딩 + var cards = container.querySelectorAll('.md-wp-card[data-wp-id]'); + cards.forEach(function(card) { + var wpId = card.getAttribute('data-wp-id'); + var hasActivity = tbmByWorkplace[wpId] || visitorsByWorkplace[wpId] || + movedByWorkplace[wpId] || issuesByWorkplace[wpId]; + if (!hasActivity) return; + card.querySelector('.md-wp-header').addEventListener('click', function() { + toggleCard(wpId); + }); + }); + } + + // ==================== 카드 확장/접기 ==================== + + function toggleCard(wpId) { + var allCards = document.querySelectorAll('.md-wp-card.expanded'); + var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]'); + if (!targetCard) return; + + var isExpanded = targetCard.classList.contains('expanded'); + + // 다른 카드 모두 접기 (아코디언) + allCards.forEach(function(card) { + card.classList.remove('expanded'); + }); + + // 토글 + if (!isExpanded) { + targetCard.classList.add('expanded'); + } + } + + function renderCardDetail(wpId) { + var html = ''; + var tbm = tbmByWorkplace[wpId]; + var visitors = visitorsByWorkplace[wpId]; + var issues = issuesByWorkplace[wpId]; + var moved = movedByWorkplace[wpId]; + + // TBM 작업 + if (tbm && tbm.sessions.length > 0) { + html += '
'; + html += '
▶ 작업
'; + tbm.sessions.forEach(function(s) { + var taskName = s.task_name || '작업명 미지정'; + var leaderName = s.leader_name || '미지정'; + var memberCount = (parseInt(s.team_member_count) || 0) + 1; + html += '
'; + html += '
' + escapeHtml(taskName) + '
'; + html += '
' + escapeHtml(leaderName) + ' · ' + memberCount + '명
'; + html += '
'; + }); + html += '
'; + } + + // 방문 + if (visitors && visitors.requests.length > 0) { + html += '
'; + html += '
▶ 방문
'; + visitors.requests.forEach(function(r) { + var company = r.visitor_company || '업체 미지정'; + var count = parseInt(r.visitor_count) || 0; + var purpose = r.purpose_name || ''; + html += '
'; + html += '
' + escapeHtml(company) + ' · ' + count + '명'; + if (purpose) html += ' · ' + escapeHtml(purpose); + html += '
'; + html += '
'; + }); + html += '
'; + } + + // 신고 + if (issues && issues.items.length > 0) { + var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' }; + html += '
'; + html += '
▶ 신고
'; + issues.items.forEach(function(issue) { + var category = issue.issue_category_name || '미분류'; + var desc = issue.additional_description || ''; + if (desc.length > 30) desc = desc.substring(0, 30) + '...'; + var statusText = statusMap[issue.status] || issue.status; + var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported'); + var reporter = issue.reporter_name || ''; + var icon = issue.status === 'in_progress' ? '🔴' : '⚠'; + html += '
'; + html += '
' + icon + ' ' + escapeHtml(category); + if (desc) html += ' · ' + escapeHtml(desc); + html += '
'; + html += '
' + statusText + ''; + if (reporter) html += ' → ' + escapeHtml(reporter); + html += '
'; + html += '
'; + }); + html += '
'; + } + + // 이동설비 + if (moved && moved.items.length > 0) { + html += '
'; + html += '
▶ 이동설비
'; + moved.items.forEach(function(eq) { + var eqName = eq.equipment_name || '설비명 미지정'; + var fromWp = eq.original_workplace_name || '?'; + var toWp = eq.current_workplace_name || '?'; + html += '
'; + html += '
' + escapeHtml(eqName) + '
'; + html += '
' + escapeHtml(fromWp) + ' → ' + escapeHtml(toWp) + '
'; + html += '
'; + }); + html += '
'; + } + + return html; + } + + // ==================== 초기화 ==================== + + document.addEventListener('DOMContentLoaded', async function() { + try { + await waitForApi(); + } catch (e) { + console.error('mobile-dashboard: apiCall not available'); + return; + } + + var view = document.getElementById('mobileDashboardView'); + if (!view) return; + view.style.display = 'block'; + + // 날짜 표시 + var now = new Date(); + var days = ['일', '월', '화', '수', '목', '금', '토']; + var dateEl = document.getElementById('mDateValue'); + if (dateEl) { + dateEl.textContent = now.getFullYear() + '.' + + String(now.getMonth() + 1).padStart(2, '0') + '.' + + String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')'; + } + + // 로딩 표시 + var listContainer = document.getElementById('mWorkplaceList'); + if (listContainer) { + listContainer.innerHTML = + '
' + + '
' + + '
'; + } + + // 데이터 병렬 로딩 + var results = await Promise.allSettled([ + window.apiCall('/workplaces/categories'), + window.apiCall('/tbm/sessions/date/' + today), + window.apiCall('/workplace-visits/requests?visit_date=' + today + '&status=approved'), + window.apiCall('/equipments/moved/list'), + window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today), + window.apiCall('/workplaces') + ]); + + // 카테고리 + if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) { + categories = results[0].value.data || []; + } + + // TBM + if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) { + groupTbmByWorkplace(results[1].value.data || []); + } + + // 방문 + if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) { + groupVisitorsByWorkplace(results[2].value.data || []); + } + + // 이동설비 + if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) { + groupMovedByWorkplace(results[3].value.data || []); + } + + // 신고 + if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) { + groupIssuesByWorkplace(results[4].value.data || []); + } + + // 작업장 전체 (카테고리별 그룹핑) + if (results[5].status === 'fulfilled' && results[5].value && results[5].value.success) { + allWorkplaces = results[5].value.data || []; + groupWorkplacesByCategory(allWorkplaces); + } + + // 렌더링 + renderCategoryTabs(); + }); +})(); diff --git a/system1-factory/web/js/tbm-create.js b/system1-factory/web/js/tbm-create.js new file mode 100644 index 0000000..d0fa09b --- /dev/null +++ b/system1-factory/web/js/tbm-create.js @@ -0,0 +1,573 @@ +/** + * TBM 모바일 위자드 - tbm-create.js + * 3단계 위자드로 TBM 세션을 생성하는 모바일 전용 페이지 로직 + * Step 1: 작업자 선택, Step 2: 프로젝트+공정 선택, Step 3: 확인 + * (작업/작업장은 생성 후 세부 편집 단계에서 입력) + */ + +(function() { + 'use strict'; + + // ==================== 위자드 상태 ==================== + const W = { + step: 1, + totalSteps: 3, + sessionDate: null, + leaderId: null, + leaderName: '', + workers: new Set(), // worker_id Set + workerNames: {}, // { worker_id: worker_name } + projectId: null, + projectName: '', + workTypeId: null, + workTypeName: '', + showAddWorkType: false, + todayAssignments: null // 당일 배정 현황 캐시 + }; + + const esc = window.escapeHtml || function(s) { return s || ''; }; + + // ==================== 초기화 ==================== + + document.addEventListener('DOMContentLoaded', async function() { + try { + // apiCall이 준비될 때까지 대기 + await waitForApiCall(); + + // 초기 데이터 로드 + await window.TbmAPI.loadInitialData(); + + // 기본 정보 자동 설정 + W.sessionDate = window.TbmUtils.getTodayKST(); + var user = window.TbmState.getUser(); + if (user) { + if (user.worker_id) { + var worker = window.TbmState.allWorkers.find(function(w) { return w.worker_id === user.worker_id; }); + if (worker) { + W.leaderId = worker.worker_id; + W.leaderName = worker.worker_name; + } else { + W.leaderName = user.name || ''; + } + } else { + W.leaderName = user.name || ''; + } + } + + // 로딩 해제 + document.getElementById('loadingOverlay').style.display = 'none'; + + // 첫 스텝 렌더링 + renderStep(1); + updateIndicator(); + updateNav(); + } catch (error) { + console.error('초기화 오류:', error); + document.getElementById('loadingOverlay').style.display = 'none'; + showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error'); + } + }); + + function waitForApiCall() { + return new Promise(function(resolve) { + if (typeof window.apiCall === 'function') { + resolve(); + return; + } + var checks = 0; + var interval = setInterval(function() { + checks++; + if (typeof window.apiCall === 'function' || checks > 50) { + clearInterval(interval); + resolve(); + } + }, 100); + }); + } + + // ==================== 네비게이션 ==================== + + window.nextStep = function() { + if (!validateStep(W.step)) return; + if (W.step < W.totalSteps) { + W.step++; + renderStep(W.step); + updateIndicator(); + updateNav(); + window.scrollTo(0, 0); + } + }; + + window.prevStep = function() { + if (W.step > 1) { + W.step--; + renderStep(W.step); + updateIndicator(); + updateNav(); + window.scrollTo(0, 0); + } + }; + + window.goBack = function() { + if (W.step > 1) { + window.prevStep(); + } else { + window.location.href = '/pages/work/tbm-mobile.html'; + } + }; + + function updateIndicator() { + var steps = document.querySelectorAll('#stepIndicator .step'); + var lines = document.querySelectorAll('#stepIndicator .step-line'); + steps.forEach(function(el, i) { + el.classList.remove('active', 'completed'); + if (i + 1 === W.step) { + el.classList.add('active'); + } else if (i + 1 < W.step) { + el.classList.add('completed'); + } + }); + lines.forEach(function(el, i) { + el.style.background = (i + 1 < W.step) ? '#10b981' : '#e5e7eb'; + }); + } + + function updateNav() { + var prevBtn = document.getElementById('prevBtn'); + var nextBtn = document.getElementById('nextBtn'); + + if (W.step === 1) { + prevBtn.style.visibility = 'hidden'; + prevBtn.onclick = null; + } else { + prevBtn.style.visibility = 'visible'; + prevBtn.onclick = window.prevStep; + } + + if (W.step === W.totalSteps) { + nextBtn.className = 'nav-btn nav-btn-save'; + nextBtn.innerHTML = '저장'; + nextBtn.onclick = saveWizard; + } else { + nextBtn.className = 'nav-btn nav-btn-next'; + nextBtn.innerHTML = '다음 →'; + nextBtn.onclick = window.nextStep; + } + nextBtn.disabled = false; + } + + // ==================== 유효성 검사 ==================== + + function validateStep(step) { + switch (step) { + case 1: // 작업자 선택 + if (W.workers.size === 0) { + showToast('최소 1명의 작업자를 선택해주세요.', 'warning'); + return false; + } + return true; + case 2: // 프로젝트 + 공정 + if (!W.workTypeId) { + showToast('공정을 선택해주세요.', 'warning'); + return false; + } + return true; + default: + return true; + } + } + + // ==================== 스텝 렌더링 ==================== + + function renderStep(step) { + var container = document.getElementById('stepContainer'); + switch (step) { + case 1: renderStepWorkers(container); break; + case 2: renderStepProjectAndWorkType(container); break; + case 3: renderStepConfirm(container); break; + } + } + + // --- Step 1: 작업자 선택 --- + async function renderStepWorkers(container) { + var workers = window.TbmState.allWorkers; + + // 당일 배정 현황 로드 (첫 로드 시) + if (!W.todayAssignments) { + try { + var today = window.TbmUtils.getTodayKST(); + var res = await window.apiCall('/tbm/sessions/date/' + today + '/assignments'); + if (res && res.success) { + W.todayAssignments = {}; + res.data.forEach(function(a) { + if (a.sessions && a.sessions.length > 0) { + W.todayAssignments[a.worker_id] = a; + } + }); + } else { + W.todayAssignments = {}; + } + } catch(e) { + console.error('배정 현황 로드 오류:', e); + W.todayAssignments = {}; + } + } + + var workerCards = workers.map(function(w) { + var selected = W.workers.has(w.worker_id) ? ' selected' : ''; + var assignment = W.todayAssignments[w.worker_id]; + var assigned = assignment && assignment.total_hours >= 8; + var partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8; + + var badgeHtml = ''; + var disabledClass = ''; + var onclick = 'toggleWorker(' + w.worker_id + ')'; + + if (assigned) { + // 종일 배정됨 - 선택 불가 + var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', '); + badgeHtml = '
' + esc(leaderNames) + ' TBM (' + assignment.total_hours + 'h)
'; + disabledClass = ' disabled'; + onclick = ''; + } else if (partiallyAssigned) { + var remaining = 8 - assignment.total_hours; + badgeHtml = '
' + remaining + 'h 가용
'; + } + + return '
' + + '
' + + '
' + + '
' + esc(w.worker_name) + '
' + + '
' + esc(w.job_type || '작업자') + '
' + + badgeHtml + + '
' + + '
'; + }).join(''); + + container.innerHTML = + '
' + + '
1작업자 선택
' + + '
' + + '' + W.workers.size + '명 선택' + + '' + + '
' + + '
' + workerCards + '
' + + '
'; + } + + window.toggleWorker = function(workerId) { + // 이미 종일 배정된 작업자는 선택 불가 + var a = W.todayAssignments && W.todayAssignments[workerId]; + if (a && a.total_hours >= 8) return; + + if (W.workers.has(workerId)) { + W.workers.delete(workerId); + delete W.workerNames[workerId]; + } else { + W.workers.add(workerId); + var w = window.TbmState.allWorkers.find(function(x) { return x.worker_id === workerId; }); + if (w) W.workerNames[workerId] = w.worker_name; + } + var card = document.querySelector('[data-wid="' + workerId + '"]'); + if (card) card.classList.toggle('selected'); + var countEl = document.getElementById('workerCount'); + if (countEl) countEl.textContent = W.workers.size + '명 선택'; + }; + + window.toggleAllWorkers = function() { + var workers = window.TbmState.allWorkers; + var availableWorkers = workers.filter(function(w) { + var a = W.todayAssignments && W.todayAssignments[w.worker_id]; + return !(a && a.total_hours >= 8); + }); + if (W.workers.size === availableWorkers.length) { + W.workers.clear(); + W.workerNames = {}; + } else { + availableWorkers.forEach(function(w) { + W.workers.add(w.worker_id); + W.workerNames[w.worker_id] = w.worker_name; + }); + } + renderStepWorkers(document.getElementById('stepContainer')); + }; + + // --- Step 2: 프로젝트 + 공정 선택 (통합) --- + function renderStepProjectAndWorkType(container) { + var projects = window.TbmState.allProjects; + var workTypes = window.TbmState.allWorkTypes; + + // 프로젝트 선택 UI + var skipSelected = W.projectId === null ? ' selected' : ''; + var projectItems = projects.map(function(p) { + var selected = W.projectId === p.project_id ? ' selected' : ''; + return '
' + + '
' + esc(p.project_name) + '
' + + '
' + esc(p.job_no || '') + '
' + + '
'; + }).join(''); + + // 공정 pill 버튼 + var pillHtml = workTypes.map(function(wt) { + var selected = W.workTypeId === wt.id ? ' selected' : ''; + return ''; + }).join(''); + pillHtml += ''; + + // 공정 인라인 추가 폼 + var addWorkTypeFormHtml = ''; + if (W.showAddWorkType) { + addWorkTypeFormHtml = + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + } + + container.innerHTML = + '
' + + '
2프로젝트 선택 (선택사항)
' + + '
' + + '선택 안함' + + '
' + + (projects.length > 0 ? projectItems : '
등록된 프로젝트가 없습니다
') + + '
' + + '
' + + '
2공정 선택 (필수)
' + + '
' + pillHtml + '
' + + addWorkTypeFormHtml + + '
'; + + // 자동 포커스 + if (W.showAddWorkType) { + var inp = document.getElementById('newWorkTypeName'); + if (inp) { + setTimeout(function() { inp.focus(); }, 50); + inp.onkeydown = function(e) { + if (e.key === 'Enter') { e.preventDefault(); saveNewWorkType(); } + if (e.key === 'Escape') { cancelAddWorkType(); } + }; + } + } + } + + window.selectProject = function(projectId, projectName) { + W.projectId = projectId; + W.projectName = projectName || ''; + // Update project list items + document.querySelectorAll('#stepContainer .list-item, #stepContainer .list-item-skip').forEach(function(el) { + el.classList.remove('selected'); + }); + if (projectId === null) { + var skipEl = document.querySelector('#stepContainer .list-item-skip'); + if (skipEl) skipEl.classList.add('selected'); + } else { + document.querySelectorAll('#stepContainer .list-item').forEach(function(el) { + var title = el.querySelector('.item-title'); + if (title && title.textContent === projectName) { + el.classList.add('selected'); + } + }); + } + }; + + window.selectWorkType = function(id, name) { + W.workTypeId = id; + W.workTypeName = name; + // Update pill buttons + document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) { + el.classList.remove('selected'); + }); + document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) { + if (el.textContent === name) { + el.classList.add('selected'); + } + }); + }; + + // --- Step 2: 인라인 추가 (공정) --- + + window.toggleAddWorkType = function() { + W.showAddWorkType = !W.showAddWorkType; + renderStepProjectAndWorkType(document.getElementById('stepContainer')); + }; + + window.cancelAddWorkType = function() { + W.showAddWorkType = false; + renderStepProjectAndWorkType(document.getElementById('stepContainer')); + }; + + window.saveNewWorkType = async function() { + var inp = document.getElementById('newWorkTypeName'); + var btn = document.getElementById('btnSaveWorkType'); + if (!inp || !btn) return; + + var name = inp.value.trim(); + if (!name) { + showToast('공정명을 입력해주세요.', 'warning'); + inp.focus(); + return; + } + + var exists = window.TbmState.allWorkTypes.some(function(wt) { + return wt.name.toLowerCase() === name.toLowerCase(); + }); + if (exists) { + showToast('이미 존재하는 공정명입니다.', 'warning'); + inp.focus(); + return; + } + + btn.disabled = true; + btn.textContent = '저장 중...'; + + try { + var response = await window.apiCall('/daily-work-reports/work-types', 'POST', { name: name }); + if (!response || !response.success) { + throw new Error(response?.message || '공정 추가 실패'); + } + + var newItem = response.data; + window.TbmState.allWorkTypes.push(newItem); + + W.workTypeId = newItem.id; + W.workTypeName = newItem.name; + W.showAddWorkType = false; + + renderStepProjectAndWorkType(document.getElementById('stepContainer')); + showToast('\'' + name + '\' 공정이 추가되었습니다.', 'success'); + } catch (error) { + console.error('공정 추가 오류:', error); + showToast('공정 추가 중 오류: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = '저장'; + } + }; + + // --- Step 3: 확인 --- + function renderStepConfirm(container) { + var dateDisplay = window.TbmUtils.formatDateFull(W.sessionDate); + + // 작업자 이름 목록 + var workerNameList = []; + W.workers.forEach(function(wid) { + workerNameList.push(W.workerNames[wid] || '작업자'); + }); + + var summaryHtml = + '
' + + '
날짜' + esc(dateDisplay) + '
' + + '
입력자' + esc(W.leaderName || '(미설정)') + '
' + + '
프로젝트' + esc(W.projectName || '선택 안함') + '
' + + '
공정' + esc(W.workTypeName) + '
' + + '
작업자' + W.workers.size + '명
' + + '
'; + + // 작업자 목록 (간단 표시) + var workerListHtml = workerNameList.map(function(name) { + return '
' + + '' + esc(name) + '' + + '세부 미입력' + + '
'; + }).join(''); + + container.innerHTML = + '
' + + '
3확인
' + + summaryHtml + + '
' + + '
' + + '
작업자 목록
' + + '
' + + '저장 후 TBM 카드를 탭하면 작업자별 작업/작업장을 입력할 수 있습니다.' + + '
' + + workerListHtml + + '
'; + } + + // ==================== 저장 ==================== + + async function saveWizard() { + // 저장 버튼 비활성화 + var saveBtn = document.getElementById('nextBtn'); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = '저장 중...'; + } + + try { + var leaderId = W.leaderId ? parseInt(W.leaderId) : null; + + // 1. TBM 세션 생성 + var sessionData = { + session_date: W.sessionDate, + leader_id: leaderId + }; + + var response = await window.apiCall('/tbm/sessions', 'POST', sessionData); + if (!response || !response.success) { + throw new Error(response?.message || '세션 생성 실패'); + } + + var sessionId = response.data.session_id; + + // 2. 팀원 일괄 추가 (task_id, workplace_id = null) + var members = []; + W.workers.forEach(function(wid) { + members.push({ + worker_id: wid, + project_id: W.projectId, + work_type_id: W.workTypeId, + task_id: null, + workplace_category_id: null, + workplace_id: null, + work_detail: null, + is_present: true + }); + }); + + var teamResponse = await window.apiCall( + '/tbm/sessions/' + sessionId + '/team/batch', + 'POST', + { members: members } + ); + + if (!teamResponse || !teamResponse.success) { + throw new Error(teamResponse?.message || '팀원 추가 실패'); + } + + showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success'); + + // 3. tbm-mobile.html로 이동 + setTimeout(function() { + window.location.href = '/pages/work/tbm-mobile.html'; + }, 1000); + } catch (error) { + console.error('TBM 저장 오류:', error); + showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = '저장'; + } + } + } + + // ==================== 토스트 (로컬) ==================== + + function showToast(message, type) { + if (window.showToast && typeof window.showToast === 'function') { + window.showToast(message, type); + return; + } + console.log('[Toast] ' + type + ': ' + message); + } + +})(); diff --git a/system1-factory/web/js/tbm.js b/system1-factory/web/js/tbm.js index ea0e759..6f0429e 100644 --- a/system1-factory/web/js/tbm.js +++ b/system1-factory/web/js/tbm.js @@ -517,15 +517,19 @@ function createSessionCard(session) { 'cancelled': '취소' }[session.status] || ''; - // 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시) const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자'); const leaderRole = escapeHtml(session.leader_name ? (session.leader_job_type || '작업자') : '관리자'); const safeSessionId = parseInt(session.session_id) || 0; + // 카드 클릭 동작: draft → 세부 편집, completed → 상세 보기 + const onClickAction = session.status === 'draft' + ? `openTeamCompositionModal(${safeSessionId})` + : `viewTbmSession(${safeSessionId})`; + return ` -
+
@@ -558,7 +562,7 @@ function createSessionCard(session) {
팀원 - ${parseInt(session.team_member_count) || 0}명 + ${escapeHtml(session.team_member_names || '')}${session.team_member_names ? '' : '없음'}
@@ -566,7 +570,7 @@ function createSessionCard(session) { ${session.status === 'draft' ? `
-
팀원 수
-
${parseInt(session.team_member_count) || team.length}명
+
팀원 (${parseInt(session.team_member_count) || team.length}명)
+
${escapeHtml(session.team_member_names || team.map(t => t.worker_name).join(', ') || '없음')}
${session.project_name ? `
diff --git a/system1-factory/web/js/tbm/api.js b/system1-factory/web/js/tbm/api.js index 2e72783..b1d0292 100644 --- a/system1-factory/web/js/tbm/api.js +++ b/system1-factory/web/js/tbm/api.js @@ -214,10 +214,11 @@ class TbmAPI { if (!this.state.isAdminUser()) { const userId = this.state.currentUser?.user_id; const workerId = this.state.currentUser?.worker_id; + const userName = this.state.currentUser?.name; sessions = sessions.filter(s => { - return s.created_by === userId || - s.leader_id === workerId || - s.created_by_name === this.state.currentUser?.name; + return (userId && String(s.created_by) === String(userId)) || + (workerId && String(s.leader_id) === String(workerId)) || + (userName && s.created_by_name === userName); }); } diff --git a/system1-factory/web/js/tbm/state.js b/system1-factory/web/js/tbm/state.js index aec8e9d..e2d9a9d 100644 --- a/system1-factory/web/js/tbm/state.js +++ b/system1-factory/web/js/tbm/state.js @@ -104,7 +104,8 @@ class TbmState { isAdminUser() { const user = this.getUser(); if (!user) return false; - return user.role === 'Admin' || user.role === 'System Admin'; + const role = (user.role || '').toLowerCase(); + return role === 'admin' || role === 'system admin' || role === 'system'; } /** diff --git a/system1-factory/web/pages/admin/accounts.html b/system1-factory/web/pages/admin/accounts.html index c76d05d..d16ccb3 100644 --- a/system1-factory/web/pages/admin/accounts.html +++ b/system1-factory/web/pages/admin/accounts.html @@ -279,7 +279,7 @@ - + diff --git a/system1-factory/web/pages/admin/attendance-report.html b/system1-factory/web/pages/admin/attendance-report.html index b2bbc91..9fc4b94 100644 --- a/system1-factory/web/pages/admin/attendance-report.html +++ b/system1-factory/web/pages/admin/attendance-report.html @@ -8,7 +8,7 @@ - + + + + + + + +
+ + + + +
+ + + +
+ + +
+ + +
+
+
+
+
+ + +
+
+
+
📝
+
수동으로 작업보고서를 추가하세요
+ +
+
+
+ + +
+
+ +
+
+
+
📋
+
날짜를 선택하세요
+
+
+
+
+ + +
+
+
+

작업시간 선택

+ +
+
+ + + + + +
+
+ + 0시간 + +
+ +
+
+ + +
+
+
+
+

부적합 입력

+ +
+
+ +
+ +
+ + +
+
+
+
+

작업장소 선택

+ +
+
+ +
+
+ + +
+
+
+
+

보고서 수정

+ +
+
+ +
+ +
+ + +
+
+
+
+
+ + +
+
+ + +
+ + + + + + + + + + +
+ + + diff --git a/system1-factory/web/pages/work/report-create.html b/system1-factory/web/pages/work/report-create.html index bca790a..afda5a8 100644 --- a/system1-factory/web/pages/work/report-create.html +++ b/system1-factory/web/pages/work/report-create.html @@ -8,9 +8,15 @@ + + - + @@ -169,7 +175,7 @@ - + @@ -178,7 +184,7 @@ - +
diff --git a/system1-factory/web/pages/work/tbm-create.html b/system1-factory/web/pages/work/tbm-create.html new file mode 100644 index 0000000..00d52ee --- /dev/null +++ b/system1-factory/web/pages/work/tbm-create.html @@ -0,0 +1,823 @@ + + + + + + TBM 시작 | (주)테크니컬코리아 + + + + + + + +
+ +

TBM 시작

+
+ + +
+
1작업자
+
+
2프로젝트+공정
+
+
3확인
+
+ + +
+ +
+ + +
+ + +
+ + + + +
+
+
데이터를 불러오는 중...
+
+ + +
+ + + + + + + + diff --git a/system1-factory/web/pages/work/tbm-mobile.html b/system1-factory/web/pages/work/tbm-mobile.html new file mode 100644 index 0000000..579153b --- /dev/null +++ b/system1-factory/web/pages/work/tbm-mobile.html @@ -0,0 +1,2212 @@ + + + + + + TBM | (주)테크니컬코리아 + + + + + + + + + + + +
+ + +
+ + +
+
+
+
+
+
+ + +
+ + +
+ + + + + + +
+
+
+
+

세부 내역 입력

+ +
+
+
+ + 전체 선택 + +
+
+ 0명 선택 + + +
+
+
+
+ + + +
+
+
+ + +
+
+
+

선택

+ +
+
+
+ + +
+
+ + +
+
+
+
+

작업 분할

+ +
+

작업자의 근무 시간을 분할합니다

+
+
+
+ + +
+
+
+ +
+
현재 TBM 유지
+
다른 반장에게
+
+
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+

팀원 목록

+ +
+

+
+
+
+ + +
+
+
+
+

빼오기

+ +
+

이동할 시간을 입력하세요

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + diff --git a/system1-factory/web/pages/work/tbm.html b/system1-factory/web/pages/work/tbm.html index 172ffef..3c4afc1 100644 --- a/system1-factory/web/pages/work/tbm.html +++ b/system1-factory/web/pages/work/tbm.html @@ -11,7 +11,7 @@ - + @@ -144,9 +144,9 @@
- + + + +
@@ -667,7 +702,7 @@ - +
diff --git a/system2-report/web/js/issue-report.js b/system2-report/web/js/issue-report.js index 60d5958..7e2e4a5 100644 --- a/system2-report/web/js/issue-report.js +++ b/system2-report/web/js/issue-report.js @@ -333,6 +333,12 @@ function renderMap() { const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0); drawWorkplaceRegion(region, workerCount, visitorCount); }); + + // 모바일일 때 전체화면 지도 버튼 표시 + const triggerBtn = document.getElementById('landscapeTriggerBtn'); + if (triggerBtn && window.innerWidth <= 768 && canvasImage && canvasImage.complete && mapRegions.length > 0) { + triggerBtn.style.display = 'inline-flex'; + } } /** @@ -453,6 +459,213 @@ function selectWorkplace(region) { updateStepStatus(); } +// ==================== 가로모드 전체화면 지도 ==================== + +function openLandscapeMap() { + if (!canvasImage || !canvasImage.complete || mapRegions.length === 0) return; + + const overlay = document.getElementById('landscapeOverlay'); + const inner = document.getElementById('landscapeInner'); + const lCanvas = document.getElementById('landscapeCanvas'); + if (!overlay || !lCanvas) return; + + overlay.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // 물리적 가로모드 여부 + const isPhysicalLandscape = window.innerWidth > window.innerHeight; + inner.className = 'landscape-inner ' + (isPhysicalLandscape ? 'no-rotate' : 'rotated'); + + // 가용 영역 + const headerH = 52; + const pad = 16; + let availW, availH; + if (isPhysicalLandscape) { + availW = window.innerWidth - pad * 2; + availH = window.innerHeight - headerH - pad * 2; + } else { + availW = window.innerHeight - pad * 2; + availH = window.innerWidth - headerH - pad * 2; + } + + // 이미지 비율 유지 + const imgRatio = canvasImage.naturalWidth / canvasImage.naturalHeight; + let cw, ch; + if (availW / availH > imgRatio) { + ch = availH; + cw = ch * imgRatio; + } else { + cw = availW; + ch = cw / imgRatio; + } + lCanvas.width = Math.round(cw); + lCanvas.height = Math.round(ch); + + drawLandscapeMap(); + + lCanvas.ontouchstart = handleLandscapeTouchStart; + lCanvas.onclick = handleLandscapeClick; +} + +function drawLandscapeMap() { + const lCanvas = document.getElementById('landscapeCanvas'); + if (!lCanvas || !canvasImage) return; + const lCtx = lCanvas.getContext('2d'); + + lCtx.drawImage(canvasImage, 0, 0, lCanvas.width, lCanvas.height); + + mapRegions.forEach(region => { + const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id); + const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id); + const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0); + const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0); + + const x1 = (region.x_start / 100) * lCanvas.width; + const y1 = (region.y_start / 100) * lCanvas.height; + const x2 = (region.x_end / 100) * lCanvas.width; + const y2 = (region.y_end / 100) * lCanvas.height; + const w = x2 - x1; + const h = y2 - y1; + const isSelected = region.workplace_id === selectedWorkplaceId; + + let fillColor, strokeColor, textColor; + if (isSelected) { + fillColor = 'rgba(34, 197, 94, 0.5)'; + strokeColor = 'rgb(22, 163, 74)'; + textColor = '#15803d'; + } else if (workerCount > 0 && visitorCount > 0) { + fillColor = 'rgba(34, 197, 94, 0.4)'; + strokeColor = 'rgb(22, 163, 74)'; + textColor = '#166534'; + } else if (workerCount > 0) { + fillColor = 'rgba(59, 130, 246, 0.4)'; + strokeColor = 'rgb(37, 99, 235)'; + textColor = '#1e40af'; + } else if (visitorCount > 0) { + fillColor = 'rgba(168, 85, 247, 0.4)'; + strokeColor = 'rgb(147, 51, 234)'; + textColor = '#7c3aed'; + } else { + fillColor = 'rgba(107, 114, 128, 0.35)'; + strokeColor = 'rgb(75, 85, 99)'; + textColor = '#374151'; + } + + lCtx.fillStyle = fillColor; + lCtx.strokeStyle = strokeColor; + lCtx.lineWidth = isSelected ? 4 : 2.5; + lCtx.beginPath(); + lCtx.rect(x1, y1, w, h); + lCtx.fill(); + lCtx.stroke(); + + const centerX = x1 + w / 2; + const centerY = y1 + h / 2; + + lCtx.font = 'bold 13px sans-serif'; + const textMetrics = lCtx.measureText(region.workplace_name); + const textWidth = textMetrics.width + 12; + const textHeight = 20; + + lCtx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + drawRoundRect(lCtx, centerX - textWidth / 2, centerY - textHeight / 2, textWidth, textHeight, 4); + lCtx.fill(); + + lCtx.fillStyle = textColor; + lCtx.textAlign = 'center'; + lCtx.textBaseline = 'middle'; + lCtx.fillText(region.workplace_name, centerX, centerY); + + const total = workerCount + visitorCount; + if (total > 0) { + lCtx.font = 'bold 12px sans-serif'; + const countText = `${total}명`; + const countMetrics = lCtx.measureText(countText); + const countWidth = countMetrics.width + 10; + const countHeight = 18; + lCtx.fillStyle = strokeColor; + drawRoundRect(lCtx, centerX - countWidth / 2, centerY + 12, countWidth, countHeight, 4); + lCtx.fill(); + lCtx.fillStyle = '#ffffff'; + lCtx.fillText(countText, centerX, centerY + 21); + } + }); +} + +function getLandscapeCoords(clientX, clientY) { + const lCanvas = document.getElementById('landscapeCanvas'); + if (!lCanvas) return null; + const rect = lCanvas.getBoundingClientRect(); + const inner = document.getElementById('landscapeInner'); + const isRotated = inner.classList.contains('rotated'); + + if (!isRotated) { + const scaleX = lCanvas.width / rect.width; + const scaleY = lCanvas.height / rect.height; + return { + x: (clientX - rect.left) * scaleX, + y: (clientY - rect.top) * scaleY + }; + } + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const dx = clientX - centerX; + const dy = clientY - centerY; + const inverseDx = dy; + const inverseDy = -dx; + + const unrotatedW = rect.height; + const unrotatedH = rect.width; + + const canvasX = (inverseDx + unrotatedW / 2) / unrotatedW * lCanvas.width; + const canvasY = (inverseDy + unrotatedH / 2) / unrotatedH * lCanvas.height; + + return { x: canvasX, y: canvasY }; +} + +function handleLandscapeTouchStart(e) { + e.preventDefault(); + const touch = e.touches[0]; + const coords = getLandscapeCoords(touch.clientX, touch.clientY); + if (coords) doLandscapeHitTest(coords.x, coords.y); +} + +function handleLandscapeClick(e) { + const coords = getLandscapeCoords(e.clientX, e.clientY); + if (coords) doLandscapeHitTest(coords.x, coords.y); +} + +function doLandscapeHitTest(cx, cy) { + const lCanvas = document.getElementById('landscapeCanvas'); + if (!lCanvas) return; + + for (const region of mapRegions) { + const x1 = (region.x_start / 100) * lCanvas.width; + const y1 = (region.y_start / 100) * lCanvas.height; + const x2 = (region.x_end / 100) * lCanvas.width; + const y2 = (region.y_end / 100) * lCanvas.height; + + if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) { + selectWorkplace(region); + drawLandscapeMap(); + setTimeout(() => closeLandscapeMap(), 300); + return; + } + } +} + +function closeLandscapeMap() { + const overlay = document.getElementById('landscapeOverlay'); + if (overlay) overlay.style.display = 'none'; + const lCanvas = document.getElementById('landscapeCanvas'); + if (lCanvas) { + lCanvas.ontouchstart = null; + lCanvas.onclick = null; + } + document.body.style.overflow = ''; +} + /** * 프로젝트 목록 렌더링 (아코디언 방식) * 범주 1: 오늘 TBM 등록 작업 (해당 위치) diff --git a/system2-report/web/pages/safety/issue-report.html b/system2-report/web/pages/safety/issue-report.html index 7228a9b..acd4f9a 100644 --- a/system2-report/web/pages/safety/issue-report.html +++ b/system2-report/web/pages/safety/issue-report.html @@ -460,6 +460,102 @@ #submitBtn:disabled { background: #d1d5db; cursor: not-allowed; } #submitBtn:not(:disabled):active { background: #dc2626; } + /* 가로모드 전체화면 지도 오버레이 */ + .landscape-overlay { + position: fixed; + inset: 0; + z-index: 10000; + background: rgba(0, 0, 0, 0.95); + display: flex; + align-items: center; + justify-content: center; + } + .landscape-inner { + display: flex; + flex-direction: column; + background: #fff; + overflow: hidden; + } + .landscape-inner.rotated { + width: 100vh; + height: 100vw; + transform: translate(-50%, -50%) rotate(90deg); + position: absolute; + top: 50%; + left: 50%; + } + .landscape-inner.no-rotate { + width: 100vw; + height: 100vh; + } + .landscape-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: linear-gradient(135deg, #ef4444, #dc2626); + color: white; + flex-shrink: 0; + } + .landscape-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + } + .landscape-close-btn { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + -webkit-tap-highlight-color: transparent; + } + .landscape-canvas-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #f1f5f9; + padding: 0.5rem; + } + .landscape-canvas-wrap canvas { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); + } + .landscape-trigger-btn { + display: none; + } + @media (max-width: 768px) { + .landscape-trigger-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + background: linear-gradient(135deg, #8b5cf6, #7c3aed); + color: white; + border: none; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; + -webkit-tap-highlight-color: transparent; + } + .landscape-trigger-btn:active { + transform: scale(0.97); + opacity: 0.85; + } + } + /* Responsive */ @media (min-width: 480px) { body { max-width: 480px; margin: 0 auto; min-height: 100vh; } @@ -514,6 +610,9 @@
+
지도에서 작업장을 클릭하여 위치를 선택하세요
@@ -569,6 +668,19 @@
+ + +