feat: 프로젝트별 작업 분포 Production Report 스타일 완성
✨ 주요 기능 - 프로젝트별 → 작업유형별 데이터 취합 및 표시 - Production Report 스타일 테이블 구현 - 연차/휴무 별도 처리 (주말 제외, 녹색 테마) - Job No. 정확한 표시 (중복 제거) 🔧 API 개선 - recent-work API에 job_no 필드 추가 - MySQL 쿼리 결과 처리 수정 (results[0] 사용) - Projects 테이블 대소문자 조인 문제 해결 🎨 UI/UX 개선 - 탭 기반 분석 인터페이스 - 색상 팔레트 개선 (파란색/녹색/노란색 계열) - 텍스트 방향 수정 (가로 표시) - 프로젝트별 합계 행 추가 📊 계산 로직 - 공수: 시간 ÷ 8 - 부하율: (개별 시간 ÷ 전체 시간) × 100% - 인건비: 공수 × 350,000원 - 주말 연차 자동 제외
This commit is contained in:
@@ -391,14 +391,24 @@ class WorkAnalysisController {
|
||||
const testResults = await db.query(testQuery, [start, end]);
|
||||
console.log('📊 데이터 확인:', testResults[0]);
|
||||
|
||||
// 프로젝트별-작업별 시간 분석 쿼리 (간단한 버전으로 테스트)
|
||||
// 먼저 간단한 테스트 쿼리로 데이터 확인
|
||||
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]);
|
||||
console.log('📊 기간 내 데이터 확인:', simpleResult[0][0]);
|
||||
|
||||
// 프로젝트별-작업별 시간 분석 쿼리 (work_types 테이블과 조인)
|
||||
const query = `
|
||||
SELECT
|
||||
COALESCE(p.project_id, 0) as project_id,
|
||||
COALESCE(p.project_name, 'Unknown Project') as project_name,
|
||||
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,
|
||||
CONCAT('Work Type ', dwr.work_type_id) as work_type_name,
|
||||
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
|
||||
|
||||
-- 총 시간
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
@@ -421,18 +431,22 @@ class WorkAnalysisController {
|
||||
) as error_rate_percent
|
||||
|
||||
FROM daily_work_reports dwr
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
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 p.project_id, p.project_name, p.job_no, dwr.work_type_id
|
||||
ORDER BY p.project_name, dwr.work_type_id
|
||||
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]);
|
||||
console.log('📊 쿼리 결과 개수:', results[0].length);
|
||||
console.log('📊 첫 번째 결과:', results[0][0]);
|
||||
console.log('📊 모든 결과:', JSON.stringify(results[0], null, 2));
|
||||
|
||||
// 데이터를 프로젝트별로 그룹화
|
||||
const groupedData = {};
|
||||
|
||||
results.forEach(row => {
|
||||
results[0].forEach(row => {
|
||||
const projectKey = `${row.project_id}_${row.project_name}`;
|
||||
|
||||
if (!groupedData[projectKey]) {
|
||||
@@ -476,7 +490,7 @@ class WorkAnalysisController {
|
||||
// 전체 요약 통계
|
||||
const totalStats = {
|
||||
total_projects: Object.keys(groupedData).length,
|
||||
total_work_types: new Set(results.map(r => r.work_type_id)).size,
|
||||
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)
|
||||
|
||||
@@ -190,6 +190,7 @@ class WorkAnalysis {
|
||||
w.worker_name,
|
||||
dwr.project_id,
|
||||
p.project_name,
|
||||
p.job_no,
|
||||
dwr.work_type_id,
|
||||
wt.name as work_type_name,
|
||||
dwr.work_status_id,
|
||||
@@ -202,7 +203,7 @@ class WorkAnalysis {
|
||||
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 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
|
||||
@@ -221,6 +222,7 @@ class WorkAnalysis {
|
||||
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
|
||||
project_id: row.project_id,
|
||||
project_name: row.project_name || `프로젝트 ${row.project_id}`,
|
||||
job_no: row.job_no || 'N/A',
|
||||
work_type_id: row.work_type_id,
|
||||
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
|
||||
work_status_id: row.work_status_id,
|
||||
|
||||
@@ -609,6 +609,152 @@ body {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
/* ========== Production Report 테이블 ========== */
|
||||
.production-report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
background: var(--white);
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
.production-report-table th {
|
||||
background: #4472C4;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
padding: 1rem 0.75rem;
|
||||
text-align: center;
|
||||
border: 1px solid #000;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.production-report-table td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #000;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 프로젝트명 (Job No.) 스타일 */
|
||||
.production-report-table .project-name {
|
||||
background: #5D9CEC;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
writing-mode: horizontal-tb;
|
||||
text-orientation: mixed;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
padding: 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 작업 내용 스타일 */
|
||||
.production-report-table .work-content {
|
||||
background: var(--white);
|
||||
color: var(--gray-800);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 공수 스타일 */
|
||||
.production-report-table .man-days {
|
||||
background: var(--white);
|
||||
color: var(--gray-800);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 부하율 스타일 */
|
||||
.production-report-table .load-rate {
|
||||
background: var(--white);
|
||||
color: var(--gray-800);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 인건비 스타일 */
|
||||
.production-report-table .labor-cost {
|
||||
background: var(--white);
|
||||
color: var(--gray-800);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 합계 행 스타일 */
|
||||
.production-report-table .total-row {
|
||||
background: #FFF3CD !important;
|
||||
font-weight: 700;
|
||||
border-top: 2px solid #F39C12;
|
||||
}
|
||||
|
||||
.production-report-table .total-row td {
|
||||
background: #FFF3CD !important;
|
||||
color: #856404;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 프로젝트 그룹 스타일 */
|
||||
.production-report-table .project-group {
|
||||
border-top: 2px solid #000;
|
||||
}
|
||||
|
||||
.production-report-table .project-group:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* 연차/휴무 스타일 */
|
||||
.production-report-table .vacation-project {
|
||||
background: #E8F5E8 !important;
|
||||
border-top: 3px solid #4CAF50 !important;
|
||||
}
|
||||
|
||||
.production-report-table .vacation-project .project-name {
|
||||
background: #4CAF50 !important;
|
||||
color: var(--white) !important;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
writing-mode: horizontal-tb !important;
|
||||
text-orientation: mixed !important;
|
||||
}
|
||||
|
||||
.production-report-table .vacation-project .work-content {
|
||||
background: #E8F5E8 !important;
|
||||
color: #2E7D32 !important;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.production-report-table .vacation-project td {
|
||||
background: #E8F5E8 !important;
|
||||
color: #2E7D32 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.production-report-table .vacation-project .man-days,
|
||||
.production-report-table .vacation-project .load-rate,
|
||||
.production-report-table .vacation-project .labor-cost {
|
||||
background: #E8F5E8 !important;
|
||||
color: #2E7D32 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 프로젝트별 합계 행 스타일 */
|
||||
.production-report-table .project-subtotal {
|
||||
background: #E8F4FD !important;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #5D9CEC;
|
||||
}
|
||||
|
||||
.production-report-table .project-subtotal td {
|
||||
background: #E8F4FD !important;
|
||||
color: #2E5BBA;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #5D9CEC;
|
||||
}
|
||||
|
||||
.work-report-table .input-hours {
|
||||
font-weight: 600;
|
||||
color: var(--success);
|
||||
@@ -720,6 +866,66 @@ body {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ========== 탭 네비게이션 ========== */
|
||||
.tab-navigation {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--gray-200);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: var(--gray-50);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: var(--gray-100);
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: var(--gradient-primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--white);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.tab-button .icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* ========== 탭 컨텐츠 ========== */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
@@ -986,6 +1192,16 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=21">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=33">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script src="/js/api-config.js?v=1" defer></script>
|
||||
@@ -83,14 +83,26 @@
|
||||
<p class="loading-text">분석 중입니다...</p>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div id="emptyState" class="empty-state">
|
||||
<div class="empty-icon">📊</div>
|
||||
<h3 class="empty-title">분석을 시작해보세요</h3>
|
||||
<p class="empty-description">
|
||||
기간을 설정하고 분석 실행 버튼을 클릭하여<br>
|
||||
상세한 작업 현황 분석을 확인할 수 있습니다.
|
||||
</p>
|
||||
<!-- 분석 탭 네비게이션 -->
|
||||
<div id="analysisTabNavigation" class="tab-navigation" style="display: none;">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" data-tab="work-status">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</button>
|
||||
<button class="tab-button" data-tab="project-distribution">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</button>
|
||||
<button class="tab-button" data-tab="worker-performance">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</button>
|
||||
<button class="tab-button" data-tab="error-analysis">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 카드 그리드 -->
|
||||
@@ -172,20 +184,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 영역 -->
|
||||
<div id="chartsContainer" style="display: none;">
|
||||
<!-- 기간별 작업 현황 테이블 -->
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<!-- 탭 컨텐츠 영역 -->
|
||||
<div id="tabContentContainer" style="display: none;">
|
||||
<!-- 기간별 작업 현황 탭 -->
|
||||
<div id="work-status-tab" class="tab-content active">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="work-report-table" id="workReportTable">
|
||||
<thead>
|
||||
@@ -217,51 +230,85 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 작업 분포 차트 -->
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 작업 분포
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
<!-- 프로젝트별 작업 분포 탭 -->
|
||||
<div id="project-distribution-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📋</span>
|
||||
프로젝트별 작업 분포
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="production-report-table" id="projectDistributionTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job No.</th>
|
||||
<th>작업 내용<br><small>Contents</small></th>
|
||||
<th>공 수<br><small>Man / Day</small></th>
|
||||
<th>전체 부하율</th>
|
||||
<th>인건비<br><small>Labor Cost</small></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectDistributionTableBody">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행하여 데이터를 확인하세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="projectDistributionTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>합계<br><small>Total</small></strong></td>
|
||||
<td><strong id="totalManDays">-</strong></td>
|
||||
<td><strong>100.00%</strong></td>
|
||||
<td><strong id="totalLaborCost">-</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="projectDistributionChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 작업자별 성과 차트 -->
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
<!-- 작업자별 성과 탭 -->
|
||||
<div id="worker-performance-tab" class="tab-content">
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<canvas id="workerPerformanceChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
<canvas id="workerPerformanceChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 오류 분석 차트 -->
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 유형별 분석
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeErrorTypes()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
<!-- 오류 분석 탭 -->
|
||||
<div id="error-analysis-tab" class="tab-content">
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 유형별 분석
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeErrorTypes()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<canvas id="errorAnalysisChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
<canvas id="errorAnalysisChart" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,12 +453,47 @@
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// 차트 컨테이너 표시
|
||||
document.getElementById('chartsContainer').style.display = 'block';
|
||||
// 탭 네비게이션과 컨텐츠 표시
|
||||
document.getElementById('analysisTabNavigation').style.display = 'block';
|
||||
document.getElementById('tabContentContainer').style.display = 'block';
|
||||
|
||||
console.log(`✅ 기간 확정: ${startDate} ~ ${endDate}`);
|
||||
}
|
||||
|
||||
// 탭 전환 함수
|
||||
function switchTab(tabId) {
|
||||
// 모든 탭 버튼 비활성화
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// 모든 탭 컨텐츠 숨기기
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// 선택된 탭 버튼 활성화
|
||||
document.querySelector(`[data-tab="${tabId}"]`).classList.add('active');
|
||||
|
||||
// 선택된 탭 컨텐츠 표시
|
||||
document.getElementById(`${tabId}-tab`).classList.add('active');
|
||||
|
||||
console.log(`🔄 탭 전환: ${tabId}`);
|
||||
}
|
||||
|
||||
// 탭 버튼 이벤트 리스너 추가
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 기존 초기화 코드...
|
||||
|
||||
// 탭 버튼 클릭 이벤트
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const tabId = this.getAttribute('data-tab');
|
||||
switchTab(tabId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 개별 분석 함수들
|
||||
async function analyzeWorkStatus() {
|
||||
if (!isAnalysisEnabled) {
|
||||
@@ -437,16 +519,31 @@
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🥧 프로젝트별 작업 분포 분석 시작');
|
||||
console.log('📋 프로젝트별 작업 분포 분석 시작');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: confirmedStartDate,
|
||||
end: confirmedEndDate
|
||||
end: confirmedEndDate,
|
||||
limit: 2000
|
||||
});
|
||||
|
||||
const response = await apiCall(`/work-analysis/project-stats?${params}`, 'GET');
|
||||
renderProjectDistributionChart(response.data);
|
||||
// 기간별 작업 현황 API를 사용해서 프로젝트별 데이터 가져오기
|
||||
const [projectWorktypeResponse, workerResponse, recentWorkResponse] = await Promise.all([
|
||||
apiCall(`/work-analysis/project-worktype-analysis?${params}`, 'GET'),
|
||||
apiCall(`/work-analysis/worker-stats?${params}`, 'GET'),
|
||||
apiCall(`/work-analysis/recent-work?${params}`, 'GET')
|
||||
]);
|
||||
|
||||
console.log('🔍 프로젝트-작업유형 API 응답:', projectWorktypeResponse);
|
||||
console.log('🔍 작업자 API 응답:', workerResponse);
|
||||
console.log('🔍 최근 작업 API 응답:', recentWorkResponse);
|
||||
|
||||
// recent-work 데이터로 프로젝트별 취합
|
||||
const projectData = aggregateProjectData(recentWorkResponse.data || []);
|
||||
console.log('📊 취합된 프로젝트 데이터:', projectData);
|
||||
|
||||
renderProjectDistributionTableFromRecentWork(projectData, workerResponse.data);
|
||||
console.log('✅ 프로젝트별 작업 분포 분석 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 프로젝트별 작업 분포 분석 오류:', error);
|
||||
@@ -499,7 +596,430 @@
|
||||
alert('오류 유형별 분석에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 프로젝트별 작업 분포 테이블 렌더링
|
||||
function renderProjectDistributionTable(projectData, workerData) {
|
||||
const tbody = document.getElementById('projectDistributionTableBody');
|
||||
const tfoot = document.getElementById('projectDistributionTableFooter');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
console.log('📋 프로젝트별 분포 테이블 렌더링:', projectData);
|
||||
console.log('📋 작업자 데이터:', workerData);
|
||||
|
||||
// 프로젝트 API 데이터가 null이면 작업자 데이터로 대체
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0 ||
|
||||
(projectData.projects[0] && projectData.projects[0].total_project_hours === null)) {
|
||||
console.log('⚠️ 프로젝트 API 데이터가 없어서 작업자 데이터로 대체합니다.');
|
||||
renderFallbackTable(workerData);
|
||||
return;
|
||||
}
|
||||
|
||||
let tableRows = [];
|
||||
let grandTotalHours = 0;
|
||||
let grandTotalManDays = 0;
|
||||
let grandTotalLaborCost = 0;
|
||||
|
||||
// 공수당 인건비 (350,000원)
|
||||
const manDayRate = 350000;
|
||||
|
||||
// 먼저 전체 시간을 계산 (부하율 계산용)
|
||||
if (projectData && projectData.projects && Array.isArray(projectData.projects)) {
|
||||
projectData.projects.forEach(project => {
|
||||
const workTypes = project.work_types || [];
|
||||
workTypes.forEach(workType => {
|
||||
grandTotalHours += parseFloat(workType.total_hours) || 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (projectData && projectData.projects && Array.isArray(projectData.projects)) {
|
||||
projectData.projects.forEach(project => {
|
||||
const projectName = project.project_name || '알 수 없는 프로젝트';
|
||||
const workTypes = project.work_types || [];
|
||||
|
||||
let projectTotalHours = 0;
|
||||
let projectTotalManDays = 0;
|
||||
let projectTotalLaborCost = 0;
|
||||
|
||||
if (workTypes.length === 0) {
|
||||
// 작업유형이 없는 경우
|
||||
const projectHours = parseFloat(project.total_hours) || 0;
|
||||
const manDays = Math.round((projectHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((projectHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
projectTotalHours += projectHours;
|
||||
projectTotalManDays += manDays;
|
||||
projectTotalLaborCost += laborCost;
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-group">
|
||||
<td class="project-name">${projectName}</td>
|
||||
<td class="work-content">전체</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
// 작업유형별로 행 생성
|
||||
workTypes.forEach((workType, index) => {
|
||||
const workTypeName = workType.work_type_name || `작업유형 ${workType.work_type_id}`;
|
||||
const workTypeHours = parseFloat(workType.total_hours) || 0;
|
||||
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
projectTotalHours += workTypeHours;
|
||||
projectTotalManDays += manDays;
|
||||
projectTotalLaborCost += laborCost;
|
||||
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length + 1; // +1 for subtotal row
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="project-group">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${projectName}</td>` : ''}
|
||||
<td class="work-content">${workTypeName}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 프로젝트별 합계 행 추가
|
||||
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
tableRows.push(`
|
||||
<tr class="project-subtotal">
|
||||
<td class="work-content"><strong>합계</strong></td>
|
||||
<td class="man-days"><strong>${Math.round(projectTotalManDays * 100) / 100}</strong></td>
|
||||
<td class="load-rate"><strong>${projectLoadRate}%</strong></td>
|
||||
<td class="labor-cost"><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
|
||||
grandTotalManDays += projectTotalManDays;
|
||||
grandTotalLaborCost += projectTotalLaborCost;
|
||||
});
|
||||
}
|
||||
|
||||
if (tableRows.length > 0) {
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 전체 합계 행 업데이트
|
||||
document.getElementById('totalManDays').textContent = `${Math.round(grandTotalManDays * 100) / 100} 공수`;
|
||||
document.getElementById('totalLaborCost').textContent = `₩${grandTotalLaborCost.toLocaleString()}`;
|
||||
|
||||
if (tfoot) {
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
} else {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 프로젝트 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tfoot) {
|
||||
tfoot.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 작업자 데이터로 대체 테이블 렌더링
|
||||
function renderFallbackTable(workerData) {
|
||||
const tbody = document.getElementById('projectDistributionTableBody');
|
||||
const tfoot = document.getElementById('projectDistributionTableFooter');
|
||||
|
||||
if (!workerData || !Array.isArray(workerData) || workerData.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tfoot) tfoot.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let tableRows = [];
|
||||
let totalHours = 0;
|
||||
let totalManDays = 0;
|
||||
let totalLaborCost = 0;
|
||||
|
||||
const manDayRate = 350000; // 공수당 인건비
|
||||
|
||||
// 작업자별로 행 생성 (임시 데이터)
|
||||
workerData.forEach(worker => {
|
||||
const hours = worker.totalHours || 0;
|
||||
const manDays = Math.round((hours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = 0; // 나중에 계산
|
||||
|
||||
totalHours += hours;
|
||||
totalManDays += manDays;
|
||||
totalLaborCost += laborCost;
|
||||
|
||||
tableRows.push({
|
||||
projectName: `작업자 ${worker.worker_name}`,
|
||||
workContent: '전체 작업',
|
||||
manDays: manDays,
|
||||
hours: hours,
|
||||
laborCost: laborCost
|
||||
});
|
||||
});
|
||||
|
||||
// 부하율 계산 및 HTML 생성
|
||||
const htmlRows = tableRows.map(row => {
|
||||
const loadRate = totalHours > 0 ? ((row.hours / totalHours) * 100).toFixed(2) : '0.00';
|
||||
return `
|
||||
<tr class="project-group">
|
||||
<td class="project-name">${row.projectName}</td>
|
||||
<td class="work-content">${row.workContent}</td>
|
||||
<td class="man-days">${row.manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${row.laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tbody.innerHTML = htmlRows.join('');
|
||||
|
||||
// 합계 행 업데이트
|
||||
document.getElementById('totalManDays').textContent = `${Math.round(totalManDays * 100) / 100} 공수`;
|
||||
document.getElementById('totalLaborCost').textContent = `₩${totalLaborCost.toLocaleString()}`;
|
||||
|
||||
if (tfoot) {
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
// 주말 체크 함수
|
||||
function isWeekend(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek === 0 || dayOfWeek === 6; // 0: 일요일, 6: 토요일
|
||||
}
|
||||
|
||||
// 연차/휴무 프로젝트 체크 함수
|
||||
function isVacationProject(projectName) {
|
||||
return projectName && (projectName.includes('연차') || projectName.includes('휴무'));
|
||||
}
|
||||
|
||||
// recent-work 데이터로 프로젝트별 취합
|
||||
function aggregateProjectData(recentWorkData) {
|
||||
const projectMap = new Map();
|
||||
let vacationData = {
|
||||
total_hours: 0,
|
||||
regular_hours: 0,
|
||||
error_hours: 0,
|
||||
total_reports: 0
|
||||
};
|
||||
|
||||
recentWorkData.forEach(work => {
|
||||
// 연차/휴무 프로젝트인 경우
|
||||
if (isVacationProject(work.project_name)) {
|
||||
// 주말 연차는 제외
|
||||
if (!isWeekend(work.report_date)) {
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
vacationData.total_hours += hours;
|
||||
vacationData.total_reports += 1;
|
||||
|
||||
if (work.work_status_name === '정규') {
|
||||
vacationData.regular_hours += hours;
|
||||
} else if (work.work_status_name === '에러') {
|
||||
vacationData.error_hours += hours;
|
||||
}
|
||||
}
|
||||
return; // 연차/휴무는 별도 처리하므로 여기서 종료
|
||||
}
|
||||
|
||||
const projectKey = `${work.project_id}_${work.project_name}`;
|
||||
|
||||
if (!projectMap.has(projectKey)) {
|
||||
projectMap.set(projectKey, {
|
||||
project_id: work.project_id,
|
||||
project_name: work.project_name,
|
||||
job_no: work.job_no || work.project_name || 'N/A',
|
||||
work_types: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectMap.get(projectKey);
|
||||
const workTypeKey = `${work.work_type_id}_${work.work_type_name}`;
|
||||
|
||||
if (!project.work_types.has(workTypeKey)) {
|
||||
project.work_types.set(workTypeKey, {
|
||||
work_type_id: work.work_type_id,
|
||||
work_type_name: work.work_type_name,
|
||||
total_hours: 0,
|
||||
regular_hours: 0,
|
||||
error_hours: 0,
|
||||
total_reports: 0
|
||||
});
|
||||
}
|
||||
|
||||
const workType = project.work_types.get(workTypeKey);
|
||||
const hours = parseFloat(work.work_hours) || 0;
|
||||
|
||||
workType.total_hours += hours;
|
||||
workType.total_reports += 1;
|
||||
|
||||
if (work.work_status_name === '정규') {
|
||||
workType.regular_hours += hours;
|
||||
} else if (work.work_status_name === '에러') {
|
||||
workType.error_hours += hours;
|
||||
}
|
||||
});
|
||||
|
||||
// Map을 배열로 변환
|
||||
const projects = Array.from(projectMap.values()).map(project => ({
|
||||
...project,
|
||||
work_types: Array.from(project.work_types.values())
|
||||
}));
|
||||
|
||||
// 연차/휴무 데이터가 있으면 맨 마지막에 추가
|
||||
if (vacationData.total_hours > 0) {
|
||||
projects.push({
|
||||
project_id: 'vacation',
|
||||
project_name: '연차/휴무',
|
||||
job_no: '연차/휴무',
|
||||
work_types: [{
|
||||
work_type_id: 'vacation',
|
||||
work_type_name: '-',
|
||||
total_hours: vacationData.total_hours,
|
||||
regular_hours: vacationData.regular_hours,
|
||||
error_hours: vacationData.error_hours,
|
||||
total_reports: vacationData.total_reports
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// recent-work 데이터로 프로젝트별 분포 테이블 렌더링
|
||||
function renderProjectDistributionTableFromRecentWork(projectData, workerData) {
|
||||
const tbody = document.getElementById('projectDistributionTableBody');
|
||||
const tfoot = document.getElementById('projectDistributionTableFooter');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
console.log('📋 프로젝트별 분포 테이블 렌더링 (recent-work 기반):', projectData);
|
||||
|
||||
if (!projectData || !projectData.projects || projectData.projects.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
해당 기간에 프로젝트 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (tfoot) tfoot.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let tableRows = [];
|
||||
let grandTotalHours = 0;
|
||||
let grandTotalManDays = 0;
|
||||
let grandTotalLaborCost = 0;
|
||||
|
||||
const manDayRate = 350000; // 공수당 인건비
|
||||
|
||||
// 먼저 전체 시간 계산 (부하율 계산용)
|
||||
projectData.projects.forEach(project => {
|
||||
project.work_types.forEach(workType => {
|
||||
grandTotalHours += workType.total_hours;
|
||||
});
|
||||
});
|
||||
|
||||
// 프로젝트별로 테이블 행 생성
|
||||
projectData.projects.forEach(project => {
|
||||
const projectName = project.project_name || '알 수 없는 프로젝트';
|
||||
const jobNo = project.job_no || project.project_name || 'N/A';
|
||||
const workTypes = project.work_types || [];
|
||||
|
||||
let projectTotalHours = 0;
|
||||
let projectTotalManDays = 0;
|
||||
let projectTotalLaborCost = 0;
|
||||
|
||||
if (workTypes.length === 0) {
|
||||
// 작업유형이 없는 경우 (빈 프로젝트)
|
||||
tableRows.push(`
|
||||
<tr class="project-group">
|
||||
<td class="project-name">${jobNo}</td>
|
||||
<td class="work-content">데이터 없음</td>
|
||||
<td class="man-days">0</td>
|
||||
<td class="load-rate">0.00%</td>
|
||||
<td class="labor-cost">₩0</td>
|
||||
</tr>
|
||||
`);
|
||||
} else {
|
||||
// 작업유형별로 행 생성
|
||||
workTypes.forEach((workType, index) => {
|
||||
const workTypeName = workType.work_type_name || `작업유형 ${workType.work_type_id}`;
|
||||
const workTypeHours = workType.total_hours || 0;
|
||||
const manDays = Math.round((workTypeHours / 8) * 100) / 100;
|
||||
const laborCost = manDays * manDayRate;
|
||||
const loadRate = grandTotalHours > 0 ? ((workTypeHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
|
||||
projectTotalHours += workTypeHours;
|
||||
projectTotalManDays += manDays;
|
||||
projectTotalLaborCost += laborCost;
|
||||
|
||||
const isFirstWorkType = index === 0;
|
||||
const rowspan = workTypes.length + 1; // +1 for subtotal row
|
||||
const isVacation = project.project_id === 'vacation';
|
||||
const rowClass = isVacation ? 'project-group vacation-project' : 'project-group';
|
||||
|
||||
// 연차/휴무는 프로젝트명만, 일반 프로젝트는 Job No.만 표시
|
||||
const displayText = isVacation ? projectName : jobNo;
|
||||
|
||||
tableRows.push(`
|
||||
<tr class="${rowClass}">
|
||||
${isFirstWorkType ? `<td class="project-name" rowspan="${rowspan}">${displayText}</td>` : ''}
|
||||
<td class="work-content">${workTypeName}</td>
|
||||
<td class="man-days">${manDays}</td>
|
||||
<td class="load-rate">${loadRate}%</td>
|
||||
<td class="labor-cost">₩${laborCost.toLocaleString()}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
// 프로젝트별 합계 행 추가 (연차/휴무는 합계 행 제외)
|
||||
if (project.project_id !== 'vacation') {
|
||||
const projectLoadRate = grandTotalHours > 0 ? ((projectTotalHours / grandTotalHours) * 100).toFixed(2) : '0.00';
|
||||
tableRows.push(`
|
||||
<tr class="project-subtotal">
|
||||
<td class="work-content"><strong>합계</strong></td>
|
||||
<td class="man-days"><strong>${Math.round(projectTotalManDays * 100) / 100}</strong></td>
|
||||
<td class="load-rate"><strong>${projectLoadRate}%</strong></td>
|
||||
<td class="labor-cost"><strong>₩${projectTotalLaborCost.toLocaleString()}</strong></td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
grandTotalManDays += projectTotalManDays;
|
||||
grandTotalLaborCost += projectTotalLaborCost;
|
||||
});
|
||||
|
||||
tbody.innerHTML = tableRows.join('');
|
||||
|
||||
// 전체 합계 행 업데이트
|
||||
document.getElementById('totalManDays').textContent = `${Math.round(grandTotalManDays * 100) / 100} 공수`;
|
||||
document.getElementById('totalLaborCost').textContent = `₩${grandTotalLaborCost.toLocaleString()}`;
|
||||
|
||||
if (tfoot) {
|
||||
tfoot.style.display = 'table-footer-group';
|
||||
}
|
||||
}
|
||||
|
||||
// 분석 모드 전환
|
||||
function switchAnalysisMode(mode) {
|
||||
// 탭 활성화 상태 변경
|
||||
|
||||
Reference in New Issue
Block a user