Версия 0.8.0 Добавлены отчеты по годам.

This commit is contained in:
anibilag 2025-09-22 22:16:31 +03:00
parent f27c964ecc
commit 87caa355f6
17 changed files with 793 additions and 43 deletions

Binary file not shown.

12
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lawn-mowing-scheduler",
"version": "0.6.2",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lawn-mowing-scheduler",
"version": "0.6.2",
"version": "0.7.0",
"dependencies": {
"@libsql/client": "^0.4.0",
"cors": "^2.8.5",
@ -14,7 +14,7 @@
"express": "^4.18.2",
"framer-motion": "^12.23.12",
"lucide-react": "^0.344.0",
"multer": "2.0.1",
"multer": "2.0.2",
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"react": "^18.3.1",
@ -5242,9 +5242,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",

View File

@ -1,7 +1,7 @@
{
"name": "lawn-mowing-scheduler",
"private": true,
"version": "0.7.0",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run server\" \"npm run client\"",
@ -20,7 +20,7 @@
"express": "^4.18.2",
"framer-motion": "^12.23.12",
"lucide-react": "^0.344.0",
"multer": "2.0.1",
"multer": "2.0.2",
"node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0",
"react": "^18.3.1",

View File

@ -730,21 +730,34 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
console.log('SQL:', sql);
console.log('ARGS:', args);
await db.execute({ sql, args });
const updateResult = await db.execute({ sql, args });
if (updateResult.rowsAffected === 0) {
return res.status(500).json({ error: 'Failed to update zone' });
}
// Delete old image if new one was provided
if (req.file && existingZone.imagePath) {
const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1));
if (fs.existsSync(oldImagePath)) {
fs.unlinkSync(oldImagePath);
try {
fs.unlinkSync(oldImagePath);
} catch (error) {
console.warn('Failed to delete old image:', error);
// Don't fail the request if we can't delete the old image
}
}
}
const updatedResult = await db.execute({
sql: 'SELECT * FROM zones WHERE id = ?',
args: [req.params.id]
});
if (updatedResult.rows.length === 0) {
return res.status(500).json({ error: 'Zone not found after update' });
}
const updatedZone = {
id: updatedResult.rows[0].id,
name: updatedResult.rows[0].name,
@ -757,9 +770,10 @@ app.put('/api/zones/:id', upload.single('image'), async (req, res) => {
createdAt: updatedResult.rows[0].createdAt
};
const zoneWithStatus = await calculateZoneStatus(zone);
const zoneWithStatus = await calculateZoneStatus(updatedZone);
res.json(zoneWithStatus);
} catch (error) {
console.error('Zone update error:', error);
res.status(500).json({ error: error.message });
}
});

View File

@ -1,5 +1,21 @@
import React, { useState, useEffect } from 'react';
import { Plus, Filter, Calendar, Scissors, AlertTriangle, Square, CheckCircle, Clock, Map, History, Zap, Send, Settings, X } from './Icons';
import {
Plus,
Filter,
Calendar,
Scissors,
AlertTriangle,
Square,
CheckCircle,
Clock,
Map,
History,
Zap,
Send,
Settings,
X,
BarChart
} from './Icons';
import { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
import { api } from '../services/api';
import ZoneCard from './ZoneCard';
@ -10,9 +26,10 @@ import MowingModal from './MowingModal';
import BulkMowingModal from './BulkMowingModal';
import TelegramSettings from './TelegramSettings';
import SeasonSettingsComponent from './SeasonSettings';
import YearlyReports from "./YearlyReports.tsx";
type FilterType = 'all' | 'due' | 'overdue' | 'new';
type ViewType = 'dashboard' | 'sitePlan' | 'history';
type ViewType = 'dashboard' | 'sitePlan' | 'history' | 'reports';
const Dashboard: React.FC = () => {
const [zones, setZones] = useState<Zone[]>([]);
@ -274,6 +291,20 @@ const Dashboard: React.FC = () => {
История
</button>
</div>
<button
onClick={() => {
setView('reports');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'reports'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<BarChart className="h-5 w-5 mr-3" />
Отчеты
</button>
<div className="mt-8">
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
@ -367,14 +398,14 @@ const Dashboard: React.FC = () => {
</button>
<div>
<h2 className="text-2xl font-bold text-gray-900">
{view === 'dashboard' && 'Dashboard'}
{view === 'sitePlan' && 'Site Plan'}
{view === 'history' && 'History'}
{view === 'dashboard' && 'Главная'}
{view === 'sitePlan' && 'План участка'}
{view === 'history' && 'История'}
</h2>
<p className="text-gray-600">
{view === 'dashboard' && 'Keep track of your lawn mowing schedule'}
{view === 'sitePlan' && 'Visualize your property zones'}
{view === 'history' && 'Track your lawn care activities'}
{view === 'dashboard' && 'Следите за своим графиком стрижки газона'}
{view === 'sitePlan' && 'Визуализируйте зоны вашего участка'}
{view === 'history' && 'Отслеживайте действия по уходу за газоном'}
</p>
</div>
</div>
@ -386,6 +417,9 @@ const Dashboard: React.FC = () => {
<div className="max-w-7xl mx-auto px-6 py-8">
{view === 'history' ? (
<HistoryView zones={zones} />
) : view === 'reports' ? (
<YearlyReports zones={zones} />
) : view === 'sitePlan' ? (
<SitePlan
zones={zones}

View File

@ -95,6 +95,12 @@ export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6"
</svg>
);
export const Download: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 13l3 3m0 0l3-3m-3 3V4" />
</svg>
);
export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />

View File

@ -65,8 +65,8 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
};
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const formatDate = (month: number, day: number) => {
@ -107,7 +107,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Settings className="h-6 w-6 text-green-600" />
Mowing Season Settings
Настройки сезона покосов
</h2>
<button
onClick={onClose}
@ -138,11 +138,11 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
)}
<div>
<h3 className="font-medium text-gray-900">
{settings.isActive && isCurrentlyInSeason() ? 'In Season' : 'Off Season'}
{settings.isActive && isCurrentlyInSeason() ? 'Сезон' : 'Не сезон'}
</h3>
<p className="text-sm text-gray-600">
{settings.isActive && isCurrentlyInSeason()
? 'Mowing schedules are active'
? 'Активны графики скашивания'
: 'All zones are considered maintained'
}
</p>
@ -160,7 +160,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
className="mr-3 text-green-600 focus:ring-green-500"
/>
<span className="text-sm font-medium text-gray-700">
Enable mowing season (disable to treat all zones as maintained year-round)
Включить сезон скашивания (отключить, чтобы обеспечить круглогодичный уход за всеми зонами)
</span>
</label>
</div>
@ -170,11 +170,11 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
{/* Season Start */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Season Start
Начало сезона
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Month</label>
<label className="block text-xs text-gray-500 mb-1">Месяц</label>
<select
value={settings.startMonth}
onChange={(e) => setSettings(prev => ({ ...prev, startMonth: parseInt(e.target.value) }))}
@ -186,7 +186,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day</label>
<label className="block text-xs text-gray-500 mb-1">День</label>
<input
type="number"
min="1"
@ -198,18 +198,18 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Season starts: {formatDate(settings.startMonth, settings.startDay)}
Сезон начинается: {formatDate(settings.startMonth, settings.startDay)}
</p>
</div>
{/* Season End */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Season End
Конец сезона
</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Month</label>
<label className="block text-xs text-gray-500 mb-1">Месяц</label>
<select
value={settings.endMonth}
onChange={(e) => setSettings(prev => ({ ...prev, endMonth: parseInt(e.target.value) }))}
@ -221,7 +221,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Day</label>
<label className="block text-xs text-gray-500 mb-1">День</label>
<input
type="number"
min="1"
@ -233,18 +233,18 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Season ends: {formatDate(settings.endMonth, settings.endDay)}
Сезон завершается: {formatDate(settings.endMonth, settings.endDay)}
</p>
</div>
{/* Season Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">How it works:</h4>
<h4 className="text-sm font-medium text-blue-900 mb-2">Как это работает:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> During season: Normal mowing schedules apply</li>
<li> Off season: All zones show as "maintained"</li>
<li> Mowing/trimming can still be recorded year-round</li>
<li> Notifications only sent during active season</li>
<li> В течение сезона: применяется обычный график скашивания</li>
<li> Межсезонье: Все зоны отображаются как "обслуженные"</li>
<li> Запись скашивания/подравнивание по-прежнему может производиться круглый год</li>
<li> Уведомления отправляются только в активный сезон</li>
</ul>
</div>
</>
@ -257,14 +257,14 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
onClick={onClose}
className="flex-1 py-2 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors duration-200"
>
Cancel
Отмена
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{saving ? 'Saving...' : 'Save Settings'}
{saving ? 'Сохранение...' : 'Сохранить настройки'}
</button>
</div>
</div>

View File

@ -0,0 +1,696 @@
import React, { useState, useEffect } from 'react';
import { BarChart, Calendar, Timer, Square, ChevronDown, ChevronUp, Scissors, Zap, Download } from './Icons';
import { Zone, MowingHistory } from '../types/zone';
import { api } from '../services/api';
interface YearlyReportsProps {
zones: Zone[];
}
interface YearlyStats {
year: number;
totalSessions: number;
totalMowingSessions: number;
totalTrimmingSessions: number;
totalMinutes: number;
totalArea: number;
uniqueZones: number;
averageSessionTime: number;
monthlyBreakdown: MonthlyData[];
zoneBreakdown: ZoneStats[];
mowerBreakdown: MowerStats[];
weatherBreakdown: WeatherStats[];
activityTimeline: ActivityData[];
}
interface MonthlyData {
month: number;
monthName: string;
sessions: number;
mowingSessions: number;
trimmingSessions: number;
minutes: number;
area: number;
uniqueZones: number;
}
interface ZoneStats {
zoneId: number;
zoneName: string;
sessions: number;
mowingSessions: number;
trimmingSessions: number;
minutes: number;
area: number;
averageInterval: number;
}
interface MowerStats {
mowerId: number;
mowerName: string;
mowerType: string;
sessions: number;
minutes: number;
area: number;
}
interface WeatherStats {
weather: string;
sessions: number;
percentage: number;
}
interface ActivityData {
date: string;
sessions: number;
mowingSessions: number;
trimmingSessions: number;
minutes: number;
area: number;
}
const YearlyReports: React.FC<YearlyReportsProps> = ({ zones }) => {
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [yearlyStats, setYearlyStats] = useState<YearlyStats | null>(null);
const [availableYears, setAvailableYears] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [expandedSection, setExpandedSection] = useState<string | null>('overview');
useEffect(() => {
loadYearlyData();
}, [selectedYear]);
const loadYearlyData = async () => {
setLoading(true);
try {
// Get all history data for the year
const startDate = new Date(selectedYear, 0, 1).toISOString();
const endDate = new Date(selectedYear, 11, 31, 23, 59, 59).toISOString();
// Fetch all data for the year (using large limit)
const historyResponse = await api.getMowingHistory(undefined, 10000, 0);
// Filter data for the selected year
const yearData = historyResponse.data.filter(entry => {
const entryDate = new Date(entry.mowedDate);
return entryDate.getFullYear() === selectedYear;
});
// Calculate comprehensive statistics
const stats = calculateYearlyStats(yearData, selectedYear);
setYearlyStats(stats);
// Get available years from all data
const years = [...new Set(historyResponse.data.map(entry =>
new Date(entry.mowedDate).getFullYear()
))].sort((a, b) => b - a);
setAvailableYears(years);
} catch (error) {
console.error('Failed to load yearly data:', error);
} finally {
setLoading(false);
}
};
const calculateYearlyStats = (data: MowingHistory[], year: number): YearlyStats => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
// Basic totals
const totalSessions = data.length;
const totalMowingSessions = data.filter(d => d.activityType !== 'trimming').length;
const totalTrimmingSessions = data.filter(d => d.activityType === 'trimming').length;
const totalMinutes = data.reduce((sum, d) => sum + (d.duration || 0), 0);
const totalArea = data.reduce((sum, d) => sum + d.zoneArea, 0);
const uniqueZones = new Set(data.map(d => d.zoneId)).size;
const averageSessionTime = totalSessions > 0 ? Math.round(totalMinutes / totalSessions) : 0;
// Monthly breakdown
const monthlyBreakdown: MonthlyData[] = [];
for (let month = 0; month < 12; month++) {
const monthData = data.filter(d => new Date(d.mowedDate).getMonth() === month);
monthlyBreakdown.push({
month: month + 1,
monthName: monthNames[month],
sessions: monthData.length,
mowingSessions: monthData.filter(d => d.activityType !== 'trimming').length,
trimmingSessions: monthData.filter(d => d.activityType === 'trimming').length,
minutes: monthData.reduce((sum, d) => sum + (d.duration || 0), 0),
area: monthData.reduce((sum, d) => sum + d.zoneArea, 0),
uniqueZones: new Set(monthData.map(d => d.zoneId)).size,
});
}
// Zone breakdown
const zoneMap = new Map<number, ZoneStats>();
data.forEach(entry => {
if (!zoneMap.has(entry.zoneId)) {
zoneMap.set(entry.zoneId, {
zoneId: entry.zoneId,
zoneName: entry.zoneName,
sessions: 0,
mowingSessions: 0,
trimmingSessions: 0,
minutes: 0,
area: 0,
averageInterval: 0,
});
}
const zoneStats = zoneMap.get(entry.zoneId)!;
zoneStats.sessions++;
if (entry.activityType === 'trimming') {
zoneStats.trimmingSessions++;
} else {
zoneStats.mowingSessions++;
}
zoneStats.minutes += entry.duration || 0;
zoneStats.area += entry.zoneArea;
});
// Calculate average intervals for zones
zoneMap.forEach((stats, zoneId) => {
const zoneEntries = data.filter(d => d.zoneId === zoneId && d.activityType !== 'trimming')
.sort((a, b) => new Date(a.mowedDate).getTime() - new Date(b.mowedDate).getTime());
if (zoneEntries.length > 1) {
const intervals = [];
for (let i = 1; i < zoneEntries.length; i++) {
const daysDiff = Math.round(
(new Date(zoneEntries[i].mowedDate).getTime() - new Date(zoneEntries[i-1].mowedDate).getTime())
/ (1000 * 60 * 60 * 24)
);
intervals.push(daysDiff);
}
stats.averageInterval = Math.round(intervals.reduce((sum, interval) => sum + interval, 0) / intervals.length);
}
});
const zoneBreakdown = Array.from(zoneMap.values()).sort((a, b) => b.sessions - a.sessions);
// Mower breakdown
const mowerMap = new Map<number, MowerStats>();
data.forEach(entry => {
if (entry.mowerId) {
if (!mowerMap.has(entry.mowerId)) {
mowerMap.set(entry.mowerId, {
mowerId: entry.mowerId,
mowerName: entry.mowerName || 'Unknown',
mowerType: entry.mowerType || 'Unknown',
sessions: 0,
minutes: 0,
area: 0,
});
}
const mowerStats = mowerMap.get(entry.mowerId)!;
mowerStats.sessions++;
mowerStats.minutes += entry.duration || 0;
mowerStats.area += entry.zoneArea;
}
});
const mowerBreakdown = Array.from(mowerMap.values()).sort((a, b) => b.sessions - a.sessions);
// Weather breakdown
const weatherMap = new Map<string, number>();
data.forEach(entry => {
if (entry.weather) {
weatherMap.set(entry.weather, (weatherMap.get(entry.weather) || 0) + 1);
}
});
const weatherBreakdown: WeatherStats[] = Array.from(weatherMap.entries())
.map(([weather, sessions]) => ({
weather,
sessions,
percentage: Math.round((sessions / totalSessions) * 100),
}))
.sort((a, b) => b.sessions - a.sessions);
// Activity timeline (daily aggregation)
const timelineMap = new Map<string, ActivityData>();
data.forEach(entry => {
const date = new Date(entry.mowedDate).toISOString().split('T')[0];
if (!timelineMap.has(date)) {
timelineMap.set(date, {
date,
sessions: 0,
mowingSessions: 0,
trimmingSessions: 0,
minutes: 0,
area: 0,
});
}
const dayStats = timelineMap.get(date)!;
dayStats.sessions++;
if (entry.activityType === 'trimming') {
dayStats.trimmingSessions++;
} else {
dayStats.mowingSessions++;
}
dayStats.minutes += entry.duration || 0;
dayStats.area += entry.zoneArea;
});
const activityTimeline = Array.from(timelineMap.values()).sort((a, b) => a.date.localeCompare(b.date));
return {
year,
totalSessions,
totalMowingSessions,
totalTrimmingSessions,
totalMinutes,
totalArea,
uniqueZones,
averageSessionTime,
monthlyBreakdown,
zoneBreakdown,
mowerBreakdown,
weatherBreakdown,
activityTimeline,
};
};
const formatDuration = (minutes: number) => {
if (minutes < 60) return `${minutes}м`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}ч ${mins}м` : `${hours}ч`;
};
const getMowerIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'battery':
return '🔋';
case 'electric':
return '⚡';
case 'gas':
case 'petrol':
return '⛽';
default:
return '🚜';
}
};
const exportToCSV = () => {
if (!yearlyStats) return;
const csvData = [
['Yearly Mowing Report', selectedYear],
[''],
['Summary'],
['Total Sessions', yearlyStats.totalSessions],
['Mowing Sessions', yearlyStats.totalMowingSessions],
['Trimming Sessions', yearlyStats.totalTrimmingSessions],
['Total Time', formatDuration(yearlyStats.totalMinutes)],
['Total Area', `${yearlyStats.totalArea.toLocaleString()} sq ft`],
['Unique Zones', yearlyStats.uniqueZones],
['Average Session Time', formatDuration(yearlyStats.averageSessionTime)],
[''],
['Monthly Breakdown'],
['Month', 'Sessions', 'Mowing', 'Trimming', 'Time', 'Area (sq ft)'],
...yearlyStats.monthlyBreakdown.map(month => [
month.monthName,
month.sessions,
month.mowingSessions,
month.trimmingSessions,
formatDuration(month.minutes),
month.area.toLocaleString(),
]),
[''],
['Zone Breakdown'],
['Zone', 'Sessions', 'Mowing', 'Trimming', 'Time', 'Area (sq ft)', 'Avg Interval (days)'],
...yearlyStats.zoneBreakdown.map(zone => [
zone.zoneName,
zone.sessions,
zone.mowingSessions,
zone.trimmingSessions,
formatDuration(zone.minutes),
zone.area.toLocaleString(),
zone.averageInterval || 'N/A',
]),
];
const csvContent = csvData.map(row => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `lawn-mowing-report-${selectedYear}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
const toggleSection = (section: string) => {
setExpandedSection(expandedSection === section ? null : section);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
);
}
if (!yearlyStats) {
return (
<div className="text-center py-12">
<BarChart className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No data available</h3>
<p className="mt-1 text-sm text-gray-500">No mowing data found for {selectedYear}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<BarChart className="h-6 w-6 text-green-600" />
Ежегодные отчеты
</h2>
<p className="text-gray-600 mt-1">Всесторонний анализ мероприятий по уходу за газоном</p>
</div>
<div className="flex gap-3">
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
<button
onClick={exportToCSV}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors duration-200"
>
<Download className="h-4 w-4" />
Экспорт в CSV
</button>
</div>
</div>
{/* Overview Stats */}
<div className="bg-white rounded-lg shadow-md">
<button
onClick={() => toggleSection('overview')}
className="w-full px-6 py-4 flex items-center justify-between text-left border-b hover:bg-gray-50 transition-colors duration-200"
>
<h3 className="text-lg font-semibold text-gray-900">
{selectedYear} Обзор
</h3>
{expandedSection === 'overview' ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedSection === 'overview' && (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-blue-600">Всего сессий</p>
<p className="text-2xl font-bold text-blue-900">{yearlyStats.totalSessions}</p>
<p className="text-xs text-blue-700">
{yearlyStats.totalMowingSessions} покосов, {yearlyStats.totalTrimmingSessions} подравниваний
</p>
</div>
<Scissors className="h-8 w-8 text-blue-600" />
</div>
</div>
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-green-600">Всего времени</p>
<p className="text-2xl font-bold text-green-900">
{formatDuration(yearlyStats.totalMinutes)}
</p>
<p className="text-xs text-green-700">
В среднем: {formatDuration(yearlyStats.averageSessionTime)}/сессию
</p>
</div>
<Timer className="h-8 w-8 text-green-600" />
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-purple-600">Общая площадь</p>
<p className="text-2xl font-bold text-purple-900">
{yearlyStats.totalArea.toLocaleString()}
</p>
<p className="text-xs text-purple-700">м2 обработано</p>
</div>
<Square className="h-8 w-8 text-purple-600" />
</div>
</div>
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-orange-600">Активнфе зоны</p>
<p className="text-2xl font-bold text-orange-900">{yearlyStats.uniqueZones}</p>
<p className="text-xs text-orange-700">зон обработано</p>
</div>
<Calendar className="h-8 w-8 text-orange-600" />
</div>
</div>
</div>
</div>
)}
</div>
{/* Monthly Breakdown */}
<div className="bg-white rounded-lg shadow-md">
<button
onClick={() => toggleSection('monthly')}
className="w-full px-6 py-4 flex items-center justify-between text-left border-b hover:bg-gray-50 transition-colors duration-200"
>
<h3 className="text-lg font-semibold text-gray-900">Разбивка по месяцам</h3>
{expandedSection === 'monthly' ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedSection === 'monthly' && (
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Месяц</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сессии</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Скошено</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Поравнено</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Время</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Площадь</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{yearlyStats.monthlyBreakdown.map((month) => (
<tr key={month.month} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{month.monthName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{month.sessions}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center gap-1">
<Scissors className="h-4 w-4 text-green-600" />
{month.mowingSessions}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center gap-1">
<Zap className="h-4 w-4 text-purple-600" />
{month.trimmingSessions}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(month.minutes)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{month.area.toLocaleString()} м2
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Zone Performance */}
<div className="bg-white rounded-lg shadow-md">
<button
onClick={() => toggleSection('zones')}
className="w-full px-6 py-4 flex items-center justify-between text-left border-b hover:bg-gray-50 transition-colors duration-200"
>
<h3 className="text-lg font-semibold text-gray-900">Производительность по зонам</h3>
{expandedSection === 'zones' ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedSection === 'zones' && (
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Зона</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сессии</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Скошено</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Подравнено</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Время</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Площадь</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Средний интервал</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{yearlyStats.zoneBreakdown.map((zone) => (
<tr key={zone.zoneId} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{zone.zoneName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{zone.sessions}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center gap-1">
<Scissors className="h-4 w-4 text-green-600" />
{zone.mowingSessions}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center gap-1">
<Zap className="h-4 w-4 text-purple-600" />
{zone.trimmingSessions}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(zone.minutes)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{zone.area.toLocaleString()} кв.м.
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{zone.averageInterval ? `${zone.averageInterval} дней` : 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Equipment Usage */}
{yearlyStats.mowerBreakdown.length > 0 && (
<div className="bg-white rounded-lg shadow-md">
<button
onClick={() => toggleSection('equipment')}
className="w-full px-6 py-4 flex items-center justify-between text-left border-b hover:bg-gray-50 transition-colors duration-200"
>
<h3 className="text-lg font-semibold text-gray-900">Использование оборудования</h3>
{expandedSection === 'equipment' ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedSection === 'equipment' && (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{yearlyStats.mowerBreakdown.map((mower) => (
<div key={mower.mowerId} className="bg-gray-50 rounded-lg p-4 border">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{getMowerIcon(mower.mowerType)}</span>
<div>
<h4 className="font-medium text-gray-900">{mower.mowerName}</h4>
<p className="text-sm text-gray-600">{mower.mowerType}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Сессии:</span>
<span className="font-medium">{mower.sessions}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Время:</span>
<span className="font-medium">{formatDuration(mower.minutes)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Площадь:</span>
<span className="font-medium">{mower.area.toLocaleString()} м2</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Weather Analysis */}
{yearlyStats.weatherBreakdown.length > 0 && (
<div className="bg-white rounded-lg shadow-md">
<button
onClick={() => toggleSection('weather')}
className="w-full px-6 py-4 flex items-center justify-between text-left border-b hover:bg-gray-50 transition-colors duration-200"
>
<h3 className="text-lg font-semibold text-gray-900">Анализ погоды</h3>
{expandedSection === 'weather' ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedSection === 'weather' && (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{yearlyStats.weatherBreakdown.map((weather) => (
<div key={weather.weather} className="bg-gray-50 rounded-lg p-4 border">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-900">{weather.weather}</h4>
<span className="text-sm font-medium text-blue-600">{weather.percentage}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${weather.percentage}%` }}
></div>
</div>
<p className="text-sm text-gray-600">{weather.sessions} сессий</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
export default YearlyReports;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB