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

@@ -21,6 +21,7 @@ function createTokenPayload(user) {
id: user.user_id, id: user.user_id,
username: user.username, username: user.username,
name: user.name, name: user.name,
worker_id: user.worker_id || null,
department: user.department, department: user.department,
role: user.role, role: user.role,
access_level: user.role, access_level: user.role,
@@ -84,6 +85,7 @@ async function login(req, res, next) {
name: user.name, name: user.name,
department: user.department, department: user.department,
role: user.role, role: user.role,
worker_id: user.worker_id || null,
system_access: payload.system_access system_access: payload.system_access
} }
}); });
@@ -159,6 +161,7 @@ async function validate(req, res, next) {
name: user.name, name: user.name,
department: user.department, department: user.department,
role: user.role, role: user.role,
worker_id: user.worker_id || null,
system_access: { system_access: {
system1: user.system1_access, system1: user.system1_access,
system2: user.system2_access, system2: user.system2_access,

View File

@@ -149,6 +149,7 @@ function setupRoutes(app) {
app.use('/api/performance', performanceRoutes); app.use('/api/performance', performanceRoutes);
app.use('/api/projects', projectRoutes); app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute); app.use('/api/tools', toolsRoute);
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 (userRoutes보다 먼저 등록 - /users/:id/page-access 매칭 우선)
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/workplaces', workplaceRoutes); app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes); app.use('/api/equipments', equipmentRoutes);
@@ -157,7 +158,6 @@ function setupRoutes(app) {
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리 app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리 app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리 app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템 app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유) app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
app.use('/api/departments', departmentRoutes); // 부서 관리 app.use('/api/departments', departmentRoutes); // 부서 관리

View File

@@ -1,5 +1,6 @@
// controllers/tbmController.js - TBM 시스템 컨트롤러 // controllers/tbmController.js - TBM 시스템 컨트롤러
const TbmModel = require('../models/tbmModel'); const TbmModel = require('../models/tbmModel');
const TbmTransferModel = require('../models/tbmTransferModel');
const TbmController = { const TbmController = {
// ==================== TBM 세션 관련 ==================== // ==================== TBM 세션 관련 ====================
@@ -151,7 +152,37 @@ const TbmController = {
completeSession: (req, res) => { completeSession: (req, res) => {
const { sessionId } = req.params; const { sessionId } = req.params;
const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8); const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8);
const attendanceData = req.body.attendance_data;
// 근태 데이터가 있으면 새 메서드 사용
if (attendanceData && Array.isArray(attendanceData) && attendanceData.length > 0) {
const createdBy = req.user.user_id;
TbmModel.completeSessionWithAttendance(sessionId, endTime, attendanceData, createdBy, (err, result) => {
if (err) {
console.error('TBM 세션 완료 처리 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 완료되었습니다.'
});
});
return;
}
// 기존 방식 (하위 호환)
TbmModel.completeSession(sessionId, endTime, (err, result) => { TbmModel.completeSession(sessionId, endTime, (err, result) => {
if (err) { if (err) {
console.error('TBM 세션 완료 처리 오류:', err); console.error('TBM 세션 완료 처리 오류:', err);
@@ -223,7 +254,8 @@ const TbmController = {
work_type_id: req.body.work_type_id || null, work_type_id: req.body.work_type_id || null,
task_id: req.body.task_id || null, task_id: req.body.task_id || null,
workplace_category_id: req.body.workplace_category_id || null, workplace_category_id: req.body.workplace_category_id || null,
workplace_id: req.body.workplace_id || null workplace_id: req.body.workplace_id || null,
work_hours: req.body.work_hours !== undefined ? req.body.work_hours : undefined
}; };
if (!assignmentData.worker_id) { if (!assignmentData.worker_id) {
@@ -250,6 +282,34 @@ const TbmController = {
}); });
}, },
/**
* 분할 항목 추가 (같은 작업자의 추가 배정)
*/
addSplitAssignment: (req, res) => {
const assignmentData = {
session_id: req.params.sessionId,
worker_id: req.body.worker_id,
work_hours: req.body.work_hours,
project_id: req.body.project_id || null,
work_type_id: req.body.work_type_id || null,
task_id: req.body.task_id || null,
workplace_category_id: req.body.workplace_category_id || null,
workplace_id: req.body.workplace_id || null
};
if (!assignmentData.worker_id || !assignmentData.work_hours) {
return res.status(400).json({ success: false, message: '작업자 ID와 작업시간이 필요합니다.' });
}
TbmModel.addSplitAssignment(assignmentData, (err, result) => {
if (err) {
console.error('분할 항목 추가 오류:', err);
return res.status(500).json({ success: false, message: '분할 항목 추가 중 오류가 발생했습니다.' });
}
res.json({ success: true, data: result });
});
},
/** /**
* 팀 구성 일괄 추가 * 팀 구성 일괄 추가
*/ */
@@ -892,6 +952,122 @@ const TbmController = {
}); });
}, },
// ==================== 작업자 이동 관련 ====================
/**
* 이동 실행 (보내기/빼오기)
*/
createTransfer: (req, res) => {
const { transfer_type, worker_id, source_session_id, dest_session_id, hours,
project_id, work_type_id, task_id, workplace_category_id, workplace_id } = req.body;
if (!transfer_type || !worker_id || !source_session_id || !dest_session_id || !hours) {
return res.status(400).json({
success: false,
message: '필수 정보가 누락되었습니다.'
});
}
const today = new Date();
const transferDate = today.getFullYear() + '-' +
String(today.getMonth() + 1).padStart(2, '0') + '-' +
String(today.getDate()).padStart(2, '0');
const transferData = {
transfer_type, worker_id, source_session_id, dest_session_id,
hours, initiated_by: req.user.user_id, transfer_date: transferDate,
project_id, work_type_id, task_id, workplace_category_id, workplace_id
};
TbmTransferModel.createTransfer(transferData, (err, result) => {
if (err) {
console.error('이동 실행 오류:', err);
return res.status(500).json({
success: false,
message: '이동 실행 중 오류가 발생했습니다.',
error: err.message
});
}
if (!result.success) {
return res.status(400).json(result);
}
res.json({
success: true,
message: '이동이 완료되었습니다.',
data: result
});
});
},
/**
* 당일 이동 내역 조회
*/
getTransfersByDate: (req, res) => {
const { date } = req.params;
TbmTransferModel.getTransfersByDate(date, (err, results) => {
if (err) {
console.error('이동 내역 조회 오류:', err);
return res.status(500).json({
success: false,
message: '이동 내역 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({ success: true, data: results });
});
},
/**
* 이동 취소 (원복)
*/
cancelTransfer: (req, res) => {
const { transferId } = req.params;
TbmTransferModel.cancelTransfer(transferId, (err, result) => {
if (err) {
console.error('이동 취소 오류:', err);
return res.status(500).json({
success: false,
message: '이동 취소 중 오류가 발생했습니다.',
error: err.message
});
}
if (!result.success) {
return res.status(400).json(result);
}
res.json({
success: true,
message: '이동이 취소되었습니다.'
});
});
},
/**
* 당일 전 작업자 배정 현황
*/
getWorkerAssignmentsByDate: (req, res) => {
const { date } = req.params;
TbmTransferModel.getWorkerAssignmentsByDate(date, (err, results) => {
if (err) {
console.error('배정 현황 조회 오류:', err);
return res.status(500).json({
success: false,
message: '배정 현황 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({ success: true, data: results });
});
},
/** /**
* 작업보고서가 작성되지 않은 TBM 팀 배정 조회 * 작업보고서가 작성되지 않은 TBM 팀 배정 조회
*/ */

View File

@@ -0,0 +1,26 @@
/**
* 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

@@ -0,0 +1,21 @@
/**
* TBM 팀 배정에 작업시간 컬럼 추가
* - work_hours: 작업시간 (NULL=종일 8h, 값=해당 시간만)
*
* @since 2026-02-25
*/
exports.up = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
ADD COLUMN work_hours DECIMAL(4,1) DEFAULT NULL
COMMENT 'NULL=종일(8h), 값 있으면 해당 시간만'
`);
};
exports.down = function(knex) {
return knex.raw(`
ALTER TABLE tbm_team_assignments
DROP COLUMN work_hours
`);
};

View File

@@ -0,0 +1,28 @@
/**
* TBM 작업자 이동 로그 테이블 생성
* - 작업자를 다른 TBM 세션으로 보내기/빼오기 기록
*
* @since 2026-02-25
*/
exports.up = function(knex) {
return knex.raw(`
CREATE TABLE tbm_transfers (
transfer_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
transfer_date DATE NOT NULL,
worker_id INT NOT NULL,
source_session_id INT UNSIGNED NOT NULL,
dest_session_id INT UNSIGNED NOT NULL,
hours DECIMAL(4,1) NOT NULL,
transfer_type ENUM('send','pull') NOT NULL,
initiated_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_date (transfer_date),
INDEX idx_worker (worker_id, transfer_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
};
exports.down = function(knex) {
return knex.raw(`DROP TABLE IF EXISTS tbm_transfers`);
};

View File

@@ -0,0 +1,43 @@
// 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

@@ -47,6 +47,11 @@ const TbmModel = {
u.username as created_by_username, u.username as created_by_username,
u.name as created_by_name, u.name as created_by_name,
COUNT(DISTINCT ta.worker_id) as team_member_count, 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.project_id,
first_ta.work_type_id, first_ta.work_type_id,
@@ -58,8 +63,9 @@ const TbmModel = {
first_wp.workplace_name as work_location first_wp.workplace_name as work_location
FROM tbm_sessions s FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id 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 tbm_team_assignments ta ON s.session_id = ta.session_id
LEFT JOIN workers w2 ON ta.worker_id = w2.worker_id
-- 첫 번째 팀원 정보 (가장 먼저 등록된 작업) -- 첫 번째 팀원 정보 (가장 먼저 등록된 작업)
LEFT JOIN ( LEFT JOIN (
SELECT * FROM tbm_team_assignments SELECT * FROM tbm_team_assignments
@@ -110,7 +116,7 @@ const TbmModel = {
first_wc.category_name as workplace_category_name first_wc.category_name as workplace_category_name
FROM tbm_sessions s FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id 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 tbm_team_assignments ta ON s.session_id = ta.session_id
LEFT JOIN ( LEFT JOIN (
SELECT * FROM tbm_team_assignments 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 상태만 가능) * TBM 세션 삭제 (draft 상태만 가능)
*/ */
@@ -215,9 +322,9 @@ const TbmModel = {
const db = await getDb(); const db = await getDb();
const sql = ` const sql = `
INSERT INTO tbm_team_assignments INSERT INTO tbm_team_assignments
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason, (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) project_id, work_type_id, task_id, workplace_category_id, workplace_id, work_hours)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
assigned_role = VALUES(assigned_role), assigned_role = VALUES(assigned_role),
work_detail = VALUES(work_detail), work_detail = VALUES(work_detail),
@@ -227,12 +334,14 @@ const TbmModel = {
work_type_id = VALUES(work_type_id), work_type_id = VALUES(work_type_id),
task_id = VALUES(task_id), task_id = VALUES(task_id),
workplace_category_id = VALUES(workplace_category_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 = [ const values = [
assignmentData.session_id, assignmentData.session_id,
assignmentData.worker_id, assignmentData.worker_id,
assignmentData.split_seq || 0,
assignmentData.assigned_role, assignmentData.assigned_role,
assignmentData.work_detail, assignmentData.work_detail,
assignmentData.is_present !== undefined ? assignmentData.is_present : true, assignmentData.is_present !== undefined ? assignmentData.is_present : true,
@@ -241,7 +350,8 @@ const TbmModel = {
assignmentData.work_type_id || null, assignmentData.work_type_id || null,
assignmentData.task_id || null, assignmentData.task_id || null,
assignmentData.workplace_category_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); 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 u.name as checked_by_name
FROM tbm_safety_records sr FROM tbm_safety_records sr
INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id 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 = ? WHERE sr.session_id = ?
ORDER BY sc.check_category, sc.display_order ORDER BY sc.check_category, sc.display_order
`; `;
@@ -571,7 +717,7 @@ const TbmModel = {
FROM team_handovers h FROM team_handovers h
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
INNER JOIN workers w2 ON h.to_leader_id = w2.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 = ? WHERE h.handover_date = ?
ORDER BY h.handover_time DESC ORDER BY h.handover_time DESC
`; `;
@@ -673,9 +819,13 @@ const TbmModel = {
const db = await getDb(); const db = await getDb();
// WHERE 조건 동적 생성 // WHERE 조건 동적 생성
// TBM 완료(근태 입력) 후에만 작업보고서 작성 가능
let whereClause = ` let whereClause = `
WHERE dwr.id IS NULL 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 = []; const params = [];
@@ -684,7 +834,10 @@ const TbmModel = {
whereClause = ` whereClause = `
WHERE s.created_by = ? WHERE s.created_by = ?
AND dwr.id IS NULL 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); params.push(userId);
} }
@@ -699,6 +852,9 @@ const TbmModel = {
ta.task_id, ta.task_id,
ta.workplace_category_id, ta.workplace_category_id,
ta.workplace_id, ta.workplace_id,
ta.attendance_type,
ta.attendance_hours,
ta.work_hours,
s.session_date, s.session_date,
s.status as session_status, s.status as session_status,
s.created_by, 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;

View File

@@ -3,6 +3,71 @@ const router = express.Router();
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const { requireAuth, requireAdmin } = require('../middlewares/auth'); 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 * GET /api/pages
@@ -29,25 +94,22 @@ router.get('/pages', requireAuth, async (req, res) => {
*/ */
router.get('/users/:userId/page-access', requireAuth, async (req, res) => { router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
try { try {
const { userId } = req.params; const ssoUserId = req.params.userId;
const db = await getDb(); const db = await getDb();
// 사용자의 역할 확인 // SSO 사용자 조회 (department_id 포함)
const [userRows] = await db.query(` const [ssoRows] = await db.query(
SELECT u.user_id, u.username, u.role_id, r.name as role_name 'SELECT user_id, username, name, role, department_id FROM sso_users WHERE user_id = ?',
FROM users u [ssoUserId]
LEFT JOIN roles r ON u.role_id = r.id );
WHERE u.user_id = ? if (ssoRows.length === 0) {
`, [userId]);
if (userRows.length === 0) {
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' }); return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
} }
const ssoUser = ssoRows[0];
const user = userRows[0]; // SSO role로 Admin 체크
const ssoRole = (ssoUser.role || '').toLowerCase();
// Admin/System Admin인 경우 모든 페이지 접근 가능 if (ssoRole === 'admin' || ssoRole === 'system') {
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
const [allPages] = await db.query(` const [allPages] = await db.query(`
SELECT id, page_key, page_name, page_path, category, is_admin_only SELECT id, page_key, page_name, page_path, category, is_admin_only
FROM pages FROM pages
@@ -62,32 +124,66 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
category: page.category, category: page.category,
is_admin_only: page.is_admin_only, is_admin_only: page.is_admin_only,
can_access: true, can_access: true,
is_default: true // Admin은 기본적으로 모든 권한 보유 is_default: true
})); }));
return res.json({ success: true, data: { user, pageAccess } }); return res.json({ success: true, data: { user: ssoUser, pageAccess } });
} }
// 일반 사용자의 페이지 접근 권한 조회 // 일반 사용자: tkuser 권한 테이블에서 조회
const [pageAccess] = await db.query(` // 1) 개인 권한 (user_page_permissions)
SELECT const [userPerms] = await db.query(
p.id as page_id, 'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?',
p.page_key, [ssoUserId]
p.page_name, );
p.page_path, const userPermMap = {};
p.category, userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; });
p.is_admin_only,
COALESCE(upa.can_access, 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]);
res.json({ success: true, data: { user, pageAccess } }); // 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 } });
} catch (error) { } catch (error) {
console.error('페이지 접근 권한 조회 오류:', error); console.error('페이지 접근 권한 조회 오류:', error);
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' }); res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });

View File

@@ -12,6 +12,9 @@ router.post('/sessions', requireAuth, TbmController.createSession);
// 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의) // 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의)
router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports); router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports);
// 당일 전 작업자 배정 현황 (더 구체적인 경로이므로 먼저 정의)
router.get('/sessions/date/:date/assignments', requireAuth, TbmController.getWorkerAssignmentsByDate);
// 특정 날짜의 TBM 세션 목록 조회 // 특정 날짜의 TBM 세션 목록 조회
router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate); router.get('/sessions/date/:date', requireAuth, TbmController.getSessionsByDate);
@@ -32,6 +35,9 @@ router.delete('/sessions/:sessionId', requireAuth, TbmController.deleteSession);
// 팀원 추가 (단일) // 팀원 추가 (단일)
router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember); router.post('/sessions/:sessionId/team', requireAuth, TbmController.addTeamMember);
// 분할 항목 추가 (같은 작업자의 추가 배정)
router.post('/sessions/:sessionId/team/split', requireAuth, TbmController.addSplitAssignment);
// 팀 구성 일괄 추가 // 팀 구성 일괄 추가
router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers); router.post('/sessions/:sessionId/team/batch', requireAuth, TbmController.addTeamMembers);
@@ -81,6 +87,17 @@ router.get('/sessions/:sessionId/weather', requireAuth, TbmController.getSession
// 세션 날씨 정보 저장 // 세션 날씨 정보 저장
router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather); router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather);
// ==================== 작업자 이동 관련 ====================
// 이동 실행 (보내기/빼오기)
router.post('/transfers', requireAuth, TbmController.createTransfer);
// 당일 이동 내역 조회
router.get('/transfers/date/:date', requireAuth, TbmController.getTransfersByDate);
// 이동 취소 (원복)
router.delete('/transfers/:transferId', requireAuth, TbmController.cancelTransfer);
// ==================== 작업 인계 관련 ==================== // ==================== 작업 인계 관련 ====================
// 작업 인계 생성 // 작업 인계 생성

View File

@@ -113,23 +113,9 @@ router.get('/me/monthly-stats', async (req, res) => {
} }
}); });
// ========== 자신의 페이지 권한 조회 (Admin 불필요) ========== // ========== 페이지 권한 조회는 pageAccessRoutes.js에서 처리 ==========
// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin) // GET /:id/page-access → /api/users/:userId/page-access (pageAccessRoutes.js)
router.get('/:id/page-access', (req, res, next) => { // tkuser의 user_page_permissions 테이블을 조회하는 통합 핸들러 사용
const requestedId = parseInt(req.params.id);
const currentUserId = req.user?.user_id;
const userRole = req.user?.role?.toLowerCase();
// 자신의 권한 조회이거나 Admin인 경우 허용
if (requestedId === currentUserId || userRole === 'admin' || userRole === 'system admin') {
return userController.getUserPageAccess(req, res, next);
}
return res.status(403).json({
success: false,
message: '자신의 페이지 권한만 조회할 수 있습니다'
});
});
// ========== 관리자 전용 API ========== // ========== 관리자 전용 API ==========
/** /**

View File

@@ -8,14 +8,14 @@
</svg> </svg>
<span class="mobile-nav-label"></span> <span class="mobile-nav-label"></span>
</a> </a>
<a href="/pages/work/tbm.html" class="mobile-nav-item" data-page="tbm"> <a href="/pages/work/tbm-mobile.html" class="mobile-nav-item" data-page="tbm">
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/> <path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/> <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg> </svg>
<span class="mobile-nav-label">TBM</span> <span class="mobile-nav-label">TBM</span>
</a> </a>
<a href="/pages/work/report-create.html" class="mobile-nav-item" data-page="report"> <a href="/pages/work/report-create-mobile.html" class="mobile-nav-item" data-page="report">
<svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="mobile-nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/> <polyline points="14 2 14 8 20 8"/>

File diff suppressed because it is too large Load Diff

View File

@@ -218,16 +218,36 @@
width: 100%; width: 100%;
} }
/* 지도 영역 */ /* 작업장 카드: 엣지투엣지 */
.workplace-status-section .card {
border-radius: 0;
margin-left: -0.75rem;
margin-right: -0.75rem;
border-left: none;
border-right: none;
}
.workplace-status-section .card-body {
padding: 0 !important;
}
#workplaceMapContainer { #workplaceMapContainer {
min-height: 300px !important; min-height: auto !important;
} }
/* 캔버스: 높이 제한 해제, 풀와이드 */
#workplaceMapCanvas { #workplaceMapCanvas {
max-height: 350px; max-height: none;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
} }
/* 범례 숨기기 또는 축소 */ .workplace-status-section .card-header {
padding: 0.75rem !important;
}
/* 범례 숨기기 */
#mapLegend { #mapLegend {
display: none; display: none;
} }
@@ -756,6 +776,264 @@
} }
} }
/* ========== 모바일 대시보드 (작업장 리스트 뷰) ========== */
@media (max-width: 768px) {
/* 모바일 뷰가 활성화되면 데스크톱 섹션 숨김 */
.mobile-dashboard-view[style*="block"] ~ .workplace-status-section,
.mobile-dashboard-view[style*="block"] ~ .moved-equipment-section,
.mobile-dashboard-view[style*="block"] ~ .dashboard-footer {
display: none !important;
}
.mobile-dashboard-view {
padding-bottom: 8px;
}
/* --- 날짜 헤더 --- */
.md-date-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 14px;
padding: 0 2px;
}
.md-date-label {
font-size: var(--text-lg, 18px);
font-weight: 700;
color: var(--text-primary, #1a202c);
}
.md-date-value {
font-size: var(--text-sm, 14px);
color: var(--text-tertiary, #718096);
font-weight: 400;
}
/* --- 카테고리 탭 --- */
.md-category-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding: 0 2px 12px;
margin-bottom: 4px;
scrollbar-width: none;
}
.md-category-tabs::-webkit-scrollbar {
display: none;
}
.md-cat-tab {
flex-shrink: 0;
padding: 7px 16px;
border-radius: var(--radius-full, 9999px);
font-size: var(--text-sm, 14px);
font-weight: 500;
white-space: nowrap;
border: 1px solid var(--border-light, #e2e8f0);
background: var(--bg-primary, #fff);
color: var(--text-secondary, #4a5568);
cursor: pointer;
transition: all 0.15s;
min-height: 36px;
-webkit-tap-highlight-color: transparent;
}
.md-cat-tab:active {
transform: scale(0.97);
}
.md-cat-tab.active {
background: var(--primary-500, #3b82f6);
color: #fff;
border-color: var(--primary-500, #3b82f6);
}
/* --- 작업장 리스트 --- */
.md-workplace-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* --- 작업장 카드 --- */
.md-wp-card {
background: var(--bg-primary, #fff);
border: 1px solid var(--border-light, #e2e8f0);
border-radius: var(--radius-lg, 12px);
padding: 14px 16px;
}
.md-wp-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1a202c);
margin: 0 0 8px;
}
.md-wp-stats {
display: flex;
flex-direction: column;
gap: 4px;
}
.md-wp-stat-row {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm, 14px);
color: var(--text-secondary, #4a5568);
line-height: 1.6;
}
.md-wp-stat-icon {
flex-shrink: 0;
width: 18px;
text-align: center;
font-size: 13px;
}
.md-wp-stat-text {
font-variant-numeric: tabular-nums;
}
.md-wp-stat--warning {
color: var(--status-warning-text, #ca8a04);
}
/* --- 빈 상태 --- */
.md-wp-no-activity {
margin: 0;
color: var(--text-tertiary, #718096);
font-size: var(--text-sm, 14px);
}
.md-wp-empty-all {
text-align: center;
padding: 32px 0;
color: var(--text-tertiary, #718096);
font-size: var(--text-sm, 14px);
}
/* --- 카드 확장 상태 --- */
.md-wp-card.expanded {
border-color: var(--primary-300, #93c5fd);
box-shadow: 0 2px 8px rgba(59,130,246,0.1);
}
.md-wp-header {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.md-wp-toggle {
float: right;
color: var(--text-tertiary, #718096);
font-size: 12px;
transition: transform 0.2s;
}
.md-wp-card.expanded .md-wp-toggle {
transform: rotate(180deg);
}
/* --- 상세 영역 --- */
.md-wp-detail {
display: none;
border-top: 1px solid var(--border-light, #e2e8f0);
margin-top: 10px;
padding-top: 10px;
}
.md-wp-card.expanded .md-wp-detail {
display: block;
}
.md-wp-detail-section {
margin-bottom: 10px;
}
.md-wp-detail-section:last-child {
margin-bottom: 0;
}
.md-wp-detail-title {
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary, #718096);
letter-spacing: 0.03em;
margin-bottom: 6px;
}
.md-wp-detail-item {
padding: 6px 0;
border-bottom: 1px solid var(--gray-100, #f5f5f5);
font-size: 14px;
}
.md-wp-detail-item:last-child {
border-bottom: none;
}
.md-wp-detail-main {
font-weight: 500;
color: var(--text-primary, #1a202c);
}
.md-wp-detail-sub {
font-size: 13px;
color: var(--text-tertiary, #718096);
margin-top: 1px;
}
/* 신고 상태 배지 */
.md-wp-issue-status {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.md-wp-issue-status--reported {
background: var(--status-warning-bg, #fef3c7);
color: var(--status-warning-text, #ca8a04);
}
.md-wp-issue-status--received {
background: var(--status-info-bg, #dbeafe);
color: var(--status-info-text, #2563eb);
}
.md-wp-issue-status--in_progress {
background: var(--status-error-bg, #fee2e2);
color: var(--status-error-text, #dc2626);
}
/* --- 로딩 스켈레톤 --- */
.md-skeleton {
height: 48px;
border-radius: var(--radius-md, 8px);
background: linear-gradient(90deg, var(--gray-100, #f5f5f5) 25%, var(--gray-200, #eee) 50%, var(--gray-100, #f5f5f5) 75%);
background-size: 200% 100%;
animation: md-shimmer 1.5s infinite;
}
@keyframes md-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
}
/* 데스크톱에서는 모바일 대시보드 절대 표시 안 함 */
@media (min-width: 769px) {
.mobile-dashboard-view {
display: none !important;
}
}
/* ========== 다크모드 지원 (선택적) ========== */ /* ========== 다크모드 지원 (선택적) ========== */
@media (max-width: 768px) and (prefers-color-scheme: dark) { @media (max-width: 768px) and (prefers-color-scheme: dark) {
/* 다크모드 색상 조정 필요시 여기에 추가 */ /* 다크모드 색상 조정 필요시 여기에 추가 */

View File

@@ -1237,6 +1237,115 @@ body.tbm-modal-open .mobile-bottom-nav {
} }
} }
/* ===== 가로모드 전체화면 지도 오버레이 ===== */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #fff;
overflow: hidden;
}
/* 세로모드(portrait)일 때 CSS 회전으로 가로 효과 */
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
/* 물리적 가로모드일 때 회전 없이 전체화면 */
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
flex-shrink: 0;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #f1f5f9;
padding: 0.5rem;
}
.landscape-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
/* 전체화면 지도 트리거 버튼 (모바일만) */
.landscape-trigger-btn {
display: none;
}
@media (max-width: 768px) {
.landscape-trigger-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
border: none;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.landscape-trigger-btn:active {
transform: scale(0.97);
opacity: 0.85;
}
}
/* ===== 작업장 선택 지도 ===== */ /* ===== 작업장 선택 지도 ===== */
.tbm-workplace-map-container { .tbm-workplace-map-container {
text-align: center; text-align: center;

View File

@@ -26,7 +26,7 @@
if (window.clearSSOAuth) { window.clearSSOAuth(); return; } if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
localStorage.removeItem('sso_token'); localStorage.removeItem('sso_token');
localStorage.removeItem('sso_user'); localStorage.removeItem('sso_user');
localStorage.removeItem('userPageAccess'); localStorage.removeItem('userPageAccess_v2');
} }
// ===== 페이지 권한 캐시 ===== // ===== 페이지 권한 캐시 =====
@@ -36,7 +36,7 @@
if (!currentUser || !currentUser.user_id) return null; if (!currentUser || !currentUser.user_id) return null;
// 캐시 확인 // 캐시 확인
const cached = localStorage.getItem('userPageAccess'); const cached = localStorage.getItem('userPageAccess_v2_v2');
if (cached) { if (cached) {
try { try {
const cacheData = JSON.parse(cached); const cacheData = JSON.parse(cached);
@@ -44,7 +44,7 @@
return cacheData.pages; return cacheData.pages;
} }
} catch (e) { } catch (e) {
localStorage.removeItem('userPageAccess'); localStorage.removeItem('userPageAccess_v2');
} }
} }
@@ -67,7 +67,7 @@
const data = await response.json(); const data = await response.json();
const pages = data.data.pageAccess || []; const pages = data.data.pageAccess || [];
localStorage.setItem('userPageAccess', JSON.stringify({ localStorage.setItem('userPageAccess_v2', JSON.stringify({
pages: pages, pages: pages,
timestamp: Date.now() timestamp: Date.now()
})); }));
@@ -91,11 +91,19 @@
} }
// ===== 현재 페이지 키 추출 ===== // ===== 현재 페이지 키 추출 =====
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
'work.tbm-mobile': 'work.tbm',
'work.report-create-mobile': 'work.report-create'
};
function getCurrentPageKey() { function getCurrentPageKey() {
const path = window.location.pathname; const path = window.location.pathname;
if (!path.startsWith('/pages/')) return null; if (!path.startsWith('/pages/')) return null;
const pagePath = path.substring(7).replace('.html', ''); const pagePath = path.substring(7).replace('.html', '');
return pagePath.replace(/\//g, '.'); const rawKey = pagePath.replace(/\//g, '.');
return PAGE_KEY_ALIASES[rawKey] || rawKey;
} }
// ===== 컴포넌트 로더 ===== // ===== 컴포넌트 로더 =====

View File

@@ -26,6 +26,12 @@ function clearAuthData() {
* /pages/admin/accounts.html -> admin.accounts * /pages/admin/accounts.html -> admin.accounts
* /pages/dashboard.html -> dashboard * /pages/dashboard.html -> dashboard
*/ */
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
var PAGE_KEY_ALIASES = {
'work.tbm-create': 'work.tbm',
'work.tbm-mobile': 'work.tbm'
};
function getCurrentPageKey() { function getCurrentPageKey() {
const path = window.location.pathname; const path = window.location.pathname;
@@ -41,9 +47,9 @@ function getCurrentPageKey() {
const withoutExt = pagePath.replace('.html', ''); const withoutExt = pagePath.replace('.html', '');
// 슬래시를 점으로 변환 // 슬래시를 점으로 변환
const pageKey = withoutExt.replace(/\//g, '.'); const rawKey = withoutExt.replace(/\//g, '.');
return pageKey; return PAGE_KEY_ALIASES[rawKey] || rawKey;
} }
/** /**

File diff suppressed because it is too large Load Diff

View File

@@ -213,6 +213,46 @@ function getUser() {
return user ? JSON.parse(user) : null; 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 + '분';
}
/** /**
* TBM 작업 목록 렌더링 (날짜별 > 세션별 그룹화) * TBM 작업 목록 렌더링 (날짜별 > 세션별 그룹화)
* - 날짜별로 접기/펼치기 가능 * - 날짜별로 접기/펼치기 가능
@@ -422,11 +462,15 @@ function renderTbmWorkList() {
} }
return false; return false;
}); });
// 근태 기반 자동 시간 채움
const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0;
const hasDefaultHours = defaultHours > 0;
const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : '';
return ` return `
<tr data-index="${index}" data-type="tbm" data-session-key="${key}"> <tr data-index="${index}" data-type="tbm" data-session-key="${key}">
<td> <td>
<div class="worker-cell"> <div class="worker-cell">
<strong>${tbm.worker_name || '작업자'}</strong> <strong>${tbm.worker_name || '작업자'}</strong>${attendanceBadgeHtml}
<div class="worker-job-type">${tbm.job_type || '-'}</div> <div class="worker-job-type">${tbm.job_type || '-'}</div>
</div> </div>
</td> </td>
@@ -440,11 +484,12 @@ function renderTbmWorkList() {
</div> </div>
</td> </td>
<td> <td>
<input type="hidden" id="totalHours_${index}" value="" required> <input type="hidden" id="totalHours_${index}" value="${hasDefaultHours ? defaultHours : ''}" required>
<div class="time-input-trigger placeholder" <div class="time-input-trigger ${hasDefaultHours ? '' : 'placeholder'}"
id="totalHoursDisplay_${index}" id="totalHoursDisplay_${index}"
onclick="openTimePicker(${index}, 'total')"> onclick="openTimePicker(${index}, 'total')"
시간 선택 style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}">
${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
</div> </div>
</td> </td>
<td> <td>

View File

@@ -0,0 +1,443 @@
// mobile-dashboard.js - 모바일 대시보드 v2
// 공장별 카테고리 탭 → 작업장 리스트 → 작업장별 상태 요약
(function() {
'use strict';
if (window.innerWidth > 768) return;
var today = new Date().toISOString().slice(0, 10);
// ==================== 캐시 변수 ====================
var categories = [];
var allWorkplaces = [];
var tbmByWorkplace = {};
var visitorsByWorkplace = {};
var movedByWorkplace = {};
var issuesByWorkplace = {};
var workplacesByCategory = {};
// ==================== 유틸리티 ====================
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function waitForApi(timeout) {
timeout = timeout || 5000;
return new Promise(function(resolve, reject) {
if (window.apiCall) return resolve();
var elapsed = 0;
var interval = setInterval(function() {
elapsed += 50;
if (window.apiCall) { clearInterval(interval); resolve(); }
else if (elapsed >= timeout) { clearInterval(interval); reject(new Error('apiCall timeout')); }
}, 50);
});
}
// ==================== 데이터 그룹핑 ====================
function groupTbmByWorkplace(sessions) {
tbmByWorkplace = {};
if (!Array.isArray(sessions)) return;
sessions.forEach(function(s) {
var wpId = s.workplace_id;
if (!wpId) return;
if (!tbmByWorkplace[wpId]) {
tbmByWorkplace[wpId] = { taskCount: 0, totalWorkers: 0, sessions: [] };
}
tbmByWorkplace[wpId].taskCount++;
// team_member_count + leader 1
tbmByWorkplace[wpId].totalWorkers += (parseInt(s.team_member_count) || 0) + 1;
tbmByWorkplace[wpId].sessions.push(s);
});
}
function groupVisitorsByWorkplace(requests) {
visitorsByWorkplace = {};
if (!Array.isArray(requests)) return;
requests.forEach(function(r) {
// 오늘 날짜 + 승인된 건만
if (r.visit_date !== today) return;
if (r.status !== 'approved') return;
var wpId = r.workplace_id;
if (!wpId) return;
if (!visitorsByWorkplace[wpId]) {
visitorsByWorkplace[wpId] = { visitCount: 0, totalVisitors: 0, requests: [] };
}
visitorsByWorkplace[wpId].visitCount++;
visitorsByWorkplace[wpId].totalVisitors += parseInt(r.visitor_count) || 0;
visitorsByWorkplace[wpId].requests.push(r);
});
}
function groupMovedByWorkplace(items) {
movedByWorkplace = {};
if (!Array.isArray(items)) return;
items.forEach(function(eq) {
var wpId = eq.current_workplace_id;
if (!wpId) return;
if (!movedByWorkplace[wpId]) {
movedByWorkplace[wpId] = { movedCount: 0, items: [] };
}
movedByWorkplace[wpId].movedCount++;
movedByWorkplace[wpId].items.push(eq);
});
}
function groupIssuesByWorkplace(issues) {
issuesByWorkplace = {};
if (!Array.isArray(issues)) return;
var activeStatuses = ['reported', 'received', 'in_progress'];
issues.forEach(function(issue) {
var wpId = issue.workplace_id;
if (!wpId) return;
if (activeStatuses.indexOf(issue.status) === -1) return;
if (!issuesByWorkplace[wpId]) {
issuesByWorkplace[wpId] = { activeCount: 0, items: [] };
}
issuesByWorkplace[wpId].activeCount++;
issuesByWorkplace[wpId].items.push(issue);
});
}
function groupWorkplacesByCategory(workplaces) {
workplacesByCategory = {};
if (!Array.isArray(workplaces)) return;
workplaces.forEach(function(wp) {
var catId = wp.category_id;
if (!catId) return;
if (!workplacesByCategory[catId]) {
workplacesByCategory[catId] = [];
}
workplacesByCategory[catId].push(wp);
});
}
// ==================== 렌더링 ====================
function renderCategoryTabs() {
var container = document.getElementById('mCategoryTabs');
if (!container || !categories.length) return;
var html = '';
categories.forEach(function(cat, idx) {
html += '<button class="md-cat-tab' + (idx === 0 ? ' active' : '') +
'" data-id="' + cat.category_id + '">' +
escapeHtml(cat.category_name) + '</button>';
});
// 전체 탭
html += '<button class="md-cat-tab" data-id="all">전체</button>';
container.innerHTML = html;
// 이벤트 바인딩
var tabs = container.querySelectorAll('.md-cat-tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var catId = tab.getAttribute('data-id');
selectCategory(catId);
});
});
// 첫 번째 카테고리 자동 선택
if (categories.length > 0) {
selectCategory(String(categories[0].category_id));
}
}
function selectCategory(categoryId) {
var workplaces;
if (categoryId === 'all') {
workplaces = allWorkplaces.filter(function(wp) { return wp.is_active !== false; });
} else {
workplaces = (workplacesByCategory[categoryId] || []).filter(function(wp) {
return wp.is_active !== false;
});
}
renderWorkplaceList(workplaces);
}
function renderWorkplaceList(workplaces) {
var container = document.getElementById('mWorkplaceList');
if (!container) return;
if (!workplaces || workplaces.length === 0) {
container.innerHTML = '<div class="md-wp-empty-all">등록된 작업장이 없습니다.</div>';
return;
}
var html = '';
workplaces.forEach(function(wp) {
var wpId = wp.workplace_id;
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var hasAny = tbm || visitors || moved || issues;
html += '<div class="md-wp-card" data-wp-id="' + wpId + '">';
// 헤더 (클릭 영역)
html += '<div class="md-wp-header">';
html += '<h3 class="md-wp-name">' + escapeHtml(wp.workplace_name);
if (hasAny) {
html += '<span class="md-wp-toggle">&#9660;</span>';
}
html += '</h3>';
if (!hasAny) {
html += '<p class="md-wp-no-activity">오늘 활동이 없습니다</p>';
} else {
html += '<div class="md-wp-stats">';
// TBM 작업
if (tbm) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128736;</span>' +
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 &middot; ' + tbm.totalWorkers + '명</span>' +
'</div>';
}
// 방문
if (visitors) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#128682;</span>' +
'<span class="md-wp-stat-text">방문 ' + visitors.visitCount + '건 &middot; ' + visitors.totalVisitors + '명</span>' +
'</div>';
}
// 신고 (미완료만)
if (issues && issues.activeCount > 0) {
html += '<div class="md-wp-stat-row md-wp-stat--warning">' +
'<span class="md-wp-stat-icon">&#9888;</span>' +
'<span class="md-wp-stat-text">신고 ' + issues.activeCount + '건</span>' +
'</div>';
}
// 이동설비
if (moved && moved.movedCount > 0) {
html += '<div class="md-wp-stat-row">' +
'<span class="md-wp-stat-icon">&#8596;</span>' +
'<span class="md-wp-stat-text">이동설비 ' + moved.movedCount + '건</span>' +
'</div>';
}
html += '</div>';
}
html += '</div>'; // .md-wp-header
// 상세 영역 (활동 있는 카드만)
if (hasAny) {
html += '<div class="md-wp-detail">' + renderCardDetail(wpId) + '</div>';
}
html += '</div>'; // .md-wp-card
});
container.innerHTML = html;
// 클릭 이벤트 바인딩
var cards = container.querySelectorAll('.md-wp-card[data-wp-id]');
cards.forEach(function(card) {
var wpId = card.getAttribute('data-wp-id');
var hasActivity = tbmByWorkplace[wpId] || visitorsByWorkplace[wpId] ||
movedByWorkplace[wpId] || issuesByWorkplace[wpId];
if (!hasActivity) return;
card.querySelector('.md-wp-header').addEventListener('click', function() {
toggleCard(wpId);
});
});
}
// ==================== 카드 확장/접기 ====================
function toggleCard(wpId) {
var allCards = document.querySelectorAll('.md-wp-card.expanded');
var targetCard = document.querySelector('.md-wp-card[data-wp-id="' + wpId + '"]');
if (!targetCard) return;
var isExpanded = targetCard.classList.contains('expanded');
// 다른 카드 모두 접기 (아코디언)
allCards.forEach(function(card) {
card.classList.remove('expanded');
});
// 토글
if (!isExpanded) {
targetCard.classList.add('expanded');
}
}
function renderCardDetail(wpId) {
var html = '';
var tbm = tbmByWorkplace[wpId];
var visitors = visitorsByWorkplace[wpId];
var issues = issuesByWorkplace[wpId];
var moved = movedByWorkplace[wpId];
// TBM 작업
if (tbm && tbm.sessions.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 작업</div>';
tbm.sessions.forEach(function(s) {
var taskName = s.task_name || '작업명 미지정';
var leaderName = s.leader_name || '미지정';
var memberCount = (parseInt(s.team_member_count) || 0) + 1;
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(taskName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(leaderName) + ' &middot; ' + memberCount + '명</div>';
html += '</div>';
});
html += '</div>';
}
// 방문
if (visitors && visitors.requests.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 방문</div>';
visitors.requests.forEach(function(r) {
var company = r.visitor_company || '업체 미지정';
var count = parseInt(r.visitor_count) || 0;
var purpose = r.purpose_name || '';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(company) + ' &middot; ' + count + '명';
if (purpose) html += ' &middot; ' + escapeHtml(purpose);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 신고
if (issues && issues.items.length > 0) {
var statusMap = { reported: '신고', received: '접수', in_progress: '처리중' };
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 신고</div>';
issues.items.forEach(function(issue) {
var category = issue.issue_category_name || '미분류';
var desc = issue.additional_description || '';
if (desc.length > 30) desc = desc.substring(0, 30) + '...';
var statusText = statusMap[issue.status] || issue.status;
var statusClass = 'md-wp-issue-status--' + (issue.status || 'reported');
var reporter = issue.reporter_name || '';
var icon = issue.status === 'in_progress' ? '&#128308;' : '&#9888;';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
if (desc) html += ' &middot; ' + escapeHtml(desc);
html += '</div>';
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
if (reporter) html += ' &rarr; ' + escapeHtml(reporter);
html += '</div>';
html += '</div>';
});
html += '</div>';
}
// 이동설비
if (moved && moved.items.length > 0) {
html += '<div class="md-wp-detail-section">';
html += '<div class="md-wp-detail-title">&#9654; 이동설비</div>';
moved.items.forEach(function(eq) {
var eqName = eq.equipment_name || '설비명 미지정';
var fromWp = eq.original_workplace_name || '?';
var toWp = eq.current_workplace_name || '?';
html += '<div class="md-wp-detail-item">';
html += '<div class="md-wp-detail-main">' + escapeHtml(eqName) + '</div>';
html += '<div class="md-wp-detail-sub">' + escapeHtml(fromWp) + ' &rarr; ' + escapeHtml(toWp) + '</div>';
html += '</div>';
});
html += '</div>';
}
return html;
}
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
await waitForApi();
} catch (e) {
console.error('mobile-dashboard: apiCall not available');
return;
}
var view = document.getElementById('mobileDashboardView');
if (!view) return;
view.style.display = 'block';
// 날짜 표시
var now = new Date();
var days = ['일', '월', '화', '수', '목', '금', '토'];
var dateEl = document.getElementById('mDateValue');
if (dateEl) {
dateEl.textContent = now.getFullYear() + '.' +
String(now.getMonth() + 1).padStart(2, '0') + '.' +
String(now.getDate()).padStart(2, '0') + ' (' + days[now.getDay()] + ')';
}
// 로딩 표시
var listContainer = document.getElementById('mWorkplaceList');
if (listContainer) {
listContainer.innerHTML =
'<div class="md-skeleton"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>' +
'<div class="md-skeleton" style="margin-top:8px;"></div>';
}
// 데이터 병렬 로딩
var results = await Promise.allSettled([
window.apiCall('/workplaces/categories'),
window.apiCall('/tbm/sessions/date/' + today),
window.apiCall('/workplace-visits/requests?visit_date=' + today + '&status=approved'),
window.apiCall('/equipments/moved/list'),
window.apiCall('/work-issues?start_date=' + today + '&end_date=' + today),
window.apiCall('/workplaces')
]);
// 카테고리
if (results[0].status === 'fulfilled' && results[0].value && results[0].value.success) {
categories = results[0].value.data || [];
}
// TBM
if (results[1].status === 'fulfilled' && results[1].value && results[1].value.success) {
groupTbmByWorkplace(results[1].value.data || []);
}
// 방문
if (results[2].status === 'fulfilled' && results[2].value && results[2].value.success) {
groupVisitorsByWorkplace(results[2].value.data || []);
}
// 이동설비
if (results[3].status === 'fulfilled' && results[3].value && results[3].value.success) {
groupMovedByWorkplace(results[3].value.data || []);
}
// 신고
if (results[4].status === 'fulfilled' && results[4].value && results[4].value.success) {
groupIssuesByWorkplace(results[4].value.data || []);
}
// 작업장 전체 (카테고리별 그룹핑)
if (results[5].status === 'fulfilled' && results[5].value && results[5].value.success) {
allWorkplaces = results[5].value.data || [];
groupWorkplacesByCategory(allWorkplaces);
}
// 렌더링
renderCategoryTabs();
});
})();

View File

@@ -0,0 +1,573 @@
/**
* TBM 모바일 위자드 - tbm-create.js
* 3단계 위자드로 TBM 세션을 생성하는 모바일 전용 페이지 로직
* Step 1: 작업자 선택, Step 2: 프로젝트+공정 선택, Step 3: 확인
* (작업/작업장은 생성 후 세부 편집 단계에서 입력)
*/
(function() {
'use strict';
// ==================== 위자드 상태 ====================
const W = {
step: 1,
totalSteps: 3,
sessionDate: null,
leaderId: null,
leaderName: '',
workers: new Set(), // worker_id Set
workerNames: {}, // { worker_id: worker_name }
projectId: null,
projectName: '',
workTypeId: null,
workTypeName: '',
showAddWorkType: false,
todayAssignments: null // 당일 배정 현황 캐시
};
const esc = window.escapeHtml || function(s) { return s || ''; };
// ==================== 초기화 ====================
document.addEventListener('DOMContentLoaded', async function() {
try {
// apiCall이 준비될 때까지 대기
await waitForApiCall();
// 초기 데이터 로드
await window.TbmAPI.loadInitialData();
// 기본 정보 자동 설정
W.sessionDate = window.TbmUtils.getTodayKST();
var user = window.TbmState.getUser();
if (user) {
if (user.worker_id) {
var worker = window.TbmState.allWorkers.find(function(w) { return w.worker_id === user.worker_id; });
if (worker) {
W.leaderId = worker.worker_id;
W.leaderName = worker.worker_name;
} else {
W.leaderName = user.name || '';
}
} else {
W.leaderName = user.name || '';
}
}
// 로딩 해제
document.getElementById('loadingOverlay').style.display = 'none';
// 첫 스텝 렌더링
renderStep(1);
updateIndicator();
updateNav();
} catch (error) {
console.error('초기화 오류:', error);
document.getElementById('loadingOverlay').style.display = 'none';
showToast('데이터를 불러오는 중 오류가 발생했습니다.', 'error');
}
});
function waitForApiCall() {
return new Promise(function(resolve) {
if (typeof window.apiCall === 'function') {
resolve();
return;
}
var checks = 0;
var interval = setInterval(function() {
checks++;
if (typeof window.apiCall === 'function' || checks > 50) {
clearInterval(interval);
resolve();
}
}, 100);
});
}
// ==================== 네비게이션 ====================
window.nextStep = function() {
if (!validateStep(W.step)) return;
if (W.step < W.totalSteps) {
W.step++;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.prevStep = function() {
if (W.step > 1) {
W.step--;
renderStep(W.step);
updateIndicator();
updateNav();
window.scrollTo(0, 0);
}
};
window.goBack = function() {
if (W.step > 1) {
window.prevStep();
} else {
window.location.href = '/pages/work/tbm-mobile.html';
}
};
function updateIndicator() {
var steps = document.querySelectorAll('#stepIndicator .step');
var lines = document.querySelectorAll('#stepIndicator .step-line');
steps.forEach(function(el, i) {
el.classList.remove('active', 'completed');
if (i + 1 === W.step) {
el.classList.add('active');
} else if (i + 1 < W.step) {
el.classList.add('completed');
}
});
lines.forEach(function(el, i) {
el.style.background = (i + 1 < W.step) ? '#10b981' : '#e5e7eb';
});
}
function updateNav() {
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
if (W.step === 1) {
prevBtn.style.visibility = 'hidden';
prevBtn.onclick = null;
} else {
prevBtn.style.visibility = 'visible';
prevBtn.onclick = window.prevStep;
}
if (W.step === W.totalSteps) {
nextBtn.className = 'nav-btn nav-btn-save';
nextBtn.innerHTML = '저장';
nextBtn.onclick = saveWizard;
} else {
nextBtn.className = 'nav-btn nav-btn-next';
nextBtn.innerHTML = '다음 &#8594;';
nextBtn.onclick = window.nextStep;
}
nextBtn.disabled = false;
}
// ==================== 유효성 검사 ====================
function validateStep(step) {
switch (step) {
case 1: // 작업자 선택
if (W.workers.size === 0) {
showToast('최소 1명의 작업자를 선택해주세요.', 'warning');
return false;
}
return true;
case 2: // 프로젝트 + 공정
if (!W.workTypeId) {
showToast('공정을 선택해주세요.', 'warning');
return false;
}
return true;
default:
return true;
}
}
// ==================== 스텝 렌더링 ====================
function renderStep(step) {
var container = document.getElementById('stepContainer');
switch (step) {
case 1: renderStepWorkers(container); break;
case 2: renderStepProjectAndWorkType(container); break;
case 3: renderStepConfirm(container); break;
}
}
// --- Step 1: 작업자 선택 ---
async function renderStepWorkers(container) {
var workers = window.TbmState.allWorkers;
// 당일 배정 현황 로드 (첫 로드 시)
if (!W.todayAssignments) {
try {
var today = window.TbmUtils.getTodayKST();
var res = await window.apiCall('/tbm/sessions/date/' + today + '/assignments');
if (res && res.success) {
W.todayAssignments = {};
res.data.forEach(function(a) {
if (a.sessions && a.sessions.length > 0) {
W.todayAssignments[a.worker_id] = a;
}
});
} else {
W.todayAssignments = {};
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
W.todayAssignments = {};
}
}
var workerCards = workers.map(function(w) {
var selected = W.workers.has(w.worker_id) ? ' selected' : '';
var assignment = W.todayAssignments[w.worker_id];
var assigned = assignment && assignment.total_hours >= 8;
var partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
var badgeHtml = '';
var disabledClass = '';
var onclick = 'toggleWorker(' + w.worker_id + ')';
if (assigned) {
// 종일 배정됨 - 선택 불가
var leaderNames = assignment.sessions.map(function(s) { return s.leader_name || ''; }).join(', ');
badgeHtml = '<div style="font-size:0.625rem; color:#ef4444; margin-top:0.125rem;">' + esc(leaderNames) + ' TBM (' + assignment.total_hours + 'h)</div>';
disabledClass = ' disabled';
onclick = '';
} else if (partiallyAssigned) {
var remaining = 8 - assignment.total_hours;
badgeHtml = '<div style="font-size:0.625rem; color:#2563eb; margin-top:0.125rem;">' + remaining + 'h 가용</div>';
}
return '<div class="worker-card' + selected + disabledClass + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') +
' data-wid="' + w.worker_id + '"' +
' style="' + (assigned ? 'opacity:0.5; pointer-events:none;' : '') + '">' +
'<div class="worker-check">&#10003;</div>' +
'<div class="worker-info">' +
'<div class="worker-name">' + esc(w.worker_name) + '</div>' +
'<div class="worker-type">' + esc(w.job_type || '작업자') + '</div>' +
badgeHtml +
'</div>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">1</span>작업자 선택</div>' +
'<div class="select-all-bar">' +
'<span class="count" id="workerCount">' + W.workers.size + '명 선택</span>' +
'<button type="button" class="select-all-btn" onclick="toggleAllWorkers()">' +
(W.workers.size === workers.length ? '전체 해제' : '전체 선택') +
'</button>' +
'</div>' +
'<div class="worker-grid">' + workerCards + '</div>' +
'</div>';
}
window.toggleWorker = function(workerId) {
// 이미 종일 배정된 작업자는 선택 불가
var a = W.todayAssignments && W.todayAssignments[workerId];
if (a && a.total_hours >= 8) return;
if (W.workers.has(workerId)) {
W.workers.delete(workerId);
delete W.workerNames[workerId];
} else {
W.workers.add(workerId);
var w = window.TbmState.allWorkers.find(function(x) { return x.worker_id === workerId; });
if (w) W.workerNames[workerId] = w.worker_name;
}
var card = document.querySelector('[data-wid="' + workerId + '"]');
if (card) card.classList.toggle('selected');
var countEl = document.getElementById('workerCount');
if (countEl) countEl.textContent = W.workers.size + '명 선택';
};
window.toggleAllWorkers = function() {
var workers = window.TbmState.allWorkers;
var availableWorkers = workers.filter(function(w) {
var a = W.todayAssignments && W.todayAssignments[w.worker_id];
return !(a && a.total_hours >= 8);
});
if (W.workers.size === availableWorkers.length) {
W.workers.clear();
W.workerNames = {};
} else {
availableWorkers.forEach(function(w) {
W.workers.add(w.worker_id);
W.workerNames[w.worker_id] = w.worker_name;
});
}
renderStepWorkers(document.getElementById('stepContainer'));
};
// --- Step 2: 프로젝트 + 공정 선택 (통합) ---
function renderStepProjectAndWorkType(container) {
var projects = window.TbmState.allProjects;
var workTypes = window.TbmState.allWorkTypes;
// 프로젝트 선택 UI
var skipSelected = W.projectId === null ? ' selected' : '';
var projectItems = projects.map(function(p) {
var selected = W.projectId === p.project_id ? ' selected' : '';
return '<div class="list-item' + selected + '" onclick="selectProject(' + p.project_id + ', \'' + esc(p.project_name).replace(/'/g, "\\'") + '\')">' +
'<div class="item-title">' + esc(p.project_name) + '</div>' +
'<div class="item-desc">' + esc(p.job_no || '') + '</div>' +
'</div>';
}).join('');
// 공정 pill 버튼
var pillHtml = workTypes.map(function(wt) {
var selected = W.workTypeId === wt.id ? ' selected' : '';
return '<button type="button" class="pill-btn' + selected + '" onclick="selectWorkType(' + wt.id + ', \'' + esc(wt.name).replace(/'/g, "\\'") + '\')">' + esc(wt.name) + '</button>';
}).join('');
pillHtml += '<button type="button" class="pill-btn-add" onclick="toggleAddWorkType()">+ 추가</button>';
// 공정 인라인 추가 폼
var addWorkTypeFormHtml = '';
if (W.showAddWorkType) {
addWorkTypeFormHtml =
'<div class="inline-add-form" id="addWorkTypeForm">' +
'<input type="text" id="newWorkTypeName" placeholder="새 공정명 입력" autocomplete="off">' +
'<div class="inline-add-btns">' +
'<button type="button" class="btn-cancel" onclick="cancelAddWorkType()">취소</button>' +
'<button type="button" class="btn-save" id="btnSaveWorkType" onclick="saveNewWorkType()">저장</button>' +
'</div>' +
'</div>';
}
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>프로젝트 선택 <span style="font-size:0.75rem;font-weight:400;color:#9ca3af;">(선택사항)</span></div>' +
'<div class="list-item-skip' + skipSelected + '" onclick="selectProject(null, \'\')">' +
'선택 안함' +
'</div>' +
(projects.length > 0 ? projectItems : '<div class="empty-state">등록된 프로젝트가 없습니다</div>') +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">2</span>공정 선택 <span style="font-size:0.75rem;font-weight:400;color:#ef4444;">(필수)</span></div>' +
'<div class="pill-grid">' + pillHtml + '</div>' +
addWorkTypeFormHtml +
'</div>';
// 자동 포커스
if (W.showAddWorkType) {
var inp = document.getElementById('newWorkTypeName');
if (inp) {
setTimeout(function() { inp.focus(); }, 50);
inp.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); saveNewWorkType(); }
if (e.key === 'Escape') { cancelAddWorkType(); }
};
}
}
}
window.selectProject = function(projectId, projectName) {
W.projectId = projectId;
W.projectName = projectName || '';
// Update project list items
document.querySelectorAll('#stepContainer .list-item, #stepContainer .list-item-skip').forEach(function(el) {
el.classList.remove('selected');
});
if (projectId === null) {
var skipEl = document.querySelector('#stepContainer .list-item-skip');
if (skipEl) skipEl.classList.add('selected');
} else {
document.querySelectorAll('#stepContainer .list-item').forEach(function(el) {
var title = el.querySelector('.item-title');
if (title && title.textContent === projectName) {
el.classList.add('selected');
}
});
}
};
window.selectWorkType = function(id, name) {
W.workTypeId = id;
W.workTypeName = name;
// Update pill buttons
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
el.classList.remove('selected');
});
document.querySelectorAll('#stepContainer .pill-btn').forEach(function(el) {
if (el.textContent === name) {
el.classList.add('selected');
}
});
};
// --- Step 2: 인라인 추가 (공정) ---
window.toggleAddWorkType = function() {
W.showAddWorkType = !W.showAddWorkType;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.cancelAddWorkType = function() {
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
};
window.saveNewWorkType = async function() {
var inp = document.getElementById('newWorkTypeName');
var btn = document.getElementById('btnSaveWorkType');
if (!inp || !btn) return;
var name = inp.value.trim();
if (!name) {
showToast('공정명을 입력해주세요.', 'warning');
inp.focus();
return;
}
var exists = window.TbmState.allWorkTypes.some(function(wt) {
return wt.name.toLowerCase() === name.toLowerCase();
});
if (exists) {
showToast('이미 존재하는 공정명입니다.', 'warning');
inp.focus();
return;
}
btn.disabled = true;
btn.textContent = '저장 중...';
try {
var response = await window.apiCall('/daily-work-reports/work-types', 'POST', { name: name });
if (!response || !response.success) {
throw new Error(response?.message || '공정 추가 실패');
}
var newItem = response.data;
window.TbmState.allWorkTypes.push(newItem);
W.workTypeId = newItem.id;
W.workTypeName = newItem.name;
W.showAddWorkType = false;
renderStepProjectAndWorkType(document.getElementById('stepContainer'));
showToast('\'' + name + '\' 공정이 추가되었습니다.', 'success');
} catch (error) {
console.error('공정 추가 오류:', error);
showToast('공정 추가 중 오류: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = '저장';
}
};
// --- Step 3: 확인 ---
function renderStepConfirm(container) {
var dateDisplay = window.TbmUtils.formatDateFull(W.sessionDate);
// 작업자 이름 목록
var workerNameList = [];
W.workers.forEach(function(wid) {
workerNameList.push(W.workerNames[wid] || '작업자');
});
var summaryHtml =
'<div class="summary-card">' +
'<div class="summary-row"><span class="summary-label">날짜</span><span class="summary-value">' + esc(dateDisplay) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">입력자</span><span class="summary-value">' + esc(W.leaderName || '(미설정)') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">프로젝트</span><span class="summary-value">' + esc(W.projectName || '선택 안함') + '</span></div>' +
'<div class="summary-row"><span class="summary-label">공정</span><span class="summary-value">' + esc(W.workTypeName) + '</span></div>' +
'<div class="summary-row"><span class="summary-label">작업자</span><span class="summary-value">' + W.workers.size + '명</span></div>' +
'</div>';
// 작업자 목록 (간단 표시)
var workerListHtml = workerNameList.map(function(name) {
return '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.5rem 0.75rem;background:#f9fafb;border-radius:0.5rem;margin-bottom:0.25rem;">' +
'<span style="font-size:0.875rem;font-weight:500;color:#1f2937;">' + esc(name) + '</span>' +
'<span style="font-size:0.6875rem;color:#9ca3af;margin-left:auto;">세부 미입력</span>' +
'</div>';
}).join('');
container.innerHTML =
'<div class="wizard-section">' +
'<div class="section-title"><span class="sn">3</span>확인</div>' +
summaryHtml +
'</div>' +
'<div class="wizard-section">' +
'<div class="section-title">작업자 목록</div>' +
'<div style="padding:0.5rem;background:#fff7ed;border:1px solid #fed7aa;border-radius:0.5rem;margin-bottom:0.75rem;font-size:0.8125rem;color:#c2410c;">' +
'저장 후 TBM 카드를 탭하면 작업자별 작업/작업장을 입력할 수 있습니다.' +
'</div>' +
workerListHtml +
'</div>';
}
// ==================== 저장 ====================
async function saveWizard() {
// 저장 버튼 비활성화
var saveBtn = document.getElementById('nextBtn');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
}
try {
var leaderId = W.leaderId ? parseInt(W.leaderId) : null;
// 1. TBM 세션 생성
var sessionData = {
session_date: W.sessionDate,
leader_id: leaderId
};
var response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (!response || !response.success) {
throw new Error(response?.message || '세션 생성 실패');
}
var sessionId = response.data.session_id;
// 2. 팀원 일괄 추가 (task_id, workplace_id = null)
var members = [];
W.workers.forEach(function(wid) {
members.push({
worker_id: wid,
project_id: W.projectId,
work_type_id: W.workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
var teamResponse = await window.apiCall(
'/tbm/sessions/' + sessionId + '/team/batch',
'POST',
{ members: members }
);
if (!teamResponse || !teamResponse.success) {
throw new Error(teamResponse?.message || '팀원 추가 실패');
}
showToast('TBM이 생성되었습니다 (작업자 ' + members.length + '명)', 'success');
// 3. tbm-mobile.html로 이동
setTimeout(function() {
window.location.href = '/pages/work/tbm-mobile.html';
}, 1000);
} catch (error) {
console.error('TBM 저장 오류:', error);
showToast('TBM 저장 중 오류가 발생했습니다: ' + error.message, 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.textContent = '저장';
}
}
}
// ==================== 토스트 (로컬) ====================
function showToast(message, type) {
if (window.showToast && typeof window.showToast === 'function') {
window.showToast(message, type);
return;
}
console.log('[Toast] ' + type + ': ' + message);
}
})();

View File

@@ -517,15 +517,19 @@ function createSessionCard(session) {
'cancelled': '<span class="tbm-card-status cancelled">취소</span>' 'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
}[session.status] || ''; }[session.status] || '';
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자'); const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
const leaderRole = escapeHtml(session.leader_name const leaderRole = escapeHtml(session.leader_name
? (session.leader_job_type || '작업자') ? (session.leader_job_type || '작업자')
: '관리자'); : '관리자');
const safeSessionId = parseInt(session.session_id) || 0; const safeSessionId = parseInt(session.session_id) || 0;
// 카드 클릭 동작: draft → 세부 편집, completed → 상세 보기
const onClickAction = session.status === 'draft'
? `openTeamCompositionModal(${safeSessionId})`
: `viewTbmSession(${safeSessionId})`;
return ` return `
<div class="tbm-session-card" onclick="viewTbmSession(${safeSessionId})"> <div class="tbm-session-card" onclick="${onClickAction}">
<div class="tbm-card-header"> <div class="tbm-card-header">
<div class="tbm-card-header-top"> <div class="tbm-card-header-top">
<div> <div>
@@ -558,7 +562,7 @@ function createSessionCard(session) {
</div> </div>
<div class="tbm-card-info-item"> <div class="tbm-card-info-item">
<span class="tbm-card-info-label">팀원</span> <span class="tbm-card-info-label">팀원</span>
<span class="tbm-card-info-value">${parseInt(session.team_member_count) || 0}</span> <span class="tbm-card-info-value">${escapeHtml(session.team_member_names || '')}${session.team_member_names ? '' : '없음'}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -566,7 +570,7 @@ function createSessionCard(session) {
${session.status === 'draft' ? ` ${session.status === 'draft' ? `
<div class="tbm-card-footer"> <div class="tbm-card-footer">
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})"> <button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
&#128101; 수정 &#128101; 세부 편집
</button> </button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})"> <button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
&#10003; 안전 체크 &#10003; 안전 체크
@@ -580,10 +584,16 @@ function createSessionCard(session) {
`; `;
} }
// 새 TBM 모달 열기 // 새 TBM 모달 열기 (간소화: 프로젝트+공정+작업자만)
function openNewTbmModal() { function openNewTbmModal() {
if (window.innerWidth <= 768) {
window.location.href = '/pages/work/tbm-create.html';
return;
}
currentSessionId = null; currentSessionId = null;
workerTaskList = []; // 작업자 목록 초기화 workerTaskList = [];
selectedWorkersForNewTbm = new Set();
todayAssignmentsMap = null; // 배정 현황 캐시 초기화
document.getElementById('modalTitle').innerHTML = '<span>&#128221;</span> 새 TBM 시작'; document.getElementById('modalTitle').innerHTML = '<span>&#128221;</span> 새 TBM 시작';
document.getElementById('sessionId').value = ''; document.getElementById('sessionId').value = '';
@@ -610,19 +620,140 @@ function openNewTbmModal() {
document.getElementById('leaderId').value = worker.worker_id; document.getElementById('leaderId').value = worker.worker_id;
} }
} else if (currentUser && currentUser.name) { } else if (currentUser && currentUser.name) {
// 관리자: 이름만 표시
document.getElementById('leaderName').textContent = currentUser.name; document.getElementById('leaderName').textContent = currentUser.name;
document.getElementById('leaderId').value = ''; document.getElementById('leaderId').value = '';
} }
// 작업자 목록 UI 초기화 // 프로젝트 드롭다운 채우기
renderWorkerTaskList(); const projSelect = document.getElementById('newTbmProjectId');
if (projSelect) {
projSelect.innerHTML = '<option value="">선택 안함</option>' +
allProjects.map(p => `<option value="${p.project_id}">${escapeHtml(p.project_name)} (${escapeHtml(p.job_no || '')})</option>`).join('');
}
// 공정 드롭다운 채우기
const wtSelect = document.getElementById('newTbmWorkTypeId');
if (wtSelect) {
wtSelect.innerHTML = '<option value="">공정 선택...</option>' +
allWorkTypes.map(wt => `<option value="${wt.id}">${escapeHtml(wt.name)}</option>`).join('');
}
// 작업자 체크박스 그리드 렌더링
renderNewTbmWorkerGrid();
document.getElementById('tbmModal').style.display = 'flex'; document.getElementById('tbmModal').style.display = 'flex';
lockBodyScroll(); lockBodyScroll();
} }
window.openNewTbmModal = openNewTbmModal; window.openNewTbmModal = openNewTbmModal;
// 새 TBM 모달용 작업자 선택 세트
let selectedWorkersForNewTbm = new Set();
let todayAssignmentsMap = null; // 당일 배정 현황
// 작업자 그리드 렌더링
async function renderNewTbmWorkerGrid() {
const grid = document.getElementById('newTbmWorkerGrid');
if (!grid) return;
// 당일 배정 현황 로드
if (!todayAssignmentsMap) {
try {
const today = getTodayKST();
const res = await apiCall(`/tbm/sessions/date/${today}/assignments`);
todayAssignmentsMap = {};
if (res && res.success) {
res.data.forEach(a => {
if (a.sessions && a.sessions.length > 0) {
todayAssignmentsMap[a.worker_id] = a;
}
});
}
} catch(e) {
console.error('배정 현황 로드 오류:', e);
todayAssignmentsMap = {};
}
}
grid.innerHTML = allWorkers.map(w => {
const checked = selectedWorkersForNewTbm.has(w.worker_id) ? 'checked' : '';
const assignment = todayAssignmentsMap[w.worker_id];
const fullyAssigned = assignment && assignment.total_hours >= 8;
const partiallyAssigned = assignment && assignment.total_hours > 0 && assignment.total_hours < 8;
let badgeHtml = '';
let disabledAttr = '';
let disabledStyle = '';
if (fullyAssigned) {
const leaderNames = assignment.sessions.map(s => s.leader_name || '').join(', ');
badgeHtml = `<span style="font-size:0.625rem; color:#ef4444; display:block;">${escapeHtml(leaderNames)} TBM (${assignment.total_hours}h)</span>`;
disabledAttr = 'disabled';
disabledStyle = 'opacity:0.5; pointer-events:none;';
} else if (partiallyAssigned) {
const remaining = 8 - assignment.total_hours;
badgeHtml = `<span style="font-size:0.625rem; color:#2563eb; display:block;">${remaining}h 가용</span>`;
}
return `
<label class="tbm-worker-select-item ${checked ? 'selected' : ''}" data-wid="${w.worker_id}" style="${disabledStyle}">
<input type="checkbox" class="new-tbm-worker-cb" data-worker-id="${w.worker_id}" ${checked} ${disabledAttr}
onchange="toggleNewTbmWorker(${w.worker_id}, this.checked)">
<span class="tbm-worker-name">${escapeHtml(w.worker_name)}</span>
<span class="tbm-worker-role">${escapeHtml(w.job_type || '작업자')}</span>
${badgeHtml}
</label>
`;
}).join('');
updateNewTbmWorkerCount();
}
function updateNewTbmWorkerCount() {
const countEl = document.getElementById('newTbmWorkerCount');
if (countEl) countEl.textContent = `(${selectedWorkersForNewTbm.size}명)`;
}
function toggleNewTbmWorker(workerId, checked) {
// 종일 배정된 작업자 선택 방지
const a = todayAssignmentsMap && todayAssignmentsMap[workerId];
if (a && a.total_hours >= 8) return;
if (checked) {
selectedWorkersForNewTbm.add(workerId);
} else {
selectedWorkersForNewTbm.delete(workerId);
}
// Update visual state
const label = document.querySelector(`#newTbmWorkerGrid label[data-wid="${workerId}"]`);
if (label) label.classList.toggle('selected', checked);
updateNewTbmWorkerCount();
}
window.toggleNewTbmWorker = toggleNewTbmWorker;
function selectAllNewTbmWorkers() {
allWorkers.forEach(w => {
const a = todayAssignmentsMap && todayAssignmentsMap[w.worker_id];
if (a && a.total_hours >= 8) return; // 종일 배정 제외
selectedWorkersForNewTbm.add(w.worker_id);
});
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => {
if (!cb.disabled) cb.checked = true;
});
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => {
if (l.style.opacity !== '0.5') l.classList.add('selected');
});
updateNewTbmWorkerCount();
}
window.selectAllNewTbmWorkers = selectAllNewTbmWorkers;
function deselectAllNewTbmWorkers() {
selectedWorkersForNewTbm.clear();
document.querySelectorAll('.new-tbm-worker-cb').forEach(cb => { cb.checked = false; });
document.querySelectorAll('#newTbmWorkerGrid label').forEach(l => l.classList.remove('selected'));
updateNewTbmWorkerCount();
}
window.deselectAllNewTbmWorkers = deselectAllNewTbmWorkers;
// 입력자 선택 드롭다운 채우기 // 입력자 선택 드롭다운 채우기
function populateLeaderSelect() { function populateLeaderSelect() {
const leaderSelect = document.getElementById('leaderId'); const leaderSelect = document.getElementById('leaderId');
@@ -729,13 +860,12 @@ function closeTbmModal() {
} }
window.closeTbmModal = closeTbmModal; window.closeTbmModal = closeTbmModal;
// TBM 세션 저장 (작업자별 상세 정보 포함) // TBM 세션 저장 (간소화: 프로젝트+공정+작업자, task/workplace=null)
async function saveTbmSession() { async function saveTbmSession() {
console.log('💾 TBM 저장 시작...'); console.log('💾 TBM 저장 시작...');
let leaderId = parseInt(document.getElementById('leaderId').value); let leaderId = parseInt(document.getElementById('leaderId').value);
// 관리자 계정인 경우 leader_id를 null로 설정
if (!leaderId || isNaN(leaderId)) { if (!leaderId || isNaN(leaderId)) {
if (!currentUser.worker_id) { if (!currentUser.worker_id) {
console.log('📝 관리자 계정: leader_id를 NULL로 설정'); console.log('📝 관리자 계정: leader_id를 NULL로 설정');
@@ -752,37 +882,21 @@ async function saveTbmSession() {
leader_id: leaderId leader_id: leaderId
}; };
console.log('📅 세션 데이터:', sessionData);
console.log('👥 작업자 리스트:', workerTaskList);
console.log('👤 현재 사용자:', currentUser);
if (!sessionData.session_date) { if (!sessionData.session_date) {
console.error('❌ 날짜 누락');
showToast('TBM 날짜를 확인해주세요.', 'error'); showToast('TBM 날짜를 확인해주세요.', 'error');
return; return;
} }
const editingSessionId = document.getElementById('sessionId').value;
// 수정 모드일 때는 기존 openTeamCompositionModal의 workerTaskList를 사용
if (editingSessionId) {
// 기존 수정 모드 로직 (openTeamCompositionModal 경유)
if (workerTaskList.length === 0) { if (workerTaskList.length === 0) {
console.error('❌ 작업자 리스트가 비어있음');
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error'); showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
return; return;
} }
// 필수 항목 검증 (공정, 작업, 작업장)
let hasError = false;
for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) {
if (!taskLine.work_type_id || !taskLine.task_id || !taskLine.workplace_id) {
showToast(`${workerData.worker_name}의 공정, 작업, 작업장을 모두 선택해주세요.`, 'error');
hasError = true;
break;
}
}
if (hasError) break;
}
if (hasError) return;
// 작업자-작업 데이터를 평평하게 변환
const members = []; const members = [];
for (const workerData of workerTaskList) { for (const workerData of workerTaskList) {
for (const taskLine of workerData.tasks) { for (const taskLine of workerData.tasks) {
@@ -799,19 +913,8 @@ async function saveTbmSession() {
} }
} }
console.log('📤 전송할 팀 데이터:', members);
try { try {
const editingSessionId = document.getElementById('sessionId').value;
if (editingSessionId) {
// 수정 모드: 기존 팀원 삭제 후 재등록
console.log('📝 TBM 수정 모드:', editingSessionId);
// 기존 팀원 삭제
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE'); await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
// 새 팀원 일괄 추가
const teamResponse = await window.apiCall( const teamResponse = await window.apiCall(
`/tbm/sessions/${editingSessionId}/team/batch`, `/tbm/sessions/${editingSessionId}/team/batch`,
'POST', 'POST',
@@ -819,29 +922,59 @@ async function saveTbmSession() {
); );
if (teamResponse && teamResponse.success) { if (teamResponse && teamResponse.success) {
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}, 작업 ${members.length})`, 'success'); showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
closeTbmModal(); closeTbmModal();
// 목록 새로고침
if (currentTab === 'tbm-input') { if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm(); await loadTodayOnlyTbm();
} else { } else {
await loadTbmSessionsByDate(sessionData.session_date); await loadRecentTbmGroupedByDate();
} }
} else { } else {
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.'); throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
} }
} else { } catch (error) {
// 생성 모드: 새 TBM 세션 생성 console.error('❌ TBM 세션 수정 오류:', error);
console.log('TBM 생성 모드'); showToast('TBM 세션 수정 중 오류가 발생했습니다.', 'error');
}
return;
}
// 생성 모드: 간소화된 새 TBM
const workTypeId = parseInt(document.getElementById('newTbmWorkTypeId')?.value);
const projectId = parseInt(document.getElementById('newTbmProjectId')?.value) || null;
if (!workTypeId) {
showToast('공정을 선택해주세요.', 'error');
return;
}
if (selectedWorkersForNewTbm.size === 0) {
showToast('최소 1명 이상의 작업자를 선택해주세요.', 'error');
return;
}
// 작업자별 members 생성 (task_id, workplace_id = null)
const members = [];
selectedWorkersForNewTbm.forEach(workerId => {
members.push({
worker_id: workerId,
project_id: projectId,
work_type_id: workTypeId,
task_id: null,
workplace_category_id: null,
workplace_id: null,
work_detail: null,
is_present: true
});
});
try {
const response = await window.apiCall('/tbm/sessions', 'POST', sessionData); const response = await window.apiCall('/tbm/sessions', 'POST', sessionData);
if (response && response.success) { if (response && response.success) {
const createdSessionId = response.data.session_id; const createdSessionId = response.data.session_id;
console.log('✅ TBM 세션 생성 완료:', createdSessionId); console.log('✅ TBM 세션 생성 완료:', createdSessionId);
// 작업자 일괄 추가
const teamResponse = await window.apiCall( const teamResponse = await window.apiCall(
`/tbm/sessions/${createdSessionId}/team/batch`, `/tbm/sessions/${createdSessionId}/team/batch`,
'POST', 'POST',
@@ -849,14 +982,13 @@ async function saveTbmSession() {
); );
if (teamResponse && teamResponse.success) { if (teamResponse && teamResponse.success) {
showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length})`, 'success'); showToast(`TBM이 생성되었습니다 (작업자 ${members.length})`, 'success');
closeTbmModal(); closeTbmModal();
// 목록 새로고침
if (currentTab === 'tbm-input') { if (currentTab === 'tbm-input') {
await loadTodayOnlyTbm(); await loadTodayOnlyTbm();
} else { } else {
await loadTbmSessionsByDate(sessionData.session_date); await loadRecentTbmGroupedByDate();
} }
} else { } else {
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.'); throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
@@ -864,7 +996,6 @@ async function saveTbmSession() {
} else { } else {
throw new Error(response.message || '저장에 실패했습니다.'); throw new Error(response.message || '저장에 실패했습니다.');
} }
}
} catch (error) { } catch (error) {
console.error('❌ TBM 세션 저장 오류:', error); console.error('❌ TBM 세션 저장 오류:', error);
showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error'); showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error');
@@ -1502,6 +1633,11 @@ window.openWorkplaceSelect = openWorkplaceSelect;
// 작업장 선택 모달 닫기 // 작업장 선택 모달 닫기
function closeWorkplaceSelectModal() { function closeWorkplaceSelectModal() {
// 가로모드 오버레이도 닫기
const landscapeOverlay = document.getElementById('landscapeOverlay');
if (landscapeOverlay && landscapeOverlay.style.display !== 'none') {
closeLandscapeMap();
}
document.getElementById('workplaceSelectModal').style.display = 'none'; document.getElementById('workplaceSelectModal').style.display = 'none';
unlockBodyScroll(); unlockBodyScroll();
document.getElementById('workplaceSelectionArea').style.display = 'none'; document.getElementById('workplaceSelectionArea').style.display = 'none';
@@ -1578,6 +1714,9 @@ async function selectCategory(categoryId, categoryName) {
document.getElementById('workplaceListSection').style.display = 'none'; document.getElementById('workplaceListSection').style.display = 'none';
document.getElementById('toggleListBtn').style.display = 'inline-flex'; document.getElementById('toggleListBtn').style.display = 'inline-flex';
document.getElementById('toggleListBtn').textContent = '리스트로 선택'; document.getElementById('toggleListBtn').textContent = '리스트로 선택';
// 전체화면 지도 버튼 표시
const triggerBtn = document.getElementById('landscapeTriggerBtn');
if (triggerBtn) triggerBtn.style.display = 'inline-flex';
} else { } else {
// 데스크톱: 리스트도 함께 표시 // 데스크톱: 리스트도 함께 표시
document.getElementById('workplaceList').style.display = 'flex'; document.getElementById('workplaceList').style.display = 'flex';
@@ -1876,6 +2015,178 @@ function syncWorkplaceListSelection(workplaceId) {
} }
window.syncWorkplaceListSelection = syncWorkplaceListSelection; window.syncWorkplaceListSelection = syncWorkplaceListSelection;
// ==================== 가로모드 전체화면 지도 ====================
function openLandscapeMap() {
if (!mapImage || !mapImage.complete || mapRegions.length === 0) return;
const overlay = document.getElementById('landscapeOverlay');
const inner = document.getElementById('landscapeInner');
const lCanvas = document.getElementById('landscapeCanvas');
if (!overlay || !lCanvas) return;
overlay.style.display = 'flex';
lockBodyScroll();
// 물리적 가로모드 여부 판단
const isPhysicalLandscape = window.innerWidth > window.innerHeight;
inner.className = 'landscape-inner ' + (isPhysicalLandscape ? 'no-rotate' : 'rotated');
// 가용 영역 계산 (헤더 52px, 패딩 여유)
const headerH = 52;
const pad = 16;
let availW, availH;
if (isPhysicalLandscape) {
availW = window.innerWidth - pad * 2;
availH = window.innerHeight - headerH - pad * 2;
} else {
// 회전: 가로↔세로 스왑
availW = window.innerHeight - pad * 2;
availH = window.innerWidth - headerH - pad * 2;
}
// 이미지 비율 유지 캔버스 크기
const imgRatio = mapImage.naturalWidth / mapImage.naturalHeight;
let cw, ch;
if (availW / availH > imgRatio) {
ch = availH;
cw = ch * imgRatio;
} else {
cw = availW;
ch = cw / imgRatio;
}
lCanvas.width = Math.round(cw);
lCanvas.height = Math.round(ch);
drawLandscapeMap();
// 이벤트 리스너
lCanvas.ontouchstart = handleLandscapeTouchStart;
lCanvas.onclick = handleLandscapeClick;
}
window.openLandscapeMap = openLandscapeMap;
function drawLandscapeMap() {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas || !mapImage) return;
const lCtx = lCanvas.getContext('2d');
lCtx.drawImage(mapImage, 0, 0, lCanvas.width, lCanvas.height);
mapRegions.forEach(region => {
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
const w = x2 - x1;
const h = y2 - y1;
const isSelected = region.workplace_id === selectedWorkplace;
lCtx.strokeStyle = isSelected ? '#3b82f6' : '#10b981';
lCtx.lineWidth = isSelected ? 4 : 2;
lCtx.strokeRect(x1, y1, w, h);
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.25)' : 'rgba(16, 185, 129, 0.15)';
lCtx.fillRect(x1, y1, w, h);
if (region.workplace_name) {
lCtx.font = 'bold 14px sans-serif';
const tm = lCtx.measureText(region.workplace_name);
const tp = 6;
lCtx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.95)' : 'rgba(16, 185, 129, 0.95)';
lCtx.fillRect(x1 + 5, y1 + 5, tm.width + tp * 2, 24);
lCtx.fillStyle = '#ffffff';
lCtx.textAlign = 'left';
lCtx.textBaseline = 'alphabetic';
lCtx.fillText(region.workplace_name, x1 + 5 + tp, y1 + 22);
}
});
}
function getLandscapeCoords(clientX, clientY) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return null;
const rect = lCanvas.getBoundingClientRect();
const inner = document.getElementById('landscapeInner');
const isRotated = inner.classList.contains('rotated');
if (!isRotated) {
// 회전 없음 - 일반 좌표
const scaleX = lCanvas.width / rect.width;
const scaleY = lCanvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
// 90° 시계방향 회전의 역변환
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = clientX - centerX;
const dy = clientY - centerY;
// 역회전 (반시계 90°)
const inverseDx = dy;
const inverseDy = -dx;
// 회전 전 실제 크기: rect가 회전된 후이므로 width↔height 스왑
const unrotatedW = rect.height;
const unrotatedH = rect.width;
const canvasX = (inverseDx + unrotatedW / 2) / unrotatedW * lCanvas.width;
const canvasY = (inverseDy + unrotatedH / 2) / unrotatedH * lCanvas.height;
return { x: canvasX, y: canvasY };
}
function handleLandscapeTouchStart(e) {
e.preventDefault(); // 고스트 클릭 방지
const touch = e.touches[0];
const coords = getLandscapeCoords(touch.clientX, touch.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function handleLandscapeClick(e) {
const coords = getLandscapeCoords(e.clientX, e.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function doLandscapeHitTest(cx, cy) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return;
for (let i = mapRegions.length - 1; i >= 0; i--) {
const region = mapRegions[i];
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) {
selectWorkplace(region.workplace_id, region.workplace_name);
drawWorkplaceMap();
syncWorkplaceListSelection(region.workplace_id);
// 하이라이트 후 자동 닫기
drawLandscapeMap();
setTimeout(() => closeLandscapeMap(), 300);
return;
}
}
}
function closeLandscapeMap() {
const overlay = document.getElementById('landscapeOverlay');
if (overlay) overlay.style.display = 'none';
const lCanvas = document.getElementById('landscapeCanvas');
if (lCanvas) {
lCanvas.ontouchstart = null;
lCanvas.onclick = null;
}
unlockBodyScroll();
}
window.closeLandscapeMap = closeLandscapeMap;
// ==================== 기존 팀 구성 모달 (백업) ==================== // ==================== 기존 팀 구성 모달 (백업) ====================
// 팀 구성 모달 열기 // 팀 구성 모달 열기
@@ -2274,8 +2585,11 @@ async function saveSafetyChecklist() {
} }
window.saveSafetyChecklist = saveSafetyChecklist; window.saveSafetyChecklist = saveSafetyChecklist;
// TBM 완료 모달용 팀원 데이터
let completeModalTeam = [];
// TBM 완료 모달 열기 // TBM 완료 모달 열기
function openCompleteTbmModal(sessionId) { async function openCompleteTbmModal(sessionId) {
currentSessionId = sessionId; currentSessionId = sessionId;
const now = new Date(); const now = new Date();
const timeString = now.toTimeString().slice(0, 5); const timeString = now.toTimeString().slice(0, 5);
@@ -2283,9 +2597,75 @@ function openCompleteTbmModal(sessionId) {
document.getElementById('completeModal').style.display = 'flex'; document.getElementById('completeModal').style.display = 'flex';
lockBodyScroll(); lockBodyScroll();
// 팀원 조회 → 근태 선택 렌더링
try {
const teamRes = await window.apiCall(`/tbm/sessions/${sessionId}/team`);
completeModalTeam = (teamRes && teamRes.data) ? teamRes.data : [];
renderCompleteAttendanceList();
} catch (e) {
console.error('팀원 조회 오류:', e);
document.getElementById('completeAttendanceList').innerHTML =
'<div style="color:#ef4444; padding:0.5rem;">팀원 목록을 불러올 수 없습니다.</div>';
}
} }
window.openCompleteTbmModal = openCompleteTbmModal; window.openCompleteTbmModal = openCompleteTbmModal;
function renderCompleteAttendanceList() {
const container = document.getElementById('completeAttendanceList');
if (completeModalTeam.length === 0) {
container.innerHTML = '<div style="color:#9ca3af; padding:0.5rem; text-align:center;">팀원이 없습니다.</div>';
return;
}
let html = '<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">' +
'<tr style="background:#f9fafb;"><th style="padding:0.5rem; text-align:left;">작업자</th><th style="padding:0.5rem; text-align:left;">직종</th><th style="padding:0.5rem; text-align:left;">근태</th><th style="padding:0.5rem; text-align:center;">추가</th></tr>';
completeModalTeam.forEach((m, i) => {
html += `<tr style="border-top:1px solid #f3f4f6;">
<td style="padding:0.5rem; font-weight:600;">${m.worker_name || ''}</td>
<td style="padding:0.5rem; color:#6b7280;">${m.job_type || '-'}</td>
<td style="padding:0.5rem;">
<select id="catt_type_${i}" onchange="onCompleteAttChange(${i})" style="padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; background:white;">
<option value="regular">정시근로 (8h)</option>
<option value="overtime">연장근무 (8h+)</option>
<option value="annual">연차 (휴무)</option>
<option value="half">반차 (4h)</option>
<option value="quarter">반반차 (6h)</option>
<option value="early">조퇴</option>
</select>
</td>
<td style="padding:0.5rem; text-align:center;">
<input type="number" id="catt_hours_${i}" step="0.5" min="0" max="8" style="display:none; width:60px; padding:0.375rem; border:1px solid #d1d5db; border-radius:0.25rem; font-size:0.8125rem; text-align:center;">
<span id="catt_hint_${i}" style="font-size:0.75rem; color:#6b7280;"></span>
</td>
</tr>`;
});
html += '</table>';
container.innerHTML = html;
}
window.onCompleteAttChange = function(idx) {
const sel = document.getElementById('catt_type_' + idx);
const inp = document.getElementById('catt_hours_' + idx);
const hint = document.getElementById('catt_hint_' + idx);
const val = sel.value;
if (val === 'overtime') {
inp.style.display = 'inline-block';
inp.placeholder = '+h';
inp.value = '';
hint.textContent = '';
} else if (val === 'early') {
inp.style.display = 'inline-block';
inp.placeholder = '시간';
inp.value = '';
hint.textContent = '';
} else {
inp.style.display = 'none';
inp.value = '';
const labels = { regular: '8h', annual: '자동처리', half: '4h', quarter: '6h' };
hint.textContent = labels[val] || '';
}
};
// 완료 모달 닫기 // 완료 모달 닫기
function closeCompleteModal() { function closeCompleteModal() {
document.getElementById('completeModal').style.display = 'none'; document.getElementById('completeModal').style.display = 'none';
@@ -2297,11 +2677,37 @@ window.closeCompleteModal = closeCompleteModal;
async function completeTbmSession() { async function completeTbmSession() {
const endTime = document.getElementById('endTime').value; const endTime = document.getElementById('endTime').value;
// 근태 데이터 수집
const attendanceData = [];
for (let i = 0; i < completeModalTeam.length; i++) {
const type = document.getElementById('catt_type_' + i).value;
const hoursVal = document.getElementById('catt_hours_' + i).value;
const hours = hoursVal ? parseFloat(hoursVal) : null;
if (type === 'overtime' && (!hours || hours <= 0)) {
showToast(`${completeModalTeam[i].worker_name}의 추가 시간을 입력해주세요.`, 'error');
return;
}
if (type === 'early' && (!hours || hours <= 0)) {
showToast(`${completeModalTeam[i].worker_name}의 근무 시간을 입력해주세요.`, 'error');
return;
}
attendanceData.push({
worker_id: completeModalTeam[i].worker_id,
attendance_type: type,
attendance_hours: hours
});
}
const btn = document.getElementById('completeModalBtn');
if (btn) { btn.disabled = true; btn.textContent = '처리 중...'; }
try { try {
const response = await window.apiCall( const response = await window.apiCall(
`/tbm/sessions/${currentSessionId}/complete`, `/tbm/sessions/${currentSessionId}/complete`,
'POST', 'POST',
{ end_time: endTime } { end_time: endTime, attendance_data: attendanceData }
); );
if (response && response.success) { if (response && response.success) {
@@ -2321,6 +2727,8 @@ async function completeTbmSession() {
} catch (error) { } catch (error) {
console.error('❌ TBM 완료 처리 오류:', error); console.error('❌ TBM 완료 처리 오류:', error);
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error'); showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="tbm-btn-icon">&#10003;</span> 완료'; }
} }
} }
window.completeTbmSession = completeTbmSession; window.completeTbmSession = completeTbmSession;
@@ -2365,8 +2773,8 @@ async function viewTbmSession(sessionId) {
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div> <div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
</div> </div>
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;"> <div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
<div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 </div> <div style="font-size: 0.75rem; color: #6b7280; margin-bottom: 0.25rem;">팀원 (${parseInt(session.team_member_count) || team.length}명)</div>
<div style="font-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}</div> <div style="font-weight: 600; color: #111827;">${escapeHtml(session.team_member_names || team.map(t => t.worker_name).join(', ') || '없음')}</div>
</div> </div>
${session.project_name ? ` ${session.project_name ? `
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;"> <div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">

View File

@@ -214,10 +214,11 @@ class TbmAPI {
if (!this.state.isAdminUser()) { if (!this.state.isAdminUser()) {
const userId = this.state.currentUser?.user_id; const userId = this.state.currentUser?.user_id;
const workerId = this.state.currentUser?.worker_id; const workerId = this.state.currentUser?.worker_id;
const userName = this.state.currentUser?.name;
sessions = sessions.filter(s => { sessions = sessions.filter(s => {
return s.created_by === userId || return (userId && String(s.created_by) === String(userId)) ||
s.leader_id === workerId || (workerId && String(s.leader_id) === String(workerId)) ||
s.created_by_name === this.state.currentUser?.name; (userName && s.created_by_name === userName);
}); });
} }

View File

@@ -104,7 +104,8 @@ class TbmState {
isAdminUser() { isAdminUser() {
const user = this.getUser(); const user = this.getUser();
if (!user) return false; if (!user) return false;
return user.role === 'Admin' || user.role === 'System Admin'; const role = (user.role || '').toLowerCase();
return role === 'admin' || role === 'system admin' || role === 'system';
} }
/** /**

View File

@@ -279,7 +279,7 @@
<!-- JavaScript --> <!-- JavaScript -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
<script src="/js/admin-settings.js?v=9"></script> <script src="/js/admin-settings.js?v=9"></script>
</body> </body>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
<!-- 스크립트 --> <!-- 스크립트 -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script> <script type="module" src="/js/vacation-allocation.js" defer></script>
</head> </head>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,20 +10,21 @@
<!-- preconnect는 Gateway 프록시 사용 시 불필요 --> <!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
<link rel="preload" href="/css/design-system.css" as="style"> <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/api-base.js" as="script">
<link rel="preload" href="/js/app-init.js?v=3" as="script"> <link rel="preload" href="/js/app-init.js?v=5" as="script">
<!-- 모던 디자인 시스템 적용 --> <!-- 모던 디자인 시스템 적용 -->
<link rel="stylesheet" href="/css/design-system.css"> <link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=3"> <link rel="stylesheet" href="/css/modern-dashboard.css?v=3">
<link rel="stylesheet" href="/css/mobile.css?v=2"> <link rel="stylesheet" href="/css/mobile.css?v=4">
<link rel="icon" type="image/png" href="/img/favicon.png"> <link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) --> <!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script type="module" src="/js/modern-dashboard.js?v=10" 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 type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
<script src="/js/workplace-status.js" defer></script> <script src="/js/workplace-status.js" defer></script>
<script src="/js/mobile-dashboard.js?v=3" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 --> <!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
</head> </head>
@@ -38,6 +39,20 @@
<!-- 메인 콘텐츠 --> <!-- 메인 콘텐츠 -->
<main class="dashboard-main"> <main class="dashboard-main">
<!-- 모바일 대시보드 (작업장 리스트 뷰) -->
<section id="mobileDashboardView" class="mobile-dashboard-view" style="display:none;">
<div class="md-date-header">
<span class="md-date-label">금일 현황</span>
<time class="md-date-value" id="mDateValue"></time>
</div>
<!-- 카테고리 탭 -->
<div class="md-category-tabs" id="mCategoryTabs"></div>
<!-- 작업장 리스트 -->
<div class="md-workplace-list" id="mWorkplaceList"></div>
</section>
<!-- 작업장 현황 --> <!-- 작업장 현황 -->
<section class="workplace-status-section"> <section class="workplace-status-section">
<div class="card"> <div class="card">
@@ -56,7 +71,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- 지도 영역 --> <!-- 지도 영역 -->
<div id="workplaceMapContainer" style="position: relative; min-height: 500px; display: none;"> <div id="workplaceMapContainer" style="position: relative; display: none;">
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas> <canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid var(--gray-300); border-radius: var(--radius-md);"></canvas>
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);"> <div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: var(--radius-md); box-shadow: var(--shadow-md);">
<h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4> <h4 style="font-size: var(--text-sm); font-weight: 700; margin-bottom: 12px;">범례</h4>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
<link rel="stylesheet" href="/css/work-analysis.css?v=41"> <link rel="stylesheet" href="/css/work-analysis.css?v=41">
<link rel="icon" type="image/png" href="/img/favicon.png"> <link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></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> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
</head> </head>

View File

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

View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>작업보고서 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<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>
<style>
/* 데스크탑이면 리다이렉트 */
@media (min-width: 769px) {
body::before {
content: 'redirect';
display: none;
}
}
</style>
<script>
// 데스크탑 접속 시 리다이렉트
if (window.innerWidth > 768) {
window.location.replace('/pages/work/report-create.html');
}
</script>
</head>
<body>
<!-- 네비게이션 바 (app-init에서 로드) -->
<div id="navbar-container"></div>
<div class="m-container">
<!-- Sticky 헤더 -->
<div class="m-header">
<h1 class="m-header-title">작업보고서</h1>
<div class="m-header-action">
<button class="m-btn-add" id="btnAddManual" onclick="MobileReport.addManualCard()" style="display:none;">+ 수동추가</button>
</div>
</div>
<!-- 탭바 -->
<div class="m-tab-bar">
<button class="m-tab-btn active" data-tab="tbm" onclick="MobileReport.switchTab('tbm')">
TBM 작업 <span class="m-tab-count" id="tbmCount">0</span>
</button>
<button class="m-tab-btn" data-tab="manual" onclick="MobileReport.switchTab('manual')">
수동 입력
</button>
<button class="m-tab-btn" data-tab="completed" onclick="MobileReport.switchTab('completed')">
완료
</button>
</div>
<!-- 메시지 -->
<div class="m-message" id="mMessage"></div>
<!-- TBM 탭 -->
<div class="m-tab-content active" id="tabTbm">
<div id="tbmCardList">
<div class="m-loading"></div>
</div>
</div>
<!-- 수동 입력 탭 -->
<div class="m-tab-content" id="tabManual">
<div id="manualCardList">
<div class="m-empty">
<div class="m-empty-icon">📝</div>
<div>수동으로 작업보고서를 추가하세요</div>
<button class="m-btn-add" style="margin-top:0.75rem;" onclick="MobileReport.addManualCard()">+ 수동추가</button>
</div>
</div>
</div>
<!-- 완료 탭 -->
<div class="m-tab-content" id="tabCompleted">
<div class="m-completed-header">
<input type="date" class="m-date-input" id="completedDate" onchange="MobileReport.loadCompletedReports()">
</div>
<div id="completedCardList">
<div class="m-empty">
<div class="m-empty-icon">📋</div>
<div>날짜를 선택하세요</div>
</div>
</div>
</div>
</div>
<!-- 시간 선택 오버레이 -->
<div class="m-time-overlay" id="mTimeOverlay" onclick="MobileReport.closeTimePicker()">
<div class="m-time-popup" onclick="event.stopPropagation()">
<div class="m-time-header">
<h3 class="m-time-title" id="mTimeTitle">작업시간 선택</h3>
<button class="m-time-close" onclick="MobileReport.closeTimePicker()">&times;</button>
</div>
<div class="m-quick-time-grid">
<button class="m-time-btn" onclick="MobileReport.setTime(0.5)">30분</button>
<button class="m-time-btn" onclick="MobileReport.setTime(1)">1시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(2)">2시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(4)">4시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(8)">8시간</button>
</div>
<div class="m-time-adjust">
<button class="m-time-adjust-btn" onclick="MobileReport.adjustTime(-0.5)">-</button>
<span class="m-time-current" id="mTimeCurrent">0시간</span>
<button class="m-time-adjust-btn" onclick="MobileReport.adjustTime(0.5)">+</button>
</div>
<button class="m-time-confirm" onclick="MobileReport.confirmTime()">확인</button>
</div>
</div>
<!-- 부적합 바텀시트 -->
<div class="m-overlay" id="defectOverlay" onclick="MobileReport.hideDefectSheet()"></div>
<div class="m-bottom-sheet" id="defectSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title">부적합 입력</h3>
<button class="m-sheet-close" onclick="MobileReport.hideDefectSheet()">&times;</button>
</div>
<div class="m-sheet-body" id="defectSheetBody">
<!-- 동적 렌더링 -->
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn primary" onclick="MobileReport.saveDefects()">저장</button>
</div>
</div>
<!-- 작업장소 바텀시트 -->
<div class="m-overlay" id="wpOverlay" onclick="MobileReport.hideWorkplaceSheet()"></div>
<div class="m-bottom-sheet" id="wpSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title" id="wpSheetTitle">작업장소 선택</h3>
<button class="m-sheet-close" onclick="MobileReport.hideWorkplaceSheet()">&times;</button>
</div>
<div class="m-sheet-body" id="wpSheetBody">
<!-- 동적 렌더링 -->
</div>
</div>
<!-- 수정 바텀시트 -->
<div class="m-overlay" id="editOverlay" onclick="MobileReport.hideEditSheet()"></div>
<div class="m-bottom-sheet" id="editSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title">보고서 수정</h3>
<button class="m-sheet-close" onclick="MobileReport.hideEditSheet()">&times;</button>
</div>
<div class="m-sheet-body m-edit-form" id="editSheetBody">
<!-- 동적 렌더링 -->
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn primary" onclick="MobileReport.saveEditedReport()">저장</button>
</div>
</div>
<!-- 결과 모달 -->
<div class="m-result-overlay" id="mResultOverlay">
<div class="m-result-box">
<div class="m-result-icon" id="mResultIcon"></div>
<div class="m-result-title" id="mResultTitle"></div>
<div class="m-result-message" id="mResultMessage"></div>
<div class="m-result-details" id="mResultDetails" style="display:none;"></div>
<button class="m-result-close" onclick="MobileReport.closeResult()">확인</button>
</div>
</div>
<!-- 토스트 -->
<div class="m-toast" id="mToast"></div>
<!-- 작업보고서 모듈 (재사용) -->
<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>
<!-- 모바일 전용 UI 로직 -->
<script src="/js/daily-work-report-mobile.js?v=3"></script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>
<script>
if (window.innerWidth <= 768) {
fetch('/components/mobile-nav.html')
.then(r => r.text())
.then(html => {
document.getElementById('mobile-nav-container').innerHTML = html;
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
scripts.forEach(s => eval(s.textContent));
});
}
</script>
</body>
</html>

View File

@@ -8,9 +8,15 @@
<link rel="stylesheet" href="/css/daily-work-report.css?v=12"> <link rel="stylesheet" href="/css/daily-work-report.css?v=12">
<link rel="stylesheet" href="/css/mobile.css?v=1"> <link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png"> <link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 모바일 자동 리다이렉트 -->
<script>
if (window.innerWidth <= 768) {
window.location.replace('/pages/work/report-create-mobile.html');
}
</script>
<!-- 최적화된 로딩 --> <!-- 최적화된 로딩 -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
</head> </head>
<body> <body>
@@ -169,7 +175,7 @@
<!-- 스크립트 --> <!-- 스크립트 -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
<!-- 작업보고서 모듈 (리팩토링된 구조) --> <!-- 작업보고서 모듈 (리팩토링된 구조) -->
@@ -178,7 +184,7 @@
<script src="/js/daily-work-report/api.js?v=1"></script> <script src="/js/daily-work-report/api.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) --> <!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/daily-work-report.js?v=29"></script> <script type="module" src="/js/daily-work-report.js?v=30"></script>
<!-- 모바일 하단 네비게이션 --> <!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div> <div id="mobile-nav-container"></div>

View File

@@ -0,0 +1,823 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-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>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: #f3f4f6;
margin: 0;
padding: 0;
padding-bottom: env(safe-area-inset-bottom);
-webkit-font-smoothing: antialiased;
}
@media (min-width: 480px) {
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
}
/* Fixed header */
.wizard-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 52px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
display: flex;
align-items: center;
padding: 0 1rem;
gap: 0.75rem;
}
@media (min-width: 480px) {
.wizard-header { max-width: 480px; margin: 0 auto; }
}
.wizard-header .back-btn {
width: 36px;
height: 36px;
border: none;
background: rgba(255,255,255,0.15);
color: white;
border-radius: 50%;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.wizard-header .back-btn:active { background: rgba(255,255,255,0.25); }
.wizard-header h1 {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
/* Step indicator */
.step-indicator {
position: sticky;
top: 52px;
z-index: 90;
display: flex;
justify-content: center;
align-items: center;
padding: 0.625rem 0.5rem;
gap: 0;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.step {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.5625rem;
color: #9ca3af;
}
.step .step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
background: #e5e7eb;
color: #9ca3af;
flex-shrink: 0;
}
.step.active .step-dot { background: #2563eb; color: white; }
.step.active { color: #2563eb; font-weight: 600; }
.step.completed .step-dot { background: #10b981; color: white; }
.step.completed { color: #10b981; }
.step-line {
width: 10px;
height: 2px;
background: #e5e7eb;
margin: 0 0.0625rem;
flex-shrink: 0;
}
.step.completed + .step-line { background: #10b981; }
/* Step content area */
.step-content {
padding: 52px 0 76px 0; /* header + bottom nav */
min-height: 100vh;
}
/* Sections */
.wizard-section {
margin: 0.75rem;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.section-title {
font-size: 0.9375rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title .sn {
width: 22px;
height: 22px;
border-radius: 50%;
background: #2563eb;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
/* Info row */
.info-row {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f3f4f6;
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: 0.8125rem;
color: #6b7280;
width: 70px;
flex-shrink: 0;
}
.info-value {
font-size: 0.9375rem;
font-weight: 600;
color: #1f2937;
flex: 1;
}
/* Worker grid */
.worker-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.worker-card {
padding: 0.75rem;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.12s;
min-height: 44px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-card:active { transform: scale(0.97); }
.worker-card.selected {
border-color: #2563eb;
background: #eff6ff;
}
.worker-card .worker-check {
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
flex-shrink: 0;
color: transparent;
}
.worker-card.selected .worker-check {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.worker-card .worker-info {
flex: 1;
min-width: 0;
}
.worker-card .worker-name {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.worker-card .worker-type {
font-size: 0.6875rem;
color: #6b7280;
}
.select-all-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.select-all-bar .count {
font-size: 0.8125rem;
color: #2563eb;
font-weight: 600;
}
.select-all-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.select-all-btn:active { background: #f3f4f6; }
/* Project / list items */
.list-item {
padding: 0.875rem 1rem;
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.12s;
}
.list-item:active { transform: scale(0.98); }
.list-item.selected {
border-color: #2563eb;
background: #eff6ff;
}
.list-item .item-title {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.list-item .item-desc {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.125rem;
}
.list-item-skip {
padding: 0.875rem 1rem;
border: 1.5px dashed #d1d5db;
border-radius: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
margin-bottom: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.list-item-skip:active { background: #f9fafb; }
.list-item-skip.selected {
border-color: #6b7280;
border-style: solid;
background: #f9fafb;
color: #374151;
font-weight: 600;
}
/* Pill buttons */
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pill-btn {
padding: 0.5rem 0.875rem;
border: 1.5px solid #d1d5db;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.12s;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
}
.pill-btn:active { transform: scale(0.97); }
.pill-btn.selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.sub-section {
margin-top: 1rem;
padding-top: 0.875rem;
border-top: 1px solid #f3f4f6;
}
.sub-section-title {
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.5rem;
}
/* Map button */
.map-open-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.875rem 1rem;
background: linear-gradient(135deg, #0d9488, #0f766e);
color: white;
border: none;
border-radius: 0.75rem;
cursor: pointer;
margin-bottom: 0.75rem;
-webkit-tap-highlight-color: transparent;
gap: 0.75rem;
}
.map-open-btn:active { opacity: 0.85; transform: scale(0.98); }
.map-open-icon { font-size: 1.5rem; flex-shrink: 0; }
.map-open-text { font-size: 0.9375rem; font-weight: 700; flex: 1; text-align: left; }
.map-open-arrow { font-size: 1.125rem; opacity: 0.7; flex-shrink: 0; }
.location-info {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #166534;
margin-bottom: 0.75rem;
line-height: 1.5;
}
.location-info.empty {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
}
/* Summary card (Step 6) */
.summary-card {
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
background: #f9fafb;
}
.summary-row {
display: flex;
padding: 0.375rem 0;
}
.summary-label {
font-size: 0.75rem;
color: #6b7280;
width: 70px;
flex-shrink: 0;
}
.summary-value {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
flex: 1;
}
/* Worker accordion (Step 6) */
.worker-accordion {
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.worker-accordion.overridden {
border-color: #f97316;
}
.worker-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #f9fafb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.worker-accordion-header:active { background: #f3f4f6; }
.worker-accordion .acc-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-accordion .acc-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.worker-accordion .acc-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-weight: 600;
}
.badge-default {
background: #dbeafe;
color: #1e40af;
}
.badge-override {
background: #ffedd5;
color: #c2410c;
}
.worker-accordion .acc-arrow {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s;
}
.worker-accordion.open .acc-arrow { transform: rotate(180deg); }
.worker-accordion-body {
display: none;
border-top: 1px solid #e5e7eb;
padding: 0.75rem 1rem;
}
.worker-accordion.open .worker-accordion-body { display: block; }
.override-row {
margin-bottom: 0.75rem;
}
.override-label {
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.override-select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.8125rem;
background: white;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.override-select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.reset-btn {
width: 100%;
padding: 0.5rem;
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
margin-top: 0.25rem;
-webkit-tap-highlight-color: transparent;
}
.reset-btn:active { background: #f3f4f6; }
/* Empty state */
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: #9ca3af;
font-size: 0.875rem;
}
/* Fixed bottom nav */
.wizard-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 68px;
background: white;
border-top: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-bottom: env(safe-area-inset-bottom);
}
@media (min-width: 480px) {
.wizard-nav { max-width: 480px; margin: 0 auto; }
}
.nav-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.75rem;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: all 0.15s;
}
.nav-btn:active { transform: scale(0.97); }
.nav-btn-prev {
background: #f3f4f6;
color: #374151;
}
.nav-btn-next {
background: #2563eb;
color: white;
}
.nav-btn-next:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.nav-btn-next:not(:disabled):active { background: #1d4ed8; }
.nav-btn-save {
background: #10b981;
color: white;
}
.nav-btn-save:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.nav-btn-save:not(:disabled):active { background: #059669; }
/* Landscape map overlay */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #111;
overflow: hidden;
}
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
flex-shrink: 0;
min-height: 44px;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-header .ls-selected {
font-size: 0.8125rem;
font-weight: 400;
opacity: 0.9;
margin-left: 0.5rem;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #111;
padding: 8px;
}
.landscape-canvas-wrap canvas {
touch-action: none;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(255,255,255,0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
font-size: 0.875rem;
color: #6b7280;
}
/* 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); }
}
/* Bulk edit */
.bulk-bar { display:flex; align-items:center; justify-content:space-between; padding:0.625rem 0; border-bottom:1px solid #f3f4f6; margin-bottom:0.5rem; }
.bulk-bar .bulk-left { display:flex; align-items:center; gap:0.5rem; }
.bulk-bar .bulk-count { font-size:0.8125rem; color:#2563eb; font-weight:600; }
.bulk-edit-btn { padding:0.375rem 0.75rem; background:#2563eb; color:#fff; border:none; border-radius:0.5rem; font-size:0.75rem; font-weight:600; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-edit-btn:disabled { background:#d1d5db; cursor:not-allowed; }
.bulk-edit-btn:not(:disabled):active { background:#1d4ed8; }
.bulk-form { background:#f0f9ff; border:1.5px solid #93c5fd; border-radius:0.75rem; padding:1rem; margin-bottom:0.75rem; }
.bulk-form .override-row { margin-bottom:0.75rem; }
.bulk-form .bulk-apply-btn { width:100%; padding:0.625rem; background:#2563eb; color:#fff; border:none; border-radius:0.5rem; font-size:0.875rem; font-weight:700; margin-top:0.5rem; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-form .bulk-apply-btn:active { background:#1d4ed8; }
.bulk-form .bulk-cancel-btn { width:100%; padding:0.5rem; background:none; border:1px solid #d1d5db; border-radius:0.5rem; font-size:0.75rem; color:#6b7280; margin-top:0.375rem; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-form .bulk-cancel-btn:active { background:#f3f4f6; }
.acc-check { width:20px; height:20px; accent-color:#2563eb; flex-shrink:0; margin:0; }
/* Inline add: dashed pill for "+" */
.pill-btn-add {
padding: 0.5rem 0.875rem;
border: 1.5px dashed #93c5fd;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
color: #2563eb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
transition: all 0.12s;
}
.pill-btn-add:active { background: #eff6ff; transform: scale(0.97); }
/* Inline add: dashed list-item for new task */
.list-item-add {
padding: 0.875rem 1rem;
border: 1.5px dashed #93c5fd;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
text-align: center;
font-size: 0.8125rem;
color: #2563eb;
transition: all 0.12s;
}
.list-item-add:active { background: #eff6ff; transform: scale(0.98); }
/* Inline add form */
.inline-add-form {
background: #f0f9ff;
border: 1.5px solid #93c5fd;
border-radius: 0.75rem;
padding: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
animation: fadeSlideIn 150ms ease-out;
}
.inline-add-form input[type="text"] {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
margin-bottom: 0.5rem;
}
.inline-add-form input[type="text"]:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.inline-add-form .inline-add-btns {
display: flex;
gap: 0.5rem;
}
.inline-add-form .inline-add-btns button {
flex: 1;
padding: 0.5rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.inline-add-form .btn-cancel {
background: white;
border: 1px solid #d1d5db;
color: #6b7280;
}
.inline-add-form .btn-cancel:active { background: #f3f4f6; }
.inline-add-form .btn-save {
background: #2563eb;
border: none;
color: white;
}
.inline-add-form .btn-save:active { background: #1d4ed8; }
.inline-add-form .btn-save:disabled {
background: #93c5fd;
cursor: not-allowed;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<!-- Fixed Header -->
<div class="wizard-header">
<button type="button" class="back-btn" onclick="goBack()">&#8592;</button>
<h1>TBM 시작</h1>
</div>
<!-- Step Indicator -->
<div class="step-indicator" id="stepIndicator">
<div class="step active"><span class="step-dot">1</span><span>작업자</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">2</span><span>프로젝트+공정</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">3</span><span>확인</span></div>
</div>
<!-- Step Content -->
<div class="step-content" id="stepContainer">
<!-- Dynamically rendered by tbm-create.js -->
</div>
<!-- Fixed Bottom Nav -->
<div class="wizard-nav" id="wizardNav">
<button type="button" class="nav-btn nav-btn-prev" id="prevBtn" onclick="prevStep()" style="visibility:hidden;">&#8592; 이전</button>
<button type="button" class="nav-btn nav-btn-next" id="nextBtn" onclick="nextStep()">다음 &#8594;</button>
</div>
<!-- Landscape Map Overlay removed - workplace selection moved to detail edit stage -->
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">데이터를 불러오는 중...</div>
</div>
<!-- Toast Container -->
<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/tbm-create.js?v=12"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
<link rel="icon" type="image/png" href="/img/favicon.png"> <link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) --> <!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script> <script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=3" defer></script> <script src="/js/app-init.js?v=5" defer></script>
<!-- instant.page: 링크 호버 시 페이지 프리로딩 --> <!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
<script src="https://instant.page/5.2.0" type="module"></script> <script src="https://instant.page/5.2.0" type="module"></script>
</head> </head>
@@ -144,9 +144,9 @@
</div> </div>
</main> </main>
<!-- TBM 생성/수정 모달 --> <!-- TBM 생성 모달 (간소화) -->
<div id="tbmModal" class="tbm-modal-overlay" style="display: none;"> <div id="tbmModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 1000px;"> <div class="tbm-modal" style="max-width: 800px;">
<div class="tbm-modal-header"> <div class="tbm-modal-header">
<h2 class="tbm-modal-title" id="modalTitle"> <h2 class="tbm-modal-title" id="modalTitle">
<span>&#128221;</span> <span>&#128221;</span>
@@ -159,7 +159,7 @@
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();"> <form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
<input type="hidden" id="sessionId"> <input type="hidden" id="sessionId">
<!-- 고정 정보 섹션 --> <!-- 기본 정보 섹션 -->
<div class="tbm-form-section"> <div class="tbm-form-section">
<h3 class="tbm-form-section-title"> <h3 class="tbm-form-section-title">
<span>&#128197;</span> <span>&#128197;</span>
@@ -177,32 +177,44 @@
<input type="hidden" id="leaderId"> <input type="hidden" id="leaderId">
</div> </div>
</div> </div>
<div class="tbm-form-row">
<div class="tbm-form-group">
<label class="tbm-form-label">프로젝트</label>
<select id="newTbmProjectId" class="tbm-form-input">
<option value="">선택 안함</option>
</select>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">공정<span class="tbm-form-required">*</span></label>
<select id="newTbmWorkTypeId" class="tbm-form-input" required>
<option value="">공정 선택...</option>
</select>
</div>
</div>
</div> </div>
<!-- 작업자 및 작업 정보 섹션 --> <!-- 작업자 선택 섹션 -->
<div class="tbm-form-section"> <div class="tbm-form-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;"> <h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">
<span>&#128101;</span> <span>&#128101;</span>
작업자 및 작업 정보 작업자 선택
<span id="newTbmWorkerCount" style="color: #3b82f6; font-size: 0.875rem;">(0명)</span>
</h3> </h3>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="openBulkSettingModal()"> <button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllNewTbmWorkers()">전체 선택</button>
일괄 설정 <button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllNewTbmWorkers()">전체 해제</button>
</button>
<button type="button" class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="openWorkerSelectionModal()">
<span class="tbm-btn-icon">+</span>
작업자 선택
</button>
</div> </div>
</div> </div>
<!-- 작업자 카드 리스트 --> <div id="newTbmWorkerGrid" class="tbm-worker-select-grid">
<div id="workerTaskList" class="tbm-worker-list"> <!-- 작업자 체크박스 그리드가 여기에 동적으로 생성됩니다 -->
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 --> </div>
<div class="tbm-empty-state" id="workerListEmpty" style="padding: 2rem; border: 2px dashed #d1d5db; border-radius: 10px;">
<div class="tbm-empty-icon">&#128101;</div> <div class="tbm-alert tbm-alert-info" style="margin-top: 1rem;">
<p class="tbm-empty-description" style="margin: 0;">작업자를 선택해주세요</p> <span class="tbm-alert-icon">&#128161;</span>
<div class="tbm-alert-content">
<div class="tbm-alert-text">저장 후 카드를 클릭하면 작업자별 <strong>작업/작업장</strong>을 입력할 수 있습니다.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -400,6 +412,9 @@
<div class="tbm-workplace-map-container"> <div class="tbm-workplace-map-container">
<canvas id="workplaceMapCanvas"></canvas> <canvas id="workplaceMapCanvas"></canvas>
</div> </div>
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
&#128250; 전체화면 지도
</button>
</div> </div>
<!-- 리스트 기반 선택 (모바일에서 토글) --> <!-- 리스트 기반 선택 (모바일에서 토글) -->
@@ -525,11 +540,18 @@
<label class="tbm-form-label">종료 시간</label> <label class="tbm-form-label">종료 시간</label>
<input type="time" id="endTime" class="tbm-form-input"> <input type="time" id="endTime" class="tbm-form-input">
</div> </div>
<div class="tbm-form-group" style="margin-top: 1rem;">
<label class="tbm-form-label">작업자 근태</label>
<div id="completeAttendanceList" style="max-height: 300px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
<div style="text-align:center; color:#9ca3af; padding:1rem;">로딩 중...</div>
</div>
</div>
</div> </div>
<div class="tbm-modal-footer"> <div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeCompleteModal()">취소</button> <button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeCompleteModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-success" onclick="completeTbmSession()"> <button type="button" class="tbm-btn tbm-btn-success" id="completeModalBtn" onclick="completeTbmSession()">
<span class="tbm-btn-icon">&#10003;</span> <span class="tbm-btn-icon">&#10003;</span>
완료 완료
</button> </button>
@@ -657,6 +679,19 @@
</div> </div>
</div> </div>
<!-- 가로모드 전체화면 지도 오버레이 -->
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
<div id="landscapeInner" class="landscape-inner">
<div class="landscape-header">
<h3>&#127981; 작업장 선택</h3>
<button type="button" class="landscape-close-btn" onclick="closeLandscapeMap()">×</button>
</div>
<div class="landscape-canvas-wrap">
<canvas id="landscapeCanvas"></canvas>
</div>
</div>
</div>
<!-- 토스트 알림 --> <!-- 토스트 알림 -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
</div> </div>
@@ -667,7 +702,7 @@
<script src="/js/tbm/api.js?v=1"></script> <script src="/js/tbm/api.js?v=1"></script>
<!-- 기존 UI 로직 (점진적 마이그레이션) --> <!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/tbm.js?v=8"></script> <script type="module" src="/js/tbm.js?v=10"></script>
<!-- 모바일 하단 네비게이션 --> <!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div> <div id="mobile-nav-container"></div>

View File

@@ -333,6 +333,12 @@ function renderMap() {
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0); const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
drawWorkplaceRegion(region, workerCount, visitorCount); drawWorkplaceRegion(region, workerCount, visitorCount);
}); });
// 모바일일 때 전체화면 지도 버튼 표시
const triggerBtn = document.getElementById('landscapeTriggerBtn');
if (triggerBtn && window.innerWidth <= 768 && canvasImage && canvasImage.complete && mapRegions.length > 0) {
triggerBtn.style.display = 'inline-flex';
}
} }
/** /**
@@ -453,6 +459,213 @@ function selectWorkplace(region) {
updateStepStatus(); updateStepStatus();
} }
// ==================== 가로모드 전체화면 지도 ====================
function openLandscapeMap() {
if (!canvasImage || !canvasImage.complete || mapRegions.length === 0) return;
const overlay = document.getElementById('landscapeOverlay');
const inner = document.getElementById('landscapeInner');
const lCanvas = document.getElementById('landscapeCanvas');
if (!overlay || !lCanvas) return;
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 물리적 가로모드 여부
const isPhysicalLandscape = window.innerWidth > window.innerHeight;
inner.className = 'landscape-inner ' + (isPhysicalLandscape ? 'no-rotate' : 'rotated');
// 가용 영역
const headerH = 52;
const pad = 16;
let availW, availH;
if (isPhysicalLandscape) {
availW = window.innerWidth - pad * 2;
availH = window.innerHeight - headerH - pad * 2;
} else {
availW = window.innerHeight - pad * 2;
availH = window.innerWidth - headerH - pad * 2;
}
// 이미지 비율 유지
const imgRatio = canvasImage.naturalWidth / canvasImage.naturalHeight;
let cw, ch;
if (availW / availH > imgRatio) {
ch = availH;
cw = ch * imgRatio;
} else {
cw = availW;
ch = cw / imgRatio;
}
lCanvas.width = Math.round(cw);
lCanvas.height = Math.round(ch);
drawLandscapeMap();
lCanvas.ontouchstart = handleLandscapeTouchStart;
lCanvas.onclick = handleLandscapeClick;
}
function drawLandscapeMap() {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas || !canvasImage) return;
const lCtx = lCanvas.getContext('2d');
lCtx.drawImage(canvasImage, 0, 0, lCanvas.width, lCanvas.height);
mapRegions.forEach(region => {
const workers = todayWorkers.filter(w => w.workplace_id === region.workplace_id);
const visitors = todayVisitors.filter(v => v.workplace_id === region.workplace_id);
const workerCount = workers.reduce((sum, w) => sum + (w.member_count || 0), 0);
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
const w = x2 - x1;
const h = y2 - y1;
const isSelected = region.workplace_id === selectedWorkplaceId;
let fillColor, strokeColor, textColor;
if (isSelected) {
fillColor = 'rgba(34, 197, 94, 0.5)';
strokeColor = 'rgb(22, 163, 74)';
textColor = '#15803d';
} else if (workerCount > 0 && visitorCount > 0) {
fillColor = 'rgba(34, 197, 94, 0.4)';
strokeColor = 'rgb(22, 163, 74)';
textColor = '#166534';
} else if (workerCount > 0) {
fillColor = 'rgba(59, 130, 246, 0.4)';
strokeColor = 'rgb(37, 99, 235)';
textColor = '#1e40af';
} else if (visitorCount > 0) {
fillColor = 'rgba(168, 85, 247, 0.4)';
strokeColor = 'rgb(147, 51, 234)';
textColor = '#7c3aed';
} else {
fillColor = 'rgba(107, 114, 128, 0.35)';
strokeColor = 'rgb(75, 85, 99)';
textColor = '#374151';
}
lCtx.fillStyle = fillColor;
lCtx.strokeStyle = strokeColor;
lCtx.lineWidth = isSelected ? 4 : 2.5;
lCtx.beginPath();
lCtx.rect(x1, y1, w, h);
lCtx.fill();
lCtx.stroke();
const centerX = x1 + w / 2;
const centerY = y1 + h / 2;
lCtx.font = 'bold 13px sans-serif';
const textMetrics = lCtx.measureText(region.workplace_name);
const textWidth = textMetrics.width + 12;
const textHeight = 20;
lCtx.fillStyle = 'rgba(255, 255, 255, 0.9)';
drawRoundRect(lCtx, centerX - textWidth / 2, centerY - textHeight / 2, textWidth, textHeight, 4);
lCtx.fill();
lCtx.fillStyle = textColor;
lCtx.textAlign = 'center';
lCtx.textBaseline = 'middle';
lCtx.fillText(region.workplace_name, centerX, centerY);
const total = workerCount + visitorCount;
if (total > 0) {
lCtx.font = 'bold 12px sans-serif';
const countText = `${total}`;
const countMetrics = lCtx.measureText(countText);
const countWidth = countMetrics.width + 10;
const countHeight = 18;
lCtx.fillStyle = strokeColor;
drawRoundRect(lCtx, centerX - countWidth / 2, centerY + 12, countWidth, countHeight, 4);
lCtx.fill();
lCtx.fillStyle = '#ffffff';
lCtx.fillText(countText, centerX, centerY + 21);
}
});
}
function getLandscapeCoords(clientX, clientY) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return null;
const rect = lCanvas.getBoundingClientRect();
const inner = document.getElementById('landscapeInner');
const isRotated = inner.classList.contains('rotated');
if (!isRotated) {
const scaleX = lCanvas.width / rect.width;
const scaleY = lCanvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = clientX - centerX;
const dy = clientY - centerY;
const inverseDx = dy;
const inverseDy = -dx;
const unrotatedW = rect.height;
const unrotatedH = rect.width;
const canvasX = (inverseDx + unrotatedW / 2) / unrotatedW * lCanvas.width;
const canvasY = (inverseDy + unrotatedH / 2) / unrotatedH * lCanvas.height;
return { x: canvasX, y: canvasY };
}
function handleLandscapeTouchStart(e) {
e.preventDefault();
const touch = e.touches[0];
const coords = getLandscapeCoords(touch.clientX, touch.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function handleLandscapeClick(e) {
const coords = getLandscapeCoords(e.clientX, e.clientY);
if (coords) doLandscapeHitTest(coords.x, coords.y);
}
function doLandscapeHitTest(cx, cy) {
const lCanvas = document.getElementById('landscapeCanvas');
if (!lCanvas) return;
for (const region of mapRegions) {
const x1 = (region.x_start / 100) * lCanvas.width;
const y1 = (region.y_start / 100) * lCanvas.height;
const x2 = (region.x_end / 100) * lCanvas.width;
const y2 = (region.y_end / 100) * lCanvas.height;
if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) {
selectWorkplace(region);
drawLandscapeMap();
setTimeout(() => closeLandscapeMap(), 300);
return;
}
}
}
function closeLandscapeMap() {
const overlay = document.getElementById('landscapeOverlay');
if (overlay) overlay.style.display = 'none';
const lCanvas = document.getElementById('landscapeCanvas');
if (lCanvas) {
lCanvas.ontouchstart = null;
lCanvas.onclick = null;
}
document.body.style.overflow = '';
}
/** /**
* 프로젝트 목록 렌더링 (아코디언 방식) * 프로젝트 목록 렌더링 (아코디언 방식)
* 범주 1: 오늘 TBM 등록 작업 (해당 위치) * 범주 1: 오늘 TBM 등록 작업 (해당 위치)

View File

@@ -460,6 +460,102 @@
#submitBtn:disabled { background: #d1d5db; cursor: not-allowed; } #submitBtn:disabled { background: #d1d5db; cursor: not-allowed; }
#submitBtn:not(:disabled):active { background: #dc2626; } #submitBtn:not(:disabled):active { background: #dc2626; }
/* 가로모드 전체화면 지도 오버레이 */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #fff;
overflow: hidden;
}
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
flex-shrink: 0;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #f1f5f9;
padding: 0.5rem;
}
.landscape-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.landscape-trigger-btn {
display: none;
}
@media (max-width: 768px) {
.landscape-trigger-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
border: none;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.landscape-trigger-btn:active {
transform: scale(0.97);
opacity: 0.85;
}
}
/* Responsive */ /* Responsive */
@media (min-width: 480px) { @media (min-width: 480px) {
body { max-width: 480px; margin: 0 auto; min-height: 100vh; } body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
@@ -514,6 +610,9 @@
<div class="map-container"> <div class="map-container">
<canvas id="issueMapCanvas"></canvas> <canvas id="issueMapCanvas"></canvas>
</div> </div>
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
&#128250; 전체화면 지도로 선택
</button>
<div id="selectedLocationInfo" class="location-info empty"> <div id="selectedLocationInfo" class="location-info empty">
지도에서 작업장을 클릭하여 위치를 선택하세요 지도에서 작업장을 클릭하여 위치를 선택하세요
</div> </div>
@@ -569,6 +668,19 @@
<button type="button" id="submitBtn" disabled onclick="submitReport()">신고 제출</button> <button type="button" id="submitBtn" disabled onclick="submitReport()">신고 제출</button>
</div> </div>
<!-- 가로모드 전체화면 지도 오버레이 -->
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
<div id="landscapeInner" class="landscape-inner">
<div class="landscape-header">
<h3>&#127981; 작업장 선택</h3>
<button type="button" class="landscape-close-btn" onclick="closeLandscapeMap()">×</button>
</div>
<div class="landscape-canvas-wrap">
<canvas id="landscapeCanvas"></canvas>
</div>
</div>
</div>
<!-- Hidden file input for camera/gallery --> <!-- Hidden file input for camera/gallery -->
<input type="file" id="photoInput" accept="image/*" style="display:none"> <input type="file" id="photoInput" accept="image/*" style="display:none">