feat(tksafety): 통합 출입신고 관리 시스템 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-13 14:24:13 +09:00
parent 5a062759c5
commit 6a20056e05
16 changed files with 810 additions and 57 deletions

View File

@@ -6,18 +6,31 @@ exports.createVisitRequest = async (req, res) => {
try { try {
const requester_id = req.user.user_id; const requester_id = req.user.user_id;
const requestData = { requester_id, ...req.body }; 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']; // 내부 출입: visitor_name 필수, 외부: visitor_company 필수
for (const field of requiredFields) { if (isInternal) {
if (!requestData[field]) { const requiredFields = ['visitor_name', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` }); 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); const requestId = await visitRequestModel.createVisitRequest(requestData);
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: '출입 신청이 성공적으로 생성되었습니다.', message: isInternal ? '내부 출입 신고가 완료되었습니다.' : '출입 신청이 성공적으로 생성되었습니다.',
data: { request_id: requestId } data: { request_id: requestId }
}); });
} catch (err) { } catch (err) {
@@ -34,7 +47,8 @@ exports.getAllVisitRequests = async (req, res) => {
start_date: req.query.start_date, start_date: req.query.start_date,
end_date: req.query.end_date, end_date: req.query.end_date,
requester_id: req.query.requester_id, 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); const requests = await visitRequestModel.getAllVisitRequests(filters);
@@ -303,3 +317,67 @@ exports.getWorkplaces = async (req, res) => {
res.status(500).json({ success: false, message: '작업장 조회 중 오류가 발생했습니다.' }); 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: '부서 목록 조회 중 오류가 발생했습니다.' });
}
};

View File

@@ -6,6 +6,7 @@ const educationRoutes = require('./routes/educationRoutes');
const visitRequestRoutes = require('./routes/visitRequestRoutes'); const visitRequestRoutes = require('./routes/visitRequestRoutes');
const checklistRoutes = require('./routes/checklistRoutes'); const checklistRoutes = require('./routes/checklistRoutes');
const dailyVisitModel = require('./models/dailyVisitModel'); const dailyVisitModel = require('./models/dailyVisitModel');
const visitRequestModel = require('./models/visitRequestModel');
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
const app = express(); const app = express();
@@ -83,8 +84,14 @@ cron.schedule('59 23 * * *', async () => {
} }
}, { timezone: 'Asia/Seoul' }); }, { timezone: 'Asia/Seoul' });
app.listen(PORT, () => { app.listen(PORT, async () => {
console.log(`tksafety-api running on port ${PORT}`); console.log(`tksafety-api running on port ${PORT}`);
// DB 마이그레이션 실행
try {
await visitRequestModel.runMigration();
} catch (err) {
console.error('Migration error:', err.message);
}
}); });
module.exports = app; module.exports = app;

View File

@@ -1,13 +1,63 @@
const { getPool } = require('../middleware/auth'); 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 createVisitRequest = async (requestData) => {
const db = getPool(); const db = getPool();
const { const {
requester_id, requester_id,
request_type = 'external',
visitor_company, visitor_company,
visitor_name = null,
visitor_count = 1, visitor_count = 1,
department_id = null,
category_id, category_id,
workplace_id, workplace_id,
visit_date, visit_date,
@@ -16,13 +66,16 @@ const createVisitRequest = async (requestData) => {
notes = null notes = null
} = requestData; } = requestData;
// 내부 출입은 바로 approved
const status = request_type === 'internal' ? 'approved' : 'pending';
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO workplace_visit_requests `INSERT INTO workplace_visit_requests
(requester_id, visitor_company, visitor_count, category_id, workplace_id, (requester_id, request_type, visitor_company, visitor_name, visitor_count,
visit_date, visit_time, purpose_id, notes) department_id, category_id, workplace_id, visit_date, visit_time, purpose_id, notes, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[requester_id, visitor_company, visitor_count, category_id, workplace_id, [requester_id, request_type, visitor_company, visitor_name, visitor_count,
visit_date, visit_time, purpose_id, notes] department_id, category_id, workplace_id, visit_date, visit_time, purpose_id, notes, status]
); );
return result.insertId; return result.insertId;
@@ -32,21 +85,26 @@ const getAllVisitRequests = async (filters = {}) => {
const db = getPool(); const db = getPool();
let query = ` let query = `
SELECT SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, vr.request_id, vr.requester_id, vr.request_type,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, 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.purpose_id, vr.notes, vr.status,
vr.check_in_time, vr.check_out_time,
vr.approved_by, vr.approved_at, vr.rejection_reason, vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at, vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name, u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name, wc.category_name, w.workplace_name,
vpt.purpose_name, vpt.purpose_name,
approver.username as approver_name approver.username as approver_name,
d.name as department_name
FROM workplace_visit_requests vr FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id 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 workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_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 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 users approver ON vr.approved_by = approver.user_id
LEFT JOIN departments d ON vr.department_id = d.department_id
WHERE 1=1 WHERE 1=1
`; `;
@@ -72,6 +130,10 @@ const getAllVisitRequests = async (filters = {}) => {
query += ` AND vr.category_id = ?`; query += ` AND vr.category_id = ?`;
params.push(filters.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`; 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 db = getPool();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT `SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count, vr.request_id, vr.requester_id, vr.request_type,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time, 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.purpose_id, vr.notes, vr.status,
vr.check_in_time, vr.check_out_time,
vr.approved_by, vr.approved_at, vr.rejection_reason, vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at, vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name, u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name, wc.category_name, w.workplace_name,
vpt.purpose_name, vpt.purpose_name,
approver.username as approver_name approver.username as approver_name,
d.name as department_name
FROM workplace_visit_requests vr FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id 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 workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_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 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 users approver ON vr.approved_by = approver.user_id
LEFT JOIN departments d ON vr.department_id = d.department_id
WHERE vr.request_id = ?`, WHERE vr.request_id = ?`,
[requestId] [requestId]
); );
@@ -342,7 +409,158 @@ const getWorkplacesByCategory = async (categoryId) => {
return rows; 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 = { module.exports = {
runMigration,
createVisitRequest, createVisitRequest,
getAllVisitRequests, getAllVisitRequests,
getVisitRequestById, getVisitRequestById,
@@ -351,6 +569,10 @@ module.exports = {
approveVisitRequest, approveVisitRequest,
rejectVisitRequest, rejectVisitRequest,
updateVisitRequestStatus, updateVisitRequestStatus,
checkIn,
checkOut,
getEntryDashboard,
getEntryStats,
getAllVisitPurposes, getAllVisitPurposes,
getActiveVisitPurposes, getActiveVisitPurposes,
createVisitPurpose, createVisitPurpose,
@@ -362,5 +584,6 @@ module.exports = {
completeTraining, completeTraining,
getTrainingRecords, getTrainingRecords,
getAllCategories, getAllCategories,
getWorkplacesByCategory getWorkplacesByCategory,
getAllDepartments
}; };

View File

@@ -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/approve', requireAdmin, visitRequestController.approveVisitRequest);
router.put('/requests/:id/reject', requireAdmin, visitRequestController.rejectVisitRequest); 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 // Categories & Workplaces
router.get('/categories', visitRequestController.getAllCategories); router.get('/categories', visitRequestController.getAllCategories);
router.get('/workplaces', visitRequestController.getWorkplaces); router.get('/workplaces', visitRequestController.getWorkplaces);
// Departments
router.get('/departments', visitRequestController.getDepartments);
// Visit purposes // Visit purposes
router.get('/purposes', visitRequestController.getAllVisitPurposes); router.get('/purposes', visitRequestController.getAllVisitPurposes);
router.get('/purposes/active', visitRequestController.getActiveVisitPurposes); router.get('/purposes/active', visitRequestController.getActiveVisitPurposes);

View File

@@ -154,7 +154,7 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-checklist.js"></script> <script src="/static/js/tksafety-checklist.js"></script>
<script>initChecklistPage();</script> <script>initChecklistPage();</script>
</body> </body>

View File

@@ -187,7 +187,7 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js?v=20260313"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-education.js?v=20260313"></script> <script src="/static/js/tksafety-education.js?v=20260313"></script>
<script>initEducationPage();</script> <script>initEducationPage();</script>
</body> </body>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출입 현황판 - TK 안전관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tksafety.css">
</head>
<body>
<!-- Header -->
<header class="bg-blue-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<i class="fas fa-shield-alt text-xl text-blue-200"></i>
<h1 class="text-lg font-semibold">TK 안전관리</h1>
</div>
<div class="flex items-center gap-4">
<div id="headerUserName" class="text-sm font-medium hidden sm:block">-</div>
<div id="headerUserAvatar" class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
<button onclick="doLogout()" class="text-blue-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-48 flex-shrink-0 pt-2"></nav>
<!-- Main -->
<div class="flex-1 min-w-0">
<!-- 날짜 선택 + 자동 새로고침 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-id-card-alt text-blue-500 mr-2"></i>출입 현황판</h2>
<input type="date" id="dashboardDate" class="input-field px-3 py-1.5 rounded-lg text-sm">
<button onclick="loadDashboard()" class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"><i class="fas fa-search"></i></button>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" id="autoRefresh" checked class="rounded"> 자동 새로고침
</label>
</div>
<!-- 통계 카드 -->
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-5">
<div class="stat-card">
<div class="stat-value text-gray-800" id="statTotal">0</div>
<div class="stat-label">현재 총 인원</div>
</div>
<div class="stat-card">
<div class="stat-value text-blue-600" id="statTbm">0</div>
<div class="stat-label">TBM (세션 기준)*</div>
</div>
<div class="stat-card">
<div class="stat-value text-green-600" id="statPartner">0</div>
<div class="stat-label">협력업체</div>
</div>
<div class="stat-card">
<div class="stat-value text-amber-600" id="statExternal">0</div>
<div class="stat-label">외부 방문자</div>
</div>
<div class="stat-card">
<div class="stat-value text-purple-600" id="statInternal">0</div>
<div class="stat-label">내부 직원</div>
</div>
</div>
<!-- 소스별 탭 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<div class="flex gap-1 border-b mb-4">
<button onclick="filterSource('')" class="source-tab px-4 py-2 text-sm font-medium border-b-2 active" data-source="">전체</button>
<button onclick="filterSource('tbm')" class="source-tab px-4 py-2 text-sm font-medium border-b-2" data-source="tbm">TBM</button>
<button onclick="filterSource('partner')" class="source-tab px-4 py-2 text-sm font-medium border-b-2" data-source="partner">협력업체</button>
<button onclick="filterSource('visit')" class="source-tab px-4 py-2 text-sm font-medium border-b-2" data-source="visit">방문(외부+내부)</button>
</div>
<div class="overflow-x-auto">
<table class="visit-table">
<thead>
<tr>
<th>소스</th>
<th>이름</th>
<th>소속</th>
<th>작업장</th>
<th>입장시간</th>
<th>퇴장시간</th>
<th>목적</th>
<th>상태</th>
<th class="hide-mobile">비고</th>
</tr>
</thead>
<tbody id="dashboardBody">
<tr><td colspan="9" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-entry-dashboard.js?v=1"></script>
<script>initEntryDashboard();</script>
</body>
</html>

View File

@@ -274,7 +274,7 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js?v=20260313"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-visit.js?v=20260313"></script> <script src="/static/js/tksafety-visit.js?v=20260313"></script>
<script>initVisitPage();</script> <script>initVisitPage();</script>
</body> </body>

View File

@@ -87,6 +87,7 @@ function renderNavbar() {
{ href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['index.html'] }, { href: '/', icon: 'fa-door-open', label: '방문 관리', match: ['index.html'] },
{ href: '/visit-request.html', icon: 'fa-file-signature', label: '출입 신청', match: ['visit-request.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: '/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: '/education.html', icon: 'fa-graduation-cap', label: '안전교육', match: ['education.html'] },
{ href: '/training.html', icon: 'fa-chalkboard-teacher', label: '안전교육 실시', match: ['training.html'], admin: true }, { 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 }, { href: '/checklist.html', icon: 'fa-tasks', label: '체크리스트 관리', match: ['checklist.html'], admin: true },

View File

@@ -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 `<span class="badge ${cls}">${label}</span>`;
}
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 `<span class="badge ${cls}">${label}</span>`;
}
/* ===== 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 = '<tr><td colspan="9" class="text-center text-gray-400 py-8">데이터가 없습니다</td></tr>';
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 ? `<span class="text-xs text-gray-500 italic">${escapeHtml(r.source_note)}</span>` : '';
const count = r.visitor_count > 1 ? ` <span class="text-xs text-gray-400">(${r.visitor_count}명)</span>` : '';
return `<tr>
<td>${sourceBadge(r.source)}</td>
<td>${name}${count}</td>
<td>${org}</td>
<td>${workplace}</td>
<td>${inTime}</td>
<td>${outTime}</td>
<td>${purpose}</td>
<td>${entryStatusBadge(r.status)}</td>
<td class="hide-mobile">${note}</td>
</tr>`;
}).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();
}

View File

@@ -8,12 +8,20 @@ function vrStatusBadge(s) {
pending: ['badge-amber', '대기중'], pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'], approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'], 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]; const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`; return `<span class="badge ${cls}">${label}</span>`;
} }
function requestTypeBadge(t) {
return t === 'internal'
? '<span class="badge badge-blue">내부</span>'
: '<span class="badge badge-amber">외부</span>';
}
/* ===== Load requests ===== */ /* ===== Load requests ===== */
async function loadRequests() { async function loadRequests() {
try { try {
@@ -21,9 +29,11 @@ async function loadRequests() {
const status = document.getElementById('filterStatus').value; const status = document.getElementById('filterStatus').value;
const dateFrom = document.getElementById('filterDateFrom').value; const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value; const dateTo = document.getElementById('filterDateTo').value;
const type = document.getElementById('filterType').value;
if (status) params.set('status', status); if (status) params.set('status', status);
if (dateFrom) params.set('start_date', dateFrom); if (dateFrom) params.set('start_date', dateFrom);
if (dateTo) params.set('end_date', dateTo); if (dateTo) params.set('end_date', dateTo);
if (type) params.set('request_type', type);
const res = await api('/visit-requests/requests?' + params.toString()); const res = await api('/visit-requests/requests?' + params.toString());
allRequests = res.data || []; allRequests = res.data || [];
@@ -35,12 +45,14 @@ async function loadRequests() {
} }
function renderStats() { 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]++; }); allRequests.forEach(r => { if (counts[r.status] !== undefined) counts[r.status]++; });
document.getElementById('statPending').textContent = counts.pending; document.getElementById('statPending').textContent = counts.pending;
document.getElementById('statApproved').textContent = counts.approved; document.getElementById('statApproved').textContent = counts.approved;
document.getElementById('statRejected').textContent = counts.rejected; document.getElementById('statRejected').textContent = counts.rejected;
document.getElementById('statTrainingDone').textContent = counts.training_completed; document.getElementById('statTrainingDone').textContent = counts.training_completed;
document.getElementById('statCheckedIn').textContent = counts.checked_in;
document.getElementById('statCheckedOut').textContent = counts.checked_out;
} }
function renderRequestsTable() { function renderRequestsTable() {
@@ -51,6 +63,8 @@ function renderRequestsTable() {
} }
tbody.innerHTML = allRequests.map(r => { tbody.innerHTML = allRequests.map(r => {
let actions = ''; let actions = '';
// 승인/반려 (pending만)
if (r.status === 'pending') { if (r.status === 'pending') {
actions = ` actions = `
<button onclick="openApproveModal(${r.request_id})" class="text-green-600 hover:text-green-800 text-xs px-2 py-1 border border-green-200 rounded hover:bg-green-50" title="승인"> <button onclick="openApproveModal(${r.request_id})" class="text-green-600 hover:text-green-800 text-xs px-2 py-1 border border-green-200 rounded hover:bg-green-50" title="승인">
@@ -60,15 +74,32 @@ function renderRequestsTable() {
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button>`; </button>`;
} }
// 체크인 버튼 (approved 또는 training_completed)
const canCheckIn = (r.request_type === 'internal' && r.status === 'approved') ||
(['approved', 'training_completed'].includes(r.status));
if (canCheckIn) {
actions += ` <button onclick="doCheckIn(${r.request_id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50" title="체크인"><i class="fas fa-sign-in-alt"></i></button>`;
}
// 체크아웃 버튼 (checked_in)
if (r.status === 'checked_in') {
actions += ` <button onclick="doCheckOut(${r.request_id})" class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50" title="체크아웃"><i class="fas fa-sign-out-alt"></i></button>`;
}
actions += ` <button onclick="openDetailModal(${r.request_id})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="상세"><i class="fas fa-eye"></i></button>`; actions += ` <button onclick="openDetailModal(${r.request_id})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="상세"><i class="fas fa-eye"></i></button>`;
if (r.status === 'pending') { if (r.status === 'pending') {
actions += ` <button onclick="doDeleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>`; actions += ` <button onclick="doDeleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>`;
} }
const displayName = r.request_type === 'internal'
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
: escapeHtml(r.visitor_company);
return `<tr> return `<tr>
<td>${formatDate(r.created_at)}</td> <td>${requestTypeBadge(r.request_type)}</td>
<td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td> <td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td>
<td>${escapeHtml(r.visitor_company)}</td> <td>${displayName}</td>
<td class="text-center">${r.visitor_count}</td> <td class="text-center">${r.visitor_count}</td>
<td>${escapeHtml(r.workplace_name || '-')}</td> <td>${escapeHtml(r.workplace_name || '-')}</td>
<td>${formatDate(r.visit_date)}</td> <td>${formatDate(r.visit_date)}</td>
@@ -80,13 +111,40 @@ function renderRequestsTable() {
}).join(''); }).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 ===== */ /* ===== Approve Modal ===== */
function openApproveModal(id) { function openApproveModal(id) {
const r = allRequests.find(x => x.request_id === id); const r = allRequests.find(x => x.request_id === id);
if (!r) return; if (!r) return;
actionRequestId = id; actionRequestId = id;
const displayName = r.request_type === 'internal'
? escapeHtml(r.visitor_name || r.requester_full_name || '-')
: escapeHtml(r.visitor_company);
document.getElementById('approveDetail').innerHTML = ` document.getElementById('approveDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p> <p><strong>유형:</strong> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</p>
<p><strong>업체/이름:</strong> ${displayName}</p>
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p> <p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p> <p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
<p><strong>인원:</strong> ${r.visitor_count}명</p> <p><strong>인원:</strong> ${r.visitor_count}명</p>
@@ -120,7 +178,7 @@ function openRejectModal(id) {
if (!r) return; if (!r) return;
actionRequestId = id; actionRequestId = id;
document.getElementById('rejectDetail').innerHTML = ` document.getElementById('rejectDetail').innerHTML = `
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</p> <p><strong>업체/이름:</strong> ${escapeHtml(r.visitor_company || r.visitor_name || '-')}</p>
<p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p> <p><strong>방문일:</strong> ${formatDate(r.visit_date)} ${r.visit_time ? String(r.visit_time).substring(0, 5) : ''}</p>
<p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p> <p><strong>작업장:</strong> ${escapeHtml(r.workplace_name || '-')}</p>
`; `;
@@ -158,8 +216,10 @@ function openDetailModal(id) {
if (!r) return; if (!r) return;
document.getElementById('detailContent').innerHTML = ` document.getElementById('detailContent').innerHTML = `
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><span class="text-gray-500">유형:</span> ${r.request_type === 'internal' ? '내부 출입' : '외부 방문'}</div>
<div><span class="text-gray-500">신청자:</span> <span class="font-medium">${escapeHtml(r.requester_full_name || r.requester_name || '-')}</span></div> <div><span class="text-gray-500">신청자:</span> <span class="font-medium">${escapeHtml(r.requester_full_name || r.requester_name || '-')}</span></div>
<div><span class="text-gray-500">업체:</span> <span class="font-medium">${escapeHtml(r.visitor_company)}</span></div> <div><span class="text-gray-500">업체:</span> <span class="font-medium">${escapeHtml(r.visitor_company || '-')}</span></div>
<div><span class="text-gray-500">방문자:</span> <span class="font-medium">${escapeHtml(r.visitor_name || '-')}</span></div>
<div><span class="text-gray-500">인원:</span> <span class="font-medium">${r.visitor_count}명</span></div> <div><span class="text-gray-500">인원:</span> <span class="font-medium">${r.visitor_count}명</span></div>
<div><span class="text-gray-500">분류:</span> <span class="font-medium">${escapeHtml(r.category_name || '-')}</span></div> <div><span class="text-gray-500">분류:</span> <span class="font-medium">${escapeHtml(r.category_name || '-')}</span></div>
<div><span class="text-gray-500">작업장:</span> <span class="font-medium">${escapeHtml(r.workplace_name || '-')}</span></div> <div><span class="text-gray-500">작업장:</span> <span class="font-medium">${escapeHtml(r.workplace_name || '-')}</span></div>
@@ -168,6 +228,9 @@ function openDetailModal(id) {
<div><span class="text-gray-500">목적:</span> <span class="font-medium">${escapeHtml(r.purpose_name || '-')}</span></div> <div><span class="text-gray-500">목적:</span> <span class="font-medium">${escapeHtml(r.purpose_name || '-')}</span></div>
<div><span class="text-gray-500">상태:</span> ${vrStatusBadge(r.status)}</div> <div><span class="text-gray-500">상태:</span> ${vrStatusBadge(r.status)}</div>
<div><span class="text-gray-500">신청일:</span> <span class="font-medium">${formatDateTime(r.created_at)}</span></div> <div><span class="text-gray-500">신청일:</span> <span class="font-medium">${formatDateTime(r.created_at)}</span></div>
${r.check_in_time ? `<div><span class="text-gray-500">체크인:</span> <span class="font-medium">${formatDateTime(r.check_in_time)}</span></div>` : ''}
${r.check_out_time ? `<div><span class="text-gray-500">체크아웃:</span> <span class="font-medium">${formatDateTime(r.check_out_time)}</span></div>` : ''}
${r.department_name ? `<div><span class="text-gray-500">부서:</span> <span class="font-medium">${escapeHtml(r.department_name)}</span></div>` : ''}
${r.approver_name ? `<div><span class="text-gray-500">처리자:</span> <span class="font-medium">${escapeHtml(r.approver_name)}</span></div>` : ''} ${r.approver_name ? `<div><span class="text-gray-500">처리자:</span> <span class="font-medium">${escapeHtml(r.approver_name)}</span></div>` : ''}
${r.approved_at ? `<div><span class="text-gray-500">처리일:</span> <span class="font-medium">${formatDateTime(r.approved_at)}</span></div>` : ''} ${r.approved_at ? `<div><span class="text-gray-500">처리일:</span> <span class="font-medium">${formatDateTime(r.approved_at)}</span></div>` : ''}
${r.rejection_reason ? `<div class="col-span-2"><span class="text-gray-500">반려사유:</span> <span class="font-medium text-red-600">${escapeHtml(r.rejection_reason)}</span></div>` : ''} ${r.rejection_reason ? `<div class="col-span-2"><span class="text-gray-500">반려사유:</span> <span class="font-medium text-red-600">${escapeHtml(r.rejection_reason)}</span></div>` : ''}
@@ -209,6 +272,7 @@ function initVisitManagementPage() {
} }
document.getElementById('filterStatus').addEventListener('change', loadRequests); document.getElementById('filterStatus').addEventListener('change', loadRequests);
document.getElementById('filterType').addEventListener('change', loadRequests);
document.getElementById('filterDateFrom').addEventListener('change', loadRequests); document.getElementById('filterDateFrom').addEventListener('change', loadRequests);
document.getElementById('filterDateTo').addEventListener('change', loadRequests); document.getElementById('filterDateTo').addEventListener('change', loadRequests);

View File

@@ -3,6 +3,7 @@ let myRequests = [];
let categories = []; let categories = [];
let workplaces = []; let workplaces = [];
let purposes = []; let purposes = [];
let departments = [];
/* ===== Status badge for visit requests ===== */ /* ===== Status badge for visit requests ===== */
function vrStatusBadge(s) { function vrStatusBadge(s) {
@@ -10,21 +11,40 @@ function vrStatusBadge(s) {
pending: ['badge-amber', '대기중'], pending: ['badge-amber', '대기중'],
approved: ['badge-green', '승인됨'], approved: ['badge-green', '승인됨'],
rejected: ['badge-red', '반려됨'], 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]; const [cls, label] = m[s] || ['badge-gray', s];
return `<span class="badge ${cls}">${label}</span>`; return `<span class="badge ${cls}">${label}</span>`;
} }
/* ===== Load form data (purposes, categories) ===== */ function requestTypeBadge(t) {
return t === 'internal'
? '<span class="badge badge-blue">내부</span>'
: '<span class="badge badge-amber">외부</span>';
}
/* ===== 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() { async function loadFormData() {
try { try {
const [purposeRes, categoryRes] = await Promise.all([ const [purposeRes, categoryRes, deptRes] = await Promise.all([
api('/visit-requests/purposes/active'), api('/visit-requests/purposes/active'),
api('/visit-requests/categories') api('/visit-requests/categories'),
api('/visit-requests/departments')
]); ]);
purposes = purposeRes.data || []; purposes = purposeRes.data || [];
categories = categoryRes.data || []; categories = categoryRes.data || [];
departments = deptRes.data || [];
const purposeSelect = document.getElementById('purposeId'); const purposeSelect = document.getElementById('purposeId');
purposeSelect.innerHTML = '<option value="">선택</option>' + purposeSelect.innerHTML = '<option value="">선택</option>' +
@@ -33,6 +53,10 @@ async function loadFormData() {
const categorySelect = document.getElementById('categoryId'); const categorySelect = document.getElementById('categoryId');
categorySelect.innerHTML = '<option value="">선택</option>' + categorySelect.innerHTML = '<option value="">선택</option>' +
categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join(''); categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join('');
const deptSelect = document.getElementById('departmentId');
deptSelect.innerHTML = '<option value="">선택 (선택사항)</option>' +
departments.map(d => `<option value="${d.department_id}">${escapeHtml(d.name)}</option>`).join('');
} catch (e) { } catch (e) {
showToast('폼 데이터 로드 실패: ' + e.message, 'error'); showToast('폼 데이터 로드 실패: ' + e.message, 'error');
} }
@@ -76,29 +100,74 @@ function renderMyRequests() {
} }
tbody.innerHTML = myRequests.map(r => { tbody.innerHTML = myRequests.map(r => {
const canDelete = r.status === 'pending'; 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 = `<button onclick="doCheckIn(${r.request_id})" class="text-blue-600 hover:text-blue-800 text-xs px-2 py-1 border border-blue-200 rounded hover:bg-blue-50" title="체크인"><i class="fas fa-sign-in-alt"></i></button>`;
}
if (r.status === 'checked_in') {
checkBtn = `<button onclick="doCheckOut(${r.request_id})" class="text-gray-600 hover:text-gray-800 text-xs px-2 py-1 border border-gray-200 rounded hover:bg-gray-50" title="체크아웃"><i class="fas fa-sign-out-alt"></i></button>`;
}
return `<tr> return `<tr>
<td>${formatDate(r.created_at)}</td> <td>${requestTypeBadge(r.request_type)}</td>
<td>${escapeHtml(r.visitor_company)}</td> <td>${displayName}</td>
<td class="text-center">${r.visitor_count}</td> <td class="text-center">${r.visitor_count}</td>
<td>${escapeHtml(r.workplace_name || '-')}</td> <td>${escapeHtml(r.workplace_name || '-')}</td>
<td>${formatDate(r.visit_date)}</td> <td>${formatDate(r.visit_date)}</td>
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td> <td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
<td>${escapeHtml(r.purpose_name || '-')}</td> <td>${escapeHtml(r.purpose_name || '-')}</td>
<td>${vrStatusBadge(r.status)}</td> <td>${vrStatusBadge(r.status)}</td>
<td class="text-right"> <td class="text-right whitespace-nowrap">
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs" title="삭제"><i class="fas fa-trash"></i></button>` : ''} ${checkBtn}
${canDelete ? `<button onclick="deleteRequest(${r.request_id})" class="text-gray-400 hover:text-red-500 text-xs ml-1" title="삭제"><i class="fas fa-trash"></i></button>` : ''}
${r.status === 'rejected' && r.rejection_reason ? `<button onclick="alert('반려 사유: ' + ${JSON.stringify(r.rejection_reason)})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="반려사유"><i class="fas fa-info-circle"></i></button>` : ''} ${r.status === 'rejected' && r.rejection_reason ? `<button onclick="alert('반려 사유: ' + ${JSON.stringify(r.rejection_reason)})" class="text-gray-400 hover:text-gray-600 text-xs ml-1" title="반려사유"><i class="fas fa-info-circle"></i></button>` : ''}
</td> </td>
</tr>`; </tr>`;
}).join(''); }).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 ===== */ /* ===== Submit request ===== */
async function submitRequest(e) { async function submitRequest(e) {
e.preventDefault(); e.preventDefault();
const requestType = document.querySelector('input[name="requestType"]:checked')?.value || 'external';
const isInternal = requestType === 'internal';
const data = { const data = {
visitor_company: document.getElementById('visitorCompany').value.trim(), request_type: requestType,
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1, 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, category_id: parseInt(document.getElementById('categoryId').value) || null,
workplace_id: parseInt(document.getElementById('workplaceId').value) || null, workplace_id: parseInt(document.getElementById('workplaceId').value) || null,
visit_date: document.getElementById('visitDate').value, visit_date: document.getElementById('visitDate').value,
@@ -107,7 +176,11 @@ async function submitRequest(e) {
notes: document.getElementById('notes').value.trim() || null 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.category_id) { showToast('작업장 분류를 선택해주세요', 'error'); return; }
if (!data.workplace_id) { showToast('작업장을 선택해주세요', 'error'); return; } if (!data.workplace_id) { showToast('작업장을 선택해주세요', 'error'); return; }
if (!data.visit_date) { showToast('방문일을 선택해주세요', 'error'); return; } if (!data.visit_date) { showToast('방문일을 선택해주세요', 'error'); return; }
@@ -116,10 +189,11 @@ async function submitRequest(e) {
try { try {
await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) }); await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) });
showToast('출입 신청이 완료되었습니다'); showToast(isInternal ? '내부 출입 신고가 완료되었습니다' : '출입 신청이 완료되었습니다');
document.getElementById('visitRequestForm').reset(); document.getElementById('visitRequestForm').reset();
document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>'; document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
document.getElementById('visitorCount').value = '1'; document.getElementById('visitorCount').value = '1';
toggleRequestType();
await loadMyRequests(); await loadMyRequests();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -146,6 +220,12 @@ function initVisitRequestPage() {
const today = new Date().toISOString().substring(0, 10); const today = new Date().toISOString().substring(0, 10);
document.getElementById('visitDate').value = today; document.getElementById('visitDate').value = today;
// Request type toggle
document.querySelectorAll('input[name="requestType"]').forEach(r => {
r.addEventListener('change', toggleRequestType);
});
toggleRequestType();
// Category change -> load workplaces // Category change -> load workplaces
document.getElementById('categoryId').addEventListener('change', function() { document.getElementById('categoryId').addEventListener('change', function() {
loadWorkplaces(this.value); loadWorkplaces(this.value);

View File

@@ -133,7 +133,7 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-training.js"></script> <script src="/static/js/tksafety-training.js"></script>
<script>initTrainingPage();</script> <script>initTrainingPage();</script>
</body> </body>

View File

@@ -34,7 +34,7 @@
<!-- Main --> <!-- Main -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<!-- 통계 카드 --> <!-- 통계 카드 -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5"> <div class="grid grid-cols-3 sm:grid-cols-6 gap-3 mb-5">
<div class="stat-card"> <div class="stat-card">
<div class="stat-value text-amber-600" id="statPending">0</div> <div class="stat-value text-amber-600" id="statPending">0</div>
<div class="stat-label">대기중</div> <div class="stat-label">대기중</div>
@@ -51,6 +51,14 @@
<div class="stat-value text-blue-600" id="statTrainingDone">0</div> <div class="stat-value text-blue-600" id="statTrainingDone">0</div>
<div class="stat-label">교육완료</div> <div class="stat-label">교육완료</div>
</div> </div>
<div class="stat-card">
<div class="stat-value text-blue-600" id="statCheckedIn">0</div>
<div class="stat-label">체크인</div>
</div>
<div class="stat-card">
<div class="stat-value text-gray-600" id="statCheckedOut">0</div>
<div class="stat-label">체크아웃</div>
</div>
</div> </div>
<!-- 필터 --> <!-- 필터 -->
@@ -59,6 +67,14 @@
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-clipboard-check text-blue-500 mr-2"></i>출입 신청 관리</h2> <h2 class="text-base font-semibold text-gray-800"><i class="fas fa-clipboard-check text-blue-500 mr-2"></i>출입 신청 관리</h2>
</div> </div>
<div class="flex flex-wrap items-end gap-3"> <div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">출입유형</label>
<select id="filterType" class="input-field px-3 py-2 rounded-lg text-sm">
<option value="">전체</option>
<option value="external">외부 방문</option>
<option value="internal">내부 출입</option>
</select>
</div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label> <label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm"> <select id="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
@@ -67,6 +83,8 @@
<option value="approved">승인됨</option> <option value="approved">승인됨</option>
<option value="rejected">반려됨</option> <option value="rejected">반려됨</option>
<option value="training_completed">교육완료</option> <option value="training_completed">교육완료</option>
<option value="checked_in">체크인</option>
<option value="checked_out">체크아웃</option>
</select> </select>
</div> </div>
<div> <div>
@@ -89,9 +107,9 @@
<table class="visit-table"> <table class="visit-table">
<thead> <thead>
<tr> <tr>
<th>신청일</th> <th>유형</th>
<th>신청자</th> <th>신청자</th>
<th>업체</th> <th>업체/이름</th>
<th class="text-center">인원</th> <th class="text-center">인원</th>
<th>작업장</th> <th>작업장</th>
<th>방문일</th> <th>방문일</th>
@@ -163,8 +181,8 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-visit-management.js"></script> <script src="/static/js/tksafety-visit-management.js?v=2"></script>
<script>initVisitManagementPage();</script> <script>initVisitManagementPage();</script>
</body> </body>
</html> </html>

View File

@@ -38,16 +38,40 @@
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-file-signature text-blue-500 mr-2"></i>출입 신청</h2> <h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-file-signature text-blue-500 mr-2"></i>출입 신청</h2>
<form id="visitRequestForm"> <form id="visitRequestForm">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<!-- 업체명 --> <!-- 출입 유형 -->
<div> <div class="sm:col-span-2 lg:col-span-3">
<label class="block text-xs font-medium text-gray-600 mb-1">업체명 <span class="text-red-400">*</span></label> <label class="block text-xs font-medium text-gray-600 mb-1">출입 유형</label>
<input type="text" id="visitorCompany" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="방문 업체명" required> <div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="requestType" value="external" checked class="text-blue-600"> <span class="text-sm">외부 방문</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="requestType" value="internal" class="text-blue-600"> <span class="text-sm">내부 출입</span>
</label>
</div>
</div> </div>
<!-- 인원 --> <!-- 업체명 (외부) -->
<div> <div id="companyField">
<label class="block text-xs font-medium text-gray-600 mb-1">업체명 <span class="text-red-400">*</span></label>
<input type="text" id="visitorCompany" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="방문 업체명">
</div>
<!-- 방문자 이름 -->
<div id="visitorNameField">
<label class="block text-xs font-medium text-gray-600 mb-1">방문자 이름 <span id="visitorNameRequired" class="text-red-400 hidden">*</span></label>
<input type="text" id="visitorName" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="방문자 이름">
</div>
<!-- 인원 (외부만) -->
<div id="countField">
<label class="block text-xs font-medium text-gray-600 mb-1">방문 인원</label> <label class="block text-xs font-medium text-gray-600 mb-1">방문 인원</label>
<input type="number" id="visitorCount" value="1" min="1" class="input-field w-full px-3 py-2 rounded-lg text-sm"> <input type="number" id="visitorCount" value="1" min="1" class="input-field w-full px-3 py-2 rounded-lg text-sm">
</div> </div>
<!-- 부서 (내부만) -->
<div id="departmentField" class="hidden">
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="departmentId" class="input-field w-full px-3 py-2 rounded-lg text-sm">
<option value="">선택 (선택사항)</option>
</select>
</div>
<!-- 작업장 분류 --> <!-- 작업장 분류 -->
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업장 분류 <span class="text-red-400">*</span></label> <label class="block text-xs font-medium text-gray-600 mb-1">작업장 분류 <span class="text-red-400">*</span></label>
@@ -100,8 +124,8 @@
<table class="visit-table"> <table class="visit-table">
<thead> <thead>
<tr> <tr>
<th>신청일</th> <th>유형</th>
<th>업체</th> <th>업체/이름</th>
<th class="text-center">인원</th> <th class="text-center">인원</th>
<th>작업장</th> <th>작업장</th>
<th>방문일</th> <th>방문일</th>
@@ -121,8 +145,8 @@
</div> </div>
</div> </div>
<script src="/static/js/tksafety-core.js"></script> <script src="/static/js/tksafety-core.js?v=2"></script>
<script src="/static/js/tksafety-visit-request.js"></script> <script src="/static/js/tksafety-visit-request.js?v=2"></script>
<script>initVisitRequestPage();</script> <script>initVisitRequestPage();</script>
</body> </body>
</html> </html>

View File

@@ -63,6 +63,7 @@ const DEFAULT_PAGES = {
'safety_visit_management': { title: '출입 관리', system: 'tksafety', group: '안전 관리', default_access: false }, 'safety_visit_management': { title: '출입 관리', system: 'tksafety', group: '안전 관리', default_access: false },
'safety_training': { 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_checklist': { title: '체크리스트 관리', system: 'tksafety', group: '안전 관리', default_access: false },
'safety_entry_dashboard': { title: '출입 현황판', system: 'tksafety', group: '안전 관리', default_access: false },
// ===== tkuser - 통합 관리 ===== // ===== tkuser - 통합 관리 =====
'tkuser.users': { title: '사용자 관리', system: 'tkuser', group: '통합 관리', default_access: false }, 'tkuser.users': { title: '사용자 관리', system: 'tkuser', group: '통합 관리', default_access: false },