refactor: TBM/작업보고 코드 통합 및 API 쿼리 버그 수정

- 공통 유틸리티 추출 (common/utils.js, common/base-state.js)
- TBM 모바일 인라인 JS/CSS 외부 파일로 분리 (tbm-mobile.js, tbm-mobile.css)
- 미사용 코드 삭제 (index.js, work-report-*.js 등 5개 파일)
- TBM/작업보고 state.js, utils.js를 공통 모듈 기반으로 전환
- 작업보고서 SSO 인증 호환 수정 (token/user 함수)
- tbmModel.js: incomplete-reports 쿼리에서 users→sso_users 조인 수정, leader_name 조인 추가
- docker-compose.yml: system1-web 볼륨 마운트 추가
- 모바일 인계(handover) 기능 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-05 07:51:24 +09:00
parent 22a37ac4d9
commit 4388628788
89 changed files with 5296 additions and 5046 deletions

View File

@@ -10,7 +10,7 @@
* 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용)
*/
const bcrypt = require('bcryptjs');
const bcrypt = require('bcrypt');
const { generateUniqueUsername } = require('../../utils/hangulToRoman');
exports.up = async function(knex) {

View File

@@ -0,0 +1,40 @@
/**
* users 테이블에 department_id 컬럼 추가
* 사용자-부서 직접 연결을 위한 마이그레이션
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('users', 'department_id');
if (!hasColumn) {
await knex.schema.table('users', (table) => {
table.integer('department_id').unsigned().defaultTo(null).after('worker_id')
.comment('소속 부서 ID');
table.foreign('department_id').references('department_id').inTable('departments')
.onDelete('SET NULL');
});
// 기존 데이터 backfill: 작업자가 연결된 사용자는 해당 작업자의 department_id를 복사
await knex.raw(`
UPDATE users u
INNER JOIN workers w ON u.worker_id = w.worker_id
SET u.department_id = w.department_id
WHERE u.worker_id IS NOT NULL AND w.department_id IS NOT NULL
`);
console.log('✅ users.department_id 컬럼 추가 및 기존 데이터 backfill 완료');
} else {
console.log('⏭️ users.department_id 컬럼이 이미 존재합니다');
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('users', 'department_id');
if (hasColumn) {
await knex.schema.table('users', (table) => {
table.dropForeign('department_id');
table.dropColumn('department_id');
});
}
};

View File

@@ -1,26 +0,0 @@
/**
* TBM 팀 배정에 근태 유형 컬럼 추가
* - attendance_type: 근태유형 (overtime/regular/annual/half/quarter/early)
* - attendance_hours: 추가시간(overtime) 또는 실제근무시간(early)
*
* @since 2026-02-24
*/
exports.up = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
ADD COLUMN attendance_type ENUM('overtime','regular','annual','half','quarter','early')
DEFAULT NULL
COMMENT '근태유형',
ADD COLUMN attendance_hours DECIMAL(4,1) DEFAULT NULL
COMMENT '추가시간(overtime) 또는 실제근무시간(early)'
`);
};
exports.down = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
DROP COLUMN attendance_type,
DROP COLUMN attendance_hours
`);
};

View File

@@ -1,43 +0,0 @@
// 20260225000003_add_split_seq.js
// 같은 작업자가 같은 세션에서 여러 분할 항목을 가질 수 있도록 split_seq 추가
exports.up = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
ADD COLUMN split_seq INT UNSIGNED DEFAULT 0 AFTER work_hours
`).then(() => {
return knex.raw(`
ALTER TABLE tbm_team_assignments
DROP INDEX tbm_team_assignments_session_id_worker_id_unique
`);
}).then(() => {
return knex.raw(`
ALTER TABLE tbm_team_assignments
ADD UNIQUE INDEX uniq_session_worker_seq (session_id, worker_id, split_seq)
`);
});
};
exports.down = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
DROP INDEX uniq_session_worker_seq
`).then(() => {
return knex.raw(`
DELETE t1 FROM tbm_team_assignments t1
INNER JOIN tbm_team_assignments t2
WHERE t1.assignment_id > t2.assignment_id
AND t1.session_id = t2.session_id
AND t1.worker_id = t2.worker_id
`);
}).then(() => {
return knex.raw(`
ALTER TABLE tbm_team_assignments
ADD UNIQUE INDEX tbm_team_assignments_session_id_worker_id_unique (session_id, worker_id)
`);
}).then(() => {
return knex.raw(`
ALTER TABLE tbm_team_assignments DROP COLUMN split_seq
`);
});
};

View File

@@ -0,0 +1,160 @@
// models/pageAccessModel.js
const db = require('../db/connection');
const PageAccessModel = {
// 사용자의 페이지 권한 조회
getUserPageAccess: (userId, callback) => {
const sql = `
SELECT
p.id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_admin_only,
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
upa.granted_at,
upa.granted_by,
granter.username as granted_by_username
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users granter ON upa.granted_by = granter.user_id
WHERE p.is_admin_only = 0
ORDER BY p.category, p.display_order
`;
db.query(sql, [userId], callback);
},
// 모든 페이지 목록 조회
getAllPages: (callback) => {
const sql = `
SELECT
id,
page_key,
page_name,
page_path,
category,
description,
is_admin_only,
display_order
FROM pages
WHERE is_admin_only = 0
ORDER BY category, display_order
`;
db.query(sql, callback);
},
// 페이지 권한 부여
grantPageAccess: (userId, pageId, grantedBy, callback) => {
const sql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES (?, ?, 1, ?, NOW())
ON DUPLICATE KEY UPDATE
can_access = 1,
granted_by = ?,
granted_at = NOW()
`;
db.query(sql, [userId, pageId, grantedBy, grantedBy], callback);
},
// 페이지 권한 회수
revokePageAccess: (userId, pageId, callback) => {
const sql = `
DELETE FROM user_page_access
WHERE user_id = ? AND page_id = ?
`;
db.query(sql, [userId, pageId], callback);
},
// 여러 페이지 권한 일괄 설정
setUserPageAccess: (userId, pageIds, grantedBy, callback) => {
db.beginTransaction((err) => {
if (err) return callback(err);
// 기존 권한 모두 삭제
const deleteSql = 'DELETE FROM user_page_access WHERE user_id = ?';
db.query(deleteSql, [userId], (err) => {
if (err) {
return db.rollback(() => callback(err));
}
// 새 권한이 없으면 커밋하고 종료
if (!pageIds || pageIds.length === 0) {
return db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, { affectedRows: 0 });
});
}
// 새 권한 추가
const values = pageIds.map(pageId => [userId, pageId, 1, grantedBy]);
const insertSql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES ?
`;
db.query(insertSql, [values], (err, result) => {
if (err) {
return db.rollback(() => callback(err));
}
db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, result);
});
});
});
});
},
// 특정 페이지 접근 권한 확인
checkPageAccess: (userId, pageKey, callback) => {
const sql = `
SELECT
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
p.is_admin_only
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
WHERE p.page_key = ?
`;
db.query(sql, [userId, pageKey], (err, results) => {
if (err) return callback(err);
if (results.length === 0) return callback(null, { can_access: false });
callback(null, results[0]);
});
},
// 계정이 있는 작업자 목록 조회 (권한 관리용)
getUsersWithAccounts: (callback) => {
const sql = `
SELECT
u.user_id,
u.username,
u.name,
u.role_id,
r.name as role_name,
u.worker_id,
w.worker_name,
w.job_type,
COUNT(upa.page_id) as granted_pages_count
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN workers w ON u.worker_id = w.worker_id
LEFT JOIN user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
WHERE u.is_active = 1
AND u.role_id IN (4, 5)
GROUP BY u.user_id
ORDER BY w.worker_name, u.username
`;
db.query(sql, callback);
}
};
module.exports = PageAccessModel;

View File

@@ -616,11 +616,13 @@ const TbmModel = {
t.task_name,
wp.workplace_name,
wc.category_name,
creator.name as created_by_name
creator.name as created_by_name,
lw.worker_name as leader_name
FROM tbm_team_assignments ta
INNER JOIN tbm_sessions s ON ta.session_id = s.session_id
INNER JOIN workers w ON ta.worker_id = w.worker_id
LEFT JOIN users creator ON s.created_by = creator.user_id
LEFT JOIN sso_users creator ON s.created_by = creator.user_id
LEFT JOIN workers lw ON s.leader_id = lw.worker_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
LEFT JOIN tasks t ON ta.task_id = t.task_id

View File

@@ -0,0 +1,284 @@
const visitRequestModel = require('../models/visitRequestModel');
const logger = require('../utils/logger');
// ==================== 출입 신청 관리 ====================
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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.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) {
logger.error('출입 신청 상태 업데이트 오류:', statusErr);
}
res.status(201).json({
success: true,
message: '안전교육 기록이 생성되었습니다.',
data: { training_id: trainingId }
});
} catch (err) {
logger.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) {
logger.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) {
logger.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) {
logger.error('출입 신청 상태 업데이트 오류:', statusErr);
}
res.json({ success: true, message: '안전교육이 완료되었습니다.' });
} catch (err) {
logger.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) {
logger.error('안전교육 기록 목록 조회 오류:', err);
res.status(500).json({ success: false, message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.' });
}
};

View File

@@ -0,0 +1,189 @@
/**
* 라우트 설정
*
* 애플리케이션의 모든 라우트를 등록하는 중앙화된 설정 파일
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger');
const { verifyToken } = require('../middlewares/auth');
const { activityLogger } = require('../middlewares/activityLogger');
const logger = require('../utils/logger');
/**
* 모든 라우트를 Express 앱에 등록
* @param {Express.Application} app - Express 애플리케이션 인스턴스
*/
function setupRoutes(app) {
// 라우터 가져오기
const authRoutes = require('../routes/authRoutes');
const projectRoutes = require('../routes/projectRoutes');
const workerRoutes = require('../routes/workerRoutes');
const workReportRoutes = require('../routes/workReportRoutes');
const toolsRoute = require('../routes/toolsRoute');
const uploadRoutes = require('../routes/uploadRoutes');
const uploadBgRoutes = require('../routes/uploadBgRoutes');
const dailyIssueReportRoutes = require('../routes/dailyIssueReportRoutes');
const issueTypeRoutes = require('../routes/issueTypeRoutes');
const healthRoutes = require('../routes/healthRoutes');
const dailyWorkReportRoutes = require('../routes/dailyWorkReportRoutes');
const workAnalysisRoutes = require('../routes/workAnalysisRoutes');
const analysisRoutes = require('../routes/analysisRoutes');
const systemRoutes = require('../routes/systemRoutes');
const performanceRoutes = require('../routes/performanceRoutes');
const userRoutes = require('../routes/userRoutes');
const setupRoutes = require('../routes/setupRoutes');
const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
const attendanceRoutes = require('../routes/attendanceRoutes');
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
const workplaceRoutes = require('../routes/workplaceRoutes');
const equipmentRoutes = require('../routes/equipmentRoutes');
const taskRoutes = require('../routes/taskRoutes');
const tbmRoutes = require('../routes/tbmRoutes');
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5회
message: '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false
});
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false,
// 관리자 및 시스템 계정은 rate limit 제외
skip: (req) => {
// 인증된 사용자 정보 확인
if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
return true; // rate limit 건너뛰기
}
return false;
}
});
// 모든 API 요청에 활동 로거 적용
app.use('/api/*', activityLogger);
// 인증 불필요 경로 - 로그인
app.use('/api/auth', loginLimiter, authRoutes);
// DB 설정 라우트 (개발용)
app.use('/api/setup', setupRoutes);
// Health check
app.use('/api/health', healthRoutes);
// 인증이 필요 없는 공개 경로 목록
const publicPaths = [
'/api/auth/login',
'/api/auth/refresh-token',
'/api/auth/check-password-strength',
'/api/health',
'/api/ping',
'/api/status',
'/api/setup/setup-attendance-db',
'/api/setup/setup-monthly-status',
'/api/setup/add-overtime-warning',
'/api/setup/migrate-existing-data',
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details'
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
app.use('/api/*', (req, res, next) => {
const isPublicPath = publicPaths.some(path => {
return req.originalUrl === path ||
req.originalUrl.startsWith(path + '?') ||
req.originalUrl.startsWith(path + '/');
});
if (isPublicPath) {
logger.debug('공개 경로 허용', { url: req.originalUrl });
return next();
}
logger.debug('인증 필요 경로', { url: req.originalUrl });
verifyToken(req, res, next);
});
// 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
app.use('/api/', apiLimiter);
// 인증된 사용자만 접근 가능한 라우트들
app.use('/api/issue-reports', dailyIssueReportRoutes);
app.use('/api/issue-types', issueTypeRoutes);
app.use('/api/workers', workerRoutes);
app.use('/api/daily-work-reports', dailyWorkReportRoutes);
app.use('/api/work-analysis', workAnalysisRoutes);
app.use('/api/analysis', analysisRoutes);
app.use('/api/daily-work-reports-analysis', workReportAnalysisRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/api/monthly-status', monthlyStatusRoutes);
app.use('/api/workreports', workReportRoutes);
app.use('/api/system', systemRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/performance', performanceRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute);
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 (userRoutes보다 먼저 등록 - /users/:id/page-access 매칭 우선)
app.use('/api/users', userRoutes);
app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
app.use('/api', uploadBgRoutes);
// Swagger API 문서
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'TK Work Management API',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
docExpansion: 'none',
filter: true,
showExtensions: true,
showCommonExtensions: true
}
}));
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
logger.info('라우트 설정 완료');
}
module.exports = setupRoutes;

View File

@@ -3,71 +3,6 @@ const router = express.Router();
const { getDb } = require('../dbPool');
const { requireAuth, requireAdmin } = require('../middlewares/auth');
// tkuser page_name → default_access 매핑 (permissionModel.js의 DEFAULT_PAGES와 동기화)
const TKUSER_DEFAULT_ACCESS = {
's1.dashboard': true,
's1.work.tbm': true,
's1.work.report_create': true,
's1.work.analysis': false,
's1.work.nonconformity': true,
's1.factory.repair_management': false,
's1.inspection.daily_patrol': false,
's1.inspection.checkin': true,
's1.inspection.work_status': false,
's1.safety.visit_request': true,
's1.safety.management': false,
's1.safety.checklist_manage': false,
's1.attendance.my_vacation_info': true,
's1.attendance.monthly': true,
's1.attendance.vacation_request': true,
's1.attendance.vacation_management': false,
's1.attendance.vacation_allocation': false,
's1.attendance.annual_overview': false,
's1.admin.workers': false,
's1.admin.projects': false,
's1.admin.tasks': false,
's1.admin.workplaces': false,
's1.admin.equipments': false,
's1.admin.issue_categories': false,
's1.admin.attendance_report': false,
};
// system1 page_key → tkuser page_name 매핑
const PAGEKEY_TO_TKUSER = {
'dashboard': 's1.dashboard',
'work.tbm': 's1.work.tbm',
'work.report-create': 's1.work.report_create',
'work.report-view': 's1.work.report_create',
'work.analysis': 's1.work.analysis',
'work.visit-request': 's1.safety.visit_request',
'work.issue-report': 's1.work.nonconformity',
'work.issue-list': 's1.work.nonconformity',
'work.issue-detail': 's1.work.nonconformity',
'safety.issue_report': 's1.work.nonconformity',
'safety.issue_list': 's1.work.nonconformity',
'safety.issue_detail': 's1.work.nonconformity',
'safety.checklist_manage': 's1.safety.checklist_manage',
'admin.workers': 's1.admin.workers',
'admin.projects': 's1.admin.projects',
'admin.tasks': 's1.admin.tasks',
'admin.workplaces': 's1.admin.workplaces',
'admin.equipments': 's1.admin.equipments',
'admin.codes': 's1.admin.tasks',
'admin.safety-management': 's1.safety.management',
'admin.safety-training-conduct': 's1.safety.management',
'admin.attendance-report-comparison': 's1.admin.attendance_report',
'admin.departments': 's1.admin.workers',
'common.daily-attendance': 's1.inspection.checkin',
'common.monthly-attendance': 's1.attendance.monthly',
'common.vacation-request': 's1.attendance.vacation_request',
'common.vacation-management': 's1.attendance.vacation_management',
'common.annual-vacation-overview': 's1.attendance.annual_overview',
'common.vacation-allocation': 's1.attendance.vacation_allocation',
'inspection.daily_patrol': 's1.inspection.daily_patrol',
'attendance.vacation_approval': 's1.attendance.vacation_management',
'attendance.vacation_input': 's1.attendance.vacation_allocation',
};
/**
* 모든 페이지 목록 조회
* GET /api/pages
@@ -94,22 +29,25 @@ router.get('/pages', requireAuth, async (req, res) => {
*/
router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
try {
const ssoUserId = req.params.userId;
const { userId } = req.params;
const db = await getDb();
// SSO 사용자 조회 (department_id 포함)
const [ssoRows] = await db.query(
'SELECT user_id, username, name, role, department_id FROM sso_users WHERE user_id = ?',
[ssoUserId]
);
if (ssoRows.length === 0) {
// 사용자의 역할 확인
const [userRows] = await db.query(`
SELECT u.user_id, u.username, u.role_id, r.name as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.user_id = ?
`, [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
}
const ssoUser = ssoRows[0];
// SSO role로 Admin 체크
const ssoRole = (ssoUser.role || '').toLowerCase();
if (ssoRole === 'admin' || ssoRole === 'system') {
const user = userRows[0];
// Admin/System Admin인 경우 모든 페이지 접근 가능
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
const [allPages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages
@@ -124,66 +62,32 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
category: page.category,
is_admin_only: page.is_admin_only,
can_access: true,
is_default: true
is_default: true // Admin은 기본적으로 모든 권한 보유
}));
return res.json({ success: true, data: { user: ssoUser, pageAccess } });
return res.json({ success: true, data: { user, pageAccess } });
}
// 일반 사용자: tkuser 권한 테이블에서 조회
// 1) 개인 권한 (user_page_permissions)
const [userPerms] = await db.query(
'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?',
[ssoUserId]
);
const userPermMap = {};
userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; });
// 일반 사용자의 페이지 접근 권한 조회
const [pageAccess] = await db.query(`
SELECT
p.id as page_id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_admin_only,
COALESCE(upa.can_access, p.is_default_accessible, 0) as can_access,
upa.granted_at,
u2.username as granted_by_username
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users u2 ON upa.granted_by = u2.user_id
WHERE p.is_admin_only = 0
ORDER BY p.display_order, p.page_name
`, [userId]);
// 2) 부서 권한 (department_page_permissions)
const deptPermMap = {};
if (ssoUser.department_id) {
const [deptPerms] = await db.query(
'SELECT page_name, can_access FROM department_page_permissions WHERE department_id = ?',
[ssoUser.department_id]
);
deptPerms.forEach(p => { deptPermMap[p.page_name] = !!p.can_access; });
}
// 3) 페이지 목록 조회 + 권한 매핑
const [pages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages
WHERE is_admin_only = 0
ORDER BY display_order, page_name
`);
const pageAccess = pages.map(page => {
const tkuserKey = PAGEKEY_TO_TKUSER[page.page_key];
let canAccess = false;
if (tkuserKey) {
// 우선순위: 개인 권한 > 부서 권한 > default_access
if (tkuserKey in userPermMap) {
canAccess = userPermMap[tkuserKey];
} else if (tkuserKey in deptPermMap) {
canAccess = deptPermMap[tkuserKey];
} else if (tkuserKey in TKUSER_DEFAULT_ACCESS) {
canAccess = TKUSER_DEFAULT_ACCESS[tkuserKey];
}
}
return {
page_id: page.id,
page_key: page.page_key,
page_name: page.page_name,
page_path: page.page_path,
category: page.category,
is_admin_only: page.is_admin_only,
can_access: canAccess ? 1 : 0
};
});
res.json({ success: true, data: { user: ssoUser, pageAccess } });
res.json({ success: true, data: { user, pageAccess } });
} catch (error) {
console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });

View File

@@ -19,6 +19,7 @@ describe('WorkReportService', () => {
describe('createWorkReportService', () => {
it('단일 보고서를 성공적으로 생성해야 함', async () => {
// Arrange
const reportData = {
report_date: '2025-12-11',
worker_id: 1,
@@ -28,32 +29,46 @@ describe('WorkReportService', () => {
work_content: '기능 개발'
};
workReportModel.create.mockResolvedValue(123);
// workReportModel.create가 콜백 형태이므로 모킹 설정
workReportModel.create = jest.fn((data, callback) => {
callback(null, 123); // insertId = 123
});
// Act
const result = await workReportService.createWorkReportService(reportData);
// Assert
expect(result).toEqual({ workReport_ids: [123] });
expect(workReportModel.create).toHaveBeenCalledTimes(1);
expect(workReportModel.create).toHaveBeenCalledWith(reportData);
expect(workReportModel.create).toHaveBeenCalledWith(
reportData,
expect.any(Function)
);
});
it('다중 보고서를 성공적으로 생성해야 함', async () => {
// Arrange
const reportsData = [
{ report_date: '2025-12-11', worker_id: 1, work_hours: 8 },
{ report_date: '2025-12-11', worker_id: 2, work_hours: 7 }
];
workReportModel.create
.mockResolvedValueOnce(101)
.mockResolvedValueOnce(102);
let callCount = 0;
workReportModel.create = jest.fn((data, callback) => {
callCount++;
callback(null, 100 + callCount);
});
// Act
const result = await workReportService.createWorkReportService(reportsData);
// Assert
expect(result).toEqual({ workReport_ids: [101, 102] });
expect(workReportModel.create).toHaveBeenCalledTimes(2);
});
it('빈 배열이면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.createWorkReportService([]))
.rejects.toThrow(ValidationError);
@@ -62,10 +77,14 @@ describe('WorkReportService', () => {
});
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
// Arrange
const reportData = { report_date: '2025-12-11', worker_id: 1 };
workReportModel.create.mockRejectedValue(new Error('DB connection failed'));
workReportModel.create = jest.fn((data, callback) => {
callback(new Error('DB connection failed'), null);
});
// Act & Assert
await expect(workReportService.createWorkReportService(reportData))
.rejects.toThrow(DatabaseError);
});
@@ -73,18 +92,24 @@ describe('WorkReportService', () => {
describe('getWorkReportsByDateService', () => {
it('날짜로 보고서를 조회해야 함', async () => {
// Arrange
const date = '2025-12-11';
const mockReports = mockWorkReports.filter(r => r.report_date === date);
workReportModel.getAllByDate.mockResolvedValue(mockReports);
workReportModel.getAllByDate = jest.fn((date, callback) => {
callback(null, mockReports);
});
// Act
const result = await workReportService.getWorkReportsByDateService(date);
// Assert
expect(result).toEqual(mockReports);
expect(workReportModel.getAllByDate).toHaveBeenCalledWith(date);
expect(workReportModel.getAllByDate).toHaveBeenCalledWith(date, expect.any(Function));
});
it('날짜가 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getWorkReportsByDateService(null))
.rejects.toThrow(ValidationError);
@@ -93,8 +118,12 @@ describe('WorkReportService', () => {
});
it('DB 오류 시 DatabaseError를 던져야 함', async () => {
workReportModel.getAllByDate.mockRejectedValue(new Error('DB error'));
// Arrange
workReportModel.getAllByDate = jest.fn((date, callback) => {
callback(new Error('DB error'), null);
});
// Act & Assert
await expect(workReportService.getWorkReportsByDateService('2025-12-11'))
.rejects.toThrow(DatabaseError);
});
@@ -102,19 +131,28 @@ describe('WorkReportService', () => {
describe('getWorkReportByIdService', () => {
it('ID로 보고서를 조회해야 함', async () => {
// Arrange
const mockReport = mockWorkReports[0];
workReportModel.getById.mockResolvedValue(mockReport);
workReportModel.getById = jest.fn((id, callback) => {
callback(null, mockReport);
});
// Act
const result = await workReportService.getWorkReportByIdService(1);
// Assert
expect(result).toEqual(mockReport);
expect(workReportModel.getById).toHaveBeenCalledWith(1);
expect(workReportModel.getById).toHaveBeenCalledWith(1, expect.any(Function));
});
it('보고서가 없으면 NotFoundError를 던져야 함', async () => {
workReportModel.getById.mockResolvedValue(null);
// Arrange
workReportModel.getById = jest.fn((id, callback) => {
callback(null, null);
});
// Act & Assert
await expect(workReportService.getWorkReportByIdService(999))
.rejects.toThrow(NotFoundError);
@@ -123,6 +161,7 @@ describe('WorkReportService', () => {
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getWorkReportByIdService(null))
.rejects.toThrow(ValidationError);
});
@@ -130,24 +169,38 @@ describe('WorkReportService', () => {
describe('updateWorkReportService', () => {
it('보고서를 성공적으로 수정해야 함', async () => {
// Arrange
const updateData = { work_hours: 9 };
workReportModel.update.mockResolvedValue(1);
workReportModel.update = jest.fn((id, data, callback) => {
callback(null, 1); // affectedRows = 1
});
// Act
const result = await workReportService.updateWorkReportService(123, updateData);
// Assert
expect(result).toEqual({ changes: 1 });
expect(workReportModel.update).toHaveBeenCalledWith(123, updateData);
expect(workReportModel.update).toHaveBeenCalledWith(
123,
updateData,
expect.any(Function)
);
});
it('수정할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
workReportModel.update.mockResolvedValue(0);
// Arrange
workReportModel.update = jest.fn((id, data, callback) => {
callback(null, 0); // affectedRows = 0
});
// Act & Assert
await expect(workReportService.updateWorkReportService(999, {}))
.rejects.toThrow(NotFoundError);
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.updateWorkReportService(null, {}))
.rejects.toThrow(ValidationError);
});
@@ -155,22 +208,32 @@ describe('WorkReportService', () => {
describe('removeWorkReportService', () => {
it('보고서를 성공적으로 삭제해야 함', async () => {
workReportModel.remove.mockResolvedValue(1);
// Arrange
workReportModel.remove = jest.fn((id, callback) => {
callback(null, 1);
});
// Act
const result = await workReportService.removeWorkReportService(123);
// Assert
expect(result).toEqual({ changes: 1 });
expect(workReportModel.remove).toHaveBeenCalledWith(123);
expect(workReportModel.remove).toHaveBeenCalledWith(123, expect.any(Function));
});
it('삭제할 보고서가 없으면 NotFoundError를 던져야 함', async () => {
workReportModel.remove.mockResolvedValue(0);
// Arrange
workReportModel.remove = jest.fn((id, callback) => {
callback(null, 0);
});
// Act & Assert
await expect(workReportService.removeWorkReportService(999))
.rejects.toThrow(NotFoundError);
});
it('ID가 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.removeWorkReportService(null))
.rejects.toThrow(ValidationError);
});
@@ -178,23 +241,34 @@ describe('WorkReportService', () => {
describe('getWorkReportsInRangeService', () => {
it('기간으로 보고서를 조회해야 함', async () => {
// Arrange
const start = '2025-12-01';
const end = '2025-12-31';
workReportModel.getByRange.mockResolvedValue(mockWorkReports);
workReportModel.getByRange = jest.fn((start, end, callback) => {
callback(null, mockWorkReports);
});
// Act
const result = await workReportService.getWorkReportsInRangeService(start, end);
// Assert
expect(result).toEqual(mockWorkReports);
expect(workReportModel.getByRange).toHaveBeenCalledWith(start, end);
expect(workReportModel.getByRange).toHaveBeenCalledWith(
start,
end,
expect.any(Function)
);
});
it('시작일이 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getWorkReportsInRangeService(null, '2025-12-31'))
.rejects.toThrow(ValidationError);
});
it('종료일이 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getWorkReportsInRangeService('2025-12-01', null))
.rejects.toThrow(ValidationError);
});
@@ -202,30 +276,41 @@ describe('WorkReportService', () => {
describe('getSummaryService', () => {
it('월간 요약을 조회해야 함', async () => {
// Arrange
const year = '2025';
const month = '12';
workReportModel.getByRange.mockResolvedValue(mockWorkReports);
workReportModel.getByRange = jest.fn((start, end, callback) => {
callback(null, mockWorkReports);
});
// Act
const result = await workReportService.getSummaryService(year, month);
// Assert
expect(result).toEqual(mockWorkReports);
expect(workReportModel.getByRange).toHaveBeenCalled();
});
it('연도가 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getSummaryService(null, '12'))
.rejects.toThrow(ValidationError);
});
it('월이 없으면 ValidationError를 던져야 함', async () => {
// Act & Assert
await expect(workReportService.getSummaryService('2025', null))
.rejects.toThrow(ValidationError);
});
it('데이터가 없으면 NotFoundError를 던져야 함', async () => {
workReportModel.getByRange.mockResolvedValue([]);
// Arrange
workReportModel.getByRange = jest.fn((start, end, callback) => {
callback(null, []);
});
// Act & Assert
await expect(workReportService.getSummaryService('2025', '12'))
.rejects.toThrow(NotFoundError);

View File

@@ -2016,3 +2016,374 @@
color: #6b7280;
font-size: 0.875rem;
}
/* ================================================
모바일 카드 레이아웃 + 터치 최적화
================================================ */
/* 전역 터치 최적화 - 더블탭 줌 방지 */
* {
touch-action: manipulation;
}
/* 로딩 상태 버튼 */
.btn-submit-compact.is-loading,
.btn-batch-submit.is-loading {
pointer-events: none;
opacity: 0.7;
position: relative;
}
.btn-submit-compact.is-loading::after,
.btn-batch-submit.is-loading::after {
content: '';
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-left: 6px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ========== 모바일 768px 이하 ========== */
@media (max-width: 768px) {
/* --- 날짜 그룹 헤더 모바일 --- */
.date-group-header {
flex-wrap: wrap;
padding: 0.75rem 1rem;
gap: 0.25rem;
}
.date-header-left {
width: 100%;
}
.date-header-center {
gap: 0.5rem;
font-size: 0.8rem;
}
.date-header-right {
margin-left: auto;
}
/* --- TBM 세션 헤더 모바일 --- */
.tbm-session-header {
flex-wrap: wrap;
padding: 0.625rem 1rem;
gap: 0.375rem;
font-size: 0.8rem;
}
.tbm-session-count {
margin-left: 0;
}
/* --- 테이블 → 카드 전환 --- */
.tbm-table-container {
overflow-x: visible;
border: none;
border-radius: 0;
background: transparent;
}
.tbm-work-table {
display: block;
}
.tbm-work-table thead {
display: none;
}
.tbm-work-table tbody {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
}
.tbm-work-table tbody tr[data-type] {
display: flex;
flex-wrap: wrap;
background: white;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
padding: 0;
overflow: hidden;
}
.tbm-work-table tbody tr[data-type] td {
border-bottom: none;
padding: 0;
}
/* 작업자 이름 (카드 상단 헤더) */
.tbm-work-table tbody tr[data-type] td:nth-child(1) {
width: 100%;
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
padding: 0.625rem 0.875rem;
border-bottom: 1px solid #e5e7eb;
}
.tbm-work-table tbody tr[data-type] td:nth-child(1) .worker-cell {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.tbm-work-table tbody tr[data-type] td:nth-child(1) .worker-cell strong {
margin-bottom: 0;
font-size: 0.95rem;
}
/* 프로젝트/공정/작업/작업장소 → 2열 그리드 */
.tbm-work-table tbody tr[data-type] td:nth-child(2),
.tbm-work-table tbody tr[data-type] td:nth-child(3),
.tbm-work-table tbody tr[data-type] td:nth-child(4),
.tbm-work-table tbody tr[data-type] td:nth-child(5) {
width: 50%;
padding: 0.5rem 0.875rem;
font-size: 0.8rem;
box-sizing: border-box;
}
.tbm-work-table tbody tr[data-type] td:nth-child(2)::before,
.tbm-work-table tbody tr[data-type] td:nth-child(3)::before,
.tbm-work-table tbody tr[data-type] td:nth-child(4)::before,
.tbm-work-table tbody tr[data-type] td:nth-child(5)::before {
content: attr(data-label);
display: block;
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.125rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* 작업시간 + 부적합 + 제출 → 하단 풀 영역 */
.tbm-work-table tbody tr[data-type] td:nth-child(6),
.tbm-work-table tbody tr[data-type] td:nth-child(7),
.tbm-work-table tbody tr[data-type] td:nth-child(8) {
padding: 0.5rem 0.875rem;
box-sizing: border-box;
}
/* 작업시간 */
.tbm-work-table tbody tr[data-type] td:nth-child(6) {
width: 40%;
border-top: 1px solid #f3f4f6;
}
.tbm-work-table tbody tr[data-type] td:nth-child(6)::before {
content: '작업시간';
display: block;
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.25rem;
}
/* 부적합 */
.tbm-work-table tbody tr[data-type] td:nth-child(7) {
width: 30%;
border-top: 1px solid #f3f4f6;
}
/* 제출 */
.tbm-work-table tbody tr[data-type] td:nth-child(8) {
width: 30%;
border-top: 1px solid #f3f4f6;
display: flex;
align-items: center;
justify-content: flex-end;
}
/* 수동 입력의 날짜 컬럼 처리 (9개 컬럼) */
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(2) {
width: 50%;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(2)::before {
content: '날짜';
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(3) {
width: 50%;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(3)::before {
content: '프로젝트';
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(4)::before {
content: '공정';
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(5)::before {
content: '작업';
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(6) {
width: 50%;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(6)::before {
content: '작업장소';
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(7) {
width: 50%;
border-top: 1px solid #f3f4f6;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(7)::before {
content: '작업시간';
display: block;
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.25rem;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(8) {
width: 25%;
border-top: 1px solid #f3f4f6;
}
.manual-input-section .tbm-work-table tbody tr[data-type] td:nth-child(9) {
width: 25%;
border-top: 1px solid #f3f4f6;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 0.875rem;
}
/* 수동 입력 select/input 모바일 크기 조정 */
.manual-input-section .form-input-compact {
width: 100% !important;
min-width: 0;
font-size: 0.8rem;
}
/* 부적합 행 (defect-row) 카드 모바일 */
.tbm-work-table tbody tr.defect-row {
display: block;
margin-top: -0.75rem;
border: 1px solid #fde68a;
border-top: none;
border-radius: 0 0 10px 10px;
background: #fef3c7;
padding: 0;
}
.tbm-work-table tbody tr.defect-row td {
display: block;
width: 100%;
padding: 0.75rem;
}
.tbm-work-table tbody tr.defect-row td[colspan] {
padding: 0.75rem;
}
/* 시간 입력 트리거 모바일 확대 */
.time-input-trigger {
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
padding: 0.5rem 0.75rem;
}
/* 제출 버튼 터치 타겟 확대 */
.btn-submit-compact {
min-height: 44px;
min-width: 60px;
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
/* 부적합 버튼 터치 타겟 확대 */
.btn-defect-toggle {
min-height: 44px;
min-width: 60px;
padding: 0.5rem 0.625rem;
font-size: 0.8rem;
}
/* 일괄제출 버튼 모바일 full-width */
.batch-submit-container {
padding: 0.75rem;
}
.btn-batch-submit {
width: 100%;
min-height: 48px;
font-size: 0.95rem;
padding: 0.75rem 1rem;
border-radius: 8px;
}
/* --- 시간 선택 피커 모바일 --- */
.time-picker-popup {
max-width: 340px;
padding: 1.25rem;
}
.quick-time-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
.time-btn {
min-height: 56px;
padding: 0.75rem 0.375rem;
font-size: 0.95rem;
border-radius: 8px;
}
.time-btn .time-value {
font-size: 1rem;
}
.adjust-btn {
min-height: 48px;
font-size: 0.95rem;
}
.confirm-btn {
min-height: 52px;
font-size: 1rem;
}
/* --- 작업장소 모달 모바일 --- */
#workplaceModal .modal-container {
width: 95% !important;
max-width: none !important;
}
/* --- 신고 리마인더 모바일 --- */
.issue-reminder-section {
margin: 0 0 0.75rem 0;
border-radius: 8px;
}
.issue-reminder-item {
flex-wrap: wrap;
gap: 0.25rem;
}
/* --- 작업 추가 버튼 모바일 --- */
.btn-add-work {
min-height: 44px;
padding: 0.625rem 1rem;
}
/* --- 삭제 버튼 모바일 --- */
.btn-delete-compact {
min-height: 44px;
min-width: 44px;
}
}

View File

@@ -0,0 +1,851 @@
/* TBM Mobile Styles */
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: #f3f4f6;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
touch-action: manipulation;
}
button, .m-tbm-row, .m-tab, .m-new-btn, .m-detail-btn, .m-load-more,
.picker-item, .split-radio-item, .split-session-item, .pull-btn,
.de-save-btn, .de-group-btn, .de-split-btn, .pill-btn, .worker-card,
[onclick] {
touch-action: manipulation;
}
@media (min-width: 480px) {
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
}
/* Header */
.m-header {
position: sticky;
top: 0;
z-index: 100;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
padding: 0.875rem 1rem;
padding-top: calc(0.875rem + env(safe-area-inset-top));
}
.m-header-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.m-header h1 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.m-header .m-date {
font-size: 0.75rem;
opacity: 0.8;
}
.m-new-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.2);
color: white;
border: 1.5px solid rgba(255,255,255,0.4);
border-radius: 2rem;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-new-btn:active { background: rgba(255,255,255,0.3); }
/* Tabs */
.m-tabs {
display: flex;
background: white;
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 90;
}
.m-tab {
flex: 1;
padding: 0.75rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: #9ca3af;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
-webkit-tap-highlight-color: transparent;
}
.m-tab.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.m-tab .tab-count {
display: inline-block;
min-width: 18px;
height: 18px;
line-height: 18px;
border-radius: 9px;
background: #e5e7eb;
color: #6b7280;
font-size: 0.6875rem;
font-weight: 700;
text-align: center;
margin-left: 0.25rem;
padding: 0 0.25rem;
}
.m-tab.active .tab-count {
background: #dbeafe;
color: #1d4ed8;
}
/* Content area */
.m-content {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
min-height: 60vh;
}
/* Date group */
.m-date-group {
padding: 0.5rem 1rem 0.25rem;
}
.m-date-label {
font-size: 0.6875rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* TBM list row */
.m-tbm-row {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: white;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-tbm-row:active { background: #f9fafb; }
.m-tbm-row:first-child { border-top: 1px solid #e5e7eb; }
.m-row-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-right: 0.75rem;
}
.m-row-status.draft { background: #f59e0b; }
.m-row-status.completed { background: #10b981; }
.m-row-status.cancelled { background: #ef4444; }
.m-row-body {
flex: 1;
min-width: 0;
}
.m-row-main {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-row-sub {
font-size: 0.6875rem;
color: #9ca3af;
margin-top: 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-row-right {
flex-shrink: 0;
text-align: right;
margin-left: 0.75rem;
}
.m-row-count {
font-size: 0.8125rem;
font-weight: 700;
color: #1f2937;
}
.m-row-count-label {
font-size: 0.625rem;
color: #9ca3af;
}
.m-row-time {
font-size: 0.625rem;
color: #9ca3af;
margin-top: 0.125rem;
}
/* TBM detail expanded */
.m-tbm-detail {
display: none;
background: #f9fafb;
padding: 0.75rem 1rem 0.75rem 2.75rem;
border-bottom: 1px solid #e5e7eb;
}
.m-tbm-row.expanded + .m-tbm-detail { display: block; }
.m-detail-row {
display: flex;
padding: 0.25rem 0;
font-size: 0.75rem;
}
.m-detail-label {
color: #6b7280;
width: 50px;
flex-shrink: 0;
}
.m-detail-value {
color: #1f2937;
font-weight: 500;
flex: 1;
}
.m-detail-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #e5e7eb;
}
.m-detail-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.m-detail-btn:active { background: #f3f4f6; }
.m-detail-btn.primary {
background: #2563eb;
color: white;
border-color: #2563eb;
}
.m-detail-btn.primary:active { background: #1d4ed8; }
.m-detail-btn.danger {
color: #ef4444;
border-color: #fca5a5;
}
/* Empty state */
.m-empty {
text-align: center;
padding: 3rem 1rem;
color: #9ca3af;
}
.m-empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.m-empty-text {
font-size: 0.875rem;
}
.m-empty-sub {
font-size: 0.75rem;
margin-top: 0.25rem;
}
/* Load more */
.m-load-more {
display: block;
width: calc(100% - 2rem);
margin: 0.75rem 1rem;
padding: 0.75rem;
border: 1px dashed #d1d5db;
border-radius: 0.75rem;
background: white;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.m-load-more:active { background: #f3f4f6; }
/* Loading skeleton */
.m-skeleton {
height: 56px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-bottom: 1px solid #f3f4f6;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Bottom nav */
.m-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 68px;
background: #ffffff;
border-top: 1px solid #e5e7eb;
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
z-index: 1000;
padding-bottom: env(safe-area-inset-bottom);
display: flex;
align-items: center;
justify-content: space-around;
}
@media (min-width: 480px) {
.m-bottom-nav { max-width: 480px; margin: 0 auto; }
}
.m-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
text-decoration: none;
color: #9ca3af;
font-family: inherit;
cursor: pointer;
padding: 0.5rem 0.25rem;
-webkit-tap-highlight-color: transparent;
border: none;
background: none;
}
.m-nav-item.active { color: #2563eb; }
.m-nav-item svg {
width: 26px;
height: 26px;
margin-bottom: 4px;
}
.m-nav-item.active svg { stroke-width: 2.5; }
.m-nav-label {
font-size: 0.6875rem;
font-weight: 500;
}
.m-nav-item.active .m-nav-label { font-weight: 700; }
/* Detail badge */
.m-detail-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.375rem;
vertical-align: middle;
}
.m-detail-badge.incomplete {
background: #fef3c7;
color: #92400e;
}
.m-detail-badge.complete {
background: #d1fae5;
color: #065f46;
}
/* Worker card status indicator */
.de-worker-card.filled {
background: #f0fdf4;
border-left: 3px solid #10b981;
padding-left: calc(0.625rem - 3px);
}
.de-worker-card.unfilled {
border-left: 3px solid #f59e0b;
padding-left: calc(0.625rem - 3px);
}
.de-worker-status {
font-size: 0.625rem;
font-weight: 600;
margin-left: 0.375rem;
}
.de-worker-status.ok { color: #059669; }
.de-worker-status.missing { color: #d97706; }
/* Group select */
.de-worker-check {
width: 20px;
height: 20px;
accent-color: #2563eb;
margin-right: 0.5rem;
flex-shrink: 0;
}
.de-group-bar {
display: none;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
margin: 0 1rem 0.5rem;
font-size: 0.75rem;
color: #1e40af;
}
.de-group-bar.visible { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }
.de-group-btn {
padding: 0.25rem 0.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.de-select-all-row {
display: flex;
align-items: center;
padding: 0.375rem 1rem;
font-size: 0.75rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
/* Detail edit bottom sheet */
.detail-edit-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9000;
}
.detail-edit-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9001;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 90vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.de-header {
position: sticky;
top: 0;
background: white;
padding: 1rem 1rem 0;
border-radius: 1rem 1rem 0 0;
z-index: 1;
}
.de-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.de-header h3 { margin: 0; font-size: 1rem; font-weight: 700; }
.de-close {
background: none;
border: none;
font-size: 1.25rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
}
/* Picker popup */
.picker-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 9100;
}
.picker-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9101;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 70vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.picker-header {
position: sticky;
top: 0;
background: white;
padding: 0.875rem 1rem 0.5rem;
border-radius: 1rem 1rem 0 0;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
.picker-close {
background: none;
border: none;
font-size: 1.125rem;
color: #6b7280;
cursor: pointer;
padding: 0.25rem;
}
.picker-list { padding: 0.25rem 0; }
.picker-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #1f2937;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
-webkit-tap-highlight-color: transparent;
}
.picker-item:active { background: #f3f4f6; }
.picker-item.selected { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
.picker-item-sub { font-size: 0.6875rem; color: #9ca3af; margin-left: 0.375rem; }
.picker-divider {
padding: 0.375rem 1rem;
font-size: 0.6875rem;
font-weight: 700;
color: #6b7280;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.picker-add-row {
display: flex;
gap: 0.375rem;
padding: 0.625rem 1rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
position: sticky;
bottom: 0;
}
.picker-add-input {
flex: 1;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.picker-add-btn {
padding: 0.5rem 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.de-worker-list { padding: 0 1rem; }
.de-worker-card {
padding: 0.625rem 0;
border-bottom: 1px solid #f3f4f6;
}
.de-worker-card:last-child { border-bottom: none; }
.de-worker-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.de-worker-job {
font-size: 0.75rem;
color: #6b7280;
}
.de-worker-fields {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.de-field-row {
display: flex;
align-items: center;
gap: 0.375rem;
}
.de-field-label {
font-size: 0.6875rem;
color: #6b7280;
width: 32px;
flex-shrink: 0;
}
.de-field-row select {
flex: 1;
padding: 0.4375rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.8125rem;
background: white;
}
.de-save-area {
padding: 0.75rem 1rem 1rem;
position: sticky;
bottom: 0;
background: white;
border-top: 1px solid #e5e7eb;
}
.de-save-btn {
width: 100%;
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
}
.de-save-btn:disabled { opacity: 0.5; }
/* My TBM highlight */
.m-tbm-row.my-tbm {
border-left: 3px solid #2563eb;
}
.m-leader-badge {
display: inline-block;
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #eff6ff;
color: #1d4ed8;
}
.m-transfer-badge {
display: inline-block;
font-size: 0.5625rem;
font-weight: 600;
padding: 0.0625rem 0.3125rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #fef3c7;
color: #92400e;
}
.m-work-hours-tag {
display: inline-block;
font-size: 0.625rem;
font-weight: 600;
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
background: #dbeafe;
color: #1d4ed8;
}
/* Split sheet */
.split-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9201;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 85vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.split-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9200;
}
.split-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.split-header h4 { margin: 0 0 0.25rem; font-size: 0.9375rem; font-weight: 700; }
.split-header p { margin: 0; font-size: 0.75rem; color: #6b7280; }
.split-body { padding: 0.75rem 1rem; }
.split-field { margin-bottom: 0.75rem; }
.split-field label { display: block; font-size: 0.75rem; font-weight: 600; color: #374151; margin-bottom: 0.25rem; }
.split-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.split-radio-group { display: flex; gap: 0.5rem; margin-top: 0.25rem; }
.split-radio-item {
flex: 1;
padding: 0.5rem;
border: 1.5px solid #d1d5db;
border-radius: 0.5rem;
text-align: center;
font-size: 0.8125rem;
cursor: pointer;
background: white;
}
.split-radio-item.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.split-session-list { margin-top: 0.5rem; }
.split-session-item {
padding: 0.625rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
margin-bottom: 0.375rem;
cursor: pointer;
font-size: 0.8125rem;
}
.split-session-item:active, .split-session-item.active {
border-color: #2563eb;
background: #eff6ff;
}
.split-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #e5e7eb;
}
.split-btn {
width: 100%;
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
}
.split-btn:disabled { opacity: 0.5; }
/* Pull sheet */
.pull-sheet {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9101;
background: white;
border-radius: 1rem 1rem 0 0;
max-height: 85vh;
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
}
.pull-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 9100;
}
.pull-header {
position: sticky;
top: 0;
background: white;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
border-radius: 1rem 1rem 0 0;
z-index: 1;
}
.pull-header h4 { margin: 0; font-size: 0.9375rem; font-weight: 700; }
.pull-header p { margin: 0.25rem 0 0; font-size: 0.75rem; color: #6b7280; }
.pull-member-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
}
.pull-member-info { flex: 1; }
.pull-member-name { font-size: 0.875rem; font-weight: 600; color: #1f2937; }
.pull-member-sub { font-size: 0.6875rem; color: #9ca3af; margin-top: 0.125rem; }
.pull-btn {
padding: 0.375rem 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.pull-btn:disabled {
background: #d1d5db;
color: #9ca3af;
cursor: default;
}
/* de-split-btn in detail edit */
.de-split-btn {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: #374151;
cursor: pointer;
margin-left: auto;
}
.de-split-btn:active { background: #e5e7eb; }
/* Toast */
.toast-container {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
/* Loading overlay */
.m-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(255,255,255,0.75);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.m-loading-overlay.active { display: flex; }
.m-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.m-loading-text {
font-size: 0.875rem;
color: #6b7280;
}

View File

@@ -7,7 +7,6 @@
*/
export function parseJwt(token) {
try {
// 토큰의 두 번째 부분(payload)을 base64 디코딩하고 JSON으로 파싱
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
console.error("잘못된 토큰입니다.", e);
@@ -20,7 +19,7 @@ export function parseJwt(token) {
*/
export function getToken() {
if (window.getSSOToken) return window.getSSOToken();
return localStorage.getItem('sso_token');
return localStorage.getItem('sso_token') || localStorage.getItem('token');
}
/**
@@ -28,9 +27,9 @@ export function getToken() {
*/
export function getUser() {
if (window.getSSOUser) return window.getSSOUser();
const user = localStorage.getItem('sso_user');
const raw = localStorage.getItem('sso_user') || localStorage.getItem('user');
try {
return user ? JSON.parse(user) : null;
return raw ? JSON.parse(raw) : null;
} catch(e) {
return null;
}
@@ -38,10 +37,16 @@ export function getUser() {
/**
* 로그인 성공 후 토큰과 사용자 정보를 저장합니다.
* 하위 호환성을 위해 sso_token/sso_user와 token/user 모두에 저장합니다.
*/
export function saveAuthData(token, user) {
const userStr = JSON.stringify(user);
// SSO 키
localStorage.setItem('sso_token', token);
localStorage.setItem('sso_user', JSON.stringify(user));
localStorage.setItem('sso_user', userStr);
// 하위 호환 키 (캐시된 구버전 app-init.js 대응)
localStorage.setItem('token', token);
localStorage.setItem('user', userStr);
}
/**
@@ -51,6 +56,9 @@ export function clearAuthData() {
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('userPageAccess');
}
/**
@@ -59,4 +67,4 @@ export function clearAuthData() {
export function isLoggedIn() {
const token = getToken();
return !!token && token !== 'undefined' && token !== 'null';
}
}

View File

@@ -15,7 +15,7 @@ export const config = {
// 페이지 경로 설정
paths: {
// 로그인 페이지 경로
loginPage: '/login',
loginPage: '/index.html',
// 메인 대시보드 경로 (모든 사용자 공통)
dashboard: '/pages/dashboard.html',
// 하위 호환성을 위한 별칭들
@@ -39,4 +39,4 @@ export const config = {
// 토큰 만료 확인 주기 (밀리초 단위, 예: 5분)
tokenRefreshInterval: 5 * 60 * 1000,
}
};
};

View File

@@ -70,17 +70,10 @@ const MobileReport = (function() {
}
}
// ===== 유틸리티 =====
function getKoreaToday() {
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
}
function formatDateForApi(date) {
if (!date) return null;
const d = date instanceof Date ? date : new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// ===== 유틸리티 (CommonUtils 위임) =====
var CU = window.CommonUtils;
function getKoreaToday() { return CU.getTodayKST(); }
function formatDateForApi(date) { return CU.formatDate(date) || null; }
function formatDate(dateString) {
if (!dateString) return '';
@@ -88,10 +81,7 @@ const MobileReport = (function() {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function getDayOfWeek(dateString) {
const days = ['일', '월', '화', '수', '목', '금', '토'];
return days[new Date(dateString).getDay()];
}
function getDayOfWeek(dateString) { return CU.getDayOfWeek(dateString); }
function formatHours(val) {
if (!val || val <= 0) return '선택';

View File

@@ -5,41 +5,24 @@
// =================================================================
// API 설정은 api-config.js에서 window 객체에 설정됨
// 전역 변수
let workTypes = [];
let workStatusTypes = [];
let errorTypes = []; // 레거시 호환용
let issueCategories = []; // 신고 카테고리 (nonconformity)
let issueItems = []; // 신고 아이템
let workers = [];
let projects = [];
let selectedWorkers = new Set();
let workEntryCounter = 0;
let currentStep = 1;
let editingWorkId = null; // 수정 중인 작업 ID
let incompleteTbms = []; // 미완료 TBM 작업 목록
let currentTab = 'tbm'; // 현재 활성 탭
// 전역 변수 → DailyWorkReportState 프록시 사용 (state.js에서 window 프록시 정의)
// workTypes, workStatusTypes, errorTypes, issueCategories, issueItems,
// workers, projects, selectedWorkers, incompleteTbms, tempDefects,
// dailyIssuesCache, currentTab, currentStep, editingWorkId, workEntryCounter,
// currentDefectIndex, currentEditingField, currentTimeValue,
// selectedWorkplace, selectedWorkplaceName, selectedWorkplaceCategory, selectedWorkplaceCategoryName
// 부적합 원인 관리
let currentDefectIndex = null; // 현재 편집 중인 행 인덱스
let tempDefects = {}; // 임시 부적합 원인 저장 { index: [{ error_type_id, defect_hours, note }] }
// 작업장소 지도 관련 변수
let mapCanvas = null;
let mapCtx = null;
let mapImage = null;
let mapRegions = [];
let selectedWorkplace = null;
let selectedWorkplaceName = null;
let selectedWorkplaceCategory = null;
let selectedWorkplaceCategoryName = null;
// 지도 관련 변수 (프록시 아님)
var mapCanvas = null;
var mapCtx = null;
var mapImage = null;
var mapRegions = [];
// 시간 선택 관련 변수
let currentEditingField = null; // { index, type: 'total' | 'error' }
let currentTimeValue = 0;
// currentEditingField, currentTimeValue → DailyWorkReportState 프록시 사용
// 당일 신고 리마인더 관련 변수
let dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] } - 날짜별 신고 캐시
// dailyIssuesCache → DailyWorkReportState 프록시 사용
// =================================================================
// TBM 작업보고 관련 함수
@@ -182,75 +165,23 @@ function getRelatedIssues(dateStr, workplaceId, projectId) {
}
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환 - 로컬 시간대 기준
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
function formatDateForApi(date) {
if (window.CommonUtils) return window.CommonUtils.formatDate(date) || null;
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
// 문자열인 경우 Date 객체로 변환
dateObj = new Date(date);
} else {
return null;
}
// 로컬 시간대 기준으로 날짜 추출 (UTC 변환 방지)
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return null;
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
}
/**
* 사용자 정보 가져오기 (auth-check.js와 동일한 로직)
*/
function getUser() {
const user = localStorage.getItem('sso_user');
return user ? JSON.parse(user) : null;
}
/**
* 근태 유형에 따른 기본 작업시간 반환
*/
function getDefaultHoursFromAttendance(tbm) {
// work_hours가 있으면 (분할 배정) 해당 값 우선 사용
if (tbm.work_hours != null && parseFloat(tbm.work_hours) > 0) {
return parseFloat(tbm.work_hours);
}
switch (tbm.attendance_type) {
case 'overtime': return 8 + (parseFloat(tbm.attendance_hours) || 0);
case 'regular': return 8;
case 'half': return 4;
case 'quarter': return 6;
case 'early': return parseFloat(tbm.attendance_hours) || 0;
default: return 0;
}
}
/**
* 근태 유형 뱃지 HTML 반환
*/
function getAttendanceBadgeHtml(type) {
const labels = { overtime: '연장근무', regular: '정시근로', annual: '연차', half: '반차', quarter: '반반차', early: '조퇴' };
const colors = { overtime: '#7c3aed', regular: '#2563eb', annual: '#ef4444', half: '#f59e0b', quarter: '#f97316', early: '#6b7280' };
if (!type || !labels[type]) return '';
return ` <span style="display:inline-block; padding:0.125rem 0.375rem; border-radius:0.25rem; font-size:0.625rem; font-weight:700; color:white; background:${colors[type]}; vertical-align:middle; margin-left:0.25rem;">${labels[type]}</span>`;
}
/**
* 시간 표시 포맷
*/
function formatHoursDisplay(val) {
if (!val || val <= 0) return '시간 선택';
val = parseFloat(val);
if (val === Math.floor(val)) return val + '시간';
const hours = Math.floor(val);
const mins = Math.round((val - hours) * 60);
return hours > 0 ? hours + '시간 ' + mins + '분' : mins + '분';
if (window.getSSOUser) return window.getSSOUser();
const raw = localStorage.getItem('sso_user') || localStorage.getItem('user');
try { return raw ? JSON.parse(raw) : null; } catch(e) { return null; }
}
/**
@@ -278,7 +209,7 @@ function renderTbmWorkList() {
byDate[dateStr].sessions[sessionKey] = {
session_id: tbm.session_id,
session_date: tbm.session_date,
created_by_name: tbm.created_by_name,
created_by_name: tbm.leader_name || tbm.created_by_name || '-',
items: []
};
}
@@ -462,34 +393,29 @@ function renderTbmWorkList() {
}
return false;
});
// 근태 기반 자동 시간 채움
const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0;
const hasDefaultHours = defaultHours > 0;
const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : '';
return `
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
<td>
<div class="worker-cell">
<strong>${tbm.worker_name || '작업자'}</strong>${attendanceBadgeHtml}
<strong>${tbm.worker_name || '작업자'}</strong>
<div class="worker-job-type">${tbm.job_type || '-'}</div>
</div>
</td>
<td>${tbm.project_name || '-'}</td>
<td>${tbm.work_type_name || '-'}</td>
<td>${tbm.task_name || '-'}</td>
<td>
<td data-label="프로젝트">${tbm.project_name || '-'}</td>
<td data-label="공정">${tbm.work_type_name || '-'}</td>
<td data-label="작업">${tbm.task_name || '-'}</td>
<td data-label="작업장소">
<div class="workplace-cell">
<div>${tbm.category_name || ''}</div>
<div>${tbm.workplace_name || '-'}</div>
</div>
</td>
<td>
<input type="hidden" id="totalHours_${index}" value="${hasDefaultHours ? defaultHours : ''}" required>
<div class="time-input-trigger ${hasDefaultHours ? '' : 'placeholder'}"
<input type="hidden" id="totalHours_${index}" value="" required>
<div class="time-input-trigger placeholder"
id="totalHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'total')"
style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}">
${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
onclick="openTimePicker(${index}, 'total')">
시간 선택
</div>
</td>
<td>
@@ -593,6 +519,10 @@ window.calculateRegularHours = function(index) {
* TBM 작업보고서 제출
*/
window.submitTbmWorkReport = async function(index) {
// busy guard - 중복 제출 방지
const submitBtn = document.querySelector(`tr[data-index="${index}"][data-type="tbm"] .btn-submit-compact`);
if (submitBtn && submitBtn.classList.contains('is-loading')) return;
const tbm = incompleteTbms[index];
const totalHours = parseFloat(document.getElementById(`totalHours_${index}`).value);
@@ -614,6 +544,13 @@ window.submitTbmWorkReport = async function(index) {
return;
}
// 로딩 상태 시작
if (submitBtn) {
submitBtn.classList.add('is-loading');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중';
}
// 부적합 원인 유효성 검사 (issue_report_id 또는 category_id 또는 error_type_id 필요)
console.log('🔍 부적합 검증 시작:', defects.map(d => ({
defect_hours: d.defect_hours,
@@ -722,6 +659,13 @@ window.submitTbmWorkReport = async function(index) {
} catch (error) {
console.error('TBM 작업보고서 제출 오류:', error);
showSaveResultModal('error', '제출 실패', error.message);
} finally {
// 로딩 상태 해제
if (submitBtn) {
submitBtn.classList.remove('is-loading');
submitBtn.disabled = false;
submitBtn.textContent = '제출';
}
}
};
@@ -729,6 +673,10 @@ window.submitTbmWorkReport = async function(index) {
* TBM 세션 일괄제출
*/
window.batchSubmitTbmSession = async function(sessionKey) {
// busy guard - 일괄제출 버튼
const batchBtn = document.querySelector(`[data-session-key="${sessionKey}"] ~ .batch-submit-container .btn-batch-submit, .tbm-session-group[data-session-key="${sessionKey}"] .btn-batch-submit`);
if (batchBtn && batchBtn.classList.contains('is-loading')) return;
// 해당 세션의 모든 항목 가져오기
const sessionRows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"]`);
@@ -804,7 +752,8 @@ window.batchSubmitTbmSession = async function(sessionKey) {
}
// 2단계: 모든 항목 제출
const submitBtn = event.target;
const submitBtn = batchBtn || event.target;
submitBtn.classList.add('is-loading');
submitBtn.disabled = true;
submitBtn.textContent = '제출 중...';
@@ -869,6 +818,7 @@ window.batchSubmitTbmSession = async function(sessionKey) {
console.error('일괄제출 오류:', error);
showSaveResultModal('error', '일괄제출 오류', error.message);
} finally {
submitBtn.classList.remove('is-loading');
submitBtn.disabled = false;
submitBtn.textContent = `📤 이 세션 일괄제출 (${sessionRows.length}건)`;
}
@@ -1160,7 +1110,7 @@ async function loadWorkplaceMap(categoryId, layoutImagePath, workplaces) {
mapCtx = mapCanvas.getContext('2d');
// 이미지 URL 생성
const baseUrl = window.API_BASE_URL || 'http://localhost:30005';
const baseUrl = window.API_BASE_URL || 'http://localhost:20005';
const apiBaseUrl = baseUrl.replace('/api', ''); // /api 제거
const fullImageUrl = layoutImagePath.startsWith('http')
? layoutImagePath
@@ -1690,12 +1640,8 @@ window.submitAllManualWorkReports = async function() {
* 날짜 포맷 함수
*/
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
if (window.CommonUtils) return window.CommonUtils.formatDate(dateString);
return formatDateForApi(dateString);
}
/**
@@ -2015,38 +1961,36 @@ window.deleteWorkReport = async function(reportId) {
// 기존 함수들
// =================================================================
// 한국 시간 기준 오늘 날짜 가져오기
// 한국 시간 기준 오늘 날짜
function getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
if (window.CommonUtils) return window.CommonUtils.getTodayKST();
const now = new Date();
return now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
}
// 현재 로그인한 사용자 정보 가져오기
function getCurrentUser() {
try {
const token = localStorage.getItem('sso_token');
if (!token) return null;
// SSO 사용자 정보 우선
if (window.getSSOUser) {
const ssoUser = window.getSSOUser();
if (ssoUser) return ssoUser;
}
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
const payload = JSON.parse(atob(payloadBase64));
console.log('토큰에서 추출한 사용자 정보:', payload);
return payload;
try {
const token = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_token') || localStorage.getItem('token'));
if (token) {
const payloadBase64 = token.split('.')[1];
if (payloadBase64) {
return JSON.parse(atob(payloadBase64));
}
}
} catch (error) {
console.log('토큰에서 사용자 정보 추출 실패:', error);
}
try {
const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('userInfo') || localStorage.getItem('currentUser');
if (userInfo) {
const parsed = JSON.parse(userInfo);
console.log('localStorage에서 가져온 사용자 정보:', parsed);
return parsed;
}
const userInfo = localStorage.getItem('sso_user') || localStorage.getItem('user') || localStorage.getItem('userInfo');
if (userInfo) return JSON.parse(userInfo);
} catch (error) {
console.log('localStorage에서 사용자 정보 가져오기 실패:', error);
}
@@ -3183,14 +3127,15 @@ function setupEventListeners() {
// 초기화
async function init() {
try {
const token = localStorage.getItem('sso_token');
if (!token || token === 'undefined') {
showMessage('로그인이 필요합니다.', 'error');
localStorage.removeItem('sso_token');
setTimeout(() => {
window.location.href = '/';
}, 2000);
return;
// app-init.js(defer)가 토큰/apiCall 설정 완료할 때까지 대기
if (window.waitForApi) {
await window.waitForApi(8000);
} else if (!window.apiCall) {
// waitForApi 없으면 간단 폴링
await new Promise((resolve, reject) => {
let elapsed = 0;
const iv = setInterval(() => { elapsed += 50; if (window.apiCall) { clearInterval(iv); resolve(); } else if (elapsed >= 8000) { clearInterval(iv); reject(new Error('apiCall timeout')); } }, 50);
});
}
await loadData();
@@ -3207,8 +3152,12 @@ async function init() {
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', init);
// 페이지 로드 시 초기화 (module 스크립트는 DOMContentLoaded 이후 실행될 수 있음)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 전역 함수로 노출
window.removeWorkEntry = removeWorkEntry;

View File

@@ -1,318 +0,0 @@
/**
* Daily Work Report - Module Loader
* 작업보고서 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class DailyWorkReportController {
constructor() {
this.state = window.DailyWorkReportState;
this.api = window.DailyWorkReportAPI;
this.utils = window.DailyWorkReportUtils;
this.initialized = false;
console.log('[Controller] DailyWorkReportController 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[Controller] 이미 초기화됨');
return;
}
console.log('[Controller] 초기화 시작...');
try {
// 이벤트 리스너 설정
this.setupEventListeners();
// 기본 데이터 로드
await this.api.loadAllData();
// TBM 탭이 기본
await this.switchTab('tbm');
this.initialized = true;
console.log('[Controller] 초기화 완료');
} catch (error) {
console.error('[Controller] 초기화 실패:', error);
window.showMessage?.('초기화 중 오류가 발생했습니다: ' + error.message, 'error');
}
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 탭 버튼
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
if (tbmBtn) {
tbmBtn.addEventListener('click', () => this.switchTab('tbm'));
}
if (completedBtn) {
completedBtn.addEventListener('click', () => this.switchTab('completed'));
}
// 완료 보고서 날짜 변경
const completedDateInput = document.getElementById('completedReportDate');
if (completedDateInput) {
completedDateInput.addEventListener('change', () => this.loadCompletedReports());
}
console.log('[Controller] 이벤트 리스너 설정 완료');
}
/**
* 탭 전환
*/
async switchTab(tab) {
this.state.setCurrentTab(tab);
const tbmBtn = document.getElementById('tbmReportTab');
const completedBtn = document.getElementById('completedReportTab');
const tbmSection = document.getElementById('tbmReportSection');
const completedSection = document.getElementById('completedReportSection');
// 모든 탭 버튼 비활성화
tbmBtn?.classList.remove('active');
completedBtn?.classList.remove('active');
// 모든 섹션 숨기기
if (tbmSection) tbmSection.style.display = 'none';
if (completedSection) completedSection.style.display = 'none';
// 선택된 탭 활성화
if (tab === 'tbm') {
tbmBtn?.classList.add('active');
if (tbmSection) tbmSection.style.display = 'block';
await this.loadTbmData();
} else if (tab === 'completed') {
completedBtn?.classList.add('active');
if (completedSection) completedSection.style.display = 'block';
// 오늘 날짜로 초기화
const dateInput = document.getElementById('completedReportDate');
if (dateInput) {
dateInput.value = this.utils.getKoreaToday();
}
await this.loadCompletedReports();
}
}
/**
* TBM 데이터 로드
*/
async loadTbmData() {
try {
await this.api.loadIncompleteTbms();
await this.api.loadDailyIssuesForTbms();
// 렌더링은 기존 함수 사용 (점진적 마이그레이션)
if (typeof window.renderTbmWorkList === 'function') {
window.renderTbmWorkList();
}
} catch (error) {
console.error('[Controller] TBM 데이터 로드 오류:', error);
window.showMessage?.('TBM 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* 완료 보고서 로드
*/
async loadCompletedReports() {
try {
const dateInput = document.getElementById('completedReportDate');
const date = dateInput?.value || this.utils.getKoreaToday();
const reports = await this.api.loadCompletedReports(date);
// 렌더링은 기존 함수 사용
if (typeof window.renderCompletedReports === 'function') {
window.renderCompletedReports(reports);
}
} catch (error) {
console.error('[Controller] 완료 보고서 로드 오류:', error);
window.showMessage?.('완료 보고서를 불러오는 중 오류가 발생했습니다.', 'error');
}
}
/**
* TBM 작업보고서 제출
*/
async submitTbmWorkReport(index) {
try {
const tbm = this.state.incompleteTbms[index];
if (!tbm) {
throw new Error('TBM 데이터를 찾을 수 없습니다.');
}
// 유효성 검사
const totalHoursInput = document.getElementById(`totalHours_${index}`);
const totalHours = parseFloat(totalHoursInput?.value);
if (!totalHours || totalHours <= 0) {
window.showMessage?.('작업시간을 입력해주세요.', 'warning');
return;
}
// 부적합 시간 계산
const defects = this.state.tempDefects[index] || [];
const errorHours = defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0);
const regularHours = totalHours - errorHours;
if (regularHours < 0) {
window.showMessage?.('부적합 시간이 총 작업시간을 초과할 수 없습니다.', 'warning');
return;
}
// API 데이터 구성
const user = this.state.getCurrentUser();
const reportData = {
tbm_session_id: tbm.session_id,
tbm_assignment_id: tbm.assignment_id,
worker_id: tbm.worker_id,
project_id: tbm.project_id,
work_type_id: tbm.work_type_id,
report_date: this.utils.formatDateForApi(tbm.session_date),
total_hours: totalHours,
regular_hours: regularHours,
error_hours: errorHours,
work_status_id: errorHours > 0 ? 2 : 1,
created_by: user?.user_id || user?.id,
defects: defects.map(d => ({
category_id: d.category_id,
item_id: d.item_id,
issue_report_id: d.issue_report_id,
defect_hours: d.defect_hours,
note: d.note
}))
};
const result = await this.api.submitTbmWorkReport(reportData);
window.showSaveResultModal?.(
'success',
'제출 완료',
`${tbm.worker_name}의 작업보고서가 제출되었습니다.`
);
// 목록 새로고침
await this.loadTbmData();
} catch (error) {
console.error('[Controller] 제출 오류:', error);
window.showSaveResultModal?.(
'error',
'제출 실패',
error.message || '작업보고서 제출 중 오류가 발생했습니다.'
);
}
}
/**
* 세션 일괄 제출
*/
async batchSubmitSession(sessionKey) {
const rows = document.querySelectorAll(`tr[data-session-key="${sessionKey}"][data-type="tbm"]`);
const indices = [];
rows.forEach(row => {
const index = parseInt(row.dataset.index);
const totalHoursInput = document.getElementById(`totalHours_${index}`);
if (totalHoursInput?.value && parseFloat(totalHoursInput.value) > 0) {
indices.push(index);
}
});
if (indices.length === 0) {
window.showMessage?.('제출할 항목이 없습니다. 작업시간을 입력해주세요.', 'warning');
return;
}
const confirmed = confirm(`${indices.length}건의 작업보고서를 일괄 제출하시겠습니까?`);
if (!confirmed) return;
let successCount = 0;
let failCount = 0;
for (const index of indices) {
try {
await this.submitTbmWorkReport(index);
successCount++;
} catch (error) {
failCount++;
console.error(`[Controller] 일괄 제출 오류 (index: ${index}):`, error);
}
}
if (failCount === 0) {
window.showSaveResultModal?.('success', '일괄 제출 완료', `${successCount}건이 성공적으로 제출되었습니다.`);
} else {
window.showSaveResultModal?.('warning', '일괄 제출 부분 완료', `성공: ${successCount}건, 실패: ${failCount}`);
}
}
/**
* 상태 디버그
*/
debug() {
console.log('[Controller] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.DailyWorkReportController = new DailyWorkReportController();
// 하위 호환성: 기존 전역 함수들
window.switchTab = (tab) => window.DailyWorkReportController.switchTab(tab);
window.submitTbmWorkReport = (index) => window.DailyWorkReportController.submitTbmWorkReport(index);
window.batchSubmitTbmSession = (sessionKey) => window.DailyWorkReportController.batchSubmitSession(sessionKey);
// 사용자 정보 함수
window.getUser = () => window.DailyWorkReportState.getUser();
window.getCurrentUser = () => window.DailyWorkReportState.getCurrentUser();
// 날짜 그룹 토글 (UI 함수)
window.toggleDateGroup = function(dateStr) {
const group = document.querySelector(`.date-group[data-date="${dateStr}"]`);
if (!group) return;
const isExpanded = group.classList.contains('expanded');
const content = group.querySelector('.date-group-content');
const icon = group.querySelector('.date-toggle-icon');
if (isExpanded) {
group.classList.remove('expanded');
group.classList.add('collapsed');
if (content) content.style.display = 'none';
if (icon) icon.textContent = '▶';
} else {
group.classList.remove('collapsed');
group.classList.add('expanded');
if (content) content.style.display = 'block';
if (icon) icon.textContent = '▼';
}
};
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
// 약간의 지연 후 초기화 (다른 스크립트 로드 대기)
setTimeout(() => {
window.DailyWorkReportController.init();
}, 100);
});
console.log('[Module] daily-work-report/index.js 로드 완료');

View File

@@ -1,10 +1,12 @@
/**
* Daily Work Report - State Manager
* 작업보고서 페이지의 전역 상태 관리
* 작업보고서 페이지의 전역 상태 관리 (BaseState 상속)
*/
class DailyWorkReportState {
class DailyWorkReportState extends BaseState {
constructor() {
super();
// 마스터 데이터
this.workTypes = [];
this.workStatusTypes = [];
@@ -45,53 +47,9 @@ class DailyWorkReportState {
// 캐시
this.dailyIssuesCache = {}; // { 'YYYY-MM-DD': [issues] }
// 리스너
this.listeners = new Map();
console.log('[State] DailyWorkReportState 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너에게 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[State] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 가져오기
*/
getUser() {
const user = localStorage.getItem('sso_user');
return user ? JSON.parse(user) : null;
}
/**
* 토큰에서 사용자 정보 추출
*/

View File

@@ -1,67 +1,29 @@
/**
* Daily Work Report - Utilities
* 작업보고서 관련 유틸리티 함수들
* 작업보고서 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class DailyWorkReportUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[Utils] DailyWorkReportUtils 초기화');
}
/**
* 한국 시간 기준 오늘 날짜 (YYYY-MM-DD)
*/
getKoreaToday() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// --- CommonUtils 위임 ---
getKoreaToday() { return this._common.getTodayKST(); }
formatDateForApi(date) { return this._common.formatDate(date); }
formatDate(date) { return this._common.formatDate(date) || '-'; }
getDayOfWeek(date) { return this._common.getDayOfWeek(date); }
isToday(date) { return this._common.isToday(date); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
debounce(func, wait) { return this._common.debounce(func, wait); }
throttle(func, limit) { return this._common.throttle(func, limit); }
deepClone(obj) { return this._common.deepClone(obj); }
isEmpty(value) { return this._common.isEmpty(value); }
groupBy(array, key) { return this._common.groupBy(array, key); }
/**
* 날짜를 API 형식(YYYY-MM-DD)으로 변환
*/
formatDateForApi(date) {
if (!date) return null;
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return null;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 날짜 포맷팅 (표시용)
*/
formatDate(date) {
if (!date) return '-';
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
dateObj = new Date(date);
} else {
return '-';
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// --- 작업보고 전용 ---
/**
* 시간 포맷팅 (HH:mm)
@@ -104,24 +66,6 @@ class DailyWorkReportUtils {
return Number(num).toFixed(decimals);
}
/**
* 요일 반환
*/
getDayOfWeek(date) {
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = date instanceof Date ? date : new Date(date);
return days[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(date) {
const today = this.getKoreaToday();
const targetDate = this.formatDateForApi(date);
return today === targetDate;
}
/**
* 두 날짜 사이 일수 계산
*/
@@ -132,63 +76,6 @@ class DailyWorkReportUtils {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
/**
* 디바운스 함수
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* 쓰로틀 함수
*/
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 객체 깊은 복사
*/
deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 빈 값 확인
*/
isEmpty(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/**
* 숫자 유효성 검사
*/
@@ -249,20 +136,6 @@ class DailyWorkReportUtils {
}
}
/**
* 배열 그룹화
*/
groupBy(array, key) {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {});
}
/**
* 배열 정렬 (다중 키)
*/
@@ -280,17 +153,6 @@ class DailyWorkReportUtils {
return 0;
});
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
// 전역 인스턴스 생성

File diff suppressed because it is too large Load Diff

View File

@@ -1,325 +0,0 @@
/**
* TBM - Module Loader
* TBM 모듈을 초기화하고 연결하는 메인 진입점
*
* 로드 순서:
* 1. state.js - 전역 상태 관리
* 2. utils.js - 유틸리티 함수
* 3. api.js - API 클라이언트
* 4. index.js - 이 파일 (메인 컨트롤러)
*/
class TbmController {
constructor() {
this.state = window.TbmState;
this.api = window.TbmAPI;
this.utils = window.TbmUtils;
this.initialized = false;
console.log('[TbmController] 생성');
}
/**
* 초기화
*/
async init() {
if (this.initialized) {
console.log('[TbmController] 이미 초기화됨');
return;
}
console.log('🛠️ TBM 관리 페이지 초기화');
// API 함수가 로드될 때까지 대기
let retryCount = 0;
while (!window.apiCall && retryCount < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
retryCount++;
}
if (!window.apiCall) {
window.showToast?.('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error');
return;
}
// 오늘 날짜 설정 (서울 시간대 기준)
const today = this.utils.getTodayKST();
const tbmDateEl = document.getElementById('tbmDate');
const sessionDateEl = document.getElementById('sessionDate');
if (tbmDateEl) tbmDateEl.value = today;
if (sessionDateEl) sessionDateEl.value = today;
// 이벤트 리스너 설정
this.setupEventListeners();
// 초기 데이터 로드
await this.api.loadInitialData();
await this.api.loadTodayOnlyTbm();
// 렌더링
this.displayTodayTbmSessions();
this.initialized = true;
console.log('[TbmController] 초기화 완료');
}
/**
* 이벤트 리스너 설정
*/
setupEventListeners() {
// 탭 버튼들
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tabName = btn.dataset.tab;
if (tabName) this.switchTbmTab(tabName);
});
});
}
/**
* 탭 전환
*/
async switchTbmTab(tabName) {
this.state.setCurrentTab(tabName);
// 탭 버튼 활성화 상태 변경
document.querySelectorAll('.tbm-tab-btn').forEach(btn => {
if (btn.dataset.tab === tabName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// 탭 컨텐츠 표시 변경
document.querySelectorAll('.tbm-tab-content').forEach(content => {
content.classList.remove('active');
});
const tabContent = document.getElementById(`${tabName}-tab`);
if (tabContent) tabContent.classList.add('active');
// 탭에 따라 데이터 로드
if (tabName === 'tbm-input') {
await this.api.loadTodayOnlyTbm();
this.displayTodayTbmSessions();
} else if (tabName === 'tbm-manage') {
await this.api.loadRecentTbmGroupedByDate();
this.displayTbmGroupedByDate();
this.updateViewModeIndicator();
}
}
/**
* 오늘의 TBM 세션 표시
*/
displayTodayTbmSessions() {
const grid = document.getElementById('todayTbmGrid');
const emptyState = document.getElementById('todayEmptyState');
const todayTotalEl = document.getElementById('todayTotalSessions');
const todayCompletedEl = document.getElementById('todayCompletedSessions');
const todayActiveEl = document.getElementById('todayActiveSessions');
const sessions = this.state.todaySessions;
if (sessions.length === 0) {
if (grid) grid.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (todayTotalEl) todayTotalEl.textContent = '0';
if (todayCompletedEl) todayCompletedEl.textContent = '0';
if (todayActiveEl) todayActiveEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
const completedCount = sessions.filter(s => s.status === 'completed').length;
const activeCount = sessions.filter(s => s.status === 'draft').length;
if (todayTotalEl) todayTotalEl.textContent = sessions.length;
if (todayCompletedEl) todayCompletedEl.textContent = completedCount;
if (todayActiveEl) todayActiveEl.textContent = activeCount;
if (grid) {
grid.innerHTML = sessions.map(session => this.createSessionCard(session)).join('');
}
}
/**
* 날짜별 그룹으로 TBM 표시
*/
displayTbmGroupedByDate() {
const container = document.getElementById('tbmDateGroupsContainer');
const emptyState = document.getElementById('emptyState');
const totalSessionsEl = document.getElementById('totalSessions');
const completedSessionsEl = document.getElementById('completedSessions');
if (!container) return;
const sortedDates = Object.keys(this.state.dateGroupedSessions).sort((a, b) =>
new Date(b) - new Date(a)
);
if (sortedDates.length === 0 || this.state.allLoadedSessions.length === 0) {
container.innerHTML = '';
if (emptyState) emptyState.style.display = 'flex';
if (totalSessionsEl) totalSessionsEl.textContent = '0';
if (completedSessionsEl) completedSessionsEl.textContent = '0';
return;
}
if (emptyState) emptyState.style.display = 'none';
// 통계 업데이트
const completedCount = this.state.allLoadedSessions.filter(s => s.status === 'completed').length;
if (totalSessionsEl) totalSessionsEl.textContent = this.state.allLoadedSessions.length;
if (completedSessionsEl) completedSessionsEl.textContent = completedCount;
// 날짜별 그룹 HTML 생성
const today = this.utils.getTodayKST();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
container.innerHTML = sortedDates.map(date => {
const sessions = this.state.dateGroupedSessions[date];
const dateObj = new Date(date + 'T00:00:00');
const dayName = dayNames[dateObj.getDay()];
const isToday = date === today;
const [year, month, day] = date.split('-');
const displayDate = `${parseInt(month)}${parseInt(day)}`;
return `
<div class="tbm-date-group" data-date="${date}">
<div class="tbm-date-header ${isToday ? 'today' : ''}" onclick="toggleDateGroup('${date}')">
<span class="tbm-date-toggle">&#9660;</span>
<span class="tbm-date-title">${displayDate}</span>
<span class="tbm-date-day">${dayName}요일</span>
${isToday ? '<span class="tbm-today-badge">오늘</span>' : ''}
<span class="tbm-date-count">${sessions.length}건</span>
</div>
<div class="tbm-date-content">
<div class="tbm-date-grid">
${sessions.map(session => this.createSessionCard(session)).join('')}
</div>
</div>
</div>
`;
}).join('');
}
/**
* 뷰 모드 표시 업데이트
*/
updateViewModeIndicator() {
const indicator = document.getElementById('viewModeIndicator');
const text = document.getElementById('viewModeText');
if (indicator && text) {
if (this.state.isAdminUser()) {
indicator.style.display = 'none';
} else {
indicator.style.display = 'inline-flex';
text.textContent = '내 TBM';
}
}
}
/**
* TBM 세션 카드 생성
*/
createSessionCard(session) {
const statusBadge = this.utils.getStatusBadge(session.status);
const leaderName = session.leader_name || session.created_by_name || '작업 책임자';
const leaderRole = session.leader_name
? (session.leader_job_type || '작업자')
: '관리자';
return `
<div class="tbm-session-card" onclick="viewTbmSession(${session.session_id})">
<div class="tbm-card-header">
<div class="tbm-card-header-top">
<div>
<h3 class="tbm-card-leader">
${leaderName}
<span class="tbm-card-leader-role">${leaderRole}</span>
</h3>
</div>
${statusBadge}
</div>
<div class="tbm-card-date">
<span>&#128197;</span>
${this.utils.formatDate(session.session_date)} ${session.start_time ? '| ' + session.start_time : ''}
</div>
</div>
<div class="tbm-card-body">
<div class="tbm-card-info-grid">
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">프로젝트</span>
<span class="tbm-card-info-value">${session.project_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">공정</span>
<span class="tbm-card-info-value">${session.work_type_name || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">작업장</span>
<span class="tbm-card-info-value">${session.work_location || '-'}</span>
</div>
<div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${session.team_member_count || 0}명</span>
</div>
</div>
</div>
${session.status === 'draft' ? `
<div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${session.session_id})">
&#128101; 팀 구성
</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${session.session_id})">
&#10003; 안전 체크
</button>
</div>
` : ''}
</div>
`;
}
/**
* 디버그
*/
debug() {
console.log('[TbmController] 상태 디버그:');
this.state.debug();
}
}
// 전역 인스턴스 생성
window.TbmController = new TbmController();
// 하위 호환성: 기존 전역 함수들
window.switchTbmTab = (tabName) => window.TbmController.switchTbmTab(tabName);
window.displayTodayTbmSessions = () => window.TbmController.displayTodayTbmSessions();
window.displayTbmGroupedByDate = () => window.TbmController.displayTbmGroupedByDate();
window.displayTbmSessions = () => window.TbmController.displayTbmGroupedByDate();
window.createSessionCard = (session) => window.TbmController.createSessionCard(session);
window.updateViewModeIndicator = () => window.TbmController.updateViewModeIndicator();
// 날짜 그룹 토글
window.toggleDateGroup = function(date) {
const group = document.querySelector(`.tbm-date-group[data-date="${date}"]`);
if (group) {
group.classList.toggle('collapsed');
}
};
// DOMContentLoaded 이벤트에서 초기화
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
window.TbmController.init();
}, 100);
});
console.log('[Module] tbm/index.js 로드 완료');

View File

@@ -1,10 +1,12 @@
/**
* TBM - State Manager
* TBM 페이지의 전역 상태 관리
* TBM 페이지의 전역 상태 관리 (BaseState 상속)
*/
class TbmState {
class TbmState extends BaseState {
constructor() {
super();
// 세션 데이터
this.allSessions = [];
this.todaySessions = [];
@@ -48,56 +50,9 @@ class TbmState {
this.mapImage = null;
this.mapRegions = [];
// 리스너
this.listeners = new Map();
console.log('[TbmState] 초기화 완료');
}
/**
* 상태 업데이트
*/
update(key, value) {
const prevValue = this[key];
this[key] = value;
this.notifyListeners(key, value, prevValue);
}
/**
* 리스너 등록
*/
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
}
/**
* 리스너 알림
*/
notifyListeners(key, newValue, prevValue) {
const keyListeners = this.listeners.get(key) || [];
keyListeners.forEach(callback => {
try {
callback(newValue, prevValue);
} catch (error) {
console.error(`[TbmState] 리스너 오류 (${key}):`, error);
}
});
}
/**
* 현재 사용자 정보 가져오기
*/
getUser() {
if (!this.currentUser) {
const userInfo = localStorage.getItem('sso_user');
this.currentUser = userInfo ? JSON.parse(userInfo) : null;
}
return this.currentUser;
}
/**
* Admin 여부 확인
*/
@@ -135,7 +90,7 @@ class TbmState {
*/
createEmptyTaskLine() {
return {
task_line_id: this.generateUUID(),
task_line_id: window.CommonUtils.generateUUID(),
project_id: null,
work_type_id: null,
task_id: null,
@@ -207,7 +162,7 @@ class TbmState {
this.allLoadedSessions = [];
sessions.forEach(session => {
const date = this.formatDate(session.session_date);
const date = window.CommonUtils.formatDate(session.session_date);
if (!this.dateGroupedSessions[date]) {
this.dateGroupedSessions[date] = [];
}
@@ -216,32 +171,6 @@ class TbmState {
});
}
/**
* 날짜 포맷팅
*/
formatDate(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 상태 초기화
*/

View File

@@ -1,46 +1,23 @@
/**
* TBM - Utilities
* TBM 관련 유틸리티 함수들
* TBM 관련 유틸리티 함수들 (공통 함수는 CommonUtils에 위임)
*/
class TbmUtils {
constructor() {
this._common = window.CommonUtils;
console.log('[TbmUtils] 초기화 완료');
}
/**
* 서울 시간대(Asia/Seoul, UTC+9) 기준 오늘 날짜를 YYYY-MM-DD 형식으로 반환
*/
getTodayKST() {
const now = new Date();
const kstOffset = 9 * 60;
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
const kstTime = new Date(utc + (kstOffset * 60000));
// --- CommonUtils 위임 ---
getTodayKST() { return this._common.getTodayKST(); }
formatDate(dateString) { return this._common.formatDate(dateString); }
getDayOfWeek(dateString) { return this._common.getDayOfWeek(dateString); }
isToday(dateString) { return this._common.isToday(dateString); }
generateUUID() { return this._common.generateUUID(); }
escapeHtml(text) { return this._common.escapeHtml(text); }
const year = kstTime.getFullYear();
const month = String(kstTime.getMonth() + 1).padStart(2, '0');
const day = String(kstTime.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* ISO 날짜 문자열을 YYYY-MM-DD 형식으로 변환
*/
formatDate(dateString) {
if (!dateString) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// --- TBM 전용 ---
/**
* 날짜 표시용 포맷 (MM월 DD일)
@@ -56,30 +33,11 @@ class TbmUtils {
*/
formatDateFull(dateString) {
if (!dateString) return '';
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const [year, month, day] = dateString.split('-');
const dateObj = new Date(dateString);
const dayName = dayNames[dateObj.getDay()];
const dayName = this._common.getDayOfWeek(dateString);
return `${year}${parseInt(month)}${parseInt(day)}일 (${dayName})`;
}
/**
* 요일 반환
*/
getDayOfWeek(dateString) {
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dateObj = new Date(dateString + 'T00:00:00');
return dayNames[dateObj.getDay()];
}
/**
* 오늘인지 확인
*/
isToday(dateString) {
const today = this.getTodayKST();
return this.formatDate(dateString) === today;
}
/**
* 현재 시간을 HH:MM 형식으로 반환
*/
@@ -87,40 +45,13 @@ class TbmUtils {
return new Date().toTimeString().slice(0, 5);
}
/**
* UUID 생성
*/
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* HTML 이스케이프
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 날씨 조건명 반환
*/
getWeatherConditionName(code) {
const names = {
clear: '맑음',
rain: '비',
snow: '눈',
heat: '폭염',
cold: '한파',
wind: '강풍',
fog: '안개',
dust: '미세먼지'
clear: '맑음', rain: '비', snow: '눈', heat: '폭염',
cold: '한파', wind: '강풍', fog: '안개', dust: '미세먼지'
};
return names[code] || code;
}
@@ -130,14 +61,8 @@ class TbmUtils {
*/
getWeatherIcon(code) {
const icons = {
clear: '☀️',
rain: '🌧️',
snow: '❄️',
heat: '🔥',
cold: '🥶',
wind: '💨',
fog: '🌫️',
dust: '😷'
clear: '☀️', rain: '🌧️', snow: '❄️', heat: '🔥',
cold: '🥶', wind: '💨', fog: '🌫️', dust: '😷'
};
return icons[code] || '🌤️';
}
@@ -147,12 +72,9 @@ class TbmUtils {
*/
getCategoryName(category) {
const names = {
'PPE': '개인 보호 장비',
'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경',
'EMERGENCY': '비상 대응',
'WEATHER': '날씨',
'TASK': '작업'
'PPE': '개인 보호 장비', 'EQUIPMENT': '장비 점검',
'ENVIRONMENT': '작업 환경', 'EMERGENCY': '비상 대응',
'WEATHER': '날씨', 'TASK': '작업'
};
return names[category] || category;
}

View File

@@ -1,51 +0,0 @@
// /js/work-report-api.js
import { apiGet, apiPost } from './api-helper.js';
/**
* 작업 보고서 작성을 위해 필요한 초기 데이터(작업자, 프로젝트, 태스크)를 가져옵니다.
* Promise.all을 사용하여 병렬로 API를 호출합니다.
* @returns {Promise<{workers: Array, projects: Array, tasks: Array}>}
*/
export async function getInitialData() {
try {
const [allWorkers, projects, tasks] = await Promise.all([
apiGet('/workers'),
apiGet('/projects'),
apiGet('/tasks')
]);
// 활성화된 작업자만 필터링
const workers = allWorkers.filter(worker => {
return worker.status === 'active' || worker.is_active === 1 || worker.is_active === true;
});
// 데이터 형식 검증
if (!Array.isArray(workers) || !Array.isArray(projects) || !Array.isArray(tasks)) {
throw new Error('서버에서 받은 데이터 형식이 올바르지 않습니다.');
}
// 작업자 목록은 ID 기준으로 정렬
workers.sort((a, b) => a.worker_id - b.worker_id);
return { workers, projects, tasks };
} catch (error) {
console.error('초기 데이터 로딩 중 오류 발생:', error);
// 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
throw error;
}
}
/**
* 작성된 작업 보고서 데이터를 서버에 전송합니다.
* @param {Array<object>} reportData - 전송할 작업 보고서 데이터 배열
* @returns {Promise<object>} - 서버의 응답 결과
*/
export async function createWorkReport(reportData) {
try {
const result = await apiPost('/workreports', reportData);
return result;
} catch (error) {
console.error('작업 보고서 생성 요청 실패:', error);
throw error;
}
}

View File

@@ -1,79 +0,0 @@
// /js/work-report-create.js
import { renderCalendar } from './calendar.js';
import { getInitialData, createWorkReport } from './work-report-api.js';
import { initializeReportTable, getReportData } from './work-report-ui.js';
// 전역 상태 변수
let selectedDate = '';
/**
* 날짜가 선택되었을 때 실행되는 콜백 함수.
* 초기 데이터를 로드하고 테이블을 렌더링합니다.
* @param {string} date - 선택된 날짜 (YYYY-MM-DD 형식)
*/
async function onDateSelect(date) {
selectedDate = date;
const tableBody = document.getElementById('reportBody');
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">데이터를 불러오는 중...</td></tr>';
try {
const initialData = await getInitialData();
initializeReportTable(initialData);
} catch (error) {
alert('데이터를 불러오는 데 실패했습니다: ' + error.message);
tableBody.innerHTML = '<tr><td colspan="8" class="text-center error">오류 발생! 데이터를 불러올 수 없습니다.</td></tr>';
}
}
/**
* '전체 등록' 버튼 클릭 시 실행되는 이벤트 핸들러.
* 폼 데이터를 서버에 전송합니다.
*/
async function handleSubmit() {
if (!selectedDate) {
alert('먼저 달력에서 날짜를 선택해주세요.');
return;
}
const reportData = getReportData();
if (!reportData) {
// getReportData 내부에서 이미 alert으로 사용자에게 알림
return;
}
// 각 항목에 선택된 날짜 추가
const payload = reportData.map(item => ({ ...item, date: selectedDate }));
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '등록 중...';
try {
const result = await createWorkReport(payload);
if (result.success) {
alert('✅ 작업 보고서가 성공적으로 등록되었습니다!');
// 성공 후 폼을 다시 로드하거나, 다른 페이지로 이동 등의 로직 추가 가능
onDateSelect(selectedDate); // 현재 날짜의 폼을 다시 로드
} else {
throw new Error(result.error || '알 수 없는 오류로 등록에 실패했습니다.');
}
} catch (error) {
alert('❌ 등록 실패: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '전체 등록';
}
}
/**
* 페이지 초기화 함수
*/
function initializePage() {
renderCalendar('calendar', onDateSelect);
const submitBtn = document.getElementById('submitBtn');
submitBtn.addEventListener('click', handleSubmit);
}
// DOM이 로드되면 페이지 초기화를 시작합니다.
document.addEventListener('DOMContentLoaded', initializePage);

View File

@@ -1,141 +0,0 @@
// /js/work-report-ui.js
const DEFAULT_PROJECT_ID = '13'; // 나중에는 API나 설정에서 받아오는 것이 좋음
const DEFAULT_TASK_ID = '15';
/**
* 주어진 데이터를 바탕으로 <select> 요소의 <option>들을 생성합니다.
* @param {Array<object>} items - 옵션으로 만들 데이터 배열
* @param {string} valueField - <option>의 value 속성에 사용할 필드 이름
* @param {string} textField - <option>의 텍스트에 사용할 필드 이름
* @returns {string} - 생성된 HTML 옵션 문자열
*/
function createOptions(items, valueField, textField) {
return items.map(item => `<option value="${item[valueField]}">${textField(item)}</option>`).join('');
}
/**
* 테이블의 모든 행 번호를 다시 매깁니다.
* @param {HTMLTableSectionElement} tableBody - tbody 요소
*/
function updateRowNumbers(tableBody) {
tableBody.querySelectorAll('tr').forEach((tr, index) => {
tr.cells[0].textContent = index + 1;
});
}
/**
* 하나의 작업 보고서 행(tr)을 생성합니다.
* @param {object} worker - 작업자 정보
* @param {Array} projects - 전체 프로젝트 목록
* @param {Array} tasks - 전체 태스크 목록
* @param {number} index - 행 번호
* @returns {HTMLTableRowElement} - 생성된 tr 요소
*/
function createReportRow(worker, projects, tasks, index) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${index + 1}</td>
<td>
<input type="hidden" name="worker_id" value="${worker.worker_id}">
${worker.worker_name}
</td>
<td><select name="project_id">${createOptions(projects, 'project_id', p => p.project_name)}</select></td>
<td><select name="task_id">${createOptions(tasks, 'task_id', t => `${t.category}:${t.subcategory}`)}</select></td>
<td>
<select name="overtime">
<option value="">없음</option>
${[1, 2, 3, 4].map(n => `<option>${n}</option>`).join('')}
</select>
</td>
<td>
<select name="work_type">
${['근무', '연차', '유급', '반차', '반반차', '조퇴', '휴무'].map(t => `<option>${t}</option>`).join('')}
</select>
</td>
<td><input type="text" name="memo" placeholder="메모"></td>
<td><button type="button" class="remove-btn">x</button></td>
`;
// 이벤트 리스너 설정
const workTypeSelect = tr.querySelector('[name="work_type"]');
const projectSelect = tr.querySelector('[name="project_id"]');
const taskSelect = tr.querySelector('[name="task_id"]');
workTypeSelect.addEventListener('change', () => {
const isDisabled = ['연차', '휴무', '유급'].includes(workTypeSelect.value);
projectSelect.disabled = isDisabled;
taskSelect.disabled = isDisabled;
if (isDisabled) {
projectSelect.value = DEFAULT_PROJECT_ID;
taskSelect.value = DEFAULT_TASK_ID;
}
});
tr.querySelector('.remove-btn').addEventListener('click', () => {
tr.remove();
updateRowNumbers(tr.parentElement);
});
return tr;
}
/**
* 작업 보고서 테이블을 초기화하고 데이터를 채웁니다.
* @param {{workers: Array, projects: Array, tasks: Array}} initialData - 초기 데이터
*/
export function initializeReportTable(initialData) {
const tableBody = document.getElementById('reportBody');
if (!tableBody) return;
tableBody.innerHTML = ''; // 기존 내용 초기화
const { workers, projects, tasks } = initialData;
if (!workers || workers.length === 0) {
tableBody.innerHTML = '<tr><td colspan="8" class="text-center">등록할 작업자 정보가 없습니다.</td></tr>';
return;
}
workers.forEach((worker, index) => {
const row = createReportRow(worker, projects, tasks, index);
tableBody.appendChild(row);
});
}
/**
* 테이블에서 폼 데이터를 추출하여 배열로 반환합니다.
* @returns {Array<object>|null} - 추출된 데이터 배열 또는 유효성 검사 실패 시 null
*/
export function getReportData() {
const tableBody = document.getElementById('reportBody');
const rows = tableBody.querySelectorAll('tr');
if (rows.length === 0 || (rows.length === 1 && rows[0].cells.length < 2)) {
alert('등록할 내용이 없습니다.');
return null;
}
const reportData = [];
const workerIds = new Set();
for (const tr of rows) {
const workerId = tr.querySelector('[name="worker_id"]').value;
if (workerIds.has(workerId)) {
alert(`오류: 작업자 '${tr.cells[1].textContent.trim()}'가 중복 등록되었습니다.`);
return null;
}
workerIds.add(workerId);
reportData.push({
worker_id: workerId,
project_id: tr.querySelector('[name="project_id"]').value,
task_id: tr.querySelector('[name="task_id"]').value,
overtime_hours: tr.querySelector('[name="overtime"]').value || 0,
work_details: tr.querySelector('[name="work_type"]').value,
memo: tr.querySelector('[name="memo"]').value
});
}
return reportData;
}

View File

@@ -278,8 +278,8 @@
<div class="toast-container" id="toastContainer"></div>
<!-- JavaScript -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="/js/admin-settings.js?v=9"></script>
</body>

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.comparison-grid {

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.department-grid {

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.type-tabs {

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.notification-page-container {

View File

@@ -366,8 +366,8 @@
</div>
</div>
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script>
let allProjects = [];
let filteredProjects = [];

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.repair-page {
max-width: 1400px;

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
.page-header {

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script>
<style>

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.page-wrapper {

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 테이블 컨테이너 */

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -12,8 +12,8 @@
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 스크립트 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
</head>

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.tabs {

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.tabs {

View File

@@ -7,8 +7,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;

View File

@@ -9,8 +9,8 @@
<!-- 리소스 프리로딩 -->
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
<link rel="preload" href="/css/design-system.css" as="style">
<link rel="preload" href="/js/api-base.js" as="script">
<link rel="preload" href="/js/app-init.js?v=5" as="script">
<link rel="preload" href="/js/api-base.js?v=2" as="script">
<link rel="preload" href="/js/app-init.js?v=9" as="script">
<!-- 모던 디자인 시스템 적용 -->
<link rel="stylesheet" href="/css/design-system.css">
@@ -18,9 +18,14 @@
<link rel="stylesheet" href="/css/mobile.css?v=4">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- SW 캐시 강제 해제 -->
<script>
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
</script>
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js" defer></script>

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
</head>
<body>
<!-- 네비게이션 바 -->

View File

@@ -627,8 +627,8 @@
</div>
</div>
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/safety-checklist-manage.js"></script>
</body>

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.status-tabs {

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 스텝 인디케이터 */

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.training-container {

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
.visit-form-container {

View File

@@ -10,8 +10,8 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head>

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/project-management.css?v=3">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<style>
/* 통계 카드 */

View File

@@ -8,8 +8,8 @@
<link rel="stylesheet" href="/css/daily-work-report-mobile.css?v=1">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
/* 데스크탑이면 리다이렉트 */
@media (min-width: 769px) {
@@ -169,13 +169,17 @@
<!-- 토스트 -->
<div class="m-toast" id="mToast"></div>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>
<!-- 작업보고서 모듈 (재사용) -->
<script src="/js/daily-work-report/state.js?v=1"></script>
<script src="/js/daily-work-report/utils.js?v=1"></script>
<script src="/js/daily-work-report/api.js?v=1"></script>
<script src="/js/daily-work-report/state.js?v=2"></script>
<script src="/js/daily-work-report/utils.js?v=2"></script>
<script src="/js/daily-work-report/api.js?v=2"></script>
<!-- 모바일 전용 UI 로직 -->
<script src="/js/daily-work-report-mobile.js?v=3"></script>
<script src="/js/daily-work-report-mobile.js?v=4"></script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>

View File

@@ -5,18 +5,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 작업보고서 작성 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/daily-work-report.css?v=12">
<link rel="stylesheet" href="/css/daily-work-report.css?v=13">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 모바일 자동 리다이렉트 -->
<!-- SW 캐시 강제 해제 (Chrome 대응) -->
<script>
if (window.innerWidth <= 768) {
window.location.replace('/pages/work/report-create-mobile.html');
}
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(reg){reg.unregister()});})}
if('caches' in window){caches.keys().then(function(k){k.forEach(function(key){caches.delete(key)})})}
</script>
<!-- 최적화된 로딩 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
</head>
<body>
@@ -173,18 +172,17 @@
</div>
</div>
<!-- 스크립트 -->
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
<script src="/js/daily-work-report/state.js?v=1"></script>
<script src="/js/daily-work-report/utils.js?v=1"></script>
<script src="/js/daily-work-report/api.js?v=1"></script>
<script src="/js/daily-work-report/state.js?v=2"></script>
<script src="/js/daily-work-report/utils.js?v=2"></script>
<script src="/js/daily-work-report/api.js?v=2"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/daily-work-report.js?v=30"></script>
<script defer src="/js/daily-work-report.js?v=36"></script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TBM 시작 | (주)테크니컬코리아</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=5" defer></script>
<script src="/js/api-base.js?v=2"></script>
<script src="/js/app-init.js?v=9" defer></script>
<style>
* { box-sizing: border-box; }
body {
@@ -820,9 +820,13 @@
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script src="/js/tbm/state.js"></script>
<script src="/js/tbm/utils.js"></script>
<script src="/js/tbm/api.js"></script>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=1"></script>
<script src="/js/common/base-state.js?v=1"></script>
<script src="/js/tbm/state.js?v=2"></script>
<script src="/js/tbm/utils.js?v=2"></script>
<script src="/js/tbm/api.js?v=3"></script>
<script src="/js/tbm-create.js?v=13"></script>
</body>
</html>

View File

@@ -1,73 +1,28 @@
// sw.js - TK공장관리 Service Worker (network-first)
// 주의: 이 파일을 수정할 때는 반드시 CACHE_VERSION을 올려주세요.
// 잘못된 수정은 사용자 브라우저에 최대 24시간 캐시됩니다.
// 자세한 내용: /docs/PWA-GUIDE.md
// sw.js - Service Worker 자체 해제 (캐시 초기화)
// 기존 SW를 교체하여 모든 캐시를 삭제하고 자체 해제합니다.
const CACHE_VERSION = 'tkfb-v3';
const CACHE_NAME = `tkfb-cache-${CACHE_VERSION}`;
self.addEventListener('install', function(event) {
self.skipWaiting();
});
// 캐시할 정적 리소스 (앱 셸)
const APP_SHELL = [
'/pages/dashboard.html',
'/css/design-system.css',
'/css/mobile.css',
'/img/icon-192x192.png'
];
// 설치: 앱 셸 프리캐시
self.addEventListener('install', (event) => {
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
// 활성화: 이전 버전 캐시 삭제
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key.startsWith('tkfb-cache-') && key !== CACHE_NAME)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// 요청 가로채기: network-first 전략
self.addEventListener('fetch', (event) => {
const request = event.request;
// API 요청은 캐시하지 않음 (항상 네트워크)
if (request.url.includes('/api/')) {
return;
}
// 로그인 관련 경로는 캐시하지 않음
if (request.url.includes('/login')) {
return;
}
// GET 요청만 캐시
if (request.method !== 'GET') {
return;
}
event.respondWith(
fetch(request)
.then((response) => {
// 정상 응답이면 캐시에 저장
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => {
// 네트워크 실패 시 캐시에서 응답
return caches.match(request);
})
caches.keys().then(function(keys) {
return Promise.all(
keys.map(function(key) {
console.log('SW: 캐시 삭제:', key);
return caches.delete(key);
})
);
}).then(function() {
console.log('SW: 모든 캐시 삭제 완료, 자체 해제');
return self.registration.unregister();
}).then(function() {
return self.clients.matchAll();
}).then(function(clients) {
clients.forEach(function(client) {
client.postMessage({ type: 'SW_CLEARED' });
});
})
);
});