From 6a20056e056f664c7f9b4eea27590ea5eaed1d17 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 13 Mar 2026 14:24:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(tksafety):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EC=B6=9C=EC=9E=85=EC=8B=A0=EA=B3=A0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 마이그레이션: request_type, visitor_name, department_id, check_in/out_time 컬럼 + status ENUM 확장 - 4소스 UNION 대시보드: 방문(외부/내부) + TBM + 협력업체 통합 조회 - 체크인/체크아웃 API + 내부 출입 신고(승인 불필요) 지원 - 통합 출입 현황판 페이지 신규 (entry-dashboard.html) - 출입 신청/관리 페이지에 유형 필터 + 체크인/아웃 버튼 추가 - safety_entry_dashboard 권한 추가 Co-Authored-By: Claude Opus 4.6 --- .../api/controllers/visitRequestController.js | 90 ++++++- tksafety/api/index.js | 9 +- tksafety/api/models/visitRequestModel.js | 247 +++++++++++++++++- tksafety/api/routes/visitRequestRoutes.js | 11 + tksafety/web/checklist.html | 2 +- tksafety/web/education.html | 2 +- tksafety/web/entry-dashboard.html | 110 ++++++++ tksafety/web/index.html | 2 +- tksafety/web/static/js/tksafety-core.js | 1 + .../web/static/js/tksafety-entry-dashboard.js | 136 ++++++++++ .../static/js/tksafety-visit-management.js | 78 +++++- .../web/static/js/tksafety-visit-request.js | 104 +++++++- tksafety/web/training.html | 2 +- tksafety/web/visit-management.html | 28 +- tksafety/web/visit-request.html | 44 +++- user-management/api/models/permissionModel.js | 1 + 16 files changed, 810 insertions(+), 57 deletions(-) create mode 100644 tksafety/web/entry-dashboard.html create mode 100644 tksafety/web/static/js/tksafety-entry-dashboard.js diff --git a/tksafety/api/controllers/visitRequestController.js b/tksafety/api/controllers/visitRequestController.js index 389811d..91f8ce3 100644 --- a/tksafety/api/controllers/visitRequestController.js +++ b/tksafety/api/controllers/visitRequestController.js @@ -6,18 +6,31 @@ exports.createVisitRequest = async (req, res) => { try { const requester_id = req.user.user_id; const requestData = { requester_id, ...req.body }; + const isInternal = requestData.request_type === 'internal'; - const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id']; - for (const field of requiredFields) { - if (!requestData[field]) { - return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` }); + // 내부 출입: visitor_name 필수, 외부: visitor_company 필수 + if (isInternal) { + const requiredFields = ['visitor_name', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id']; + for (const field of requiredFields) { + if (!requestData[field]) { + return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` }); + } + } + requestData.visitor_count = 1; + requestData.visitor_company = requestData.visitor_company || '내부'; + } else { + const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id']; + for (const field of requiredFields) { + if (!requestData[field]) { + return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` }); + } } } const requestId = await visitRequestModel.createVisitRequest(requestData); res.status(201).json({ success: true, - message: '출입 신청이 성공적으로 생성되었습니다.', + message: isInternal ? '내부 출입 신고가 완료되었습니다.' : '출입 신청이 성공적으로 생성되었습니다.', data: { request_id: requestId } }); } catch (err) { @@ -34,7 +47,8 @@ exports.getAllVisitRequests = async (req, res) => { start_date: req.query.start_date, end_date: req.query.end_date, requester_id: req.query.requester_id, - category_id: req.query.category_id + category_id: req.query.category_id, + request_type: req.query.request_type }; const requests = await visitRequestModel.getAllVisitRequests(filters); @@ -303,3 +317,67 @@ exports.getWorkplaces = async (req, res) => { res.status(500).json({ success: false, message: '작업장 조회 중 오류가 발생했습니다.' }); } }; + +// ==================== 체크인/체크아웃 ==================== + +exports.checkIn = async (req, res) => { + try { + const result = await visitRequestModel.checkIn(req.params.id, req.user.user_id); + if (result.error) { + return res.status(result.status).json({ success: false, message: result.error }); + } + res.json({ success: true, message: '체크인되었습니다.' }); + } catch (err) { + console.error('체크인 오류:', err); + res.status(500).json({ success: false, message: '체크인 중 오류가 발생했습니다.' }); + } +}; + +exports.checkOut = async (req, res) => { + try { + const result = await visitRequestModel.checkOut(req.params.id, req.user.user_id); + if (result.error) { + return res.status(result.status).json({ success: false, message: result.error }); + } + res.json({ success: true, message: '체크아웃되었습니다.' }); + } catch (err) { + console.error('체크아웃 오류:', err); + res.status(500).json({ success: false, message: '체크아웃 중 오류가 발생했습니다.' }); + } +}; + +// ==================== 통합 대시보드 ==================== + +exports.getEntryDashboard = async (req, res) => { + try { + const date = req.query.date || new Date().toISOString().substring(0, 10); + const data = await visitRequestModel.getEntryDashboard(date); + res.json({ success: true, data, date }); + } catch (err) { + console.error('출입 대시보드 조회 오류:', err); + res.status(500).json({ success: false, message: '출입 대시보드 조회 중 오류가 발생했습니다.' }); + } +}; + +exports.getEntryStats = async (req, res) => { + try { + const date = req.query.date || new Date().toISOString().substring(0, 10); + const stats = await visitRequestModel.getEntryStats(date); + res.json({ success: true, data: stats, date }); + } catch (err) { + console.error('출입 통계 조회 오류:', err); + res.status(500).json({ success: false, message: '출입 통계 조회 중 오류가 발생했습니다.' }); + } +}; + +// ==================== 부서 목록 ==================== + +exports.getDepartments = async (req, res) => { + try { + const departments = await visitRequestModel.getAllDepartments(); + res.json({ success: true, data: departments }); + } catch (err) { + console.error('부서 목록 조회 오류:', err); + res.status(500).json({ success: false, message: '부서 목록 조회 중 오류가 발생했습니다.' }); + } +}; diff --git a/tksafety/api/index.js b/tksafety/api/index.js index 34a6b02..36178fc 100644 --- a/tksafety/api/index.js +++ b/tksafety/api/index.js @@ -6,6 +6,7 @@ const educationRoutes = require('./routes/educationRoutes'); const visitRequestRoutes = require('./routes/visitRequestRoutes'); const checklistRoutes = require('./routes/checklistRoutes'); const dailyVisitModel = require('./models/dailyVisitModel'); +const visitRequestModel = require('./models/visitRequestModel'); const { requireAuth } = require('./middleware/auth'); const app = express(); @@ -83,8 +84,14 @@ cron.schedule('59 23 * * *', async () => { } }, { timezone: 'Asia/Seoul' }); -app.listen(PORT, () => { +app.listen(PORT, async () => { console.log(`tksafety-api running on port ${PORT}`); + // DB 마이그레이션 실행 + try { + await visitRequestModel.runMigration(); + } catch (err) { + console.error('Migration error:', err.message); + } }); module.exports = app; diff --git a/tksafety/api/models/visitRequestModel.js b/tksafety/api/models/visitRequestModel.js index 31f651a..1eb3caf 100644 --- a/tksafety/api/models/visitRequestModel.js +++ b/tksafety/api/models/visitRequestModel.js @@ -1,13 +1,63 @@ const { getPool } = require('../middleware/auth'); +// ==================== DB 마이그레이션 ==================== + +const runMigration = async () => { + const db = getPool(); + try { + // 컬럼 존재 여부 체크 + const [cols] = await db.query( + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'workplace_visit_requests' + AND COLUMN_NAME IN ('request_type','visitor_name','department_id','check_in_time','check_out_time')` + ); + const existing = cols.map(c => c.COLUMN_NAME); + + if (!existing.includes('request_type')) { + await db.query(`ALTER TABLE workplace_visit_requests ADD COLUMN request_type ENUM('external','internal') NOT NULL DEFAULT 'external' AFTER request_id`); + console.log('[migration] Added request_type column'); + } + if (!existing.includes('visitor_name')) { + await db.query(`ALTER TABLE workplace_visit_requests ADD COLUMN visitor_name VARCHAR(100) NULL AFTER visitor_company`); + console.log('[migration] Added visitor_name column'); + } + if (!existing.includes('department_id')) { + await db.query(`ALTER TABLE workplace_visit_requests ADD COLUMN department_id INT NULL AFTER visitor_name`); + console.log('[migration] Added department_id column'); + } + if (!existing.includes('check_in_time')) { + await db.query(`ALTER TABLE workplace_visit_requests ADD COLUMN check_in_time DATETIME NULL AFTER status`); + console.log('[migration] Added check_in_time column'); + } + if (!existing.includes('check_out_time')) { + await db.query(`ALTER TABLE workplace_visit_requests ADD COLUMN check_out_time DATETIME NULL AFTER check_in_time`); + console.log('[migration] Added check_out_time column'); + } + + // status ENUM 확장 (checked_in, checked_out 추가) + await db.query(`ALTER TABLE workplace_visit_requests MODIFY COLUMN status ENUM('pending','approved','rejected','training_completed','checked_in','checked_out') NOT NULL DEFAULT 'pending'`); + + // 인덱스 추가 (이미 있으면 무시) + try { await db.query(`CREATE INDEX idx_visit_date_type ON workplace_visit_requests (visit_date, request_type)`); } catch (e) { /* already exists */ } + try { await db.query(`CREATE INDEX idx_checkin_time ON workplace_visit_requests (check_in_time)`); } catch (e) { /* already exists */ } + + console.log('[migration] workplace_visit_requests migration complete'); + } catch (err) { + console.error('[migration] Error:', err.message); + } +}; + // ==================== 출입 신청 관리 ==================== const createVisitRequest = async (requestData) => { const db = getPool(); const { requester_id, + request_type = 'external', visitor_company, + visitor_name = null, visitor_count = 1, + department_id = null, category_id, workplace_id, visit_date, @@ -16,13 +66,16 @@ const createVisitRequest = async (requestData) => { notes = null } = requestData; + // 내부 출입은 바로 approved + const status = request_type === 'internal' ? 'approved' : 'pending'; + const [result] = await db.query( `INSERT INTO workplace_visit_requests - (requester_id, visitor_company, visitor_count, category_id, workplace_id, - visit_date, visit_time, purpose_id, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [requester_id, visitor_company, visitor_count, category_id, workplace_id, - visit_date, visit_time, purpose_id, notes] + (requester_id, request_type, visitor_company, visitor_name, visitor_count, + department_id, category_id, workplace_id, visit_date, visit_time, purpose_id, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [requester_id, request_type, visitor_company, visitor_name, visitor_count, + department_id, category_id, workplace_id, visit_date, visit_time, purpose_id, notes, status] ); return result.insertId; @@ -32,21 +85,26 @@ const getAllVisitRequests = async (filters = {}) => { const db = getPool(); let query = ` SELECT - vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, - vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, + vr.request_id, vr.requester_id, vr.request_type, + vr.visitor_company, vr.visitor_name, vr.visitor_count, + vr.department_id, vr.category_id, vr.workplace_id, + vr.visit_date, vr.visit_time, vr.purpose_id, vr.notes, vr.status, + vr.check_in_time, vr.check_out_time, vr.approved_by, vr.approved_at, vr.rejection_reason, vr.created_at, vr.updated_at, u.username as requester_name, u.name as requester_full_name, wc.category_name, w.workplace_name, vpt.purpose_name, - approver.username as approver_name + approver.username as approver_name, + d.name as department_name FROM workplace_visit_requests vr INNER JOIN users u ON vr.requester_id = u.user_id INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id LEFT JOIN users approver ON vr.approved_by = approver.user_id + LEFT JOIN departments d ON vr.department_id = d.department_id WHERE 1=1 `; @@ -72,6 +130,10 @@ const getAllVisitRequests = async (filters = {}) => { query += ` AND vr.category_id = ?`; params.push(filters.category_id); } + if (filters.request_type) { + query += ` AND vr.request_type = ?`; + params.push(filters.request_type); + } query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`; @@ -83,21 +145,26 @@ const getVisitRequestById = async (requestId) => { const db = getPool(); const [rows] = await db.query( `SELECT - vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, - vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, + vr.request_id, vr.requester_id, vr.request_type, + vr.visitor_company, vr.visitor_name, vr.visitor_count, + vr.department_id, vr.category_id, vr.workplace_id, + vr.visit_date, vr.visit_time, vr.purpose_id, vr.notes, vr.status, + vr.check_in_time, vr.check_out_time, vr.approved_by, vr.approved_at, vr.rejection_reason, vr.created_at, vr.updated_at, u.username as requester_name, u.name as requester_full_name, wc.category_name, w.workplace_name, vpt.purpose_name, - approver.username as approver_name + approver.username as approver_name, + d.name as department_name FROM workplace_visit_requests vr INNER JOIN users u ON vr.requester_id = u.user_id INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id LEFT JOIN users approver ON vr.approved_by = approver.user_id + LEFT JOIN departments d ON vr.department_id = d.department_id WHERE vr.request_id = ?`, [requestId] ); @@ -342,7 +409,158 @@ const getWorkplacesByCategory = async (categoryId) => { return rows; }; +// ==================== 체크인/체크아웃 ==================== + +const checkIn = async (requestId, userId) => { + const db = getPool(); + // 승인 또는 교육완료 상태만 체크인 가능 + const [rows] = await db.query( + `SELECT request_id, requester_id, status, request_type FROM workplace_visit_requests WHERE request_id = ?`, + [requestId] + ); + if (!rows.length) return { error: '신청을 찾을 수 없습니다.', status: 404 }; + const req = rows[0]; + + const allowedStatuses = req.request_type === 'internal' + ? ['approved'] + : ['approved', 'training_completed']; + if (!allowedStatuses.includes(req.status)) { + return { error: `체크인 불가 상태입니다. (현재: ${req.status})`, status: 400 }; + } + + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET status = 'checked_in', check_in_time = NOW(), updated_at = NOW() + WHERE request_id = ?`, + [requestId] + ); + return { success: true, result }; +}; + +const checkOut = async (requestId, userId) => { + const db = getPool(); + const [rows] = await db.query( + `SELECT request_id, requester_id, status FROM workplace_visit_requests WHERE request_id = ?`, + [requestId] + ); + if (!rows.length) return { error: '신청을 찾을 수 없습니다.', status: 404 }; + if (rows[0].status !== 'checked_in') { + return { error: `체크아웃 불가 상태입니다. (현재: ${rows[0].status})`, status: 400 }; + } + + const [result] = await db.query( + `UPDATE workplace_visit_requests + SET status = 'checked_out', check_out_time = NOW(), updated_at = NOW() + WHERE request_id = ?`, + [requestId] + ); + return { success: true, result }; +}; + +// ==================== 통합 대시보드 ==================== + +const getEntryDashboard = async (date) => { + const db = getPool(); + const query = ` + SELECT 'visit' as source, vr.request_type, vr.visitor_company, vr.visitor_name, + vr.visitor_count, wc.category_name, w.workplace_name, + vr.visit_date as entry_date, vr.visit_time as entry_time, + vr.check_in_time, vr.check_out_time, vr.status, + u.name as reporter_name, vpt.purpose_name, NULL as source_note + FROM workplace_visit_requests vr + LEFT JOIN workplace_categories wc ON vr.category_id = wc.category_id + LEFT JOIN workplaces w ON vr.workplace_id = w.workplace_id + LEFT JOIN users u ON vr.requester_id = u.user_id + LEFT JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id + WHERE vr.visit_date = ? AND vr.status NOT IN ('pending','rejected') + + UNION ALL + + SELECT 'tbm' as source, 'internal' as request_type, NULL as visitor_company, wk.worker_name as visitor_name, + 1 as visitor_count, NULL as category_name, ts.work_location as workplace_name, + ts.session_date as entry_date, ts.start_time as entry_time, + ts.start_time as check_in_time, ts.end_time as check_out_time, + CASE WHEN ta.is_present=1 THEN 'checked_in' ELSE 'absent' END as status, + leader.worker_name as reporter_name, '작업(TBM)' as purpose_name, 'TBM 세션 기준' as source_note + FROM tbm_team_assignments ta + JOIN tbm_sessions ts ON ta.session_id = ts.session_id + JOIN workers wk ON ta.user_id = wk.worker_id + LEFT JOIN workers leader ON ts.leader_user_id = leader.worker_id + WHERE ts.session_date = ? AND ts.status != 'cancelled' + + UNION ALL + + SELECT 'partner' as source, 'external' as request_type, + pc.company_name as visitor_company, pwc.worker_names as visitor_name, + pwc.actual_worker_count as visitor_count, NULL as category_name, ps.workplace_name, + DATE(pwc.check_in_time) as entry_date, TIME(pwc.check_in_time) as entry_time, + pwc.check_in_time, pwc.check_out_time, + CASE WHEN pwc.check_out_time IS NOT NULL THEN 'checked_out' ELSE 'checked_in' END as status, + u2.name as reporter_name, ps.work_description as purpose_name, NULL as source_note + FROM partner_work_checkins pwc + JOIN partner_schedules ps ON pwc.schedule_id = ps.id + JOIN partner_companies pc ON pwc.company_id = pc.id + LEFT JOIN sso_users u2 ON pwc.checked_by = u2.user_id + WHERE DATE(pwc.check_in_time) = ? + + ORDER BY entry_date DESC, check_in_time DESC + `; + + const [rows] = await db.query(query, [date, date, date]); + return rows; +}; + +const getEntryStats = async (date) => { + const db = getPool(); + + // 방문자 (visit source — request_type별 분리) + const [visitStats] = await db.query( + `SELECT request_type, SUM(visitor_count) as cnt + FROM workplace_visit_requests + WHERE visit_date = ? AND status = 'checked_in' + GROUP BY request_type`, + [date] + ); + + // 협력업체 + const [partnerStats] = await db.query( + `SELECT COALESCE(SUM(actual_worker_count), 0) as cnt + FROM partner_work_checkins + WHERE DATE(check_in_time) = ? AND check_out_time IS NULL`, + [date] + ); + + // TBM (참고용) + const [tbmStats] = await db.query( + `SELECT COUNT(*) as cnt FROM tbm_team_assignments ta + JOIN tbm_sessions ts ON ta.session_id = ts.session_id + WHERE ts.session_date = ? AND ts.status != 'cancelled' AND ta.is_present = 1`, + [date] + ); + + const external = visitStats.find(v => v.request_type === 'external'); + const internal = visitStats.find(v => v.request_type === 'internal'); + + return { + external_visit: Number(external?.cnt || 0), + internal_visit: Number(internal?.cnt || 0), + partner: Number(partnerStats[0]?.cnt || 0), + tbm: Number(tbmStats[0]?.cnt || 0) + }; +}; + +// ==================== 부서 목록 조회 ==================== + +const getAllDepartments = async () => { + const db = getPool(); + const [rows] = await db.query( + 'SELECT department_id, name FROM departments WHERE is_active = 1 ORDER BY name' + ); + return rows; +}; + module.exports = { + runMigration, createVisitRequest, getAllVisitRequests, getVisitRequestById, @@ -351,6 +569,10 @@ module.exports = { approveVisitRequest, rejectVisitRequest, updateVisitRequestStatus, + checkIn, + checkOut, + getEntryDashboard, + getEntryStats, getAllVisitPurposes, getActiveVisitPurposes, createVisitPurpose, @@ -362,5 +584,6 @@ module.exports = { completeTraining, getTrainingRecords, getAllCategories, - getWorkplacesByCategory + getWorkplacesByCategory, + getAllDepartments }; diff --git a/tksafety/api/routes/visitRequestRoutes.js b/tksafety/api/routes/visitRequestRoutes.js index fef1250..e02d6e2 100644 --- a/tksafety/api/routes/visitRequestRoutes.js +++ b/tksafety/api/routes/visitRequestRoutes.js @@ -14,10 +14,21 @@ router.delete('/requests/:id', requirePage('safety_visit_request'), visitRequest router.put('/requests/:id/approve', requireAdmin, visitRequestController.approveVisitRequest); router.put('/requests/:id/reject', requireAdmin, visitRequestController.rejectVisitRequest); +// Check-in / Check-out +router.put('/requests/:id/check-in', visitRequestController.checkIn); +router.put('/requests/:id/check-out', visitRequestController.checkOut); + +// Entry Dashboard (통합 출입 현황) +router.get('/entry-dashboard', visitRequestController.getEntryDashboard); +router.get('/entry-dashboard/stats', visitRequestController.getEntryStats); + // Categories & Workplaces router.get('/categories', visitRequestController.getAllCategories); router.get('/workplaces', visitRequestController.getWorkplaces); +// Departments +router.get('/departments', visitRequestController.getDepartments); + // Visit purposes router.get('/purposes', visitRequestController.getAllVisitPurposes); router.get('/purposes/active', visitRequestController.getActiveVisitPurposes); diff --git a/tksafety/web/checklist.html b/tksafety/web/checklist.html index a580194..8a2a28d 100644 --- a/tksafety/web/checklist.html +++ b/tksafety/web/checklist.html @@ -154,7 +154,7 @@ - + diff --git a/tksafety/web/education.html b/tksafety/web/education.html index 00dd228..0107f77 100644 --- a/tksafety/web/education.html +++ b/tksafety/web/education.html @@ -187,7 +187,7 @@ - + diff --git a/tksafety/web/entry-dashboard.html b/tksafety/web/entry-dashboard.html new file mode 100644 index 0000000..9182c94 --- /dev/null +++ b/tksafety/web/entry-dashboard.html @@ -0,0 +1,110 @@ + + + + + + 출입 현황판 - TK 안전관리 + + + + + + +
+
+
+
+ +

TK 안전관리

+
+
+ +
-
+ +
+
+
+
+ +
+
+ + + + +
+ +
+
+

출입 현황판

+ + +
+ +
+ + +
+
+
0
+
현재 총 인원
+
+
+
0
+
TBM (세션 기준)*
+
+
+
0
+
협력업체
+
+
+
0
+
외부 방문자
+
+
+
0
+
내부 직원
+
+
+ + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + +
소스이름소속작업장입장시간퇴장시간목적상태비고
로딩 중...
+
+
+
+
+
+ + + + + + diff --git a/tksafety/web/index.html b/tksafety/web/index.html index 6e40724..21b17d1 100644 --- a/tksafety/web/index.html +++ b/tksafety/web/index.html @@ -274,7 +274,7 @@ - + diff --git a/tksafety/web/static/js/tksafety-core.js b/tksafety/web/static/js/tksafety-core.js index 5a41821..a623c4a 100644 --- a/tksafety/web/static/js/tksafety-core.js +++ b/tksafety/web/static/js/tksafety-core.js @@ -87,6 +87,7 @@ function renderNavbar() { { href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['index.html'] }, { href: '/visit-request.html', icon: 'fa-file-signature', label: '출입 신청', match: ['visit-request.html'] }, { href: '/visit-management.html', icon: 'fa-clipboard-check', label: '출입 관리', match: ['visit-management.html'], admin: true }, + { href: '/entry-dashboard.html', icon: 'fa-id-card-alt', label: '출입 현황판', match: ['entry-dashboard.html'], admin: true }, { href: '/education.html', icon: 'fa-graduation-cap', label: '안전교육', match: ['education.html'] }, { href: '/training.html', icon: 'fa-chalkboard-teacher', label: '안전교육 실시', match: ['training.html'], admin: true }, { href: '/checklist.html', icon: 'fa-tasks', label: '체크리스트 관리', match: ['checklist.html'], admin: true }, diff --git a/tksafety/web/static/js/tksafety-entry-dashboard.js b/tksafety/web/static/js/tksafety-entry-dashboard.js new file mode 100644 index 0000000..fec7dd8 --- /dev/null +++ b/tksafety/web/static/js/tksafety-entry-dashboard.js @@ -0,0 +1,136 @@ +/* ===== Entry Dashboard (출입 현황판) ===== */ +let dashboardData = []; +let currentSourceFilter = ''; +let refreshTimer = null; + +/* ===== Source/Status badges ===== */ +function sourceBadge(s) { + const m = { + tbm: ['badge-blue', 'TBM'], + partner: ['badge-green', '협력업체'], + visit: ['badge-amber', '방문'] + }; + const [cls, label] = m[s] || ['badge-gray', s]; + return `${label}`; +} + +function entryStatusBadge(s) { + const m = { + checked_in: ['badge-blue', '체크인'], + checked_out: ['badge-gray', '체크아웃'], + approved: ['badge-green', '승인'], + training_completed: ['badge-blue', '교육완료'], + absent: ['badge-red', '불참'] + }; + const [cls, label] = m[s] || ['badge-gray', s]; + return `${label}`; +} + +/* ===== Load dashboard data ===== */ +async function loadDashboard() { + const date = document.getElementById('dashboardDate').value; + try { + const [dashRes, statsRes] = await Promise.all([ + api('/visit-requests/entry-dashboard?date=' + date), + api('/visit-requests/entry-dashboard/stats?date=' + date) + ]); + dashboardData = dashRes.data || []; + updateStats(statsRes.data || {}); + renderDashboard(); + } catch (e) { + showToast('대시보드 로드 실패: ' + e.message, 'error'); + } +} + +function updateStats(stats) { + const total = stats.external_visit + stats.internal_visit + stats.partner + stats.tbm; + document.getElementById('statTotal').textContent = total; + document.getElementById('statTbm').textContent = stats.tbm; + document.getElementById('statPartner').textContent = stats.partner; + document.getElementById('statExternal').textContent = stats.external_visit; + document.getElementById('statInternal').textContent = stats.internal_visit; +} + +/* ===== Render table ===== */ +function renderDashboard() { + const tbody = document.getElementById('dashboardBody'); + const filtered = currentSourceFilter + ? dashboardData.filter(r => r.source === currentSourceFilter) + : dashboardData; + + if (!filtered.length) { + tbody.innerHTML = '데이터가 없습니다'; + return; + } + + tbody.innerHTML = filtered.map(r => { + const name = escapeHtml(r.visitor_name || '-'); + const org = escapeHtml(r.visitor_company || '-'); + const workplace = escapeHtml(r.workplace_name || '-'); + const inTime = r.check_in_time ? String(r.check_in_time).substring(11, 16) : (r.entry_time ? String(r.entry_time).substring(0, 5) : '-'); + const outTime = r.check_out_time ? String(r.check_out_time).substring(11, 16) : '-'; + const purpose = escapeHtml(r.purpose_name || '-'); + const note = r.source_note ? `${escapeHtml(r.source_note)}` : ''; + const count = r.visitor_count > 1 ? ` (${r.visitor_count}명)` : ''; + + return ` + ${sourceBadge(r.source)} + ${name}${count} + ${org} + ${workplace} + ${inTime} + ${outTime} + ${purpose} + ${entryStatusBadge(r.status)} + ${note} + `; + }).join(''); +} + +/* ===== Tab filter ===== */ +function filterSource(source) { + currentSourceFilter = source; + document.querySelectorAll('.source-tab').forEach(t => { + t.classList.toggle('active', t.dataset.source === source); + }); + renderDashboard(); +} + +/* ===== Auto refresh ===== */ +function setupAutoRefresh() { + const cb = document.getElementById('autoRefresh'); + cb.addEventListener('change', () => { + if (cb.checked) startAutoRefresh(); + else stopAutoRefresh(); + }); + startAutoRefresh(); +} + +function startAutoRefresh() { + stopAutoRefresh(); + refreshTimer = setInterval(loadDashboard, 180000); // 3분 +} + +function stopAutoRefresh() { + if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } +} + +/* ===== Init ===== */ +function initEntryDashboard() { + if (!initAuth()) return; + + const today = new Date().toISOString().substring(0, 10); + document.getElementById('dashboardDate').value = today; + + document.getElementById('dashboardDate').addEventListener('change', loadDashboard); + + // Source tab styling + const style = document.createElement('style'); + style.textContent = `.source-tab { border-bottom-color: transparent; color: #6b7280; cursor: pointer; } + .source-tab:hover { color: #374151; background: #f9fafb; } + .source-tab.active { border-bottom-color: #2563eb; color: #2563eb; font-weight: 600; }`; + document.head.appendChild(style); + + loadDashboard(); + setupAutoRefresh(); +} diff --git a/tksafety/web/static/js/tksafety-visit-management.js b/tksafety/web/static/js/tksafety-visit-management.js index 25f8b28..cf6d33b 100644 --- a/tksafety/web/static/js/tksafety-visit-management.js +++ b/tksafety/web/static/js/tksafety-visit-management.js @@ -8,12 +8,20 @@ function vrStatusBadge(s) { pending: ['badge-amber', '대기중'], approved: ['badge-green', '승인됨'], rejected: ['badge-red', '반려됨'], - training_completed: ['badge-blue', '교육완료'] + training_completed: ['badge-blue', '교육완료'], + checked_in: ['badge-blue', '체크인'], + checked_out: ['badge-gray', '체크아웃'] }; const [cls, label] = m[s] || ['badge-gray', s]; return `${label}`; } +function requestTypeBadge(t) { + return t === 'internal' + ? '내부' + : '외부'; +} + /* ===== Load requests ===== */ async function loadRequests() { try { @@ -21,9 +29,11 @@ async function loadRequests() { const status = document.getElementById('filterStatus').value; const dateFrom = document.getElementById('filterDateFrom').value; const dateTo = document.getElementById('filterDateTo').value; + const type = document.getElementById('filterType').value; if (status) params.set('status', status); if (dateFrom) params.set('start_date', dateFrom); if (dateTo) params.set('end_date', dateTo); + if (type) params.set('request_type', type); const res = await api('/visit-requests/requests?' + params.toString()); allRequests = res.data || []; @@ -35,12 +45,14 @@ async function loadRequests() { } function renderStats() { - const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0 }; + const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 0, checked_in: 0, checked_out: 0 }; allRequests.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; }); document.getElementById('statPending').textContent = counts.pending; document.getElementById('statApproved').textContent = counts.approved; document.getElementById('statRejected').textContent = counts.rejected; document.getElementById('statTrainingDone').textContent = counts.training_completed; + document.getElementById('statCheckedIn').textContent = counts.checked_in; + document.getElementById('statCheckedOut').textContent = counts.checked_out; } function renderRequestsTable() { @@ -51,6 +63,8 @@ function renderRequestsTable() { } tbody.innerHTML = allRequests.map(r => { let actions = ''; + + // 승인/반려 (pending만) if (r.status === 'pending') { actions = ` `; } + + // 체크인 버튼 (approved 또는 training_completed) + const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') || + (['approved', 'training_completed'].includes(r.status)); + if (canCheckIn) { + actions += ` `; + } + + // 체크아웃 버튼 (checked_in) + if (r.status === 'checked_in') { + actions += ` `; + } + actions += ` `; if (r.status === 'pending') { actions += ` `; } + const displayName = r.request_type === 'internal' + ? escapeHtml(r.visitor_name || r.requester_full_name || '-') + : escapeHtml(r.visitor_company); + return ` - ${formatDate(r.created_at)} + ${requestTypeBadge(r.request_type)} ${escapeHtml(r.requester_full_name || r.requester_name || '-')} - ${escapeHtml(r.visitor_company)} + ${displayName} ${r.visitor_count} ${escapeHtml(r.workplace_name || '-')} ${formatDate(r.visit_date)} @@ -80,13 +111,40 @@ function renderRequestsTable() { }).join(''); } +/* ===== Check-in / Check-out ===== */ +async function doCheckIn(id) { + if (!confirm('체크인 처리하시겠습니까?')) return; + try { + await api('/visit-requests/requests/' + id + '/check-in', { method: 'PUT', body: JSON.stringify({}) }); + showToast('체크인 완료'); + await loadRequests(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function doCheckOut(id) { + if (!confirm('체크아웃 처리하시겠습니까?')) return; + try { + await api('/visit-requests/requests/' + id + '/check-out', { method: 'PUT', body: JSON.stringify({}) }); + showToast('체크아웃 완료'); + await loadRequests(); + } catch (e) { + showToast(e.message, 'error'); + } +} + /* ===== Approve Modal ===== */ function openApproveModal(id) { const r = allRequests.find(x => x.request_id === id); if (!r) return; actionRequestId = id; + const displayName = r.request_type === 'internal' + ? escapeHtml(r.visitor_name || r.requester_full_name || '-') + : escapeHtml(r.visitor_company); document.getElementById('approveDetail').innerHTML = ` -

업체: ${escapeHtml(r.visitor_company)}

+

유형: ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}

+

업체/이름: ${displayName}

방문일: ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}

작업장: ${escapeHtml(r.workplace_name || '-')}

인원: ${r.visitor_count}명

@@ -120,7 +178,7 @@ function openRejectModal(id) { if (!r) return; actionRequestId = id; document.getElementById('rejectDetail').innerHTML = ` -

업체: ${escapeHtml(r.visitor_company)}

+

업체/이름: ${escapeHtml(r.visitor_company || r.visitor_name || '-')}

방문일: ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}

작업장: ${escapeHtml(r.workplace_name || '-')}

`; @@ -158,8 +216,10 @@ function openDetailModal(id) { if (!r) return; document.getElementById('detailContent').innerHTML = `
+
유형: ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}
신청자: ${escapeHtml(r.requester_full_name || r.requester_name || '-')}
-
업체: ${escapeHtml(r.visitor_company)}
+
업체: ${escapeHtml(r.visitor_company || '-')}
+
방문자: ${escapeHtml(r.visitor_name || '-')}
인원: ${r.visitor_count}명
분류: ${escapeHtml(r.category_name || '-')}
작업장: ${escapeHtml(r.workplace_name || '-')}
@@ -168,6 +228,9 @@ function openDetailModal(id) {
목적: ${escapeHtml(r.purpose_name || '-')}
상태: ${vrStatusBadge(r.status)}
신청일: ${formatDateTime(r.created_at)}
+ ${r.check_in_time ? `
체크인: ${formatDateTime(r.check_in_time)}
` : ''} + ${r.check_out_time ? `
체크아웃: ${formatDateTime(r.check_out_time)}
` : ''} + ${r.department_name ? `
부서: ${escapeHtml(r.department_name)}
` : ''} ${r.approver_name ? `
처리자: ${escapeHtml(r.approver_name)}
` : ''} ${r.approved_at ? `
처리일: ${formatDateTime(r.approved_at)}
` : ''} ${r.rejection_reason ? `
반려사유: ${escapeHtml(r.rejection_reason)}
` : ''} @@ -209,6 +272,7 @@ function initVisitManagementPage() { } document.getElementById('filterStatus').addEventListener('change', loadRequests); + document.getElementById('filterType').addEventListener('change', loadRequests); document.getElementById('filterDateFrom').addEventListener('change', loadRequests); document.getElementById('filterDateTo').addEventListener('change', loadRequests); diff --git a/tksafety/web/static/js/tksafety-visit-request.js b/tksafety/web/static/js/tksafety-visit-request.js index 861c8e8..8a46e47 100644 --- a/tksafety/web/static/js/tksafety-visit-request.js +++ b/tksafety/web/static/js/tksafety-visit-request.js @@ -3,6 +3,7 @@ let myRequests = []; let categories = []; let workplaces = []; let purposes = []; +let departments = []; /* ===== Status badge for visit requests ===== */ function vrStatusBadge(s) { @@ -10,21 +11,40 @@ function vrStatusBadge(s) { pending: ['badge-amber', '대기중'], approved: ['badge-green', '승인됨'], rejected: ['badge-red', '반려됨'], - training_completed: ['badge-blue', '교육완료'] + training_completed: ['badge-blue', '교육완료'], + checked_in: ['badge-blue', '체크인'], + checked_out: ['badge-gray', '체크아웃'] }; const [cls, label] = m[s] || ['badge-gray', s]; return `${label}`; } -/* ===== Load form data (purposes, categories) ===== */ +function requestTypeBadge(t) { + return t === 'internal' + ? '내부' + : '외부'; +} + +/* ===== Toggle form fields based on request type ===== */ +function toggleRequestType() { + const isInternal = document.querySelector('input[name="requestType"]:checked')?.value === 'internal'; + document.getElementById('companyField').style.display = isInternal ? 'none' : ''; + document.getElementById('countField').style.display = isInternal ? 'none' : ''; + document.getElementById('departmentField').style.display = isInternal ? '' : 'none'; + document.getElementById('visitorNameRequired').classList.toggle('hidden', !isInternal); +} + +/* ===== Load form data (purposes, categories, departments) ===== */ async function loadFormData() { try { - const [purposeRes, categoryRes] = await Promise.all([ + const [purposeRes, categoryRes, deptRes] = await Promise.all([ api('/visit-requests/purposes/active'), - api('/visit-requests/categories') + api('/visit-requests/categories'), + api('/visit-requests/departments') ]); purposes = purposeRes.data || []; categories = categoryRes.data || []; + departments = deptRes.data || []; const purposeSelect = document.getElementById('purposeId'); purposeSelect.innerHTML = '' + @@ -33,6 +53,10 @@ async function loadFormData() { const categorySelect = document.getElementById('categoryId'); categorySelect.innerHTML = '' + categories.map(c => ``).join(''); + + const deptSelect = document.getElementById('departmentId'); + deptSelect.innerHTML = '' + + departments.map(d => ``).join(''); } catch (e) { showToast('폼 데이터 로드 실패: ' + e.message, 'error'); } @@ -76,29 +100,74 @@ function renderMyRequests() { } tbody.innerHTML = myRequests.map(r => { const canDelete = r.status === 'pending'; + const displayName = r.request_type === 'internal' + ? escapeHtml(r.visitor_name || r.requester_full_name || '-') + : escapeHtml(r.visitor_company); + + // 체크인/체크아웃 버튼 + let checkBtn = ''; + const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') || + (r.request_type === 'external' && ['approved', 'training_completed'].includes(r.status)); + if (canCheckIn) { + checkBtn = ``; + } + if (r.status === 'checked_in') { + checkBtn = ``; + } + return ` - ${formatDate(r.created_at)} - ${escapeHtml(r.visitor_company)} + ${requestTypeBadge(r.request_type)} + ${displayName} ${r.visitor_count} ${escapeHtml(r.workplace_name || '-')} ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'} ${escapeHtml(r.purpose_name || '-')} ${vrStatusBadge(r.status)} - - ${canDelete ? `` : ''} + + ${checkBtn} + ${canDelete ? `` : ''} ${r.status === 'rejected' && r.rejection_reason ? `` : ''} `; }).join(''); } +/* ===== Check-in / Check-out ===== */ +async function doCheckIn(id) { + if (!confirm('체크인 하시겠습니까?')) return; + try { + await api('/visit-requests/requests/' + id + '/check-in', { method: 'PUT', body: JSON.stringify({}) }); + showToast('체크인 완료'); + await loadMyRequests(); + } catch (e) { + showToast(e.message, 'error'); + } +} + +async function doCheckOut(id) { + if (!confirm('체크아웃 하시겠습니까?')) return; + try { + await api('/visit-requests/requests/' + id + '/check-out', { method: 'PUT', body: JSON.stringify({}) }); + showToast('체크아웃 완료'); + await loadMyRequests(); + } catch (e) { + showToast(e.message, 'error'); + } +} + /* ===== Submit request ===== */ async function submitRequest(e) { e.preventDefault(); + const requestType = document.querySelector('input[name="requestType"]:checked')?.value || 'external'; + const isInternal = requestType === 'internal'; + const data = { - visitor_company: document.getElementById('visitorCompany').value.trim(), - visitor_count: parseInt(document.getElementById('visitorCount').value) || 1, + request_type: requestType, + visitor_company: isInternal ? '내부' : document.getElementById('visitorCompany').value.trim(), + visitor_name: document.getElementById('visitorName').value.trim() || null, + visitor_count: isInternal ? 1 : (parseInt(document.getElementById('visitorCount').value) || 1), + department_id: isInternal ? (parseInt(document.getElementById('departmentId').value) || null) : null, category_id: parseInt(document.getElementById('categoryId').value) || null, workplace_id: parseInt(document.getElementById('workplaceId').value) || null, visit_date: document.getElementById('visitDate').value, @@ -107,7 +176,11 @@ async function submitRequest(e) { notes: document.getElementById('notes').value.trim() || null }; - if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; } + if (isInternal) { + if (!data.visitor_name) { showToast('방문자 이름을 입력해주세요', 'error'); return; } + } else { + if (!data.visitor_company) { showToast('업체명을 입력해주세요', 'error'); return; } + } if (!data.category_id) { showToast('작업장 분류를 선택해주세요', 'error'); return; } if (!data.workplace_id) { showToast('작업장을 선택해주세요', 'error'); return; } if (!data.visit_date) { showToast('방문일을 선택해주세요', 'error'); return; } @@ -116,10 +189,11 @@ async function submitRequest(e) { try { await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) }); - showToast('출입 신청이 완료되었습니다'); + showToast(isInternal ? '내부 출입 신고가 완료되었습니다' : '출입 신청이 완료되었습니다'); document.getElementById('visitRequestForm').reset(); document.getElementById('workplaceId').innerHTML = ''; document.getElementById('visitorCount').value = '1'; + toggleRequestType(); await loadMyRequests(); } catch (e) { showToast(e.message, 'error'); @@ -146,6 +220,12 @@ function initVisitRequestPage() { const today = new Date().toISOString().substring(0, 10); document.getElementById('visitDate').value = today; + // Request type toggle + document.querySelectorAll('input[name="requestType"]').forEach(r => { + r.addEventListener('change', toggleRequestType); + }); + toggleRequestType(); + // Category change -> load workplaces document.getElementById('categoryId').addEventListener('change', function() { loadWorkplaces(this.value); diff --git a/tksafety/web/training.html b/tksafety/web/training.html index b95aa19..244fd5a 100644 --- a/tksafety/web/training.html +++ b/tksafety/web/training.html @@ -133,7 +133,7 @@
- + diff --git a/tksafety/web/visit-management.html b/tksafety/web/visit-management.html index a836633..2b8b184 100644 --- a/tksafety/web/visit-management.html +++ b/tksafety/web/visit-management.html @@ -34,7 +34,7 @@
-
+
0
대기중
@@ -51,6 +51,14 @@
0
교육완료
+
+
0
+
체크인
+
+
+
0
+
체크아웃
+
@@ -59,6 +67,14 @@

출입 신청 관리

+
+ + +
@@ -89,9 +107,9 @@ - + - + @@ -163,8 +181,8 @@ - - + + diff --git a/tksafety/web/visit-request.html b/tksafety/web/visit-request.html index 8379a4d..e6628b2 100644 --- a/tksafety/web/visit-request.html +++ b/tksafety/web/visit-request.html @@ -38,16 +38,40 @@

출입 신청

- -
- - + +
+ +
+ + +
- -
+ +
+ + +
+ +
+ + +
+ +
+ +
@@ -100,8 +124,8 @@
신청일유형 신청자업체업체/이름 인원 작업장 방문일
- - + + @@ -121,8 +145,8 @@ - - + + diff --git a/user-management/api/models/permissionModel.js b/user-management/api/models/permissionModel.js index 64fbcc7..291f7c3 100644 --- a/user-management/api/models/permissionModel.js +++ b/user-management/api/models/permissionModel.js @@ -63,6 +63,7 @@ const DEFAULT_PAGES = { 'safety_visit_management': { title: '출입 관리', system: 'tksafety', group: '안전 관리', default_access: false }, 'safety_training': { title: '안전교육 실시', system: 'tksafety', group: '안전 관리', default_access: false }, 'safety_checklist': { title: '체크리스트 관리', system: 'tksafety', group: '안전 관리', default_access: false }, + 'safety_entry_dashboard': { title: '출입 현황판', system: 'tksafety', group: '안전 관리', default_access: false }, // ===== tkuser - 통합 관리 ===== 'tkuser.users': { title: '사용자 관리', system: 'tkuser', group: '통합 관리', default_access: false },
신청일업체유형업체/이름 인원 작업장 방문일