diff --git a/src/modules/mileage/StatisticsView.tsx b/src/modules/mileage/StatisticsView.tsx index bdbe5c1..38d7fc1 100644 --- a/src/modules/mileage/StatisticsView.tsx +++ b/src/modules/mileage/StatisticsView.tsx @@ -9,7 +9,7 @@ import { Truck, ChevronDown, Maximize2, Minimize2, Search, ArrowUpDown, X, RotateCcw, Calendar, } from 'lucide-react'; -import type { TargetSummary, TargetVehicle, TrendPoint } from './types'; +import type { TargetSummary, TargetVehicle, TargetYearlyAssessment, TrendPoint } from './types'; import { fetchTargets, fetchTargetVehicles, fetchTrend } from './api'; import Blur from '../../components/Blur'; @@ -19,11 +19,25 @@ function getDefaultDate(): string { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; } +function getCurrentDateLabel(): string { + const now = new Date(); + return `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`; +} + function fmtKm(value: number): string { if (value >= 10000) return (value / 10000).toFixed(2) + '万'; return value.toLocaleString(); } +function fmtPercent(value: number): string { + return `${value.toFixed(1)}%`; +} + +function getTargetAssessment(target: TargetSummary, selectedYear?: number): TargetYearlyAssessment | null { + if (target.yearlyAssessments.length === 0) return null; + return target.yearlyAssessments.find(item => item.yearNumber === selectedYear) || target.yearlyAssessments[0]; +} + function shortTargetName(name: string): string { // Extract the number and a short description const match = name.match(/(\d+)[辆台](.+)/); @@ -39,6 +53,7 @@ function shortTargetName(name: string): string { } export default function StatisticsView() { + const currentDateLabel = getCurrentDateLabel(); const [targets, setTargets] = useState([]); const [trendData, setTrendData] = useState([]); const [targetVehiclesMap, setTargetVehiclesMap] = useState>({}); @@ -46,7 +61,8 @@ export default function StatisticsView() { const [chartType, setChartType] = useState<'bar' | 'line' | 'area'>('bar'); const [isTableFullscreen, setIsTableFullscreen] = useState(false); - const [expandedModel, setExpandedModel] = useState(null); + const [expandedTargetId, setExpandedTargetId] = useState(null); + const [assessmentYearMap, setAssessmentYearMap] = useState>({}); const [viewAllTargetId, setViewAllTargetId] = useState(null); const [viewAllTargetName, setViewAllTargetName] = useState(''); const [viewAllSearch, setViewAllSearch] = useState(''); @@ -54,12 +70,25 @@ export default function StatisticsView() { const [viewAllDate, setViewAllDate] = useState(getDefaultDate); const [viewAllLoading, setViewAllLoading] = useState(false); + const selectedTarget = targets.find(t => t.id === selectedTargetId); + const selectedAssessment = selectedTarget ? getTargetAssessment(selectedTarget, assessmentYearMap[selectedTarget.id]) : null; + const selectedCompletion = selectedAssessment?.completionRate ?? selectedTarget?.avgCompletion ?? 0; + // Load targets on mount useEffect(() => { fetchTargets().then(data => { - setTargets(data); - if (data.length > 0 && !selectedTargetId) { - setSelectedTargetId(data[0].id); + const focused = data.find(item => item.targetName.includes('羚牛136')) || data[0]; + const ordered = focused + ? [focused, ...data.filter(item => item.id !== focused.id)] + : data; + setTargets(ordered); + if (ordered.length > 0 && !selectedTargetId) { + setSelectedTargetId(focused.id); + setExpandedTargetId(focused.id); + setAssessmentYearMap(Object.fromEntries(ordered.map(item => [item.id, item.yearlyAssessments[0]?.yearNumber || 1]))); + fetchTargetVehicles(focused.id).then(vehicles => { + setTargetVehiclesMap(prev => ({ ...prev, [focused.id]: vehicles })); + }).catch(() => {}); } }).catch(() => {}); }, []); @@ -103,7 +132,7 @@ export default function StatisticsView() {
{/* KPI Cards in Landscape — linked to selected target */} {(() => { - const sel = targets.find(t => t.id === selectedTargetId); + const sel = selectedTarget; return (
@@ -130,7 +159,7 @@ export default function StatisticsView() {
完成率
- {(sel?.avgCompletion ?? 0).toFixed(1)} + {selectedCompletion.toFixed(1)} %
@@ -224,12 +253,17 @@ export default function StatisticsView() {
{targets.map((target, idx) => ( + (() => { + const assessment = getTargetAssessment(target, assessmentYearMap[target.id]); + const primaryCompletion = assessment?.completionRate ?? target.avgCompletion; + const primaryQualified = assessment?.qualifiedCount ?? target.yearQualifiedCount; + const primaryQualifiedLabel = assessment ? `${assessment.label}达标:` : '达标:'; + return (
{ - const name = target.targetName; - setExpandedModel(expandedModel === name ? null : name); + setExpandedTargetId(expandedTargetId === target.id ? null : target.id); if (!targetVehiclesMap[target.id]) { fetchTargetVehicles(target.id).then(data => { setTargetVehiclesMap(prev => ({ ...prev, [target.id]: data })); @@ -249,12 +283,12 @@ export default function StatisticsView() {
- 完成率: - = 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{target.avgCompletion.toFixed(1)}% + {assessment ? `${assessment.label}完成:` : '完成率:'} + = 90 ? 'text-emerald-500' : 'text-blue-500'}`}>{fmtPercent(primaryCompletion)}
- 达标: - {target.yearQualifiedCount}台 + {primaryQualifiedLabel} + {primaryQualified}台
@@ -269,7 +303,7 @@ export default function StatisticsView() {
@@ -278,7 +312,7 @@ export default function StatisticsView() {
- {expandedModel === target.targetName && ( + {expandedTargetId === target.id && (
-
-

考核区间

- {target.periods.map((p, i) => ( -

{p}

- ))} +
+ 考核年度 +
-
-

总考核里程

-

{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km

-
-
-

年考核任务/辆

-

{fmtKm(target.annualMileagePerVehicle)} km

-
-
-

50%达标数

-

{target.halfQualifiedCount} 台

-
-
-

本年需完成

-

{fmtKm(target.currentYearTarget)} km

-
-
-

已完成(截止3.31)

-

{fmtKm(target.currentYearCompleted)} km

-
-
-

未完成总数

-

{fmtKm(target.remaining)} km

-
-
-

日均需完成

-

{fmtKm(target.dailyTarget)} km

+
+
+

总考核区间

+ {target.periods.map((p, i) => ( +

{p}

+ ))} +
+
+

总考核里程

+

{fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km

+
+ {assessment ? ( + <> +
+

{assessment.label}区间

+

{assessment.startDate} ~ {assessment.endDate}

+
+
+

{assessment.label}任务/辆

+

{fmtKm(target.annualMileagePerVehicle * assessment.yearNumber)} km

+
+
+

{assessment.label}考核车辆

+

{assessment.vehicleCount} 台

+
+
+

{assessment.label}需完成

+

{fmtKm(assessment.target)} km

+
+
+

{assessment.label}已完成(截止{currentDateLabel})

+

{fmtKm(assessment.completed)} km

+
+
+

{assessment.label}完成率

+

{fmtPercent(assessment.completionRate)}

+
+
+

{assessment.label}达标率

+

{fmtPercent(assessment.qualifiedRate)} ({assessment.qualifiedCount}台)

+
+
+

{assessment.label}剩余里程

+

{fmtKm(assessment.remaining)} km

+
+
+

{assessment.label}日均需完成

+

+ {assessment.daysLeft > 0 ? `${fmtKm(assessment.dailyTarget)} km` : '已到期'} +

+
+ + ) : ( +
+ 暂无第一年度车辆,当前车型车辆已进入后续考核年度。 +
+ )}
- 剩余考核天数 - {target.daysLeft} 天 + {assessment ? `${assessment.label}剩余考核天数` : '剩余考核天数'} + {assessment?.daysLeft ?? target.daysLeft} 天
{/* Vehicle List Detail */} @@ -363,6 +439,8 @@ export default function StatisticsView() { )}
+ ); + })() ))}
@@ -391,7 +469,7 @@ export default function StatisticsView() { | 车辆 {targets.reduce((sum, t) => sum + t.vehicleCount, 0)} | - 完成率 {targets.length > 0 ? (targets.reduce((sum, t) => sum + t.avgCompletion, 0) / targets.length).toFixed(1) : '0.0'} % + 所选年度完成率 {targets.length > 0 ? (targets.reduce((sum, t) => sum + (getTargetAssessment(t, assessmentYearMap[t.id])?.completionRate ?? t.avgCompletion), 0) / targets.length).toFixed(1) : '0.0'} %
@@ -417,12 +495,12 @@ export default function StatisticsView() { 车型 台数 - 完成进度 - 年任务/辆 - 达标 + 年度进度 + 年度任务/辆 + 年度达标 50%达标 今日里程 - 本年目标 + 年度目标 已完成 未完成 余天 @@ -430,10 +508,22 @@ export default function StatisticsView() { - {targets.map((target, idx) => ( + {targets.map((target, idx) => { + const assessment = getTargetAssessment(target, assessmentYearMap[target.id]); + const completion = assessment?.completionRate ?? target.avgCompletion; + const qualified = assessment?.qualifiedCount ?? target.yearQualifiedCount; + const halfQualified = assessment?.halfQualifiedCount ?? target.halfQualifiedCount; + const goal = assessment?.target ?? target.currentYearTarget; + const completed = assessment?.completed ?? target.currentYearCompleted; + const remainingMileage = assessment?.remaining ?? target.remaining; + const days = assessment?.daysLeft ?? target.daysLeft; + const daily = assessment?.dailyTarget ?? target.dailyTarget; + const taskPerVehicle = target.annualMileagePerVehicle * (assessment?.yearNumber || 1); + return (
{target.targetName}
+
{assessment?.label || '当前年度'}
{target.periods.map((p, i) => {p})}
{target.vehicleCount} @@ -441,28 +531,31 @@ export default function StatisticsView() {
= 90 ? 'bg-emerald-500' : target.avgCompletion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`} - style={{ width: `${Math.min(target.avgCompletion, 100)}%` }} + className={`h-full rounded-full ${completion >= 90 ? 'bg-emerald-500' : completion >= 50 ? 'bg-amber-500' : 'bg-amber-500/60'}`} + style={{ width: `${Math.min(completion, 100)}%` }} />
- {target.avgCompletion.toFixed(1)}% + {completion.toFixed(1)}%
- {fmtKm(target.cumulativeTotal)} - / {fmtKm(target.totalMileagePerVehicle * target.vehicleCount)} km + {fmtKm(completed)} + / {fmtKm(goal)} km
- {fmtKm(target.annualMileagePerVehicle)} km - {target.yearQualifiedCount} - {target.halfQualifiedCount} + {fmtKm(taskPerVehicle)} km + {qualified} + {halfQualified} {fmtKm(target.todayTotal)} km - {fmtKm(target.currentYearTarget)} km - {fmtKm(target.currentYearCompleted)} km - {fmtKm(target.remaining)} km - {target.daysLeft} - {fmtKm(target.dailyTarget)} km + {fmtKm(goal)} km + {fmtKm(completed)} km + {fmtKm(remainingMileage)} km + {days} + + {assessment && days === 0 ? '已到期' : `${fmtKm(daily)} km`} + - ))} + ); + })}
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index d851958..435e5ac 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -63,6 +63,36 @@ export interface TargetSummary { remaining: number; daysLeft: number; dailyTarget: number; + firstYearVehicleCount: number; + firstYearTarget: number; + firstYearCompleted: number; + firstYearRemaining: number; + firstYearCompletionRate: number; + firstYearQualifiedCount: number; + firstYearQualifiedRate: number; + firstYearHalfQualifiedCount: number; + firstYearDaysLeft: number; + firstYearDailyTarget: number; + firstYearStartDate: string | null; + firstYearEndDate: string | null; + yearlyAssessments: TargetYearlyAssessment[]; +} + +export interface TargetYearlyAssessment { + yearNumber: number; + label: string; + vehicleCount: number; + target: number; + completed: number; + remaining: number; + completionRate: number; + qualifiedCount: number; + qualifiedRate: number; + halfQualifiedCount: number; + daysLeft: number; + dailyTarget: number; + startDate: string | null; + endDate: string | null; } export interface TargetVehicle { diff --git a/src/server/routes/mileage/targets.ts b/src/server/routes/mileage/targets.ts index f6bb3b3..f848c89 100644 --- a/src/server/routes/mileage/targets.ts +++ b/src/server/routes/mileage/targets.ts @@ -32,6 +32,58 @@ app.get('/', async (c) => { const statsMap = new Map(); for (const s of vehicleStats) statsMap.set(s.target_id, s); + const [firstYearRows] = await pool.execute(` + SELECT + v.target_id, + COUNT(*) as first_year_total, + SUM(t.annual_mileage_per_vehicle) as first_year_target, + SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed, + SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining, + SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate, + SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count, + SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count, + DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date, + DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date + FROM tab_mileage_assessment_vehicle v + JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0 + WHERE v.is_deleted = 0 + GROUP BY v.target_id + `) as [any[], unknown]; + + const firstYearMap = new Map(); + for (const s of firstYearRows) firstYearMap.set(s.target_id, s); + + const [yearlyRows] = await pool.execute(` + SELECT + v.target_id, + y.year_number, + COUNT(*) as vehicle_count, + SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage, + SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage, + SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage, + SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) + / NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate, + SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count, + SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count, + DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date, + DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date + FROM tab_mileage_assessment_vehicle v + JOIN tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0 + JOIN ( + SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number) + WHERE v.is_deleted = 0 + GROUP BY v.target_id, y.year_number + ORDER BY v.target_id, y.year_number + `) as [any[], unknown]; + + const yearlyMap = new Map(); + for (const row of yearlyRows) { + const list = yearlyMap.get(row.target_id) || []; + list.push(row); + yearlyMap.set(row.target_id, list); + } + const [periodRows] = await pool.execute(` SELECT target_id, DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date, @@ -71,12 +123,43 @@ app.get('/', async (c) => { const now = new Date(); const result = targets.map((t: any) => { const s = statsMap.get(t.id) || {}; + const fy = firstYearMap.get(t.id) || {}; const currentYearTarget = Number(s.current_year_target) || 0; const currentYearCompleted = Number(s.current_year_completed) || 0; const remaining = Math.max(0, currentYearTarget - currentYearCompleted); const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now; const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000)); const dailyTarget = remaining / daysLeft; + const firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now; + const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000)); + const firstYearRemaining = Number(fy.first_year_remaining) || 0; + const firstYearVehicleCount = Number(fy.first_year_total) || 0; + const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0; + const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => { + const vehicleCount = Number(row.vehicle_count) || 0; + const qualifiedCount = Number(row.qualified_count) || 0; + const remainingMileage = Number(row.remaining_mileage) || 0; + const endDate = row.end_date ? new Date(row.end_date) : now; + const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000)); + const yearNumber = Number(row.year_number) || 0; + + return { + yearNumber, + label: `第${yearNumber}年`, + vehicleCount, + target: Number(row.target_mileage) || 0, + completed: Number(row.completed_mileage) || 0, + remaining: remainingMileage, + completionRate: (Number(row.completion_rate) || 0) * 100, + qualifiedCount, + qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0, + halfQualifiedCount: Number(row.half_qualified_count) || 0, + daysLeft: assessmentDaysLeft, + dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0, + startDate: row.start_date || null, + endDate: row.end_date || null, + }; + }); const periods = periodsMap.get(t.id) || []; if (periods.length === 0) { @@ -104,6 +187,19 @@ app.get('/', async (c) => { remaining, daysLeft, dailyTarget: Math.round(dailyTarget * 10) / 10, + firstYearVehicleCount, + firstYearTarget: Number(fy.first_year_target) || 0, + firstYearCompleted: Number(fy.first_year_completed) || 0, + firstYearRemaining, + firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100, + firstYearQualifiedCount, + firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0, + firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0, + firstYearDaysLeft, + firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0, + firstYearStartDate: fy.first_year_start_date || null, + firstYearEndDate: fy.first_year_end_date || null, + yearlyAssessments, }; });