Версия 0.8.0 Добавлены отчеты по годам.
12
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
696
src/components/YearlyReports.tsx
Normal 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;
|
||||
|
Before Width: | Height: | Size: 42 KiB |
BIN
uploads/image-1756390282554-909211750.jpg
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
uploads/image-1756390310578-305332377.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
uploads/image-1756390719823-218029209.jpg
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
uploads/image-1756390753486-359720367.jpg
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
uploads/image-1756390769487-660762043.jpg
Normal file
|
After Width: | Height: | Size: 347 KiB |
BIN
uploads/image-1756390804281-192310067.jpg
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
uploads/image-1756390825972-180905785.jpg
Normal file
|
After Width: | Height: | Size: 377 KiB |
BIN
uploads/image-1756929231404-731431154.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |