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:
@@ -21,6 +21,7 @@ function createTokenPayload(user) {
|
||||
id: user.user_id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
worker_id: user.worker_id || null,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
access_level: user.role,
|
||||
@@ -84,6 +85,7 @@ async function login(req, res, next) {
|
||||
name: user.name,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
worker_id: user.worker_id || null,
|
||||
system_access: payload.system_access
|
||||
}
|
||||
});
|
||||
@@ -159,6 +161,7 @@ async function validate(req, res, next) {
|
||||
name: user.name,
|
||||
department: user.department,
|
||||
role: user.role,
|
||||
worker_id: user.worker_id || null,
|
||||
system_access: {
|
||||
system1: user.system1_access,
|
||||
system2: user.system2_access,
|
||||
|
||||
@@ -149,6 +149,7 @@ function setupRoutes(app) {
|
||||
app.use('/api/performance', performanceRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/tools', toolsRoute);
|
||||
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리 (userRoutes보다 먼저 등록 - /users/:id/page-access 매칭 우선)
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/workplaces', workplaceRoutes);
|
||||
app.use('/api/equipments', equipmentRoutes);
|
||||
@@ -157,7 +158,6 @@ function setupRoutes(app) {
|
||||
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
|
||||
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
|
||||
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
|
||||
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
|
||||
app.use('/api/tbm', tbmRoutes); // TBM 시스템
|
||||
app.use('/api/work-issues', workIssueRoutes); // 카테고리/아이템 + 신고 조회 (같은 MariaDB 공유)
|
||||
app.use('/api/departments', departmentRoutes); // 부서 관리
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// controllers/tbmController.js - TBM 시스템 컨트롤러
|
||||
const TbmModel = require('../models/tbmModel');
|
||||
const TbmTransferModel = require('../models/tbmTransferModel');
|
||||
|
||||
const TbmController = {
|
||||
// ==================== TBM 세션 관련 ====================
|
||||
@@ -151,7 +152,37 @@ const TbmController = {
|
||||
completeSession: (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
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) => {
|
||||
if (err) {
|
||||
console.error('TBM 세션 완료 처리 오류:', err);
|
||||
@@ -223,7 +254,8 @@ const TbmController = {
|
||||
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
|
||||
workplace_id: req.body.workplace_id || null,
|
||||
work_hours: req.body.work_hours !== undefined ? req.body.work_hours : undefined
|
||||
};
|
||||
|
||||
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 팀 배정 조회
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
};
|
||||
@@ -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
|
||||
`);
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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
|
||||
`);
|
||||
});
|
||||
};
|
||||
@@ -47,6 +47,11 @@ const TbmModel = {
|
||||
u.username as created_by_username,
|
||||
u.name as created_by_name,
|
||||
COUNT(DISTINCT ta.worker_id) as team_member_count,
|
||||
GROUP_CONCAT(DISTINCT w2.worker_name ORDER BY ta.assignment_id SEPARATOR ', ') as team_member_names,
|
||||
-- 이동 수 (이 세션이 source 또는 dest인 이동 건수)
|
||||
(SELECT COUNT(*) FROM tbm_transfers tf
|
||||
WHERE (tf.source_session_id = s.session_id OR tf.dest_session_id = s.session_id)
|
||||
AND tf.transfer_date = s.session_date) as transfer_count,
|
||||
-- 첫 번째 팀원의 작업 정보 가져오기
|
||||
first_ta.project_id,
|
||||
first_ta.work_type_id,
|
||||
@@ -58,8 +63,9 @@ const TbmModel = {
|
||||
first_wp.workplace_name as work_location
|
||||
FROM tbm_sessions s
|
||||
LEFT JOIN workers w ON s.leader_id = w.worker_id
|
||||
LEFT JOIN users u ON s.created_by = u.user_id
|
||||
LEFT JOIN sso_users u ON s.created_by = u.user_id
|
||||
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
|
||||
LEFT JOIN workers w2 ON ta.worker_id = w2.worker_id
|
||||
-- 첫 번째 팀원 정보 (가장 먼저 등록된 작업)
|
||||
LEFT JOIN (
|
||||
SELECT * FROM tbm_team_assignments
|
||||
@@ -110,7 +116,7 @@ const TbmModel = {
|
||||
first_wc.category_name as workplace_category_name
|
||||
FROM tbm_sessions s
|
||||
LEFT JOIN workers w ON s.leader_id = w.worker_id
|
||||
LEFT JOIN users u ON s.created_by = u.user_id
|
||||
LEFT JOIN sso_users u ON s.created_by = u.user_id
|
||||
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
|
||||
LEFT JOIN (
|
||||
SELECT * FROM tbm_team_assignments
|
||||
@@ -188,6 +194,107 @@ const TbmModel = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션 완료 처리 (근태 유형 포함)
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
* @param {string} endTime - 종료 시간
|
||||
* @param {Array} attendanceData - [{worker_id, attendance_type, attendance_hours}]
|
||||
* @param {number} createdBy - 처리자 user_id
|
||||
*/
|
||||
completeSessionWithAttendance: async (sessionId, endTime, attendanceData, createdBy, callback) => {
|
||||
let conn;
|
||||
try {
|
||||
const db = await getDb();
|
||||
conn = await db.getConnection();
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 1. 세션 정보 조회 (날짜 확인용)
|
||||
const [sessionRows] = await conn.query(
|
||||
'SELECT session_date FROM tbm_sessions WHERE session_id = ?',
|
||||
[sessionId]
|
||||
);
|
||||
if (sessionRows.length === 0) {
|
||||
await conn.rollback();
|
||||
conn.release();
|
||||
return callback(null, { affectedRows: 0 });
|
||||
}
|
||||
const sessionDate = sessionRows[0].session_date;
|
||||
// sessionDate를 YYYY-MM-DD 형식으로 변환
|
||||
let reportDate;
|
||||
if (sessionDate instanceof Date) {
|
||||
reportDate = sessionDate.toISOString().split('T')[0];
|
||||
} else if (typeof sessionDate === 'string') {
|
||||
reportDate = sessionDate.split('T')[0];
|
||||
} else {
|
||||
reportDate = new Date(sessionDate).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 2. 각 작업자의 근태 유형 업데이트
|
||||
for (const item of attendanceData) {
|
||||
await conn.query(
|
||||
`UPDATE tbm_team_assignments
|
||||
SET attendance_type = ?, attendance_hours = ?
|
||||
WHERE session_id = ? AND worker_id = ?`,
|
||||
[item.attendance_type, item.attendance_hours || null, sessionId, item.worker_id]
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 연차(annual) 작업자 → 작업보고서 자동 생성 (project_id=13, 8h)
|
||||
const annualWorkers = attendanceData.filter(a => a.attendance_type === 'annual');
|
||||
for (const aw of annualWorkers) {
|
||||
// 해당 작업자의 assignment_id 조회
|
||||
const [assignRows] = await conn.query(
|
||||
'SELECT assignment_id FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
|
||||
[sessionId, aw.worker_id]
|
||||
);
|
||||
if (assignRows.length > 0) {
|
||||
// 이미 보고서가 있는지 확인
|
||||
const [existingReport] = await conn.query(
|
||||
'SELECT id FROM daily_work_reports WHERE tbm_assignment_id = ?',
|
||||
[assignRows[0].assignment_id]
|
||||
);
|
||||
if (existingReport.length === 0) {
|
||||
await conn.query(
|
||||
`INSERT INTO daily_work_reports
|
||||
(report_date, worker_id, project_id, work_hours, work_status_id, created_by, tbm_assignment_id, created_at)
|
||||
VALUES (?, ?, 13, 8, 1, ?, ?, NOW())`,
|
||||
[reportDate, aw.worker_id, createdBy, assignRows[0].assignment_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 세션 완료 처리
|
||||
await conn.query(
|
||||
`UPDATE tbm_sessions
|
||||
SET status = 'completed', end_time = ?, updated_at = NOW()
|
||||
WHERE session_id = ?`,
|
||||
[endTime, sessionId]
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
conn.release();
|
||||
|
||||
// 5. 연차 작업자 근태 동기화
|
||||
for (const aw of annualWorkers) {
|
||||
try {
|
||||
const AttendanceModel = require('./attendanceModel');
|
||||
await AttendanceModel.syncWithWorkReports(aw.worker_id, reportDate);
|
||||
} catch (syncErr) {
|
||||
console.error('근태 동기화 오류 (무시됨):', syncErr);
|
||||
}
|
||||
}
|
||||
|
||||
callback(null, { affectedRows: 1 });
|
||||
} catch (err) {
|
||||
if (conn) {
|
||||
try { await conn.rollback(); } catch (e) {}
|
||||
conn.release();
|
||||
}
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* TBM 세션 삭제 (draft 상태만 가능)
|
||||
*/
|
||||
@@ -215,9 +322,9 @@ const TbmModel = {
|
||||
const db = await getDb();
|
||||
const sql = `
|
||||
INSERT INTO tbm_team_assignments
|
||||
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
|
||||
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(session_id, worker_id, split_seq, assigned_role, work_detail, is_present, absence_reason,
|
||||
project_id, work_type_id, task_id, workplace_category_id, workplace_id, work_hours)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
assigned_role = VALUES(assigned_role),
|
||||
work_detail = VALUES(work_detail),
|
||||
@@ -227,12 +334,14 @@ const TbmModel = {
|
||||
work_type_id = VALUES(work_type_id),
|
||||
task_id = VALUES(task_id),
|
||||
workplace_category_id = VALUES(workplace_category_id),
|
||||
workplace_id = VALUES(workplace_id)
|
||||
workplace_id = VALUES(workplace_id),
|
||||
work_hours = COALESCE(VALUES(work_hours), work_hours)
|
||||
`;
|
||||
|
||||
const values = [
|
||||
assignmentData.session_id,
|
||||
assignmentData.worker_id,
|
||||
assignmentData.split_seq || 0,
|
||||
assignmentData.assigned_role,
|
||||
assignmentData.work_detail,
|
||||
assignmentData.is_present !== undefined ? assignmentData.is_present : true,
|
||||
@@ -241,7 +350,8 @@ const TbmModel = {
|
||||
assignmentData.work_type_id || null,
|
||||
assignmentData.task_id || null,
|
||||
assignmentData.workplace_category_id || null,
|
||||
assignmentData.workplace_id || null
|
||||
assignmentData.workplace_id || null,
|
||||
assignmentData.work_hours !== undefined ? assignmentData.work_hours : null
|
||||
];
|
||||
|
||||
const [result] = await db.query(sql, values);
|
||||
@@ -251,6 +361,42 @@ const TbmModel = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 분할 항목 추가 (같은 세션+작업자에 split_seq 자동 증가)
|
||||
*/
|
||||
addSplitAssignment: async (assignmentData, callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
// 현재 최대 split_seq 조회
|
||||
const [maxRows] = await db.query(
|
||||
'SELECT COALESCE(MAX(split_seq), -1) as max_seq FROM tbm_team_assignments WHERE session_id = ? AND worker_id = ?',
|
||||
[assignmentData.session_id, assignmentData.worker_id]
|
||||
);
|
||||
const nextSeq = (maxRows[0].max_seq || 0) + 1;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO tbm_team_assignments
|
||||
(session_id, worker_id, split_seq, work_hours, project_id, work_type_id,
|
||||
task_id, workplace_category_id, workplace_id, is_present)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`;
|
||||
const [result] = await db.query(sql, [
|
||||
assignmentData.session_id,
|
||||
assignmentData.worker_id,
|
||||
nextSeq,
|
||||
assignmentData.work_hours,
|
||||
assignmentData.project_id || null,
|
||||
assignmentData.work_type_id || null,
|
||||
assignmentData.task_id || null,
|
||||
assignmentData.workplace_category_id || null,
|
||||
assignmentData.workplace_id || null
|
||||
]);
|
||||
callback(null, { assignment_id: result.insertId, split_seq: nextSeq });
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 팀 구성 일괄 추가 (작업자별 상세 정보 포함)
|
||||
*/
|
||||
@@ -420,7 +566,7 @@ const TbmModel = {
|
||||
u.name as checked_by_name
|
||||
FROM tbm_safety_records sr
|
||||
INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id
|
||||
LEFT JOIN users u ON sr.checked_by = u.user_id
|
||||
LEFT JOIN sso_users u ON sr.checked_by = u.user_id
|
||||
WHERE sr.session_id = ?
|
||||
ORDER BY sc.check_category, sc.display_order
|
||||
`;
|
||||
@@ -571,7 +717,7 @@ const TbmModel = {
|
||||
FROM team_handovers h
|
||||
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
|
||||
INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id
|
||||
LEFT JOIN users u ON h.confirmed_by = u.user_id
|
||||
LEFT JOIN sso_users u ON h.confirmed_by = u.user_id
|
||||
WHERE h.handover_date = ?
|
||||
ORDER BY h.handover_time DESC
|
||||
`;
|
||||
@@ -673,9 +819,13 @@ const TbmModel = {
|
||||
const db = await getDb();
|
||||
|
||||
// WHERE 조건 동적 생성
|
||||
// TBM 완료(근태 입력) 후에만 작업보고서 작성 가능
|
||||
let whereClause = `
|
||||
WHERE dwr.id IS NULL
|
||||
AND s.status = 'draft'
|
||||
AND s.status = 'completed'
|
||||
AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual')
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND ta.workplace_id IS NOT NULL
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
@@ -684,7 +834,10 @@ const TbmModel = {
|
||||
whereClause = `
|
||||
WHERE s.created_by = ?
|
||||
AND dwr.id IS NULL
|
||||
AND s.status = 'draft'
|
||||
AND s.status = 'completed'
|
||||
AND (ta.attendance_type IS NULL OR ta.attendance_type != 'annual')
|
||||
AND ta.task_id IS NOT NULL
|
||||
AND ta.workplace_id IS NOT NULL
|
||||
`;
|
||||
params.push(userId);
|
||||
}
|
||||
@@ -699,6 +852,9 @@ const TbmModel = {
|
||||
ta.task_id,
|
||||
ta.workplace_category_id,
|
||||
ta.workplace_id,
|
||||
ta.attendance_type,
|
||||
ta.attendance_hours,
|
||||
ta.work_hours,
|
||||
s.session_date,
|
||||
s.status as session_status,
|
||||
s.created_by,
|
||||
|
||||
294
system1-factory/api/models/tbmTransferModel.js
Normal file
294
system1-factory/api/models/tbmTransferModel.js
Normal 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;
|
||||
@@ -3,6 +3,71 @@ const router = express.Router();
|
||||
const { getDb } = require('../dbPool');
|
||||
const { requireAuth, requireAdmin } = require('../middlewares/auth');
|
||||
|
||||
// tkuser page_name → default_access 매핑 (permissionModel.js의 DEFAULT_PAGES와 동기화)
|
||||
const TKUSER_DEFAULT_ACCESS = {
|
||||
's1.dashboard': true,
|
||||
's1.work.tbm': true,
|
||||
's1.work.report_create': true,
|
||||
's1.work.analysis': false,
|
||||
's1.work.nonconformity': true,
|
||||
's1.factory.repair_management': false,
|
||||
's1.inspection.daily_patrol': false,
|
||||
's1.inspection.checkin': true,
|
||||
's1.inspection.work_status': false,
|
||||
's1.safety.visit_request': true,
|
||||
's1.safety.management': false,
|
||||
's1.safety.checklist_manage': false,
|
||||
's1.attendance.my_vacation_info': true,
|
||||
's1.attendance.monthly': true,
|
||||
's1.attendance.vacation_request': true,
|
||||
's1.attendance.vacation_management': false,
|
||||
's1.attendance.vacation_allocation': false,
|
||||
's1.attendance.annual_overview': false,
|
||||
's1.admin.workers': false,
|
||||
's1.admin.projects': false,
|
||||
's1.admin.tasks': false,
|
||||
's1.admin.workplaces': false,
|
||||
's1.admin.equipments': false,
|
||||
's1.admin.issue_categories': false,
|
||||
's1.admin.attendance_report': false,
|
||||
};
|
||||
|
||||
// system1 page_key → tkuser page_name 매핑
|
||||
const PAGEKEY_TO_TKUSER = {
|
||||
'dashboard': 's1.dashboard',
|
||||
'work.tbm': 's1.work.tbm',
|
||||
'work.report-create': 's1.work.report_create',
|
||||
'work.report-view': 's1.work.report_create',
|
||||
'work.analysis': 's1.work.analysis',
|
||||
'work.visit-request': 's1.safety.visit_request',
|
||||
'work.issue-report': 's1.work.nonconformity',
|
||||
'work.issue-list': 's1.work.nonconformity',
|
||||
'work.issue-detail': 's1.work.nonconformity',
|
||||
'safety.issue_report': 's1.work.nonconformity',
|
||||
'safety.issue_list': 's1.work.nonconformity',
|
||||
'safety.issue_detail': 's1.work.nonconformity',
|
||||
'safety.checklist_manage': 's1.safety.checklist_manage',
|
||||
'admin.workers': 's1.admin.workers',
|
||||
'admin.projects': 's1.admin.projects',
|
||||
'admin.tasks': 's1.admin.tasks',
|
||||
'admin.workplaces': 's1.admin.workplaces',
|
||||
'admin.equipments': 's1.admin.equipments',
|
||||
'admin.codes': 's1.admin.tasks',
|
||||
'admin.safety-management': 's1.safety.management',
|
||||
'admin.safety-training-conduct': 's1.safety.management',
|
||||
'admin.attendance-report-comparison': 's1.admin.attendance_report',
|
||||
'admin.departments': 's1.admin.workers',
|
||||
'common.daily-attendance': 's1.inspection.checkin',
|
||||
'common.monthly-attendance': 's1.attendance.monthly',
|
||||
'common.vacation-request': 's1.attendance.vacation_request',
|
||||
'common.vacation-management': 's1.attendance.vacation_management',
|
||||
'common.annual-vacation-overview': 's1.attendance.annual_overview',
|
||||
'common.vacation-allocation': 's1.attendance.vacation_allocation',
|
||||
'inspection.daily_patrol': 's1.inspection.daily_patrol',
|
||||
'attendance.vacation_approval': 's1.attendance.vacation_management',
|
||||
'attendance.vacation_input': 's1.attendance.vacation_allocation',
|
||||
};
|
||||
|
||||
/**
|
||||
* 모든 페이지 목록 조회
|
||||
* GET /api/pages
|
||||
@@ -29,25 +94,22 @@ router.get('/pages', requireAuth, async (req, res) => {
|
||||
*/
|
||||
router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const ssoUserId = req.params.userId;
|
||||
const db = await getDb();
|
||||
|
||||
// 사용자의 역할 확인
|
||||
const [userRows] = await db.query(`
|
||||
SELECT u.user_id, u.username, u.role_id, r.name as role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.user_id = ?
|
||||
`, [userId]);
|
||||
|
||||
if (userRows.length === 0) {
|
||||
// SSO 사용자 조회 (department_id 포함)
|
||||
const [ssoRows] = await db.query(
|
||||
'SELECT user_id, username, name, role, department_id FROM sso_users WHERE user_id = ?',
|
||||
[ssoUserId]
|
||||
);
|
||||
if (ssoRows.length === 0) {
|
||||
return res.status(404).json({ success: false, error: '사용자를 찾을 수 없습니다.' });
|
||||
}
|
||||
const ssoUser = ssoRows[0];
|
||||
|
||||
const user = userRows[0];
|
||||
|
||||
// Admin/System Admin인 경우 모든 페이지 접근 가능
|
||||
if (user.role_name === 'Admin' || user.role_name === 'System Admin') {
|
||||
// SSO role로 Admin 체크
|
||||
const ssoRole = (ssoUser.role || '').toLowerCase();
|
||||
if (ssoRole === 'admin' || ssoRole === 'system') {
|
||||
const [allPages] = await db.query(`
|
||||
SELECT id, page_key, page_name, page_path, category, is_admin_only
|
||||
FROM pages
|
||||
@@ -62,32 +124,66 @@ router.get('/users/:userId/page-access', requireAuth, async (req, res) => {
|
||||
category: page.category,
|
||||
is_admin_only: page.is_admin_only,
|
||||
can_access: true,
|
||||
is_default: true // Admin은 기본적으로 모든 권한 보유
|
||||
is_default: true
|
||||
}));
|
||||
|
||||
return res.json({ success: true, data: { user, pageAccess } });
|
||||
return res.json({ success: true, data: { user: ssoUser, pageAccess } });
|
||||
}
|
||||
|
||||
// 일반 사용자의 페이지 접근 권한 조회
|
||||
const [pageAccess] = await db.query(`
|
||||
SELECT
|
||||
p.id as page_id,
|
||||
p.page_key,
|
||||
p.page_name,
|
||||
p.page_path,
|
||||
p.category,
|
||||
p.is_admin_only,
|
||||
COALESCE(upa.can_access, 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]);
|
||||
// 일반 사용자: tkuser 권한 테이블에서 조회
|
||||
// 1) 개인 권한 (user_page_permissions)
|
||||
const [userPerms] = await db.query(
|
||||
'SELECT page_name, can_access FROM user_page_permissions WHERE user_id = ?',
|
||||
[ssoUserId]
|
||||
);
|
||||
const userPermMap = {};
|
||||
userPerms.forEach(p => { userPermMap[p.page_name] = !!p.can_access; });
|
||||
|
||||
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) {
|
||||
console.error('페이지 접근 권한 조회 오류:', error);
|
||||
res.status(500).json({ success: false, error: '페이지 접근 권한을 불러오는데 실패했습니다.' });
|
||||
|
||||
@@ -12,6 +12,9 @@ router.post('/sessions', requireAuth, TbmController.createSession);
|
||||
// 작업보고서가 작성되지 않은 TBM 팀 배정 조회 (구체적인 경로이므로 먼저 정의)
|
||||
router.get('/sessions/incomplete-reports', requireAuth, TbmController.getIncompleteWorkReports);
|
||||
|
||||
// 당일 전 작업자 배정 현황 (더 구체적인 경로이므로 먼저 정의)
|
||||
router.get('/sessions/date/:date/assignments', requireAuth, TbmController.getWorkerAssignmentsByDate);
|
||||
|
||||
// 특정 날짜의 TBM 세션 목록 조회
|
||||
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/split', requireAuth, TbmController.addSplitAssignment);
|
||||
|
||||
// 팀 구성 일괄 추가
|
||||
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('/transfers', requireAuth, TbmController.createTransfer);
|
||||
|
||||
// 당일 이동 내역 조회
|
||||
router.get('/transfers/date/:date', requireAuth, TbmController.getTransfersByDate);
|
||||
|
||||
// 이동 취소 (원복)
|
||||
router.delete('/transfers/:transferId', requireAuth, TbmController.cancelTransfer);
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
// 작업 인계 생성
|
||||
|
||||
@@ -113,23 +113,9 @@ router.get('/me/monthly-stats', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 자신의 페이지 권한 조회 (Admin 불필요) ==========
|
||||
// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin)
|
||||
router.get('/:id/page-access', (req, res, next) => {
|
||||
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: '자신의 페이지 권한만 조회할 수 있습니다'
|
||||
});
|
||||
});
|
||||
// ========== 페이지 권한 조회는 pageAccessRoutes.js에서 처리 ==========
|
||||
// GET /:id/page-access → /api/users/:userId/page-access (pageAccessRoutes.js)
|
||||
// tkuser의 user_page_permissions 테이블을 조회하는 통합 핸들러 사용
|
||||
|
||||
// ========== 관리자 전용 API ==========
|
||||
/**
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
</svg>
|
||||
<span class="mobile-nav-label">홈</span>
|
||||
</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">
|
||||
<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"/>
|
||||
</svg>
|
||||
<span class="mobile-nav-label">TBM</span>
|
||||
</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">
|
||||
<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"/>
|
||||
|
||||
1318
system1-factory/web/css/daily-work-report-mobile.css
Normal file
1318
system1-factory/web/css/daily-work-report-mobile.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,16 +218,36 @@
|
||||
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 {
|
||||
min-height: 300px !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
/* 캔버스: 높이 제한 해제, 풀와이드 */
|
||||
#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 {
|
||||
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) {
|
||||
/* 다크모드 색상 조정 필요시 여기에 추가 */
|
||||
|
||||
@@ -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 {
|
||||
text-align: center;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
if (window.clearSSOAuth) { window.clearSSOAuth(); return; }
|
||||
localStorage.removeItem('sso_token');
|
||||
localStorage.removeItem('sso_user');
|
||||
localStorage.removeItem('userPageAccess');
|
||||
localStorage.removeItem('userPageAccess_v2');
|
||||
}
|
||||
|
||||
// ===== 페이지 권한 캐시 =====
|
||||
@@ -36,7 +36,7 @@
|
||||
if (!currentUser || !currentUser.user_id) return null;
|
||||
|
||||
// 캐시 확인
|
||||
const cached = localStorage.getItem('userPageAccess');
|
||||
const cached = localStorage.getItem('userPageAccess_v2_v2');
|
||||
if (cached) {
|
||||
try {
|
||||
const cacheData = JSON.parse(cached);
|
||||
@@ -44,7 +44,7 @@
|
||||
return cacheData.pages;
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem('userPageAccess');
|
||||
localStorage.removeItem('userPageAccess_v2');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
const data = await response.json();
|
||||
const pages = data.data.pageAccess || [];
|
||||
|
||||
localStorage.setItem('userPageAccess', JSON.stringify({
|
||||
localStorage.setItem('userPageAccess_v2', JSON.stringify({
|
||||
pages: pages,
|
||||
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() {
|
||||
const path = window.location.pathname;
|
||||
if (!path.startsWith('/pages/')) return null;
|
||||
const pagePath = path.substring(7).replace('.html', '');
|
||||
return pagePath.replace(/\//g, '.');
|
||||
const rawKey = pagePath.replace(/\//g, '.');
|
||||
return PAGE_KEY_ALIASES[rawKey] || rawKey;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 로더 =====
|
||||
|
||||
@@ -26,6 +26,12 @@ function clearAuthData() {
|
||||
* /pages/admin/accounts.html -> admin.accounts
|
||||
* /pages/dashboard.html -> dashboard
|
||||
*/
|
||||
// 하위 페이지 → 부모 페이지 키 매핑 (동일 권한 공유)
|
||||
var PAGE_KEY_ALIASES = {
|
||||
'work.tbm-create': 'work.tbm',
|
||||
'work.tbm-mobile': 'work.tbm'
|
||||
};
|
||||
|
||||
function getCurrentPageKey() {
|
||||
const path = window.location.pathname;
|
||||
|
||||
@@ -41,9 +47,9 @@ function getCurrentPageKey() {
|
||||
const withoutExt = pagePath.replace('.html', '');
|
||||
|
||||
// 슬래시를 점으로 변환
|
||||
const pageKey = withoutExt.replace(/\//g, '.');
|
||||
const rawKey = withoutExt.replace(/\//g, '.');
|
||||
|
||||
return pageKey;
|
||||
return PAGE_KEY_ALIASES[rawKey] || rawKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1584
system1-factory/web/js/daily-work-report-mobile.js
Normal file
1584
system1-factory/web/js/daily-work-report-mobile.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -213,6 +213,46 @@ function getUser() {
|
||||
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 작업 목록 렌더링 (날짜별 > 세션별 그룹화)
|
||||
* - 날짜별로 접기/펼치기 가능
|
||||
@@ -422,11 +462,15 @@ function renderTbmWorkList() {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// 근태 기반 자동 시간 채움
|
||||
const defaultHours = tbm.attendance_type ? getDefaultHoursFromAttendance(tbm) : 0;
|
||||
const hasDefaultHours = defaultHours > 0;
|
||||
const attendanceBadgeHtml = tbm.attendance_type ? getAttendanceBadgeHtml(tbm.attendance_type) : '';
|
||||
return `
|
||||
<tr data-index="${index}" data-type="tbm" data-session-key="${key}">
|
||||
<td>
|
||||
<div class="worker-cell">
|
||||
<strong>${tbm.worker_name || '작업자'}</strong>
|
||||
<strong>${tbm.worker_name || '작업자'}</strong>${attendanceBadgeHtml}
|
||||
<div class="worker-job-type">${tbm.job_type || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -440,11 +484,12 @@ function renderTbmWorkList() {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="hidden" id="totalHours_${index}" value="" required>
|
||||
<div class="time-input-trigger placeholder"
|
||||
<input type="hidden" id="totalHours_${index}" value="${hasDefaultHours ? defaultHours : ''}" required>
|
||||
<div class="time-input-trigger ${hasDefaultHours ? '' : 'placeholder'}"
|
||||
id="totalHoursDisplay_${index}"
|
||||
onclick="openTimePicker(${index}, 'total')">
|
||||
시간 선택
|
||||
onclick="openTimePicker(${index}, 'total')"
|
||||
style="${hasDefaultHours ? 'color:#1f2937; font-weight:600;' : ''}">
|
||||
${hasDefaultHours ? formatHoursDisplay(defaultHours) : '시간 선택'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
443
system1-factory/web/js/mobile-dashboard.js
Normal file
443
system1-factory/web/js/mobile-dashboard.js
Normal 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">▼</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">🛠</span>' +
|
||||
'<span class="md-wp-stat-text">작업 ' + tbm.taskCount + '건 · ' + tbm.totalWorkers + '명</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 방문
|
||||
if (visitors) {
|
||||
html += '<div class="md-wp-stat-row">' +
|
||||
'<span class="md-wp-stat-icon">🚪</span>' +
|
||||
'<span class="md-wp-stat-text">방문 ' + visitors.visitCount + '건 · ' + 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">⚠</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">↔</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">▶ 작업</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) + ' · ' + 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">▶ 방문</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) + ' · ' + count + '명';
|
||||
if (purpose) html += ' · ' + 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">▶ 신고</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' ? '🔴' : '⚠';
|
||||
html += '<div class="md-wp-detail-item">';
|
||||
html += '<div class="md-wp-detail-main">' + icon + ' ' + escapeHtml(category);
|
||||
if (desc) html += ' · ' + escapeHtml(desc);
|
||||
html += '</div>';
|
||||
html += '<div class="md-wp-detail-sub"><span class="md-wp-issue-status ' + statusClass + '">' + statusText + '</span>';
|
||||
if (reporter) html += ' → ' + 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">▶ 이동설비</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) + ' → ' + 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();
|
||||
});
|
||||
})();
|
||||
573
system1-factory/web/js/tbm-create.js
Normal file
573
system1-factory/web/js/tbm-create.js
Normal 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 = '다음 →';
|
||||
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">✓</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);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -517,15 +517,19 @@ function createSessionCard(session) {
|
||||
'cancelled': '<span class="tbm-card-status cancelled">취소</span>'
|
||||
}[session.status] || '';
|
||||
|
||||
// 작업 책임자 표시 (leader_name이 있으면 표시, 없으면 created_by_name 표시)
|
||||
const leaderName = escapeHtml(session.leader_name || session.created_by_name || '작업 책임자');
|
||||
const leaderRole = escapeHtml(session.leader_name
|
||||
? (session.leader_job_type || '작업자')
|
||||
: '관리자');
|
||||
const safeSessionId = parseInt(session.session_id) || 0;
|
||||
|
||||
// 카드 클릭 동작: draft → 세부 편집, completed → 상세 보기
|
||||
const onClickAction = session.status === 'draft'
|
||||
? `openTeamCompositionModal(${safeSessionId})`
|
||||
: `viewTbmSession(${safeSessionId})`;
|
||||
|
||||
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-top">
|
||||
<div>
|
||||
@@ -558,7 +562,7 @@ function createSessionCard(session) {
|
||||
</div>
|
||||
<div class="tbm-card-info-item">
|
||||
<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>
|
||||
@@ -566,7 +570,7 @@ function createSessionCard(session) {
|
||||
${session.status === 'draft' ? `
|
||||
<div class="tbm-card-footer">
|
||||
<button class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="event.stopPropagation(); openTeamCompositionModal(${safeSessionId})">
|
||||
👥 수정
|
||||
👥 세부 편집
|
||||
</button>
|
||||
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="event.stopPropagation(); openSafetyCheckModal(${safeSessionId})">
|
||||
✓ 안전 체크
|
||||
@@ -580,10 +584,16 @@ function createSessionCard(session) {
|
||||
`;
|
||||
}
|
||||
|
||||
// 새 TBM 모달 열기
|
||||
// 새 TBM 모달 열기 (간소화: 프로젝트+공정+작업자만)
|
||||
function openNewTbmModal() {
|
||||
if (window.innerWidth <= 768) {
|
||||
window.location.href = '/pages/work/tbm-create.html';
|
||||
return;
|
||||
}
|
||||
currentSessionId = null;
|
||||
workerTaskList = []; // 작업자 목록 초기화
|
||||
workerTaskList = [];
|
||||
selectedWorkersForNewTbm = new Set();
|
||||
todayAssignmentsMap = null; // 배정 현황 캐시 초기화
|
||||
|
||||
document.getElementById('modalTitle').innerHTML = '<span>📝</span> 새 TBM 시작';
|
||||
document.getElementById('sessionId').value = '';
|
||||
@@ -610,19 +620,140 @@ function openNewTbmModal() {
|
||||
document.getElementById('leaderId').value = worker.worker_id;
|
||||
}
|
||||
} else if (currentUser && currentUser.name) {
|
||||
// 관리자: 이름만 표시
|
||||
document.getElementById('leaderName').textContent = currentUser.name;
|
||||
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';
|
||||
lockBodyScroll();
|
||||
}
|
||||
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() {
|
||||
const leaderSelect = document.getElementById('leaderId');
|
||||
@@ -729,13 +860,12 @@ function closeTbmModal() {
|
||||
}
|
||||
window.closeTbmModal = closeTbmModal;
|
||||
|
||||
// TBM 세션 저장 (작업자별 상세 정보 포함)
|
||||
// TBM 세션 저장 (간소화: 프로젝트+공정+작업자, task/workplace=null)
|
||||
async function saveTbmSession() {
|
||||
console.log('💾 TBM 저장 시작...');
|
||||
|
||||
let leaderId = parseInt(document.getElementById('leaderId').value);
|
||||
|
||||
// 관리자 계정인 경우 leader_id를 null로 설정
|
||||
if (!leaderId || isNaN(leaderId)) {
|
||||
if (!currentUser.worker_id) {
|
||||
console.log('📝 관리자 계정: leader_id를 NULL로 설정');
|
||||
@@ -752,37 +882,21 @@ async function saveTbmSession() {
|
||||
leader_id: leaderId
|
||||
};
|
||||
|
||||
console.log('📅 세션 데이터:', sessionData);
|
||||
console.log('👥 작업자 리스트:', workerTaskList);
|
||||
console.log('👤 현재 사용자:', currentUser);
|
||||
|
||||
if (!sessionData.session_date) {
|
||||
console.error('❌ 날짜 누락');
|
||||
showToast('TBM 날짜를 확인해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const editingSessionId = document.getElementById('sessionId').value;
|
||||
|
||||
// 수정 모드일 때는 기존 openTeamCompositionModal의 workerTaskList를 사용
|
||||
if (editingSessionId) {
|
||||
// 기존 수정 모드 로직 (openTeamCompositionModal 경유)
|
||||
if (workerTaskList.length === 0) {
|
||||
console.error('❌ 작업자 리스트가 비어있음');
|
||||
showToast('최소 1명 이상의 작업자를 추가해주세요.', 'error');
|
||||
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 = [];
|
||||
for (const workerData of workerTaskList) {
|
||||
for (const taskLine of workerData.tasks) {
|
||||
@@ -799,19 +913,8 @@ async function saveTbmSession() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📤 전송할 팀 데이터:', members);
|
||||
|
||||
try {
|
||||
const editingSessionId = document.getElementById('sessionId').value;
|
||||
|
||||
if (editingSessionId) {
|
||||
// 수정 모드: 기존 팀원 삭제 후 재등록
|
||||
console.log('📝 TBM 수정 모드:', editingSessionId);
|
||||
|
||||
// 기존 팀원 삭제
|
||||
await window.apiCall(`/tbm/sessions/${editingSessionId}/team/clear`, 'DELETE');
|
||||
|
||||
// 새 팀원 일괄 추가
|
||||
const teamResponse = await window.apiCall(
|
||||
`/tbm/sessions/${editingSessionId}/team/batch`,
|
||||
'POST',
|
||||
@@ -819,29 +922,59 @@ async function saveTbmSession() {
|
||||
);
|
||||
|
||||
if (teamResponse && teamResponse.success) {
|
||||
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
|
||||
showToast(`TBM이 수정되었습니다 (작업자 ${workerTaskList.length}명)`, 'success');
|
||||
closeTbmModal();
|
||||
|
||||
// 목록 새로고침
|
||||
if (currentTab === 'tbm-input') {
|
||||
await loadTodayOnlyTbm();
|
||||
} else {
|
||||
await loadTbmSessionsByDate(sessionData.session_date);
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
} else {
|
||||
throw new Error(teamResponse.message || '팀원 수정에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 생성 모드: 새 TBM 세션 생성
|
||||
console.log('✨ TBM 생성 모드');
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 세션 수정 오류:', error);
|
||||
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);
|
||||
|
||||
if (response && response.success) {
|
||||
const createdSessionId = response.data.session_id;
|
||||
console.log('✅ TBM 세션 생성 완료:', createdSessionId);
|
||||
|
||||
// 작업자 일괄 추가
|
||||
const teamResponse = await window.apiCall(
|
||||
`/tbm/sessions/${createdSessionId}/team/batch`,
|
||||
'POST',
|
||||
@@ -849,14 +982,13 @@ async function saveTbmSession() {
|
||||
);
|
||||
|
||||
if (teamResponse && teamResponse.success) {
|
||||
showToast(`TBM이 생성되었습니다 (작업자 ${workerTaskList.length}명, 작업 ${members.length}건)`, 'success');
|
||||
showToast(`TBM이 생성되었습니다 (작업자 ${members.length}명)`, 'success');
|
||||
closeTbmModal();
|
||||
|
||||
// 목록 새로고침
|
||||
if (currentTab === 'tbm-input') {
|
||||
await loadTodayOnlyTbm();
|
||||
} else {
|
||||
await loadTbmSessionsByDate(sessionData.session_date);
|
||||
await loadRecentTbmGroupedByDate();
|
||||
}
|
||||
} else {
|
||||
throw new Error(teamResponse.message || '팀원 추가에 실패했습니다.');
|
||||
@@ -864,7 +996,6 @@ async function saveTbmSession() {
|
||||
} else {
|
||||
throw new Error(response.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 세션 저장 오류:', error);
|
||||
showToast('TBM 세션 저장 중 오류가 발생했습니다.', 'error');
|
||||
@@ -1502,6 +1633,11 @@ window.openWorkplaceSelect = openWorkplaceSelect;
|
||||
|
||||
// 작업장 선택 모달 닫기
|
||||
function closeWorkplaceSelectModal() {
|
||||
// 가로모드 오버레이도 닫기
|
||||
const landscapeOverlay = document.getElementById('landscapeOverlay');
|
||||
if (landscapeOverlay && landscapeOverlay.style.display !== 'none') {
|
||||
closeLandscapeMap();
|
||||
}
|
||||
document.getElementById('workplaceSelectModal').style.display = 'none';
|
||||
unlockBodyScroll();
|
||||
document.getElementById('workplaceSelectionArea').style.display = 'none';
|
||||
@@ -1578,6 +1714,9 @@ async function selectCategory(categoryId, categoryName) {
|
||||
document.getElementById('workplaceListSection').style.display = 'none';
|
||||
document.getElementById('toggleListBtn').style.display = 'inline-flex';
|
||||
document.getElementById('toggleListBtn').textContent = '리스트로 선택';
|
||||
// 전체화면 지도 버튼 표시
|
||||
const triggerBtn = document.getElementById('landscapeTriggerBtn');
|
||||
if (triggerBtn) triggerBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
// 데스크톱: 리스트도 함께 표시
|
||||
document.getElementById('workplaceList').style.display = 'flex';
|
||||
@@ -1876,6 +2015,178 @@ function syncWorkplaceListSelection(workplaceId) {
|
||||
}
|
||||
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;
|
||||
|
||||
// TBM 완료 모달용 팀원 데이터
|
||||
let completeModalTeam = [];
|
||||
|
||||
// TBM 완료 모달 열기
|
||||
function openCompleteTbmModal(sessionId) {
|
||||
async function openCompleteTbmModal(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
const now = new Date();
|
||||
const timeString = now.toTimeString().slice(0, 5);
|
||||
@@ -2283,9 +2597,75 @@ function openCompleteTbmModal(sessionId) {
|
||||
|
||||
document.getElementById('completeModal').style.display = 'flex';
|
||||
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;
|
||||
|
||||
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() {
|
||||
document.getElementById('completeModal').style.display = 'none';
|
||||
@@ -2297,11 +2677,37 @@ window.closeCompleteModal = closeCompleteModal;
|
||||
async function completeTbmSession() {
|
||||
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 {
|
||||
const response = await window.apiCall(
|
||||
`/tbm/sessions/${currentSessionId}/complete`,
|
||||
'POST',
|
||||
{ end_time: endTime }
|
||||
{ end_time: endTime, attendance_data: attendanceData }
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
@@ -2321,6 +2727,8 @@ async function completeTbmSession() {
|
||||
} catch (error) {
|
||||
console.error('❌ TBM 완료 처리 오류:', error);
|
||||
showToast('TBM 완료 처리 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="tbm-btn-icon">✓</span> 완료'; }
|
||||
}
|
||||
}
|
||||
window.completeTbmSession = completeTbmSession;
|
||||
@@ -2365,8 +2773,8 @@ async function viewTbmSession(sessionId) {
|
||||
<div style="font-weight: 600; color: #111827;">${escapeHtml(statusText)}</div>
|
||||
</div>
|
||||
<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-weight: 600; color: #111827;">${parseInt(session.team_member_count) || team.length}명</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;">${escapeHtml(session.team_member_names || team.map(t => t.worker_name).join(', ') || '없음')}</div>
|
||||
</div>
|
||||
${session.project_name ? `
|
||||
<div style="padding: 0.75rem; background: #f9fafb; border-radius: 0.375rem;">
|
||||
|
||||
@@ -214,10 +214,11 @@ class TbmAPI {
|
||||
if (!this.state.isAdminUser()) {
|
||||
const userId = this.state.currentUser?.user_id;
|
||||
const workerId = this.state.currentUser?.worker_id;
|
||||
const userName = this.state.currentUser?.name;
|
||||
sessions = sessions.filter(s => {
|
||||
return s.created_by === userId ||
|
||||
s.leader_id === workerId ||
|
||||
s.created_by_name === this.state.currentUser?.name;
|
||||
return (userId && String(s.created_by) === String(userId)) ||
|
||||
(workerId && String(s.leader_id) === String(workerId)) ||
|
||||
(userName && s.created_by_name === userName);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,8 @@ class TbmState {
|
||||
isAdminUser() {
|
||||
const user = this.getUser();
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
|
||||
<!-- JavaScript -->
|
||||
<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="/js/admin-settings.js?v=9"></script>
|
||||
</body>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.comparison-grid {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.department-grid {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/equipment-detail.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/equipment-management.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.type-tabs {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.notification-page-container {
|
||||
|
||||
@@ -367,7 +367,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
let allProjects = [];
|
||||
let filteredProjects = [];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<style>
|
||||
.repair-page {
|
||||
max-width: 1400px;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<style>
|
||||
.page-wrapper { padding: 1rem 1.5rem; max-width: 1400px; }
|
||||
.page-header {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/workplace-management.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 테이블 컨테이너 */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<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 type="module" src="/js/vacation-allocation.js" defer></script>
|
||||
</head>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.tabs {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<style>
|
||||
.page-wrapper {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -10,20 +10,21 @@
|
||||
<!-- preconnect는 Gateway 프록시 사용 시 불필요 -->
|
||||
<link rel="preload" href="/css/design-system.css" as="style">
|
||||
<link rel="preload" href="/js/api-base.js" as="script">
|
||||
<link rel="preload" href="/js/app-init.js?v=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/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">
|
||||
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<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/group-leader-dashboard.js?v=1" defer></script>
|
||||
<script src="/js/workplace-status.js" defer></script>
|
||||
<script src="/js/mobile-dashboard.js?v=3" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
@@ -38,6 +39,20 @@
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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">
|
||||
<div class="card">
|
||||
@@ -56,7 +71,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 네비게이션 바 -->
|
||||
|
||||
@@ -628,7 +628,7 @@
|
||||
</div>
|
||||
|
||||
<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 type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.status-tabs {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 스텝 인디케이터 */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.training-container {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
.visit-form-container {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=41">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=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://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
<style>
|
||||
/* 통계 카드 */
|
||||
|
||||
194
system1-factory/web/pages/work/report-create-mobile.html
Normal file
194
system1-factory/web/pages/work/report-create-mobile.html
Normal 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()">×</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()">×</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()">×</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()">×</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>
|
||||
@@ -8,9 +8,15 @@
|
||||
<link rel="stylesheet" href="/css/daily-work-report.css?v=12">
|
||||
<link rel="stylesheet" href="/css/mobile.css?v=1">
|
||||
<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/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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -169,7 +175,7 @@
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<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>
|
||||
|
||||
<!-- 작업보고서 모듈 (리팩토링된 구조) -->
|
||||
@@ -178,7 +184,7 @@
|
||||
<script src="/js/daily-work-report/api.js?v=1"></script>
|
||||
|
||||
<!-- 기존 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>
|
||||
|
||||
823
system1-factory/web/pages/work/tbm-create.html
Normal file
823
system1-factory/web/pages/work/tbm-create.html
Normal 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()">←</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;">← 이전</button>
|
||||
<button type="button" class="nav-btn nav-btn-next" id="nextBtn" onclick="nextStep()">다음 →</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>
|
||||
2212
system1-factory/web/pages/work/tbm-mobile.html
Normal file
2212
system1-factory/web/pages/work/tbm-mobile.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
|
||||
<script src="/js/api-base.js"></script>
|
||||
<script src="/js/app-init.js?v=3" defer></script>
|
||||
<script src="/js/app-init.js?v=5" defer></script>
|
||||
<!-- instant.page: 링크 호버 시 페이지 프리로딩 -->
|
||||
<script src="https://instant.page/5.2.0" type="module"></script>
|
||||
</head>
|
||||
@@ -144,9 +144,9 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- TBM 생성/수정 모달 -->
|
||||
<!-- TBM 생성 모달 (간소화) -->
|
||||
<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">
|
||||
<h2 class="tbm-modal-title" id="modalTitle">
|
||||
<span>📝</span>
|
||||
@@ -159,7 +159,7 @@
|
||||
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
|
||||
<input type="hidden" id="sessionId">
|
||||
|
||||
<!-- 고정 정보 섹션 -->
|
||||
<!-- 기본 정보 섹션 -->
|
||||
<div class="tbm-form-section">
|
||||
<h3 class="tbm-form-section-title">
|
||||
<span>📅</span>
|
||||
@@ -177,32 +177,44 @@
|
||||
<input type="hidden" id="leaderId">
|
||||
</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 class="tbm-form-section">
|
||||
<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;">
|
||||
<span>👥</span>
|
||||
작업자 및 작업 정보
|
||||
작업자 선택
|
||||
<span id="newTbmWorkerCount" style="color: #3b82f6; font-size: 0.875rem;">(0명)</span>
|
||||
</h3>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="openBulkSettingModal()">
|
||||
일괄 설정
|
||||
</button>
|
||||
<button type="button" class="tbm-btn tbm-btn-primary tbm-btn-sm" onclick="openWorkerSelectionModal()">
|
||||
<span class="tbm-btn-icon">+</span>
|
||||
작업자 선택
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 카드 리스트 -->
|
||||
<div id="workerTaskList" class="tbm-worker-list">
|
||||
<!-- 작업자 카드들이 여기에 동적으로 추가됩니다 -->
|
||||
<div class="tbm-empty-state" id="workerListEmpty" style="padding: 2rem; border: 2px dashed #d1d5db; border-radius: 10px;">
|
||||
<div class="tbm-empty-icon">👥</div>
|
||||
<p class="tbm-empty-description" style="margin: 0;">작업자를 선택해주세요</p>
|
||||
<div id="newTbmWorkerGrid" class="tbm-worker-select-grid">
|
||||
<!-- 작업자 체크박스 그리드가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="tbm-alert tbm-alert-info" style="margin-top: 1rem;">
|
||||
<span class="tbm-alert-icon">💡</span>
|
||||
<div class="tbm-alert-content">
|
||||
<div class="tbm-alert-text">저장 후 카드를 클릭하면 작업자별 <strong>작업/작업장</strong>을 입력할 수 있습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,6 +412,9 @@
|
||||
<div class="tbm-workplace-map-container">
|
||||
<canvas id="workplaceMapCanvas"></canvas>
|
||||
</div>
|
||||
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
|
||||
📺 전체화면 지도
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 기반 선택 (모바일에서 토글) -->
|
||||
@@ -525,11 +540,18 @@
|
||||
<label class="tbm-form-label">종료 시간</label>
|
||||
<input type="time" id="endTime" class="tbm-form-input">
|
||||
</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 class="tbm-modal-footer">
|
||||
<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">✓</span>
|
||||
완료
|
||||
</button>
|
||||
@@ -657,6 +679,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 가로모드 전체화면 지도 오버레이 -->
|
||||
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
|
||||
<div id="landscapeInner" class="landscape-inner">
|
||||
<div class="landscape-header">
|
||||
<h3>🏭 작업장 선택</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>
|
||||
@@ -667,7 +702,7 @@
|
||||
<script src="/js/tbm/api.js?v=1"></script>
|
||||
|
||||
<!-- 기존 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>
|
||||
|
||||
@@ -333,6 +333,12 @@ function renderMap() {
|
||||
const visitorCount = visitors.reduce((sum, v) => sum + (v.visitor_count || 0), 0);
|
||||
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();
|
||||
}
|
||||
|
||||
// ==================== 가로모드 전체화면 지도 ====================
|
||||
|
||||
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 등록 작업 (해당 위치)
|
||||
|
||||
@@ -460,6 +460,102 @@
|
||||
#submitBtn:disabled { background: #d1d5db; cursor: not-allowed; }
|
||||
#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 */
|
||||
@media (min-width: 480px) {
|
||||
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
|
||||
@@ -514,6 +610,9 @@
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
|
||||
📺 전체화면 지도로 선택
|
||||
</button>
|
||||
<div id="selectedLocationInfo" class="location-info empty">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
@@ -569,6 +668,19 @@
|
||||
<button type="button" id="submitBtn" disabled onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
|
||||
<!-- 가로모드 전체화면 지도 오버레이 -->
|
||||
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
|
||||
<div id="landscapeInner" class="landscape-inner">
|
||||
<div class="landscape-header">
|
||||
<h3>🏭 작업장 선택</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 -->
|
||||
<input type="file" id="photoInput" accept="image/*" style="display:none">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user