두 개의 복잡한 분석 컨트롤러를 현대적인 패턴으로 전면 개선: ## workReportAnalysisController.js (381 → 430 lines) - 7개 SQL 쿼리 기반 복합 분석 엔드포인트 개선 - console.error → logger.info/error/warn 전환 - try-catch → asyncHandler 미들웨어 적용 - Error → ValidationError, DatabaseError 전환 - JSDoc 문서화 및 구조화된 로깅 추가 - 4개 함수: getAnalysisFilters, getAnalyticsByPeriod, getProjectAnalysis, getWorkerAnalysis ## workAnalysisController.js (523 → 622 lines) - 클래스 기반 → 함수 기반 컨트롤러 전환 - console.error → logger.info/error/debug 전환 - try-catch → asyncHandler 미들웨어 적용 - Error → ValidationError, DatabaseError 전환 - validateDateRange 헬퍼 함수 개선 (상세한 에러 컨텍스트) - JSDoc 문서화 및 구조화된 로깅 추가 - 12개 함수: getStats, getDailyTrend, getWorkerStats, getProjectStats, getWorkTypeStats, getRecentWork, getWeekdayPattern, getErrorAnalysis, getMonthlyComparison, getWorkerSpecialization, getDashboardData, getProjectWorkTypeAnalysis ## 기술적 개선사항 - 통합 에러 처리: 커스텀 에러 클래스로 일관된 에러 핸들링 - 구조화된 로깅: 모든 API 호출에 컨텍스트 정보 포함 - 자동 에러 전파: asyncHandler로 보일러플레이트 코드 제거 - 향상된 유효성 검사: 상세한 에러 메시지와 컨텍스트 - 프로덕션 준비: 표준화된 응답 형식 및 에러 처리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
622 lines
18 KiB
JavaScript
622 lines
18 KiB
JavaScript
/**
|
|
* 작업 분석 컨트롤러
|
|
*
|
|
* 작업 보고서 다차원 분석 API 엔드포인트 핸들러
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2025-12-11
|
|
*/
|
|
|
|
const WorkAnalysis = require('../models/WorkAnalysis');
|
|
const { getDb } = require('../dbPool');
|
|
const { ValidationError, DatabaseError } = require('../utils/errors');
|
|
const { asyncHandler } = require('../middlewares/errorHandler');
|
|
const logger = require('../utils/logger');
|
|
|
|
/**
|
|
* 날짜 유효성 검사 헬퍼 함수
|
|
*/
|
|
const validateDateRange = (startDate, endDate) => {
|
|
if (!startDate || !endDate) {
|
|
throw new ValidationError('시작일과 종료일을 입력해주세요', {
|
|
required: ['start', 'end'],
|
|
received: { start: startDate, end: endDate }
|
|
});
|
|
}
|
|
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
|
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
|
throw new ValidationError('올바른 날짜 형식을 입력해주세요', {
|
|
format: 'YYYY-MM-DD',
|
|
received: { start: startDate, end: endDate }
|
|
});
|
|
}
|
|
|
|
if (start > end) {
|
|
throw new ValidationError('시작일이 종료일보다 늦을 수 없습니다', {
|
|
start: startDate,
|
|
end: endDate
|
|
});
|
|
}
|
|
|
|
// 너무 긴 기간 방지 (1년 제한)
|
|
const diffTime = Math.abs(end - start);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
if (diffDays > 365) {
|
|
throw new ValidationError('조회 기간은 1년을 초과할 수 없습니다', {
|
|
days: diffDays,
|
|
max: 365
|
|
});
|
|
}
|
|
|
|
return { start, end };
|
|
};
|
|
|
|
/**
|
|
* 기본 통계 조회
|
|
*/
|
|
const getStats = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('기본 통계 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const stats = await workAnalysis.getBasicStats(start, end);
|
|
|
|
logger.info('기본 통계 조회 성공', { start, end });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: stats,
|
|
message: '기본 통계 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('기본 통계 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('기본 통계 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 일별 작업시간 추이 조회
|
|
*/
|
|
const getDailyTrend = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('일별 추이 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const trendData = await workAnalysis.getDailyTrend(start, end);
|
|
|
|
logger.info('일별 추이 조회 성공', { start, end, dataPoints: trendData.length });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: trendData,
|
|
message: '일별 추이 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('일별 추이 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('일별 추이 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 작업자별 통계 조회
|
|
*/
|
|
const getWorkerStats = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('작업자별 통계 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const workerStats = await workAnalysis.getWorkerStats(start, end);
|
|
|
|
logger.info('작업자별 통계 조회 성공', {
|
|
start,
|
|
end,
|
|
workerCount: workerStats.length
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: workerStats,
|
|
message: '작업자별 통계 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('작업자별 통계 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('작업자별 통계 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 프로젝트별 통계 조회
|
|
*/
|
|
const getProjectStats = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('프로젝트별 통계 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const projectStats = await workAnalysis.getProjectStats(start, end);
|
|
|
|
logger.info('프로젝트별 통계 조회 성공', {
|
|
start,
|
|
end,
|
|
projectCount: projectStats.length
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: projectStats,
|
|
message: '프로젝트별 통계 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('프로젝트별 통계 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('프로젝트별 통계 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 작업유형별 통계 조회
|
|
*/
|
|
const getWorkTypeStats = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('작업유형별 통계 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const workTypeStats = await workAnalysis.getWorkTypeStats(start, end);
|
|
|
|
logger.info('작업유형별 통계 조회 성공', {
|
|
start,
|
|
end,
|
|
workTypeCount: workTypeStats.length
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: workTypeStats,
|
|
message: '작업유형별 통계 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('작업유형별 통계 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('작업유형별 통계 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 최근 작업 현황 조회
|
|
*/
|
|
const getRecentWork = asyncHandler(async (req, res) => {
|
|
const { start, end, limit = 10 } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
// limit 유효성 검사 (최대 5000까지 허용)
|
|
const limitNum = parseInt(limit);
|
|
if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) {
|
|
throw new ValidationError('limit은 1~5000 사이의 숫자여야 합니다', {
|
|
received: limit,
|
|
min: 1,
|
|
max: 5000
|
|
});
|
|
}
|
|
|
|
logger.info('최근 작업 현황 조회 요청', { start, end, limit: limitNum });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const recentWork = await workAnalysis.getRecentWork(start, end, limitNum);
|
|
|
|
logger.info('최근 작업 현황 조회 성공', {
|
|
start,
|
|
end,
|
|
limit: limitNum,
|
|
resultCount: recentWork.length
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: recentWork,
|
|
message: '최근 작업 현황 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('최근 작업 현황 조회 실패', {
|
|
start,
|
|
end,
|
|
limit: limitNum,
|
|
error: error.message
|
|
});
|
|
throw new DatabaseError('최근 작업 현황 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 요일별 패턴 분석 조회
|
|
*/
|
|
const getWeekdayPattern = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('요일별 패턴 분석 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end);
|
|
|
|
logger.info('요일별 패턴 분석 성공', { start, end });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: weekdayPattern,
|
|
message: '요일별 패턴 분석 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('요일별 패턴 분석 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('요일별 패턴 분석 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 에러 분석 조회
|
|
*/
|
|
const getErrorAnalysis = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('에러 분석 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end);
|
|
|
|
logger.info('에러 분석 성공', { start, end });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: errorAnalysis,
|
|
message: '에러 분석 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('에러 분석 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('에러 분석 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 월별 비교 분석 조회
|
|
*/
|
|
const getMonthlyComparison = asyncHandler(async (req, res) => {
|
|
const { year = new Date().getFullYear() } = req.query;
|
|
|
|
const yearNum = parseInt(year);
|
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) {
|
|
throw new ValidationError('올바른 연도를 입력해주세요', {
|
|
received: year,
|
|
min: 2000,
|
|
max: 2050
|
|
});
|
|
}
|
|
|
|
logger.info('월별 비교 분석 요청', { year: yearNum });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const monthlyData = await workAnalysis.getMonthlyComparison(yearNum);
|
|
|
|
logger.info('월별 비교 분석 성공', { year: yearNum });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: monthlyData,
|
|
message: '월별 비교 분석 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('월별 비교 분석 실패', { year: yearNum, error: error.message });
|
|
throw new DatabaseError('월별 비교 분석 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 작업자별 전문분야 분석 조회
|
|
*/
|
|
const getWorkerSpecialization = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('작업자별 전문분야 분석 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
const specializationData = await workAnalysis.getWorkerSpecialization(start, end);
|
|
|
|
// 작업자별로 그룹화하여 정리
|
|
const groupedData = specializationData.reduce((acc, item) => {
|
|
if (!acc[item.worker_id]) {
|
|
acc[item.worker_id] = [];
|
|
}
|
|
acc[item.worker_id].push({
|
|
work_type_id: item.work_type_id,
|
|
project_id: item.project_id,
|
|
totalHours: item.totalHours,
|
|
totalReports: item.totalReports,
|
|
percentage: item.percentage
|
|
});
|
|
return acc;
|
|
}, {});
|
|
|
|
logger.info('작업자별 전문분야 분석 성공', {
|
|
start,
|
|
end,
|
|
workerCount: Object.keys(groupedData).length
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: groupedData,
|
|
message: '작업자별 전문분야 분석 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('작업자별 전문분야 분석 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('작업자별 전문분야 분석 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 대시보드용 종합 데이터 조회
|
|
*/
|
|
const getDashboardData = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('대시보드 데이터 조회 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
const workAnalysis = new WorkAnalysis(db);
|
|
|
|
// 병렬로 여러 데이터 조회
|
|
const [
|
|
stats,
|
|
dailyTrend,
|
|
workerStats,
|
|
projectStats,
|
|
workTypeStats,
|
|
recentWork
|
|
] = await Promise.all([
|
|
workAnalysis.getBasicStats(start, end),
|
|
workAnalysis.getDailyTrend(start, end),
|
|
workAnalysis.getWorkerStats(start, end),
|
|
workAnalysis.getProjectStats(start, end),
|
|
workAnalysis.getWorkTypeStats(start, end),
|
|
workAnalysis.getRecentWork(start, end, 10)
|
|
]);
|
|
|
|
logger.info('대시보드 데이터 조회 성공', { start, end });
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
stats,
|
|
dailyTrend,
|
|
workerStats,
|
|
projectStats,
|
|
workTypeStats,
|
|
recentWork
|
|
},
|
|
message: '대시보드 데이터 조회 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('대시보드 데이터 조회 실패', { start, end, error: error.message });
|
|
throw new DatabaseError('대시보드 데이터 조회 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
|
|
*/
|
|
const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
|
|
const { start, end } = req.query;
|
|
validateDateRange(start, end);
|
|
|
|
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
|
|
|
|
try {
|
|
const db = await getDb();
|
|
|
|
// 먼저 데이터 존재 여부 확인
|
|
const testQuery = `
|
|
SELECT
|
|
COUNT(*) as total_count,
|
|
MIN(report_date) as min_date,
|
|
MAX(report_date) as max_date,
|
|
SUM(work_hours) as total_hours
|
|
FROM daily_work_reports
|
|
WHERE report_date BETWEEN ? AND ?
|
|
`;
|
|
|
|
const testResults = await db.query(testQuery, [start, end]);
|
|
logger.debug('데이터 확인', {
|
|
start,
|
|
end,
|
|
count: testResults[0][0]?.total_count
|
|
});
|
|
|
|
// 먼저 간단한 테스트 쿼리로 데이터 확인
|
|
const simpleQuery = `
|
|
SELECT COUNT(*) as count, MIN(report_date) as min_date, MAX(report_date) as max_date
|
|
FROM daily_work_reports
|
|
WHERE report_date BETWEEN ? AND ?
|
|
`;
|
|
|
|
const simpleResult = await db.query(simpleQuery, [start, end]);
|
|
logger.debug('기간 내 데이터 확인', {
|
|
start,
|
|
end,
|
|
result: simpleResult[0][0]
|
|
});
|
|
|
|
// 프로젝트별-작업별 시간 분석 쿼리 (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,
|
|
dwr.work_type_id,
|
|
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
|
|
|
|
-- 총 시간
|
|
SUM(dwr.work_hours) as total_hours,
|
|
|
|
-- 정규 시간 (work_status_id = 1)
|
|
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
|
|
|
|
-- 에러 시간 (work_status_id = 2)
|
|
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
|
|
|
|
-- 작업 건수
|
|
COUNT(*) as total_reports,
|
|
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
|
|
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
|
|
|
|
-- 에러율 계산
|
|
ROUND(
|
|
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
|
|
SUM(dwr.work_hours)) * 100, 2
|
|
) as error_rate_percent
|
|
|
|
FROM daily_work_reports dwr
|
|
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
|
WHERE dwr.report_date BETWEEN ? AND ?
|
|
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name
|
|
ORDER BY p.project_name, wt.name
|
|
`;
|
|
|
|
const results = await db.query(query, [start, end]);
|
|
logger.debug('쿼리 결과', {
|
|
start,
|
|
end,
|
|
resultCount: results[0].length
|
|
});
|
|
|
|
// 데이터를 프로젝트별로 그룹화
|
|
const groupedData = {};
|
|
|
|
results[0].forEach(row => {
|
|
const projectKey = `${row.project_id}_${row.project_name}`;
|
|
|
|
if (!groupedData[projectKey]) {
|
|
groupedData[projectKey] = {
|
|
project_id: row.project_id,
|
|
project_name: row.project_name,
|
|
job_no: row.job_no,
|
|
total_project_hours: 0,
|
|
total_regular_hours: 0,
|
|
total_error_hours: 0,
|
|
work_types: []
|
|
};
|
|
}
|
|
|
|
// 프로젝트 총계 누적
|
|
groupedData[projectKey].total_project_hours += parseFloat(row.total_hours);
|
|
groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours);
|
|
groupedData[projectKey].total_error_hours += parseFloat(row.error_hours);
|
|
|
|
// 작업 유형별 데이터 추가
|
|
groupedData[projectKey].work_types.push({
|
|
work_type_id: row.work_type_id,
|
|
work_type_name: row.work_type_name,
|
|
total_hours: parseFloat(row.total_hours),
|
|
regular_hours: parseFloat(row.regular_hours),
|
|
error_hours: parseFloat(row.error_hours),
|
|
total_reports: row.total_reports,
|
|
regular_reports: row.regular_reports,
|
|
error_reports: row.error_reports,
|
|
error_rate_percent: parseFloat(row.error_rate_percent) || 0
|
|
});
|
|
});
|
|
|
|
// 프로젝트별 에러율 계산
|
|
Object.values(groupedData).forEach(project => {
|
|
project.project_error_rate = project.total_project_hours > 0
|
|
? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100
|
|
: 0;
|
|
});
|
|
|
|
// 전체 요약 통계
|
|
const totalStats = {
|
|
total_projects: Object.keys(groupedData).length,
|
|
total_work_types: new Set(results[0].map(r => r.work_type_id)).size,
|
|
grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0),
|
|
grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0),
|
|
grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0)
|
|
};
|
|
|
|
totalStats.grand_error_rate = totalStats.grand_total_hours > 0
|
|
? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100
|
|
: 0;
|
|
|
|
logger.info('프로젝트별-작업별 시간 분석 성공', {
|
|
start,
|
|
end,
|
|
projectCount: totalStats.total_projects,
|
|
workTypeCount: totalStats.total_work_types,
|
|
totalHours: totalStats.grand_total_hours
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
summary: totalStats,
|
|
projects: Object.values(groupedData),
|
|
period: { start, end }
|
|
},
|
|
message: '프로젝트별-작업별 시간 분석 완료'
|
|
});
|
|
} catch (error) {
|
|
logger.error('프로젝트별-작업별 시간 분석 실패', {
|
|
start,
|
|
end,
|
|
error: error.message
|
|
});
|
|
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
|
|
}
|
|
});
|
|
|
|
module.exports = {
|
|
getStats,
|
|
getDailyTrend,
|
|
getWorkerStats,
|
|
getProjectStats,
|
|
getWorkTypeStats,
|
|
getRecentWork,
|
|
getWeekdayPattern,
|
|
getErrorAnalysis,
|
|
getMonthlyComparison,
|
|
getWorkerSpecialization,
|
|
getDashboardData,
|
|
getProjectWorkTypeAnalysis
|
|
};
|