feat: TBM 모바일 시스템 + 작업 분할/이동 + 권한 통합

TBM 시스템:
- 4단계 워크플로우 (draft→세부편집→완료→작업보고)
- 모바일 전용 TBM 페이지 (tbm-mobile.html) + 3단계 생성 위자드
- 작업자 작업 분할 (work_hours + split_seq)
- 작업자 이동 보내기/빼오기 (tbm_transfers 테이블)
- 생성 시 중복 배정 방지 (당일 배정 현황 조회)
- 데스크탑 TBM 페이지 세부편집 기능 추가

작업보고서:
- 모바일 전용 작업보고서 페이지 (report-create-mobile.html)
- TBM에서 사전 등록된 work_hours 자동 반영

권한 시스템:
- tkuser user_page_permissions 테이블과 system1 페이지 접근 연동
- pageAccessRoutes를 userRoutes보다 먼저 등록 (라우트 우선순위 수정)
- TKUSER_DEFAULT_ACCESS 폴백 추가 (개인→부서→기본값 3단계)
- 권한 캐시키 갱신 (userPageAccess_v2)

기타:
- app-init.js 캐시 버스팅 (v=5)
- iOS Safari touch-action: manipulation 적용
- KST 타임존 날짜 버그 수정 (toISOString UTC 이슈)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-25 07:46:21 +09:00
parent d36303101e
commit 7637be33f3
65 changed files with 9470 additions and 240 deletions

View File

@@ -47,6 +47,11 @@ const TbmModel = {
u.username as created_by_username,
u.name as created_by_name,
COUNT(DISTINCT ta.worker_id) as team_member_count,
GROUP_CONCAT(DISTINCT w2.worker_name ORDER BY ta.assignment_id SEPARATOR ', ') as team_member_names,
-- 이동 수 (이 세션이 source 또는 dest인 이동 건수)
(SELECT COUNT(*) FROM tbm_transfers tf
WHERE (tf.source_session_id = s.session_id OR tf.dest_session_id = s.session_id)
AND tf.transfer_date = s.session_date) as transfer_count,
-- 첫 번째 팀원의 작업 정보 가져오기
first_ta.project_id,
first_ta.work_type_id,
@@ -58,8 +63,9 @@ const TbmModel = {
first_wp.workplace_name as work_location
FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN users u ON s.created_by = u.user_id
LEFT JOIN sso_users u ON s.created_by = u.user_id
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
LEFT JOIN workers w2 ON ta.worker_id = w2.worker_id
-- 첫 번째 팀원 정보 (가장 먼저 등록된 작업)
LEFT JOIN (
SELECT * FROM tbm_team_assignments
@@ -110,7 +116,7 @@ const TbmModel = {
first_wc.category_name as workplace_category_name
FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN users u ON s.created_by = u.user_id
LEFT JOIN sso_users u ON s.created_by = u.user_id
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
LEFT JOIN (
SELECT * FROM tbm_team_assignments
@@ -188,6 +194,107 @@ const TbmModel = {
}
},
/**
* TBM 세션 완료 처리 (근태 유형 포함)
* @param {number} sessionId - TBM 세션 ID
* @param {string} endTime - 종료 시간
* @param {Array} attendanceData - [{worker_id, attendance_type, attendance_hours}]
* @param {number} createdBy - 처리자 user_id
*/
completeSessionWithAttendance: async (sessionId, endTime, attendanceData, createdBy, callback) => {
let conn;
try {
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
// 1. 세션 정보 조회 (날짜 확인용)
const [sessionRows] = await conn.query(
'SELECT session_date FROM tbm_sessions WHERE session_id = ?',
[sessionId]
);
if (sessionRows.length === 0) {
await conn.rollback();
conn.release();
return callback(null, { affectedRows: 0 });
}
const sessionDate = sessionRows[0].session_date;
// sessionDate를 YYYY-MM-DD 형식으로 변환
let reportDate;
if (sessionDate instanceof Date) {
reportDate = sessionDate.toISOString().split('T')[0];
} else if (typeof sessionDate === 'string') {
reportDate = sessionDate.split('T')[0];
} else {
reportDate = new Date(sessionDate).toISOString().split('T')[0];
}
// 2. 각 작업자의 근태 유형 업데이트
for (const item of attendanceData) {
await conn.query(
`UPDATE tbm_team_assignments
SET attendance_type = ?, attendance_hours = ?
WHERE session_id = ? AND worker_id = ?`,
[item.attendance_type, item.attendance_hours || null, sessionId, item.worker_id]
);
}
// 3. 연차(annual) 작업자 → 작업보고서 자동 생성 (project_id=13, 8h)
const annualWorkers = attendanceData.filter(a => a.attendance_type === 'annual');
for (const aw of annualWorkers) {
// 해당 작업자의 assignment_id 조회
const [assignRows] = await conn.query(
'SELECT assignment_id FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[sessionId, aw.worker_id]
);
if (assignRows.length > 0) {
// 이미 보고서가 있는지 확인
const [existingReport] = await conn.query(
'SELECT id FROM daily_work_reports WHERE tbm_assignment_id = ?',
[assignRows[0].assignment_id]
);
if (existingReport.length === 0) {
await conn.query(
`INSERT INTO daily_work_reports
(report_date, worker_id, project_id, work_hours, work_status_id, created_by, tbm_assignment_id, created_at)
VALUES (?, ?, 13, 8, 1, ?, ?, NOW())`,
[reportDate, aw.worker_id, createdBy, assignRows[0].assignment_id]
);
}
}
}
// 4. 세션 완료 처리
await conn.query(
`UPDATE tbm_sessions
SET status = 'completed', end_time = ?, updated_at = NOW()
WHERE session_id = ?`,
[endTime, sessionId]
);
await conn.commit();
conn.release();
// 5. 연차 작업자 근태 동기화
for (const aw of annualWorkers) {
try {
const AttendanceModel = require('./attendanceModel');
await AttendanceModel.syncWithWorkReports(aw.worker_id, reportDate);
} catch (syncErr) {
console.error('근태 동기화 오류 (무시됨):', syncErr);
}
}
callback(null, { affectedRows: 1 });
} catch (err) {
if (conn) {
try { await conn.rollback(); } catch (e) {}
conn.release();
}
callback(err);
}
},
/**
* TBM 세션 삭제 (draft 상태만 가능)
*/
@@ -215,9 +322,9 @@ const TbmModel = {
const db = await getDb();
const sql = `
INSERT INTO tbm_team_assignments
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(session_id, worker_id, split_seq, assigned_role, work_detail, is_present, absence_reason,
project_id, work_type_id, task_id, workplace_category_id, workplace_id, work_hours)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
assigned_role = VALUES(assigned_role),
work_detail = VALUES(work_detail),
@@ -227,12 +334,14 @@ const TbmModel = {
work_type_id = VALUES(work_type_id),
task_id = VALUES(task_id),
workplace_category_id = VALUES(workplace_category_id),
workplace_id = VALUES(workplace_id)
workplace_id = VALUES(workplace_id),
work_hours = COALESCE(VALUES(work_hours), work_hours)
`;
const values = [
assignmentData.session_id,
assignmentData.worker_id,
assignmentData.split_seq || 0,
assignmentData.assigned_role,
assignmentData.work_detail,
assignmentData.is_present !== undefined ? assignmentData.is_present : true,
@@ -241,7 +350,8 @@ const TbmModel = {
assignmentData.work_type_id || null,
assignmentData.task_id || null,
assignmentData.workplace_category_id || null,
assignmentData.workplace_id || null
assignmentData.workplace_id || null,
assignmentData.work_hours !== undefined ? assignmentData.work_hours : null
];
const [result] = await db.query(sql, values);
@@ -251,6 +361,42 @@ const TbmModel = {
}
},
/**
* 분할 항목 추가 (같은 세션+작업자에 split_seq 자동 증가)
*/
addSplitAssignment: async (assignmentData, callback) => {
try {
const db = await getDb();
// 현재 최대 split_seq 조회
const [maxRows] = await db.query(
'SELECT COALESCE(MAX(split_seq), -1) as max_seq FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[assignmentData.session_id, assignmentData.worker_id]
);
const nextSeq = (maxRows[0].max_seq || 0) + 1;
const sql = `
INSERT INTO tbm_team_assignments
(session_id, worker_id, split_seq, work_hours, project_id, work_type_id,
task_id, workplace_category_id, workplace_id, is_present)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`;
const [result] = await db.query(sql, [
assignmentData.session_id,
assignmentData.worker_id,
nextSeq,
assignmentData.work_hours,
assignmentData.project_id || null,
assignmentData.work_type_id || null,
assignmentData.task_id || null,
assignmentData.workplace_category_id || null,
assignmentData.workplace_id || null
]);
callback(null, { assignment_id: result.insertId, split_seq: nextSeq });
} catch (err) {
callback(err);
}
},
/**
* 팀 구성 일괄 추가 (작업자별 상세 정보 포함)
*/
@@ -420,7 +566,7 @@ const TbmModel = {
u.name as checked_by_name
FROM tbm_safety_records sr
INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id
LEFT JOIN users u ON sr.checked_by = u.user_id
LEFT JOIN sso_users u ON sr.checked_by = u.user_id
WHERE sr.session_id = ?
ORDER BY sc.check_category, sc.display_order
`;
@@ -571,7 +717,7 @@ const TbmModel = {
FROM team_handovers h
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id
LEFT JOIN users u ON h.confirmed_by = u.user_id
LEFT JOIN sso_users u ON h.confirmed_by = u.user_id
WHERE h.handover_date = ?
ORDER BY h.handover_time DESC
`;
@@ -673,9 +819,13 @@ const TbmModel = {
const db = await getDb();
// WHERE 조건 동적 생성
// TBM 완료(근태 입력) 후에만 작업보고서 작성 가능
let whereClause = `
WHERE dwr.id IS NULL
AND s.status = 'draft'
AND s.status = 'completed'
AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual')
AND ta.task_id IS NOT NULL
AND ta.workplace_id IS NOT NULL
`;
const params = [];
@@ -684,7 +834,10 @@ const TbmModel = {
whereClause = `
WHERE s.created_by = ?
AND dwr.id IS NULL
AND s.status = 'draft'
AND s.status = 'completed'
AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual')
AND ta.task_id IS NOT NULL
AND ta.workplace_id IS NOT NULL
`;
params.push(userId);
}
@@ -699,6 +852,9 @@ const TbmModel = {
ta.task_id,
ta.workplace_category_id,
ta.workplace_id,
ta.attendance_type,
ta.attendance_hours,
ta.work_hours,
s.session_date,
s.status as session_status,
s.created_by,

View File

@@ -0,0 +1,294 @@
// models/tbmTransferModel.js - TBM 작업자 이동 모델
const { getDb } = require('../dbPool');
const TbmTransferModel = {
/**
* 작업자 이동 실행 (보내기/빼오기)
* 트랜잭션: source work_hours 업데이트 + dest INSERT + 로그 INSERT
*/
createTransfer: async (transferData, callback) => {
let conn;
try {
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
const {
transfer_type, worker_id, source_session_id, dest_session_id,
hours, initiated_by, transfer_date,
project_id, work_type_id, task_id, workplace_category_id, workplace_id
} = transferData;
// 1. source 세션에서 해당 작업자의 work_hours 업데이트
const [sourceRows] = await conn.query(
'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[source_session_id, worker_id]
);
if (sourceRows.length === 0) {
await conn.rollback();
conn.release();
return callback(null, { success: false, message: '원본 세션에서 해당 작업자를 찾을 수 없습니다.' });
}
const currentHours = sourceRows[0].work_hours === null ? 8 : parseFloat(sourceRows[0].work_hours);
const newSourceHours = currentHours - parseFloat(hours);
if (newSourceHours < 0) {
await conn.rollback();
conn.release();
return callback(null, { success: false, message: '이동 시간이 현재 배정 시간보다 큽니다.' });
}
await conn.query(
'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?',
[newSourceHours, source_session_id, worker_id]
);
// 2. dest 세션에 작업자 INSERT (이미 있으면 work_hours 증가)
const [destRows] = await conn.query(
'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[dest_session_id, worker_id]
);
if (destRows.length > 0) {
// 이미 있으면 시간만 추가
const existingHours = destRows[0].work_hours === null ? 8 : parseFloat(destRows[0].work_hours);
await conn.query(
'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?',
[existingHours + parseFloat(hours), dest_session_id, worker_id]
);
} else {
// 새로 INSERT
await conn.query(
`INSERT INTO tbm_team_assignments
(session_id, worker_id, work_hours, project_id, work_type_id, task_id, workplace_category_id, workplace_id, is_present)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[dest_session_id, worker_id, parseFloat(hours),
project_id || null, work_type_id || null, task_id || null,
workplace_category_id || null, workplace_id || null]
);
}
// 3. tbm_transfers에 로그 INSERT
const [logResult] = await conn.query(
`INSERT INTO tbm_transfers
(transfer_date, worker_id, source_session_id, dest_session_id, hours, transfer_type, initiated_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[transfer_date, worker_id, source_session_id, dest_session_id, parseFloat(hours), transfer_type, initiated_by]
);
// 4. 합계 시간 검증 (경고만, 차단 안함)
const [totalRows] = await conn.query(
`SELECT SUM(COALESCE(work_hours, 8)) as total_hours
FROM tbm_team_assignments
WHERE worker_id = ?
AND session_id IN (SELECT session_id FROM tbm_sessions WHERE session_date = ?)`,
[worker_id, transfer_date]
);
const totalHours = totalRows[0] ? parseFloat(totalRows[0].total_hours) : 0;
await conn.commit();
conn.release();
const result = {
success: true,
transfer_id: logResult.insertId,
total_hours: totalHours
};
if (totalHours > 8) {
result.warning = `해당 작업자의 당일 합계가 ${totalHours}h입니다 (8h 초과).`;
}
callback(null, result);
} catch (err) {
if (conn) {
try { await conn.rollback(); } catch (e) {}
conn.release();
}
callback(err);
}
},
/**
* 이동 취소 (원복)
*/
cancelTransfer: async (transferId, callback) => {
let conn;
try {
const db = await getDb();
conn = await db.getConnection();
await conn.beginTransaction();
// 1. 이동 로그 조회
const [transfers] = await conn.query(
'SELECT * FROM tbm_transfers WHERE transfer_id = ?',
[transferId]
);
if (transfers.length === 0) {
await conn.rollback();
conn.release();
return callback(null, { success: false, message: '이동 기록을 찾을 수 없습니다.' });
}
const t = transfers[0];
// 2. dest 세션에서 작업자 work_hours 감소 (또는 삭제)
const [destRows] = await conn.query(
'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[t.dest_session_id, t.worker_id]
);
if (destRows.length > 0) {
const destHours = destRows[0].work_hours === null ? 8 : parseFloat(destRows[0].work_hours);
const newDestHours = destHours - parseFloat(t.hours);
if (newDestHours <= 0) {
// 삭제
await conn.query(
'DELETE FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[t.dest_session_id, t.worker_id]
);
} else {
await conn.query(
'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?',
[newDestHours, t.dest_session_id, t.worker_id]
);
}
}
// 3. source 세션에서 작업자 work_hours 복원
const [sourceRows] = await conn.query(
'SELECT assignment_id, work_hours FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
[t.source_session_id, t.worker_id]
);
if (sourceRows.length > 0) {
const sourceHours = sourceRows[0].work_hours === null ? 8 : parseFloat(sourceRows[0].work_hours);
const restoredHours = sourceHours + parseFloat(t.hours);
// 8이면 NULL로 복원 (종일)
await conn.query(
'UPDATE tbm_team_assignments SET work_hours = ? WHERE session_id = ? AND worker_id = ?',
[restoredHours >= 8 ? null : restoredHours, t.source_session_id, t.worker_id]
);
}
// 4. 이동 로그 삭제
await conn.query('DELETE FROM tbm_transfers WHERE transfer_id = ?', [transferId]);
await conn.commit();
conn.release();
callback(null, { success: true });
} catch (err) {
if (conn) {
try { await conn.rollback(); } catch (e) {}
conn.release();
}
callback(err);
}
},
/**
* 당일 이동 내역 조회
*/
getTransfersByDate: async (date, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
t.*,
w.worker_name,
w.job_type,
sl.worker_name as source_leader_name,
dl.worker_name as dest_leader_name,
u.name as initiated_by_name
FROM tbm_transfers t
INNER JOIN workers w ON t.worker_id = w.worker_id
LEFT JOIN tbm_sessions ss ON t.source_session_id = ss.session_id
LEFT JOIN workers sl ON ss.leader_id = sl.worker_id
LEFT JOIN tbm_sessions ds ON t.dest_session_id = ds.session_id
LEFT JOIN workers dl ON ds.leader_id = dl.worker_id
LEFT JOIN sso_users u ON t.initiated_by = u.user_id
WHERE t.transfer_date = ?
ORDER BY t.created_at DESC
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 당일 전 작업자 배정 현황 조회
*/
getWorkerAssignmentsByDate: async (date, callback) => {
try {
const db = await getDb();
// 1. 해당 날짜의 모든 배정 가져오기
const [assignments] = await db.query(`
SELECT
ta.worker_id,
ta.session_id,
ta.work_hours,
w.worker_name,
w.job_type,
s.leader_id,
lw.worker_name as leader_name,
s.status as session_status
FROM tbm_team_assignments ta
INNER JOIN tbm_sessions s ON ta.session_id = s.session_id
INNER JOIN workers w ON ta.worker_id = w.worker_id
LEFT JOIN workers lw ON s.leader_id = lw.worker_id
WHERE s.session_date = ?
ORDER BY w.worker_name
`, [date]);
// 2. 모든 작업자 가져오기 (배정 안 된 사람도 포함)
const [allWorkers] = await db.query(
"SELECT worker_id, worker_name, job_type FROM workers WHERE status = 'active' AND department = '생산' ORDER BY worker_name"
);
// 3. 작업자별 배정 현황 구성
const workerMap = {};
allWorkers.forEach(w => {
workerMap[w.worker_id] = {
worker_id: w.worker_id,
worker_name: w.worker_name,
job_type: w.job_type,
sessions: [],
total_hours: 0,
available: true
};
});
assignments.forEach(a => {
const hours = a.work_hours === null ? 8 : parseFloat(a.work_hours);
if (workerMap[a.worker_id]) {
workerMap[a.worker_id].sessions.push({
session_id: a.session_id,
leader_name: a.leader_name,
work_hours: hours,
session_status: a.session_status
});
workerMap[a.worker_id].total_hours += hours;
}
});
// available 판단
Object.values(workerMap).forEach(w => {
w.available = w.total_hours < 8;
});
callback(null, Object.values(workerMap));
} catch (err) {
callback(err);
}
}
};
module.exports = TbmTransferModel;