feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

@@ -182,8 +182,10 @@ class WorkAnalysis {
// 최근 작업 현황
async getRecentWork(startDate, endDate, limit = 50) {
// work_type_id가 work_types에 있으면 직접 사용,
// 없으면 tasks 테이블을 통해 해당 task의 work_type_id로 공정(대분류) 조회
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정(대분류) 조회
// 매칭 안 되면 직접 work_types 테이블 조회 (레거시 데이터 호환)
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
const query = `
SELECT
dwr.id,
@@ -194,13 +196,20 @@ class WorkAnalysis {
p.project_name,
p.job_no,
dwr.work_type_id as original_work_type_id,
COALESCE(wt.id, t.work_type_id) as work_type_id,
COALESCE(wt.name, wt2.name) as work_type_name,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
) as work_type_name,
t.task_name as task_name,
dwr.work_status_id,
wst.name as work_status_name,
dwr.error_type_id,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
dwr.work_hours,
dwr.created_by,
u.name as created_by_name,
@@ -212,7 +221,8 @@ class WorkAnalysis {
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
WHERE dwr.report_date BETWEEN ? AND ?
ORDER BY dwr.created_at DESC
@@ -236,6 +246,7 @@ class WorkAnalysis {
work_status_name: row.work_status_name || '정상',
error_type_id: row.error_type_id,
error_type_name: row.error_type_name || null,
error_category_name: row.error_category_name || null,
work_hours: parseFloat(row.work_hours) || 0,
created_by: row.created_by,
created_by_name: row.created_by_name || '미지정',
@@ -286,20 +297,23 @@ class WorkAnalysis {
}
// 에러 분석
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
async getErrorAnalysis(startDate, endDate) {
const query = `
SELECT
SELECT
dwr.error_type_id,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as affected_workers,
COUNT(DISTINCT dwr.project_id) as affected_projects
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.report_date BETWEEN ? AND ?
AND dwr.work_status_id = 2
GROUP BY dwr.error_type_id, et.name
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name
ORDER BY error_count DESC
`;
@@ -308,6 +322,7 @@ class WorkAnalysis {
return results.map(row => ({
error_type_id: row.error_type_id,
error_type_name: row.error_type_name || `에러유형 ${row.error_type_id}`,
error_category_name: row.error_category_name || null,
errorCount: parseInt(row.error_count) || 0,
totalHours: parseFloat(row.total_hours) || 0,
affectedworkers: parseInt(row.affected_workers) || 0,
@@ -436,14 +451,23 @@ class WorkAnalysis {
}
// 프로젝트별-작업별 시간 분석용 데이터 조회 (공정/대분류 기준)
async getProjectWorkTypeRawData(startDate, endDate) {
// work_type_id가 실제로 task_id인 경우 해당 task의 work_type_id로 공정 조회
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정 조회
// 매칭 안 되면 직접 work_types 조회 (레거시 데이터 호환)
const query = `
SELECT
COALESCE(p.project_id, dwr.project_id) as project_id,
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
COALESCE(p.job_no, 'N/A') as job_no,
COALESCE(wt.id, t.work_type_id) as work_type_id,
COALESCE(wt.name, wt2.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name,
CONCAT('작업유형 ', dwr.work_type_id)
) as work_type_name,
-- 총 시간
SUM(dwr.work_hours) as total_hours,
@@ -472,8 +496,14 @@ class WorkAnalysis {
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name, p.job_no,
COALESCE(wt.id, t.work_type_id),
COALESCE(wt.name, wt2.name)
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
),
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
)
ORDER BY p.project_name, work_type_name
`;

View File

@@ -432,21 +432,23 @@ class AttendanceModel {
static async getMonthlyAttendanceStats(year, month, workerId = null) {
const db = await getDb();
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
// vacation_types: 1=ANNUAL(연차), 2=HALF_ANNUAL(반차), 3=SICK(병가), 4=SPECIAL(경조사)
let query = `
SELECT
SELECT
w.worker_id,
w.worker_name,
COUNT(CASE WHEN dar.status = 'complete' THEN 1 END) as regular_days,
COUNT(CASE WHEN dar.status = 'overtime' THEN 1 END) as overtime_days,
COUNT(CASE WHEN dar.status = 'vacation' THEN 1 END) as vacation_days,
COUNT(CASE WHEN dar.status = 'partial' THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.status = 'incomplete' THEN 1 END) as incomplete_days,
SUM(dar.total_work_hours) as total_work_hours,
AVG(dar.total_work_hours) as avg_work_hours
COUNT(CASE WHEN dar.attendance_type_id = 1 AND (dar.is_overtime_approved = 0 OR dar.is_overtime_approved IS NULL) THEN 1 END) as regular_days,
COUNT(CASE WHEN dar.is_overtime_approved = 1 OR dar.total_work_hours > 8 THEN 1 END) as overtime_days,
COUNT(CASE WHEN dar.attendance_type_id = 5 AND dar.vacation_type_id = 1 THEN 1 END) as vacation_days,
COUNT(CASE WHEN dar.vacation_type_id = 2 THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.attendance_type_id = 4 THEN 1 END) as incomplete_days,
COALESCE(SUM(dar.total_work_hours), 0) as total_work_hours,
COALESCE(AVG(dar.total_work_hours), 0) as avg_work_hours
FROM workers w
LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
WHERE w.is_active = TRUE
WHERE w.employment_status = 'employed'
`;
const params = [year, month];

View File

@@ -29,7 +29,21 @@ const getAllWorkStatusTypes = async (callback) => {
const getAllErrorTypes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description, severity, solution_guide, created_at, updated_at FROM error_types ORDER BY name ASC');
// issue_report_items에서 부적합(nonconformity) 타입의 항목만 조회
const [rows] = await db.query(`
SELECT
iri.item_id as id,
iri.item_name as name,
iri.description,
iri.severity,
irc.category_name as category,
iri.display_order,
iri.created_at
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity' AND iri.is_active = TRUE
ORDER BY irc.display_order, iri.display_order, iri.item_name ASC
`);
callback(null, rows);
} catch (err) {
console.error('에러 유형 조회 오류:', err);
@@ -301,9 +315,10 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => {
/**
* 공통 SELECT 쿼리 부분
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/
const getSelectQuery = () => `
SELECT
SELECT
dwr.id,
dwr.report_date,
dwr.worker_id,
@@ -317,7 +332,8 @@ const getSelectQuery = () => `
p.project_name,
wt.name as work_type_name,
wst.name as work_status_name,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name,
dwr.created_at,
dwr.updated_at
@@ -326,7 +342,8 @@ const getSelectQuery = () => `
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
`;
@@ -873,9 +890,11 @@ const createReportEntries = async ({ report_date, worker_id, entries }) => {
/**
* [V2] 공통 SELECT 쿼리 (새로운 스키마 기준)
* 주의: work_type_id 컬럼에는 실제로 task_id가 저장됨
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/
const getSelectQueryV2 = () => `
SELECT
SELECT
dwr.id,
dwr.report_date,
dwr.worker_id,
@@ -887,17 +906,21 @@ const getSelectQueryV2 = () => `
dwr.created_by,
w.worker_name,
p.project_name,
t.task_name,
wt.name as work_type_name,
wst.name as work_status_name,
et.name as error_type_name,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name,
dwr.created_at
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id
`;
@@ -967,9 +990,9 @@ const updateReportById = async (reportId, updateData) => {
}
}
// updated_by_user_id는 항상 업데이트
// updated_by는 항상 업데이트
if (updateData.updated_by_user_id) {
setClauses.push('updated_by_user_id = ?');
setClauses.push('updated_by = ?');
queryParams.push(updateData.updated_by_user_id);
}

View File

@@ -58,7 +58,7 @@ const getActiveTasks = async () => {
};
/**
* 공정별 작업 목록 조회
* 공정별 작업 목록 조회 (활성 작업만)
*/
const getTasksByWorkType = async (workTypeId) => {
const db = await getDb();
@@ -68,8 +68,8 @@ const getTasksByWorkType = async (workTypeId) => {
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.work_type_id = ?
ORDER BY t.task_id DESC`,
WHERE t.work_type_id = ? AND t.is_active = 1
ORDER BY t.task_name ASC`,
[workTypeId]
);
return rows;

View File

@@ -260,6 +260,118 @@ const vacationBalanceModel = {
return Math.min(15 + additionalDays, 25);
},
/**
* 휴가 사용 시 우선순위에 따라 잔액에서 차감 (Promise 버전)
* - 일일 근태 기록 저장 시 호출
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToDeduct - 차감할 일수 (1, 0.5, 0.25)
* @returns {Promise<Object>} - 차감 결과
*/
async deductByPriority(workerId, year, daysToDeduct) {
const db = await getDb();
// 우선순위순으로 잔여 일수가 있는 잔액 조회
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days,
(vbd.total_days - vbd.used_days) as remaining_days,
vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
AND (vbd.total_days - vbd.used_days) > 0
ORDER BY vt.priority ASC
`, [workerId, year]);
if (balances.length === 0) {
// 잔액이 없어도 일단 기록은 저장 (경고만)
console.warn(`[VacationBalance] 작업자 ${workerId}${year}년 휴가 잔액이 없습니다`);
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
}
let remaining = daysToDeduct;
const deductions = [];
for (const balance of balances) {
if (remaining <= 0) break;
const available = parseFloat(balance.remaining_days);
const toDeduct = Math.min(remaining, available);
if (toDeduct > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days + ?, updated_at = NOW()
WHERE id = ?
`, [toDeduct, balance.id]);
deductions.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
deducted: toDeduct
});
remaining -= toDeduct;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToDeduct}일 차감 완료`, deductions);
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
},
/**
* 휴가 취소 시 우선순위 역순으로 복구 (Promise 버전)
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToRestore - 복구할 일수
* @returns {Promise<Object>} - 복구 결과
*/
async restoreByPriority(workerId, year, daysToRestore) {
const db = await getDb();
// 우선순위 역순으로 사용 일수가 있는 잔액 조회 (나중에 차감된 것부터 복구)
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days,
vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
AND vbd.used_days > 0
ORDER BY vt.priority DESC
`, [workerId, year]);
let remaining = daysToRestore;
const restorations = [];
for (const balance of balances) {
if (remaining <= 0) break;
const usedDays = parseFloat(balance.used_days);
const toRestore = Math.min(remaining, usedDays);
if (toRestore > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days - ?, updated_at = NOW()
WHERE id = ?
`, [toRestore, balance.id]);
restorations.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
restored: toRestore
});
remaining -= toRestore;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToRestore}일 복구 완료`, restorations);
return { success: true, restorations, totalRestored: daysToRestore - remaining };
},
/**
* 특정 ID로 휴가 잔액 조회
*/