feat: 안전 코드 tksafety 이관 + 사용자 관리 정리 + UI Tailwind 전환
Phase 1: tksafety에 출입신청/체크리스트 API·웹 추가, tkfb 안전 코드 삭제
Phase 2: 사용자 관리 페이지 삭제, API 축소, 알림 수신자 tkuser 이관
Phase 3: tkuser 권한 페이지 정의 업데이트
Phase 4: 전체 34개 페이지 Tailwind CSS + tkfb-core.js 전환,
미사용 CSS 20개·인프라 JS 10개·템플릿·컴포넌트 삭제
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
85
tksafety/api/controllers/checklistController.js
Normal file
85
tksafety/api/controllers/checklistController.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const checklistModel = require('../models/checklistModel');
|
||||
|
||||
exports.getAllChecks = async (req, res) => {
|
||||
try {
|
||||
const checks = await checklistModel.getAllChecks();
|
||||
res.json({ success: true, data: checks });
|
||||
} catch (err) {
|
||||
console.error('체크리스트 조회 오류:', err);
|
||||
res.status(500).json({ success: false, error: '체크리스트 조회 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCheckById = async (req, res) => {
|
||||
try {
|
||||
const check = await checklistModel.getCheckById(req.params.id);
|
||||
if (!check) return res.status(404).json({ success: false, error: '항목을 찾을 수 없습니다' });
|
||||
res.json({ success: true, data: check });
|
||||
} catch (err) {
|
||||
console.error('체크리스트 항목 조회 오류:', err);
|
||||
res.status(500).json({ success: false, error: '조회 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.createCheck = async (req, res) => {
|
||||
try {
|
||||
if (!req.body.check_item) return res.status(400).json({ success: false, error: 'check_item은 필수입니다' });
|
||||
const checkId = await checklistModel.createCheck(req.body);
|
||||
res.status(201).json({ success: true, message: '항목이 추가되었습니다', data: { check_id: checkId } });
|
||||
} catch (err) {
|
||||
console.error('체크리스트 항목 추가 오류:', err);
|
||||
res.status(500).json({ success: false, error: '추가 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateCheck = async (req, res) => {
|
||||
try {
|
||||
const result = await checklistModel.updateCheck(req.params.id, req.body);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '항목을 찾을 수 없습니다' });
|
||||
res.json({ success: true, message: '항목이 수정되었습니다' });
|
||||
} catch (err) {
|
||||
console.error('체크리스트 항목 수정 오류:', err);
|
||||
res.status(500).json({ success: false, error: '수정 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteCheck = async (req, res) => {
|
||||
try {
|
||||
const result = await checklistModel.deleteCheck(req.params.id);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ success: false, error: '항목을 찾을 수 없습니다' });
|
||||
res.json({ success: true, message: '항목이 삭제되었습니다' });
|
||||
} catch (err) {
|
||||
console.error('체크리스트 항목 삭제 오류:', err);
|
||||
res.status(500).json({ success: false, error: '삭제 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getWeatherConditions = async (req, res) => {
|
||||
try {
|
||||
const conditions = await checklistModel.getWeatherConditions();
|
||||
res.json({ success: true, data: conditions });
|
||||
} catch (err) {
|
||||
console.error('날씨 조건 조회 오류:', err);
|
||||
res.status(500).json({ success: false, error: '조회 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getWorkTypes = async (req, res) => {
|
||||
try {
|
||||
const types = await checklistModel.getWorkTypes();
|
||||
res.json({ success: true, data: types });
|
||||
} catch (err) {
|
||||
console.error('공정 조회 오류:', err);
|
||||
res.status(500).json({ success: false, error: '조회 실패' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTasksByWorkType = async (req, res) => {
|
||||
try {
|
||||
const tasks = await checklistModel.getTasksByWorkType(req.params.workTypeId);
|
||||
res.json({ success: true, data: tasks });
|
||||
} catch (err) {
|
||||
console.error('작업 목록 조회 오류:', err);
|
||||
res.status(500).json({ success: false, error: '조회 실패' });
|
||||
}
|
||||
};
|
||||
305
tksafety/api/controllers/visitRequestController.js
Normal file
305
tksafety/api/controllers/visitRequestController.js
Normal file
@@ -0,0 +1,305 @@
|
||||
const visitRequestModel = require('../models/visitRequestModel');
|
||||
|
||||
// ==================== 출입 신청 관리 ====================
|
||||
|
||||
exports.createVisitRequest = async (req, res) => {
|
||||
try {
|
||||
const requester_id = req.user.user_id;
|
||||
const requestData = { requester_id, ...req.body };
|
||||
|
||||
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: '출입 신청이 성공적으로 생성되었습니다.',
|
||||
data: { request_id: requestId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('출입 신청 생성 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 생성 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAllVisitRequests = async (req, res) => {
|
||||
try {
|
||||
const filters = {
|
||||
status: req.query.status,
|
||||
visit_date: req.query.visit_date,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
requester_id: req.query.requester_id,
|
||||
category_id: req.query.category_id
|
||||
};
|
||||
|
||||
const requests = await visitRequestModel.getAllVisitRequests(filters);
|
||||
res.json({ success: true, data: requests });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 목록 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getVisitRequestById = async (req, res) => {
|
||||
try {
|
||||
const request = await visitRequestModel.getVisitRequestById(req.params.id);
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, data: request });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateVisitRequest = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.updateVisitRequest(req.params.id, req.body);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '출입 신청이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 수정 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 수정 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteVisitRequest = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.deleteVisitRequest(req.params.id);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '출입 신청이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 삭제 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 삭제 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.approveVisitRequest = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.approveVisitRequest(req.params.id, req.user.user_id);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '출입 신청이 승인되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 승인 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 승인 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.rejectVisitRequest = async (req, res) => {
|
||||
try {
|
||||
const rejectionData = {
|
||||
approved_by: req.user.user_id,
|
||||
rejection_reason: req.body.rejection_reason || '사유 없음'
|
||||
};
|
||||
const result = await visitRequestModel.rejectVisitRequest(req.params.id, rejectionData);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '출입 신청을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '출입 신청이 반려되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('출입 신청 반려 오류:', err);
|
||||
res.status(500).json({ success: false, message: '출입 신청 반려 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 방문 목적 관리 ====================
|
||||
|
||||
exports.getAllVisitPurposes = async (req, res) => {
|
||||
try {
|
||||
const purposes = await visitRequestModel.getAllVisitPurposes();
|
||||
res.json({ success: true, data: purposes });
|
||||
} catch (err) {
|
||||
console.error('방문 목적 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '방문 목적 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getActiveVisitPurposes = async (req, res) => {
|
||||
try {
|
||||
const purposes = await visitRequestModel.getActiveVisitPurposes();
|
||||
res.json({ success: true, data: purposes });
|
||||
} catch (err) {
|
||||
console.error('활성 방문 목적 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '활성 방문 목적 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.createVisitPurpose = async (req, res) => {
|
||||
try {
|
||||
if (!req.body.purpose_name) {
|
||||
return res.status(400).json({ success: false, message: 'purpose_name은 필수 입력 항목입니다.' });
|
||||
}
|
||||
const purposeId = await visitRequestModel.createVisitPurpose(req.body);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '방문 목적이 추가되었습니다.',
|
||||
data: { purpose_id: purposeId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('방문 목적 추가 오류:', err);
|
||||
res.status(500).json({ success: false, message: '방문 목적 추가 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateVisitPurpose = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.updateVisitPurpose(req.params.id, req.body);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '방문 목적을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '방문 목적이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('방문 목적 수정 오류:', err);
|
||||
res.status(500).json({ success: false, message: '방문 목적 수정 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteVisitPurpose = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.deleteVisitPurpose(req.params.id);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '방문 목적을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '방문 목적이 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('방문 목적 삭제 오류:', err);
|
||||
res.status(500).json({ success: false, message: '방문 목적 삭제 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 안전교육 기록 관리 ====================
|
||||
|
||||
exports.createTrainingRecord = async (req, res) => {
|
||||
try {
|
||||
const trainingData = { trainer_id: req.user.user_id, ...req.body };
|
||||
|
||||
const requiredFields = ['request_id', 'training_date', 'training_start_time'];
|
||||
for (const field of requiredFields) {
|
||||
if (!trainingData[field]) {
|
||||
return res.status(400).json({ success: false, message: `${field}는 필수 입력 항목입니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const trainingId = await visitRequestModel.createTrainingRecord(trainingData);
|
||||
|
||||
// 안전교육 기록 생성 후 출입 신청 상태를 training_completed로 변경
|
||||
try {
|
||||
await visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed');
|
||||
} catch (statusErr) {
|
||||
console.error('출입 신청 상태 업데이트 오류:', statusErr);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '안전교육 기록이 생성되었습니다.',
|
||||
data: { training_id: trainingId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('안전교육 기록 생성 오류:', err);
|
||||
res.status(500).json({ success: false, message: '안전교육 기록 생성 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTrainingRecordByRequestId = async (req, res) => {
|
||||
try {
|
||||
const record = await visitRequestModel.getTrainingRecordByRequestId(req.params.requestId);
|
||||
res.json({ success: true, data: record || null });
|
||||
} catch (err) {
|
||||
console.error('안전교육 기록 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '안전교육 기록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateTrainingRecord = async (req, res) => {
|
||||
try {
|
||||
const result = await visitRequestModel.updateTrainingRecord(req.params.id, req.body);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '안전교육 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
res.json({ success: true, message: '안전교육 기록이 수정되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('안전교육 기록 수정 오류:', err);
|
||||
res.status(500).json({ success: false, message: '안전교육 기록 수정 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.completeTraining = async (req, res) => {
|
||||
try {
|
||||
const trainingId = req.params.id;
|
||||
const signatureData = req.body.signature_data;
|
||||
|
||||
if (!signatureData) {
|
||||
return res.status(400).json({ success: false, message: '서명 데이터가 필요합니다.' });
|
||||
}
|
||||
|
||||
const result = await visitRequestModel.completeTraining(trainingId, signatureData);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ success: false, message: '안전교육 기록을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
// 교육 완료 후 출입 신청 상태 변경
|
||||
try {
|
||||
const record = await visitRequestModel.getTrainingRecordByRequestId(trainingId);
|
||||
if (record) {
|
||||
await visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed');
|
||||
}
|
||||
} catch (statusErr) {
|
||||
console.error('출입 신청 상태 업데이트 오류:', statusErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '안전교육이 완료되었습니다.' });
|
||||
} catch (err) {
|
||||
console.error('안전교육 완료 처리 오류:', err);
|
||||
res.status(500).json({ success: false, message: '안전교육 완료 처리 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTrainingRecords = async (req, res) => {
|
||||
try {
|
||||
const filters = {
|
||||
training_date: req.query.training_date,
|
||||
start_date: req.query.start_date,
|
||||
end_date: req.query.end_date,
|
||||
trainer_id: req.query.trainer_id
|
||||
};
|
||||
const records = await visitRequestModel.getTrainingRecords(filters);
|
||||
res.json({ success: true, data: records });
|
||||
} catch (err) {
|
||||
console.error('안전교육 기록 목록 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 작업장 분류/작업장 조회 ====================
|
||||
|
||||
exports.getAllCategories = async (req, res) => {
|
||||
try {
|
||||
const categories = await visitRequestModel.getAllCategories();
|
||||
res.json({ success: true, data: categories });
|
||||
} catch (err) {
|
||||
console.error('작업장 분류 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '작업장 분류 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getWorkplaces = async (req, res) => {
|
||||
try {
|
||||
const workplaces = await visitRequestModel.getWorkplacesByCategory(req.query.category_id);
|
||||
res.json({ success: true, data: workplaces });
|
||||
} catch (err) {
|
||||
console.error('작업장 조회 오류:', err);
|
||||
res.status(500).json({ success: false, message: '작업장 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,8 @@ const cors = require('cors');
|
||||
const cron = require('node-cron');
|
||||
const dailyVisitRoutes = require('./routes/dailyVisitRoutes');
|
||||
const educationRoutes = require('./routes/educationRoutes');
|
||||
const visitRequestRoutes = require('./routes/visitRequestRoutes');
|
||||
const checklistRoutes = require('./routes/checklistRoutes');
|
||||
const dailyVisitModel = require('./models/dailyVisitModel');
|
||||
const { requireAuth } = require('./middleware/auth');
|
||||
|
||||
@@ -37,6 +39,8 @@ app.get('/health', (req, res) => {
|
||||
// Routes
|
||||
app.use('/api/daily-visits', dailyVisitRoutes);
|
||||
app.use('/api/education', educationRoutes);
|
||||
app.use('/api/visit-requests', visitRequestRoutes);
|
||||
app.use('/api/checklist', checklistRoutes);
|
||||
|
||||
// Partner search (autocomplete)
|
||||
app.get('/api/partners/search', requireAuth, async (req, res) => {
|
||||
|
||||
67
tksafety/api/models/checklistModel.js
Normal file
67
tksafety/api/models/checklistModel.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { getPool } = require('../middleware/auth');
|
||||
|
||||
// Get all safety checks
|
||||
async function getAllChecks() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM tbm_safety_checks ORDER BY check_type, check_category, display_order, check_id');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get check by ID
|
||||
async function getCheckById(checkId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM tbm_safety_checks WHERE check_id = ?', [checkId]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Create check
|
||||
async function createCheck(data) {
|
||||
const db = getPool();
|
||||
const { check_type, check_category, check_item, description, is_required, display_order, weather_condition, task_id } = data;
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO tbm_safety_checks (check_type, check_category, check_item, description, is_required, display_order, weather_condition, task_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[check_type, check_category || null, check_item, description || null, is_required ? 1 : 0, display_order || 0, weather_condition || null, task_id || null]
|
||||
);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
// Update check
|
||||
async function updateCheck(checkId, data) {
|
||||
const db = getPool();
|
||||
const { check_type, check_category, check_item, description, is_required, display_order, weather_condition, task_id } = data;
|
||||
const [result] = await db.query(
|
||||
'UPDATE tbm_safety_checks SET check_type = ?, check_category = ?, check_item = ?, description = ?, is_required = ?, display_order = ?, weather_condition = ?, task_id = ? WHERE check_id = ?',
|
||||
[check_type, check_category || null, check_item, description || null, is_required ? 1 : 0, display_order || 0, weather_condition || null, task_id || null, checkId]
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Delete check
|
||||
async function deleteCheck(checkId) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query('DELETE FROM tbm_safety_checks WHERE check_id = ?', [checkId]);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get weather conditions
|
||||
async function getWeatherConditions() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM tbm_weather_conditions ORDER BY display_order, condition_code');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get work types
|
||||
async function getWorkTypes() {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM work_types ORDER BY name');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get tasks by work type
|
||||
async function getTasksByWorkType(workTypeId) {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query('SELECT * FROM tasks WHERE work_type_id = ? ORDER BY task_name', [workTypeId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
module.exports = { getAllChecks, getCheckById, createCheck, updateCheck, deleteCheck, getWeatherConditions, getWorkTypes, getTasksByWorkType };
|
||||
366
tksafety/api/models/visitRequestModel.js
Normal file
366
tksafety/api/models/visitRequestModel.js
Normal file
@@ -0,0 +1,366 @@
|
||||
const { getPool } = require('../middleware/auth');
|
||||
|
||||
// ==================== 출입 신청 관리 ====================
|
||||
|
||||
const createVisitRequest = async (requestData) => {
|
||||
const db = getPool();
|
||||
const {
|
||||
requester_id,
|
||||
visitor_company,
|
||||
visitor_count = 1,
|
||||
category_id,
|
||||
workplace_id,
|
||||
visit_date,
|
||||
visit_time,
|
||||
purpose_id,
|
||||
notes = null
|
||||
} = requestData;
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
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.purpose_id, vr.notes, vr.status,
|
||||
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
|
||||
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
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
if (filters.status) {
|
||||
query += ` AND vr.status = ?`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
if (filters.visit_date) {
|
||||
query += ` AND vr.visit_date = ?`;
|
||||
params.push(filters.visit_date);
|
||||
}
|
||||
if (filters.start_date && filters.end_date) {
|
||||
query += ` AND vr.visit_date BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
if (filters.requester_id) {
|
||||
query += ` AND vr.requester_id = ?`;
|
||||
params.push(filters.requester_id);
|
||||
}
|
||||
if (filters.category_id) {
|
||||
query += ` AND vr.category_id = ?`;
|
||||
params.push(filters.category_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`;
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
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.purpose_id, vr.notes, vr.status,
|
||||
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
|
||||
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
|
||||
WHERE vr.request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
return rows[0];
|
||||
};
|
||||
|
||||
const updateVisitRequest = async (requestId, requestData) => {
|
||||
const db = getPool();
|
||||
const {
|
||||
visitor_company, visitor_count, category_id, workplace_id,
|
||||
visit_date, visit_time, purpose_id, notes
|
||||
} = requestData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET visitor_company = ?, visitor_count = ?, category_id = ?, workplace_id = ?,
|
||||
visit_date = ?, visit_time = ?, purpose_id = ?, notes = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[visitor_company, visitor_count, category_id, workplace_id,
|
||||
visit_date, visit_time, purpose_id, notes, requestId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const deleteVisitRequest = async (requestId) => {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM workplace_visit_requests WHERE request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const approveVisitRequest = async (requestId, approvedBy) => {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[approvedBy, requestId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const rejectVisitRequest = async (requestId, rejectionData) => {
|
||||
const db = getPool();
|
||||
const { approved_by, rejection_reason } = rejectionData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = 'rejected', approved_by = ?, approved_at = NOW(),
|
||||
rejection_reason = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[approved_by, rejection_reason, requestId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const updateVisitRequestStatus = async (requestId, status) => {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`UPDATE workplace_visit_requests
|
||||
SET status = ?, updated_at = NOW()
|
||||
WHERE request_id = ?`,
|
||||
[status, requestId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
// ==================== 방문 목적 관리 ====================
|
||||
|
||||
const getAllVisitPurposes = async () => {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
|
||||
FROM visit_purpose_types
|
||||
ORDER BY display_order ASC, purpose_id ASC`
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const getActiveVisitPurposes = async () => {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
|
||||
FROM visit_purpose_types
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order ASC, purpose_id ASC`
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const createVisitPurpose = async (purposeData) => {
|
||||
const db = getPool();
|
||||
const { purpose_name, display_order = 0, is_active = true } = purposeData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO visit_purpose_types (purpose_name, display_order, is_active)
|
||||
VALUES (?, ?, ?)`,
|
||||
[purpose_name, display_order, is_active]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const updateVisitPurpose = async (purposeId, purposeData) => {
|
||||
const db = getPool();
|
||||
const { purpose_name, display_order, is_active } = purposeData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE visit_purpose_types
|
||||
SET purpose_name = ?, display_order = ?, is_active = ?
|
||||
WHERE purpose_id = ?`,
|
||||
[purpose_name, display_order, is_active, purposeId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const deleteVisitPurpose = async (purposeId) => {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`DELETE FROM visit_purpose_types WHERE purpose_id = ?`,
|
||||
[purposeId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
// ==================== 안전교육 기록 관리 ====================
|
||||
|
||||
const createTrainingRecord = async (trainingData) => {
|
||||
const db = getPool();
|
||||
const {
|
||||
request_id, trainer_id, training_date,
|
||||
training_start_time, training_end_time = null, training_topics = null
|
||||
} = trainingData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO safety_training_records
|
||||
(request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics]
|
||||
);
|
||||
return result.insertId;
|
||||
};
|
||||
|
||||
const getTrainingRecordByRequestId = async (requestId) => {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
`SELECT
|
||||
str.training_id, str.request_id, str.trainer_id, str.training_date,
|
||||
str.training_start_time, str.training_end_time, str.training_topics,
|
||||
str.signature_data, str.completed_at, str.created_at, str.updated_at,
|
||||
u.username as trainer_name, u.name as trainer_full_name
|
||||
FROM safety_training_records str
|
||||
INNER JOIN users u ON str.trainer_id = u.user_id
|
||||
WHERE str.request_id = ?`,
|
||||
[requestId]
|
||||
);
|
||||
return rows[0];
|
||||
};
|
||||
|
||||
const updateTrainingRecord = async (trainingId, trainingData) => {
|
||||
const db = getPool();
|
||||
const { training_date, training_start_time, training_end_time, training_topics } = trainingData;
|
||||
|
||||
const [result] = await db.query(
|
||||
`UPDATE safety_training_records
|
||||
SET training_date = ?, training_start_time = ?, training_end_time = ?,
|
||||
training_topics = ?, updated_at = NOW()
|
||||
WHERE training_id = ?`,
|
||||
[training_date, training_start_time, training_end_time, training_topics, trainingId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const completeTraining = async (trainingId, signatureData) => {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`UPDATE safety_training_records
|
||||
SET signature_data = ?, completed_at = NOW(), updated_at = NOW()
|
||||
WHERE training_id = ?`,
|
||||
[signatureData, trainingId]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getTrainingRecords = async (filters = {}) => {
|
||||
const db = getPool();
|
||||
let query = `
|
||||
SELECT
|
||||
str.training_id, str.request_id, str.trainer_id, str.training_date,
|
||||
str.training_start_time, str.training_end_time, str.training_topics,
|
||||
str.completed_at, str.created_at, str.updated_at,
|
||||
u.username as trainer_name, u.name as trainer_full_name,
|
||||
vr.visitor_company, vr.visitor_count, vr.visit_date
|
||||
FROM safety_training_records str
|
||||
INNER JOIN users u ON str.trainer_id = u.user_id
|
||||
INNER JOIN workplace_visit_requests vr ON str.request_id = vr.request_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
|
||||
if (filters.training_date) {
|
||||
query += ` AND str.training_date = ?`;
|
||||
params.push(filters.training_date);
|
||||
}
|
||||
if (filters.start_date && filters.end_date) {
|
||||
query += ` AND str.training_date BETWEEN ? AND ?`;
|
||||
params.push(filters.start_date, filters.end_date);
|
||||
}
|
||||
if (filters.trainer_id) {
|
||||
query += ` AND str.trainer_id = ?`;
|
||||
params.push(filters.trainer_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY str.training_date DESC, str.training_start_time DESC`;
|
||||
|
||||
const [rows] = await db.query(query, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
// ==================== 작업장 분류/작업장 조회 ====================
|
||||
|
||||
const getAllCategories = async () => {
|
||||
const db = getPool();
|
||||
const [rows] = await db.query(
|
||||
'SELECT category_id, category_name FROM workplace_categories ORDER BY category_name'
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const getWorkplacesByCategory = async (categoryId) => {
|
||||
const db = getPool();
|
||||
let query = 'SELECT workplace_id, workplace_name, category_id FROM workplaces';
|
||||
const params = [];
|
||||
if (categoryId) {
|
||||
query += ' WHERE category_id = ?';
|
||||
params.push(categoryId);
|
||||
}
|
||||
query += ' ORDER BY workplace_name';
|
||||
const [rows] = await db.query(query, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createVisitRequest,
|
||||
getAllVisitRequests,
|
||||
getVisitRequestById,
|
||||
updateVisitRequest,
|
||||
deleteVisitRequest,
|
||||
approveVisitRequest,
|
||||
rejectVisitRequest,
|
||||
updateVisitRequestStatus,
|
||||
getAllVisitPurposes,
|
||||
getActiveVisitPurposes,
|
||||
createVisitPurpose,
|
||||
updateVisitPurpose,
|
||||
deleteVisitPurpose,
|
||||
createTrainingRecord,
|
||||
getTrainingRecordByRequestId,
|
||||
updateTrainingRecord,
|
||||
completeTraining,
|
||||
getTrainingRecords,
|
||||
getAllCategories,
|
||||
getWorkplacesByCategory
|
||||
};
|
||||
18
tksafety/api/routes/checklistRoutes.js
Normal file
18
tksafety/api/routes/checklistRoutes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const checklistController = require('../controllers/checklistController');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// Safety checks CRUD
|
||||
router.get('/', checklistController.getAllChecks);
|
||||
router.get('/weather-conditions', checklistController.getWeatherConditions);
|
||||
router.get('/work-types', checklistController.getWorkTypes);
|
||||
router.get('/tasks/:workTypeId', checklistController.getTasksByWorkType);
|
||||
router.get('/:id', checklistController.getCheckById);
|
||||
router.post('/', requireAdmin, checklistController.createCheck);
|
||||
router.put('/:id', requireAdmin, checklistController.updateCheck);
|
||||
router.delete('/:id', requireAdmin, checklistController.deleteCheck);
|
||||
|
||||
module.exports = router;
|
||||
35
tksafety/api/routes/visitRequestRoutes.js
Normal file
35
tksafety/api/routes/visitRequestRoutes.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const visitRequestController = require('../controllers/visitRequestController');
|
||||
const { requireAuth, requireAdmin, requirePage } = require('../middleware/auth');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// Visit requests CRUD
|
||||
router.post('/requests', requirePage('safety_visit_request'), visitRequestController.createVisitRequest);
|
||||
router.get('/requests', visitRequestController.getAllVisitRequests);
|
||||
router.get('/requests/:id', visitRequestController.getVisitRequestById);
|
||||
router.put('/requests/:id', requirePage('safety_visit_request'), visitRequestController.updateVisitRequest);
|
||||
router.delete('/requests/:id', requirePage('safety_visit_request'), visitRequestController.deleteVisitRequest);
|
||||
router.put('/requests/:id/approve', requireAdmin, visitRequestController.approveVisitRequest);
|
||||
router.put('/requests/:id/reject', requireAdmin, visitRequestController.rejectVisitRequest);
|
||||
|
||||
// Categories & Workplaces
|
||||
router.get('/categories', visitRequestController.getAllCategories);
|
||||
router.get('/workplaces', visitRequestController.getWorkplaces);
|
||||
|
||||
// Visit purposes
|
||||
router.get('/purposes', visitRequestController.getAllVisitPurposes);
|
||||
router.get('/purposes/active', visitRequestController.getActiveVisitPurposes);
|
||||
router.post('/purposes', requireAdmin, visitRequestController.createVisitPurpose);
|
||||
router.put('/purposes/:id', requireAdmin, visitRequestController.updateVisitPurpose);
|
||||
router.delete('/purposes/:id', requireAdmin, visitRequestController.deleteVisitPurpose);
|
||||
|
||||
// Training records
|
||||
router.post('/training', requireAdmin, visitRequestController.createTrainingRecord);
|
||||
router.get('/training', visitRequestController.getTrainingRecords);
|
||||
router.get('/training/request/:requestId', visitRequestController.getTrainingRecordByRequestId);
|
||||
router.put('/training/:id', requireAdmin, visitRequestController.updateTrainingRecord);
|
||||
router.post('/training/:id/complete', requireAdmin, visitRequestController.completeTraining);
|
||||
|
||||
module.exports = router;
|
||||
161
tksafety/web/checklist.html
Normal file
161
tksafety/web/checklist.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<!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-orange-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-orange-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-orange-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-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="bg-white rounded-xl shadow-sm mb-5">
|
||||
<div class="flex border-b">
|
||||
<button onclick="switchTab('basic')" id="tabBasic" class="checklist-tab active flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors">
|
||||
<i class="fas fa-list-check mr-1"></i>기본 항목
|
||||
</button>
|
||||
<button onclick="switchTab('weather')" id="tabWeather" class="checklist-tab flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors">
|
||||
<i class="fas fa-cloud-sun mr-1"></i>날씨별 항목
|
||||
</button>
|
||||
<button onclick="switchTab('worktype')" id="tabWorktype" class="checklist-tab flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors">
|
||||
<i class="fas fa-hard-hat mr-1"></i>작업별 항목
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 항목 탭 -->
|
||||
<div id="panelBasic" class="tab-panel">
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-list-check text-orange-500 mr-2"></i>기본 체크리스트 항목</h2>
|
||||
<button onclick="openAddItem('basic')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>항목 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="basicItemsList" class="space-y-2">
|
||||
<div class="text-center text-gray-400 py-8">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 항목 탭 -->
|
||||
<div id="panelWeather" class="tab-panel hidden">
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-cloud-sun text-orange-500 mr-2"></i>날씨별 체크리스트 항목</h2>
|
||||
<button onclick="openAddItem('weather')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>항목 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="weatherItemsList" class="space-y-4">
|
||||
<div class="text-center text-gray-400 py-8">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 항목 탭 -->
|
||||
<div id="panelWorktype" class="tab-panel hidden">
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-hard-hat text-orange-500 mr-2"></i>작업별 체크리스트 항목</h2>
|
||||
<button onclick="openAddItem('worktype')" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>항목 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="worktypeItemsList" class="space-y-4">
|
||||
<div class="text-center text-gray-400 py-8">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 추가/수정 모달 -->
|
||||
<div id="itemModal" class="hidden modal-overlay" onclick="if(event.target===this)closeItemModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 id="itemModalTitle" class="text-lg font-semibold">체크리스트 항목 추가</h3>
|
||||
<button onclick="closeItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<form id="itemForm">
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
|
||||
<select id="itemType" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="basic">기본</option>
|
||||
<option value="weather">날씨별</option>
|
||||
<option value="work_type">작업별</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 날씨 조건 (weather type only) -->
|
||||
<div id="weatherConditionField" class="hidden">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">날씨 조건 <span class="text-red-400">*</span></label>
|
||||
<select id="itemWeatherCondition" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 작업유형 (work_type only) -->
|
||||
<div id="workTypeField" class="hidden">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">작업 유형 <span class="text-red-400">*</span></label>
|
||||
<select id="itemWorkType" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
|
||||
<input type="text" id="itemCategory" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="예: 개인보호구, 작업환경">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">점검 항목 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="itemContent" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder="점검 항목 내용" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">표시 순서</label>
|
||||
<input type="number" id="itemDisplayOrder" class="input-field w-full px-3 py-2 rounded-lg text-sm" value="0" min="0">
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="itemIsActive" checked class="h-4 w-4 text-orange-500 rounded border-gray-300">
|
||||
<span class="text-sm text-gray-700">활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeItemModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js"></script>
|
||||
<script src="/static/js/tksafety-checklist.js"></script>
|
||||
<script>initChecklistPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -54,6 +54,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
|
||||
.safety-warning { animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||
|
||||
/* Checklist tabs */
|
||||
.checklist-tab { border-bottom-color: transparent; color: #6b7280; }
|
||||
.checklist-tab:hover { color: #374151; background: #f9fafb; }
|
||||
.checklist-tab.active { border-bottom-color: #ea580c; color: #ea580c; font-weight: 600; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stat-card .stat-value { font-size: 1.25rem; }
|
||||
|
||||
286
tksafety/web/static/js/tksafety-checklist.js
Normal file
286
tksafety/web/static/js/tksafety-checklist.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/* ===== Checklist Management (체크리스트 관리 - 관리자) ===== */
|
||||
let checklistItems = [];
|
||||
let weatherConditions = [];
|
||||
let workTypes = [];
|
||||
let editingItemId = null;
|
||||
let currentTab = 'basic';
|
||||
|
||||
/* ===== Tab switching ===== */
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
['basic', 'weather', 'worktype'].forEach(t => {
|
||||
document.getElementById('panel' + t.charAt(0).toUpperCase() + t.slice(1)).classList.toggle('hidden', t !== tab);
|
||||
const tabBtn = document.getElementById('tab' + t.charAt(0).toUpperCase() + t.slice(1));
|
||||
if (t === tab) {
|
||||
tabBtn.classList.add('active');
|
||||
} else {
|
||||
tabBtn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ===== Load checklist items ===== */
|
||||
async function loadChecklistItems() {
|
||||
try {
|
||||
const res = await api('/checklist');
|
||||
checklistItems = res.data || [];
|
||||
renderBasicItems();
|
||||
renderWeatherItems();
|
||||
renderWorktypeItems();
|
||||
} catch (e) {
|
||||
showToast('체크리스트 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Load lookup data ===== */
|
||||
async function loadLookupData() {
|
||||
try {
|
||||
const [wcRes, wtRes] = await Promise.all([
|
||||
api('/checklist/weather-conditions'),
|
||||
api('/checklist/work-types')
|
||||
]);
|
||||
weatherConditions = wcRes.data || [];
|
||||
workTypes = wtRes.data || [];
|
||||
|
||||
// Populate selects
|
||||
document.getElementById('itemWeatherCondition').innerHTML = '<option value="">선택</option>' +
|
||||
weatherConditions.map(w => `<option value="${w.condition_id}">${escapeHtml(w.condition_name)}</option>`).join('');
|
||||
|
||||
document.getElementById('itemWorkType').innerHTML = '<option value="">선택</option>' +
|
||||
workTypes.map(w => `<option value="${w.work_type_id}">${escapeHtml(w.work_type_name)}</option>`).join('');
|
||||
} catch (e) {
|
||||
console.error('Lookup data load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Render basic items ===== */
|
||||
function renderBasicItems() {
|
||||
const items = checklistItems.filter(i => i.item_type === 'basic');
|
||||
const container = document.getElementById('basicItemsList');
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-8">기본 항목이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const groups = {};
|
||||
items.forEach(i => {
|
||||
const cat = i.category || '미분류';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(i);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(groups).map(([cat, items]) => `
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-50 px-4 py-2 font-medium text-sm text-gray-700">${escapeHtml(cat)}</div>
|
||||
<div class="divide-y">
|
||||
${items.map(i => renderItemRow(i)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/* ===== Render weather items ===== */
|
||||
function renderWeatherItems() {
|
||||
const items = checklistItems.filter(i => i.item_type === 'weather');
|
||||
const container = document.getElementById('weatherItemsList');
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-8">날씨별 항목이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by weather condition
|
||||
const groups = {};
|
||||
items.forEach(i => {
|
||||
const cond = i.weather_condition_name || '미지정';
|
||||
if (!groups[cond]) groups[cond] = [];
|
||||
groups[cond].push(i);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(groups).map(([cond, items]) => `
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-blue-50 px-4 py-2 font-medium text-sm text-blue-700">
|
||||
<i class="fas fa-cloud mr-1"></i>${escapeHtml(cond)}
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
${items.map(i => renderItemRow(i)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/* ===== Render worktype items ===== */
|
||||
function renderWorktypeItems() {
|
||||
const items = checklistItems.filter(i => i.item_type === 'work_type');
|
||||
const container = document.getElementById('worktypeItemsList');
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="text-center text-gray-400 py-8">작업별 항목이 없습니다</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by work type
|
||||
const groups = {};
|
||||
items.forEach(i => {
|
||||
const wt = i.work_type_name || '미지정';
|
||||
if (!groups[wt]) groups[wt] = [];
|
||||
groups[wt].push(i);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(groups).map(([wt, items]) => `
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="bg-amber-50 px-4 py-2 font-medium text-sm text-amber-700">
|
||||
<i class="fas fa-hard-hat mr-1"></i>${escapeHtml(wt)}
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
${items.map(i => renderItemRow(i)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/* ===== Render single item row ===== */
|
||||
function renderItemRow(item) {
|
||||
const activeClass = item.is_active ? '' : 'opacity-50';
|
||||
const activeIcon = item.is_active
|
||||
? '<i class="fas fa-check-circle text-green-500 text-xs"></i>'
|
||||
: '<i class="fas fa-times-circle text-gray-400 text-xs"></i>';
|
||||
return `
|
||||
<div class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 ${activeClass}">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
${activeIcon}
|
||||
<span class="text-sm text-gray-700 truncate">${escapeHtml(item.item_content)}</span>
|
||||
${item.category ? `<span class="badge badge-gray text-xs">${escapeHtml(item.category)}</span>` : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<span class="text-xs text-gray-400 mr-2">#${item.display_order}</span>
|
||||
<button onclick="openEditItem(${item.item_id})" class="text-gray-400 hover:text-gray-600 text-xs p-1" title="수정">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button onclick="doDeleteItem(${item.item_id})" class="text-gray-400 hover:text-red-500 text-xs p-1" title="삭제">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ===== Add/Edit Modal ===== */
|
||||
function openAddItem(tab) {
|
||||
editingItemId = null;
|
||||
document.getElementById('itemModalTitle').textContent = '체크리스트 항목 추가';
|
||||
document.getElementById('itemForm').reset();
|
||||
document.getElementById('itemIsActive').checked = true;
|
||||
document.getElementById('itemDisplayOrder').value = '0';
|
||||
|
||||
// Set type based on tab
|
||||
const typeMap = { basic: 'basic', weather: 'weather', worktype: 'work_type' };
|
||||
document.getElementById('itemType').value = typeMap[tab] || 'basic';
|
||||
toggleTypeFields();
|
||||
|
||||
document.getElementById('itemModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openEditItem(id) {
|
||||
const item = checklistItems.find(i => i.item_id === id);
|
||||
if (!item) return;
|
||||
editingItemId = id;
|
||||
document.getElementById('itemModalTitle').textContent = '체크리스트 항목 수정';
|
||||
document.getElementById('itemType').value = item.item_type;
|
||||
document.getElementById('itemCategory').value = item.category || '';
|
||||
document.getElementById('itemContent').value = item.item_content || '';
|
||||
document.getElementById('itemDisplayOrder').value = item.display_order || 0;
|
||||
document.getElementById('itemIsActive').checked = item.is_active !== false && item.is_active !== 0;
|
||||
|
||||
if (item.weather_condition_id) {
|
||||
document.getElementById('itemWeatherCondition').value = item.weather_condition_id;
|
||||
}
|
||||
if (item.work_type_id) {
|
||||
document.getElementById('itemWorkType').value = item.work_type_id;
|
||||
}
|
||||
|
||||
toggleTypeFields();
|
||||
document.getElementById('itemModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeItemModal() {
|
||||
document.getElementById('itemModal').classList.add('hidden');
|
||||
editingItemId = null;
|
||||
}
|
||||
|
||||
function toggleTypeFields() {
|
||||
const type = document.getElementById('itemType').value;
|
||||
document.getElementById('weatherConditionField').classList.toggle('hidden', type !== 'weather');
|
||||
document.getElementById('workTypeField').classList.toggle('hidden', type !== 'work_type');
|
||||
}
|
||||
|
||||
/* ===== Submit item ===== */
|
||||
async function submitItem(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
item_type: document.getElementById('itemType').value,
|
||||
category: document.getElementById('itemCategory').value.trim() || null,
|
||||
item_content: document.getElementById('itemContent').value.trim(),
|
||||
display_order: parseInt(document.getElementById('itemDisplayOrder').value) || 0,
|
||||
is_active: document.getElementById('itemIsActive').checked
|
||||
};
|
||||
|
||||
if (!data.item_content) { showToast('점검 항목을 입력해주세요', 'error'); return; }
|
||||
|
||||
if (data.item_type === 'weather') {
|
||||
data.weather_condition_id = parseInt(document.getElementById('itemWeatherCondition').value) || null;
|
||||
if (!data.weather_condition_id) { showToast('날씨 조건을 선택해주세요', 'error'); return; }
|
||||
}
|
||||
if (data.item_type === 'work_type') {
|
||||
data.work_type_id = parseInt(document.getElementById('itemWorkType').value) || null;
|
||||
if (!data.work_type_id) { showToast('작업 유형을 선택해주세요', 'error'); return; }
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingItemId) {
|
||||
await api('/checklist/' + editingItemId, { method: 'PUT', body: JSON.stringify(data) });
|
||||
showToast('수정되었습니다');
|
||||
} else {
|
||||
await api('/checklist', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('추가되었습니다');
|
||||
}
|
||||
closeItemModal();
|
||||
await loadChecklistItems();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Delete item ===== */
|
||||
async function doDeleteItem(id) {
|
||||
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/checklist/' + id, { method: 'DELETE' });
|
||||
showToast('삭제되었습니다');
|
||||
await loadChecklistItems();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
function initChecklistPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// Check admin
|
||||
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
|
||||
if (!isAdmin) {
|
||||
document.querySelector('.flex-1.min-w-0').innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-sm p-10 text-center">
|
||||
<i class="fas fa-lock text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">관리자 권한이 필요합니다</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Type change handler
|
||||
document.getElementById('itemType').addEventListener('change', toggleTypeFields);
|
||||
document.getElementById('itemForm').addEventListener('submit', submitItem);
|
||||
|
||||
loadLookupData();
|
||||
loadChecklistItems();
|
||||
}
|
||||
@@ -82,13 +82,18 @@ function doLogout() {
|
||||
/* ===== Navbar ===== */
|
||||
function renderNavbar() {
|
||||
const currentPage = location.pathname.replace(/\//g, '') || 'index.html';
|
||||
const isAdmin = currentUser && ['admin','system'].includes(currentUser.role);
|
||||
const links = [
|
||||
{ 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: '/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 },
|
||||
];
|
||||
const nav = document.getElementById('sideNav');
|
||||
if (!nav) return;
|
||||
nav.innerHTML = links.map(l => {
|
||||
nav.innerHTML = links.filter(l => !l.admin || isAdmin).map(l => {
|
||||
const active = l.match.some(m => currentPage === m || currentPage.endsWith(m));
|
||||
return `<a href="${l.href}" class="nav-link flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm transition-colors ${active ? 'active' : 'text-gray-600 hover:bg-gray-100'}">
|
||||
<i class="fas ${l.icon} w-5 text-center"></i><span>${l.label}</span></a>`;
|
||||
|
||||
239
tksafety/web/static/js/tksafety-training.js
Normal file
239
tksafety/web/static/js/tksafety-training.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/* ===== Training Management (안전교육 실시 - 관리자) ===== */
|
||||
let pendingRequests = [];
|
||||
let completedTrainings = [];
|
||||
let trainingRequestId = null;
|
||||
|
||||
/* Signature canvas state */
|
||||
let sigCanvas, sigCtx;
|
||||
let isDrawing = false;
|
||||
let hasSignature = false;
|
||||
|
||||
/* ===== Load approved requests needing training ===== */
|
||||
async function loadPendingTraining() {
|
||||
try {
|
||||
const res = await api('/visit-requests/requests?status=approved');
|
||||
pendingRequests = res.data || [];
|
||||
renderPendingTraining();
|
||||
} catch (e) {
|
||||
showToast('대기 목록 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPendingTraining() {
|
||||
const tbody = document.getElementById('pendingTrainingBody');
|
||||
if (!pendingRequests.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-8">교육 대기 중인 신청이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = pendingRequests.map(r => `<tr>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
|
||||
<td>${escapeHtml(r.purpose_name || '-')}</td>
|
||||
<td class="text-right">
|
||||
<button onclick="openTrainingModal(${r.request_id})" class="text-orange-600 hover:text-orange-800 text-xs px-2 py-1 border border-orange-200 rounded hover:bg-orange-50">
|
||||
<i class="fas fa-chalkboard-teacher mr-1"></i>교육실시
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
/* ===== Load completed training records ===== */
|
||||
async function loadCompletedTraining() {
|
||||
try {
|
||||
const res = await api('/visit-requests/training');
|
||||
completedTrainings = res.data || [];
|
||||
renderCompletedTraining();
|
||||
} catch (e) {
|
||||
showToast('이력 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompletedTraining() {
|
||||
const tbody = document.getElementById('completedTrainingBody');
|
||||
if (!completedTrainings.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-8">교육 완료 이력이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = completedTrainings.map(t => {
|
||||
const timeRange = t.training_start_time
|
||||
? String(t.training_start_time).substring(0, 5) + (t.training_end_time ? ' ~ ' + String(t.training_end_time).substring(0, 5) : '')
|
||||
: '-';
|
||||
return `<tr>
|
||||
<td>${formatDate(t.training_date)}</td>
|
||||
<td>${escapeHtml(t.visitor_company || '-')}</td>
|
||||
<td class="text-center">${t.visitor_count || '-'}</td>
|
||||
<td>${timeRange}</td>
|
||||
<td class="hide-mobile">${escapeHtml(t.training_topics || '-')}</td>
|
||||
<td>${escapeHtml(t.trainer_full_name || t.trainer_name || '-')}</td>
|
||||
<td class="text-right">
|
||||
${t.completed_at ? '<span class="badge badge-green">완료</span>' : '<span class="badge badge-amber">진행중</span>'}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Training Modal ===== */
|
||||
function openTrainingModal(requestId) {
|
||||
const r = pendingRequests.find(x => x.request_id === requestId);
|
||||
if (!r) return;
|
||||
trainingRequestId = requestId;
|
||||
|
||||
document.getElementById('trainingRequestInfo').innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-gray-500">업체:</span> <strong>${escapeHtml(r.visitor_company)}</strong></div>
|
||||
<div><span class="text-gray-500">인원:</span> <strong>${r.visitor_count}명</strong></div>
|
||||
<div><span class="text-gray-500">작업장:</span> <strong>${escapeHtml(r.workplace_name || '-')}</strong></div>
|
||||
<div><span class="text-gray-500">방문일:</span> <strong>${formatDate(r.visit_date)}</strong></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set defaults
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
const now = new Date().toTimeString().substring(0, 5);
|
||||
document.getElementById('trainingDate').value = today;
|
||||
document.getElementById('trainingStartTime').value = now;
|
||||
document.getElementById('trainingEndTime').value = '';
|
||||
document.getElementById('trainingTopics').value = '';
|
||||
|
||||
// Reset signature
|
||||
clearSignature();
|
||||
|
||||
document.getElementById('trainingModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeTrainingModal() {
|
||||
document.getElementById('trainingModal').classList.add('hidden');
|
||||
trainingRequestId = null;
|
||||
}
|
||||
|
||||
/* ===== Submit Training ===== */
|
||||
async function submitTraining(e) {
|
||||
e.preventDefault();
|
||||
if (!trainingRequestId) return;
|
||||
|
||||
const data = {
|
||||
request_id: trainingRequestId,
|
||||
training_date: document.getElementById('trainingDate').value,
|
||||
training_start_time: document.getElementById('trainingStartTime').value,
|
||||
training_end_time: document.getElementById('trainingEndTime').value || null,
|
||||
training_topics: document.getElementById('trainingTopics').value.trim() || null
|
||||
};
|
||||
|
||||
if (!data.training_date) { showToast('교육일을 선택해주세요', 'error'); return; }
|
||||
if (!data.training_start_time) { showToast('시작시간을 입력해주세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
// 1. Create training record
|
||||
const createRes = await api('/visit-requests/training', {
|
||||
method: 'POST', body: JSON.stringify(data)
|
||||
});
|
||||
const trainingId = createRes.data?.training_id || createRes.data?.insertId;
|
||||
|
||||
// 2. Complete with signature if exists
|
||||
if (trainingId && hasSignature) {
|
||||
const signatureData = sigCanvas.toDataURL('image/png');
|
||||
await api('/visit-requests/training/' + trainingId + '/complete', {
|
||||
method: 'PUT', body: JSON.stringify({ signature_data: signatureData })
|
||||
});
|
||||
}
|
||||
|
||||
showToast('안전교육이 완료되었습니다');
|
||||
closeTrainingModal();
|
||||
await Promise.all([loadPendingTraining(), loadCompletedTraining()]);
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Signature Pad ===== */
|
||||
function initSignaturePad() {
|
||||
sigCanvas = document.getElementById('signatureCanvas');
|
||||
if (!sigCanvas) return;
|
||||
sigCtx = sigCanvas.getContext('2d');
|
||||
|
||||
// Adjust canvas resolution for retina displays
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
sigCanvas.width = rect.width * dpr;
|
||||
sigCanvas.height = rect.height * dpr;
|
||||
sigCtx.scale(dpr, dpr);
|
||||
sigCtx.lineCap = 'round';
|
||||
sigCtx.lineJoin = 'round';
|
||||
sigCtx.lineWidth = 2;
|
||||
sigCtx.strokeStyle = '#1f2937';
|
||||
|
||||
// Mouse events
|
||||
sigCanvas.addEventListener('mousedown', startDraw);
|
||||
sigCanvas.addEventListener('mousemove', draw);
|
||||
sigCanvas.addEventListener('mouseup', stopDraw);
|
||||
sigCanvas.addEventListener('mouseleave', stopDraw);
|
||||
|
||||
// Touch events
|
||||
sigCanvas.addEventListener('touchstart', function(e) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
startDraw(touchToMouse(touch));
|
||||
});
|
||||
sigCanvas.addEventListener('touchmove', function(e) {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
draw(touchToMouse(touch));
|
||||
});
|
||||
sigCanvas.addEventListener('touchend', function(e) {
|
||||
e.preventDefault();
|
||||
stopDraw();
|
||||
});
|
||||
}
|
||||
|
||||
function touchToMouse(touch) {
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
return { offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top };
|
||||
}
|
||||
|
||||
function startDraw(e) {
|
||||
isDrawing = true;
|
||||
sigCtx.beginPath();
|
||||
sigCtx.moveTo(e.offsetX, e.offsetY);
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
hasSignature = true;
|
||||
sigCtx.lineTo(e.offsetX, e.offsetY);
|
||||
sigCtx.stroke();
|
||||
}
|
||||
|
||||
function stopDraw() {
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
function clearSignature() {
|
||||
if (!sigCanvas || !sigCtx) return;
|
||||
const rect = sigCanvas.getBoundingClientRect();
|
||||
sigCtx.clearRect(0, 0, rect.width, rect.height);
|
||||
hasSignature = false;
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
function initTrainingPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// Check admin
|
||||
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
|
||||
if (!isAdmin) {
|
||||
document.querySelector('.flex-1.min-w-0').innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-sm p-10 text-center">
|
||||
<i class="fas fa-lock text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">관리자 권한이 필요합니다</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('trainingForm').addEventListener('submit', submitTraining);
|
||||
initSignaturePad();
|
||||
loadPendingTraining();
|
||||
loadCompletedTraining();
|
||||
}
|
||||
216
tksafety/web/static/js/tksafety-visit-management.js
Normal file
216
tksafety/web/static/js/tksafety-visit-management.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* ===== Visit Management (출입 관리 - 관리자) ===== */
|
||||
let allRequests = [];
|
||||
let actionRequestId = null;
|
||||
|
||||
/* ===== Status badge for visit requests ===== */
|
||||
function vrStatusBadge(s) {
|
||||
const m = {
|
||||
pending: ['badge-amber', '대기중'],
|
||||
approved: ['badge-green', '승인됨'],
|
||||
rejected: ['badge-red', '반려됨'],
|
||||
training_completed: ['badge-blue', '교육완료']
|
||||
};
|
||||
const [cls, label] = m[s] || ['badge-gray', s];
|
||||
return `<span class="badge ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
/* ===== Load requests ===== */
|
||||
async function loadRequests() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const dateFrom = document.getElementById('filterDateFrom').value;
|
||||
const dateTo = document.getElementById('filterDateTo').value;
|
||||
if (status) params.set('status', status);
|
||||
if (dateFrom) params.set('start_date', dateFrom);
|
||||
if (dateTo) params.set('end_date', dateTo);
|
||||
|
||||
const res = await api('/visit-requests/requests?' + params.toString());
|
||||
allRequests = res.data || [];
|
||||
renderStats();
|
||||
renderRequestsTable();
|
||||
} catch (e) {
|
||||
showToast('데이터 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const counts = { pending: 0, approved: 0, rejected: 0, training_completed: 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;
|
||||
}
|
||||
|
||||
function renderRequestsTable() {
|
||||
const tbody = document.getElementById('requestsTableBody');
|
||||
if (!allRequests.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-400 py-8">신청 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = allRequests.map(r => {
|
||||
let actions = '';
|
||||
if (r.status === 'pending') {
|
||||
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="승인">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button onclick="openRejectModal(${r.request_id})" class="text-red-600 hover:text-red-800 text-xs px-2 py-1 border border-red-200 rounded hover:bg-red-50 ml-1" title="반려">
|
||||
<i class="fas fa-times"></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') {
|
||||
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>`;
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td>${formatDate(r.created_at)}</td>
|
||||
<td>${escapeHtml(r.requester_full_name || r.requester_name || '-')}</td>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
|
||||
<td>${escapeHtml(r.purpose_name || '-')}</td>
|
||||
<td>${vrStatusBadge(r.status)}</td>
|
||||
<td class="text-right whitespace-nowrap">${actions}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Approve Modal ===== */
|
||||
function openApproveModal(id) {
|
||||
const r = allRequests.find(x => x.request_id === id);
|
||||
if (!r) return;
|
||||
actionRequestId = id;
|
||||
document.getElementById('approveDetail').innerHTML = `
|
||||
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</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> ${r.visitor_count}명</p>
|
||||
<p class="mt-2">이 출입 신청을 승인하시겠습니까?</p>
|
||||
`;
|
||||
document.getElementById('approveModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeApproveModal() {
|
||||
document.getElementById('approveModal').classList.add('hidden');
|
||||
actionRequestId = null;
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!actionRequestId) return;
|
||||
try {
|
||||
await api('/visit-requests/requests/' + actionRequestId + '/approve', {
|
||||
method: 'PUT', body: JSON.stringify({})
|
||||
});
|
||||
showToast('승인되었습니다');
|
||||
closeApproveModal();
|
||||
await loadRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Reject Modal ===== */
|
||||
function openRejectModal(id) {
|
||||
const r = allRequests.find(x => x.request_id === id);
|
||||
if (!r) return;
|
||||
actionRequestId = id;
|
||||
document.getElementById('rejectDetail').innerHTML = `
|
||||
<p><strong>업체:</strong> ${escapeHtml(r.visitor_company)}</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>
|
||||
`;
|
||||
document.getElementById('rejectionReason').value = '';
|
||||
document.getElementById('rejectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
document.getElementById('rejectModal').classList.add('hidden');
|
||||
actionRequestId = null;
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!actionRequestId) return;
|
||||
const reason = document.getElementById('rejectionReason').value.trim();
|
||||
if (!reason) {
|
||||
showToast('반려 사유를 입력해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('/visit-requests/requests/' + actionRequestId + '/reject', {
|
||||
method: 'PUT', body: JSON.stringify({ rejection_reason: reason })
|
||||
});
|
||||
showToast('반려되었습니다');
|
||||
closeRejectModal();
|
||||
await loadRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Detail Modal ===== */
|
||||
function openDetailModal(id) {
|
||||
const r = allRequests.find(x => x.request_id === id);
|
||||
if (!r) return;
|
||||
document.getElementById('detailContent').innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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">${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.workplace_name || '-')}</span></div>
|
||||
<div><span class="text-gray-500">방문일:</span> <span class="font-medium">${formatDate(r.visit_date)}</span></div>
|
||||
<div><span class="text-gray-500">방문시간:</span> <span class="font-medium">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</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> <span class="font-medium">${formatDateTime(r.created_at)}</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.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.notes ? `<div class="col-span-2"><span class="text-gray-500">비고:</span> <span class="font-medium">${escapeHtml(r.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('detailModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
/* ===== Delete request ===== */
|
||||
async function doDeleteRequest(id) {
|
||||
if (!confirm('이 신청을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/visit-requests/requests/' + id, { method: 'DELETE' });
|
||||
showToast('삭제되었습니다');
|
||||
await loadRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
function initVisitManagementPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// Check admin
|
||||
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
|
||||
if (!isAdmin) {
|
||||
document.querySelector('.flex-1.min-w-0').innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-sm p-10 text-center">
|
||||
<i class="fas fa-lock text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">관리자 권한이 필요합니다</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('filterStatus').addEventListener('change', loadRequests);
|
||||
document.getElementById('filterDateFrom').addEventListener('change', loadRequests);
|
||||
document.getElementById('filterDateTo').addEventListener('change', loadRequests);
|
||||
|
||||
loadRequests();
|
||||
}
|
||||
158
tksafety/web/static/js/tksafety-visit-request.js
Normal file
158
tksafety/web/static/js/tksafety-visit-request.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ===== Visit Request (출입 신청) ===== */
|
||||
let myRequests = [];
|
||||
let categories = [];
|
||||
let workplaces = [];
|
||||
let purposes = [];
|
||||
|
||||
/* ===== Status badge for visit requests ===== */
|
||||
function vrStatusBadge(s) {
|
||||
const m = {
|
||||
pending: ['badge-amber', '대기중'],
|
||||
approved: ['badge-green', '승인됨'],
|
||||
rejected: ['badge-red', '반려됨'],
|
||||
training_completed: ['badge-blue', '교육완료']
|
||||
};
|
||||
const [cls, label] = m[s] || ['badge-gray', s];
|
||||
return `<span class="badge ${cls}">${label}</span>`;
|
||||
}
|
||||
|
||||
/* ===== Load form data (purposes, categories) ===== */
|
||||
async function loadFormData() {
|
||||
try {
|
||||
const [purposeRes, categoryRes] = await Promise.all([
|
||||
api('/visit-requests/purposes/active'),
|
||||
api('/visit-requests/categories')
|
||||
]);
|
||||
purposes = purposeRes.data || [];
|
||||
categories = categoryRes.data || [];
|
||||
|
||||
const purposeSelect = document.getElementById('purposeId');
|
||||
purposeSelect.innerHTML = '<option value="">선택</option>' +
|
||||
purposes.map(p => `<option value="${p.purpose_id}">${escapeHtml(p.purpose_name)}</option>`).join('');
|
||||
|
||||
const categorySelect = document.getElementById('categoryId');
|
||||
categorySelect.innerHTML = '<option value="">선택</option>' +
|
||||
categories.map(c => `<option value="${c.category_id}">${escapeHtml(c.category_name)}</option>`).join('');
|
||||
} catch (e) {
|
||||
showToast('폼 데이터 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Load workplaces by category ===== */
|
||||
async function loadWorkplaces(categoryId) {
|
||||
const workplaceSelect = document.getElementById('workplaceId');
|
||||
if (!categoryId) {
|
||||
workplaceSelect.innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
|
||||
workplaces = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api('/visit-requests/workplaces?category_id=' + categoryId);
|
||||
workplaces = res.data || [];
|
||||
workplaceSelect.innerHTML = '<option value="">선택</option>' +
|
||||
workplaces.map(w => `<option value="${w.workplace_id}">${escapeHtml(w.workplace_name)}</option>`).join('');
|
||||
} catch (e) {
|
||||
showToast('작업장 로드 실패: ' + e.message, 'error');
|
||||
workplaceSelect.innerHTML = '<option value="">로드 실패</option>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Load my requests ===== */
|
||||
async function loadMyRequests() {
|
||||
try {
|
||||
const res = await api('/visit-requests/requests?requester_id=' + currentUser.id);
|
||||
myRequests = res.data || [];
|
||||
renderMyRequests();
|
||||
} catch (e) {
|
||||
showToast('신청 목록 로드 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMyRequests() {
|
||||
const tbody = document.getElementById('myRequestsBody');
|
||||
if (!myRequests.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-gray-400 py-8">신청 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = myRequests.map(r => {
|
||||
const canDelete = r.status === 'pending';
|
||||
return `<tr>
|
||||
<td>${formatDate(r.created_at)}</td>
|
||||
<td>${escapeHtml(r.visitor_company)}</td>
|
||||
<td class="text-center">${r.visitor_count}</td>
|
||||
<td>${escapeHtml(r.workplace_name || '-')}</td>
|
||||
<td>${formatDate(r.visit_date)}</td>
|
||||
<td class="hide-mobile">${r.visit_time ? String(r.visit_time).substring(0, 5) : '-'}</td>
|
||||
<td>${escapeHtml(r.purpose_name || '-')}</td>
|
||||
<td>${vrStatusBadge(r.status)}</td>
|
||||
<td class="text-right">
|
||||
${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>` : ''}
|
||||
${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>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ===== Submit request ===== */
|
||||
async function submitRequest(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
visitor_company: document.getElementById('visitorCompany').value.trim(),
|
||||
visitor_count: parseInt(document.getElementById('visitorCount').value) || 1,
|
||||
category_id: parseInt(document.getElementById('categoryId').value) || null,
|
||||
workplace_id: parseInt(document.getElementById('workplaceId').value) || null,
|
||||
visit_date: document.getElementById('visitDate').value,
|
||||
visit_time: document.getElementById('visitTime').value,
|
||||
purpose_id: parseInt(document.getElementById('purposeId').value) || null,
|
||||
notes: document.getElementById('notes').value.trim() || null
|
||||
};
|
||||
|
||||
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; }
|
||||
if (!data.visit_time) { showToast('방문시간을 입력해주세요', 'error'); return; }
|
||||
if (!data.purpose_id) { showToast('방문 목적을 선택해주세요', 'error'); return; }
|
||||
|
||||
try {
|
||||
await api('/visit-requests/requests', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('출입 신청이 완료되었습니다');
|
||||
document.getElementById('visitRequestForm').reset();
|
||||
document.getElementById('workplaceId').innerHTML = '<option value="">분류를 먼저 선택하세요</option>';
|
||||
document.getElementById('visitorCount').value = '1';
|
||||
await loadMyRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Delete request ===== */
|
||||
async function deleteRequest(id) {
|
||||
if (!confirm('이 신청을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api('/visit-requests/requests/' + id, { method: 'DELETE' });
|
||||
showToast('삭제되었습니다');
|
||||
await loadMyRequests();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Init ===== */
|
||||
function initVisitRequestPage() {
|
||||
if (!initAuth()) return;
|
||||
|
||||
// Set default visit date to today
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
document.getElementById('visitDate').value = today;
|
||||
|
||||
// Category change -> load workplaces
|
||||
document.getElementById('categoryId').addEventListener('change', function() {
|
||||
loadWorkplaces(this.value);
|
||||
});
|
||||
|
||||
document.getElementById('visitRequestForm').addEventListener('submit', submitRequest);
|
||||
|
||||
loadFormData();
|
||||
loadMyRequests();
|
||||
}
|
||||
140
tksafety/web/training.html
Normal file
140
tksafety/web/training.html
Normal file
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전교육 실시 - 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-orange-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-orange-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-orange-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-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="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-chalkboard-teacher text-orange-500 mr-2"></i>안전교육 대기 (승인된 신청)</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>업체</th>
|
||||
<th class="text-center">인원</th>
|
||||
<th>작업장</th>
|
||||
<th>방문일</th>
|
||||
<th class="hide-mobile">방문시간</th>
|
||||
<th>목적</th>
|
||||
<th class="text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pendingTrainingBody">
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육 완료 이력 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-history text-orange-500 mr-2"></i>교육 완료 이력</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>교육일</th>
|
||||
<th>업체</th>
|
||||
<th class="text-center">인원</th>
|
||||
<th>교육시간</th>
|
||||
<th class="hide-mobile">교육내용</th>
|
||||
<th>교육자</th>
|
||||
<th class="text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="completedTrainingBody">
|
||||
<tr><td colspan="7" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 교육 실시 모달 -->
|
||||
<div id="trainingModal" class="hidden modal-overlay" onclick="if(event.target===this)closeTrainingModal()">
|
||||
<div class="modal-content p-6" style="max-width: 32rem;">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">안전교육 실시</h3>
|
||||
<button onclick="closeTrainingModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="trainingRequestInfo" class="bg-gray-50 rounded-lg p-3 mb-4 text-sm"></div>
|
||||
<form id="trainingForm">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">교육일 <span class="text-red-400">*</span></label>
|
||||
<input type="date" id="trainingDate" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">시작시간 <span class="text-red-400">*</span></label>
|
||||
<input type="time" id="trainingStartTime" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">종료시간</label>
|
||||
<input type="time" id="trainingEndTime" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">교육 내용</label>
|
||||
<textarea id="trainingTopics" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="3" placeholder="교육 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서명 패드 -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">교육자 서명</label>
|
||||
<div class="border rounded-lg overflow-hidden bg-white" style="touch-action: none;">
|
||||
<canvas id="signatureCanvas" width="460" height="150" class="w-full cursor-crosshair"></canvas>
|
||||
</div>
|
||||
<div class="flex justify-end mt-1">
|
||||
<button type="button" onclick="clearSignature()" class="text-xs text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-eraser mr-1"></i>서명 지우기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 gap-2">
|
||||
<button type="button" onclick="closeTrainingModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">
|
||||
<i class="fas fa-check mr-1"></i>교육 완료
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js"></script>
|
||||
<script src="/static/js/tksafety-training.js"></script>
|
||||
<script>initTrainingPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
170
tksafety/web/visit-management.html
Normal file
170
tksafety/web/visit-management.html
Normal file
@@ -0,0 +1,170 @@
|
||||
<!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-orange-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-orange-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-orange-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-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="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-amber-600" id="statPending">0</div>
|
||||
<div class="stat-label">대기중</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-green-600" id="statApproved">0</div>
|
||||
<div class="stat-label">승인됨</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-red-600" id="statRejected">0</div>
|
||||
<div class="stat-label">반려됨</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-blue-600" id="statTrainingDone">0</div>
|
||||
<div class="stat-label">교육완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-clipboard-check text-orange-500 mr-2"></i>출입 신청 관리</h2>
|
||||
</div>
|
||||
<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="filterStatus" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
<option value="">전체</option>
|
||||
<option value="pending">대기중</option>
|
||||
<option value="approved">승인됨</option>
|
||||
<option value="rejected">반려됨</option>
|
||||
<option value="training_completed">교육완료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">시작일</label>
|
||||
<input type="date" id="filterDateFrom" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">종료일</label>
|
||||
<input type="date" id="filterDateTo" class="input-field px-3 py-2 rounded-lg text-sm">
|
||||
</div>
|
||||
<button onclick="loadRequests()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm font-medium">
|
||||
<i class="fas fa-search mr-1"></i>조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 신청 목록 테이블 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일</th>
|
||||
<th>신청자</th>
|
||||
<th>업체</th>
|
||||
<th class="text-center">인원</th>
|
||||
<th>작업장</th>
|
||||
<th>방문일</th>
|
||||
<th class="hide-mobile">방문시간</th>
|
||||
<th>목적</th>
|
||||
<th>상태</th>
|
||||
<th class="text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="requestsTableBody">
|
||||
<tr><td colspan="10" class="text-center text-gray-400 py-8">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 확인 모달 -->
|
||||
<div id="approveModal" class="hidden modal-overlay" onclick="if(event.target===this)closeApproveModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">출입 승인</h3>
|
||||
<button onclick="closeApproveModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="approveDetail" class="mb-4 text-sm text-gray-600"></div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="closeApproveModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button onclick="confirmApprove()" class="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700">
|
||||
<i class="fas fa-check mr-1"></i>승인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 반려 모달 -->
|
||||
<div id="rejectModal" class="hidden modal-overlay" onclick="if(event.target===this)closeRejectModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">출입 반려</h3>
|
||||
<button onclick="closeRejectModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="rejectDetail" class="mb-4 text-sm text-gray-600"></div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">반려 사유 <span class="text-red-400">*</span></label>
|
||||
<textarea id="rejectionReason" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="3" placeholder="반려 사유를 입력해주세요" required></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="closeRejectModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
|
||||
<button onclick="confirmReject()" class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700">
|
||||
<i class="fas fa-times mr-1"></i>반려
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세보기 모달 -->
|
||||
<div id="detailModal" class="hidden modal-overlay" onclick="if(event.target===this)closeDetailModal()">
|
||||
<div class="modal-content p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">신청 상세</h3>
|
||||
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div id="detailContent" class="text-sm"></div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button onclick="closeDetailModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/tksafety-core.js"></script>
|
||||
<script src="/static/js/tksafety-visit-management.js"></script>
|
||||
<script>initVisitManagementPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
128
tksafety/web/visit-request.html
Normal file
128
tksafety/web/visit-request.html
Normal file
@@ -0,0 +1,128 @@
|
||||
<!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-orange-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-orange-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-orange-600 rounded-full flex items-center justify-center text-sm font-semibold">-</div>
|
||||
<button onclick="doLogout()" class="text-orange-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="bg-white rounded-xl shadow-sm p-5 mb-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-file-signature text-orange-500 mr-2"></i>출입 신청</h2>
|
||||
<form id="visitRequestForm">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<!-- 업체명 -->
|
||||
<div>
|
||||
<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="방문 업체명" required>
|
||||
</div>
|
||||
<!-- 인원 -->
|
||||
<div>
|
||||
<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">
|
||||
</div>
|
||||
<!-- 작업장 분류 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">작업장 분류 <span class="text-red-400">*</span></label>
|
||||
<select id="categoryId" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 작업장 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">작업장 <span class="text-red-400">*</span></label>
|
||||
<select id="workplaceId" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="">분류를 먼저 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 방문일 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">방문일 <span class="text-red-400">*</span></label>
|
||||
<input type="date" id="visitDate" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<!-- 방문시간 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">방문시간 <span class="text-red-400">*</span></label>
|
||||
<input type="time" id="visitTime" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<!-- 방문 목적 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">방문 목적 <span class="text-red-400">*</span></label>
|
||||
<select id="purposeId" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 비고 -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
|
||||
<textarea id="notes" class="input-field w-full px-3 py-2 rounded-lg text-sm" rows="2" placeholder="추가 메모 (선택사항)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" class="px-6 py-2.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm font-medium">
|
||||
<i class="fas fa-paper-plane mr-2"></i>신청
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 내 신청 현황 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-list text-orange-500 mr-2"></i>내 신청 현황</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="visit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일</th>
|
||||
<th>업체</th>
|
||||
<th class="text-center">인원</th>
|
||||
<th>작업장</th>
|
||||
<th>방문일</th>
|
||||
<th class="hide-mobile">방문시간</th>
|
||||
<th>목적</th>
|
||||
<th>상태</th>
|
||||
<th class="text-right">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="myRequestsBody">
|
||||
<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"></script>
|
||||
<script src="/static/js/tksafety-visit-request.js"></script>
|
||||
<script>initVisitRequestPage();</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user