Версия 0.8.0 Добавлены отчеты по годам.
12
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"version": "0.6.2",
|
"version": "0.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@ -14,7 +14,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"multer": "2.0.1",
|
"multer": "2.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -5242,9 +5242,9 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
|
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lawn-mowing-scheduler",
|
"name": "lawn-mowing-scheduler",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||||
@ -20,7 +20,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"multer": "2.0.1",
|
"multer": "2.0.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
"react": "^18.3.1",
|
"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('SQL:', sql);
|
||||||
console.log('ARGS:', args);
|
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
|
// Delete old image if new one was provided
|
||||||
if (req.file && existingZone.imagePath) {
|
if (req.file && existingZone.imagePath) {
|
||||||
const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1));
|
const oldImagePath = path.join(process.cwd(), existingZone.imagePath.substring(1));
|
||||||
if (fs.existsSync(oldImagePath)) {
|
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({
|
const updatedResult = await db.execute({
|
||||||
sql: 'SELECT * FROM zones WHERE id = ?',
|
sql: 'SELECT * FROM zones WHERE id = ?',
|
||||||
args: [req.params.id]
|
args: [req.params.id]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updatedResult.rows.length === 0) {
|
||||||
|
return res.status(500).json({ error: 'Zone not found after update' });
|
||||||
|
}
|
||||||
|
|
||||||
const updatedZone = {
|
const updatedZone = {
|
||||||
id: updatedResult.rows[0].id,
|
id: updatedResult.rows[0].id,
|
||||||
name: updatedResult.rows[0].name,
|
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
|
createdAt: updatedResult.rows[0].createdAt
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoneWithStatus = await calculateZoneStatus(zone);
|
const zoneWithStatus = await calculateZoneStatus(updatedZone);
|
||||||
res.json(zoneWithStatus);
|
res.json(zoneWithStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Zone update error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,21 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { Zone, MowingFormData, BulkMowingFormData } from '../types/zone';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import ZoneCard from './ZoneCard';
|
import ZoneCard from './ZoneCard';
|
||||||
@ -10,9 +26,10 @@ import MowingModal from './MowingModal';
|
|||||||
import BulkMowingModal from './BulkMowingModal';
|
import BulkMowingModal from './BulkMowingModal';
|
||||||
import TelegramSettings from './TelegramSettings';
|
import TelegramSettings from './TelegramSettings';
|
||||||
import SeasonSettingsComponent from './SeasonSettings';
|
import SeasonSettingsComponent from './SeasonSettings';
|
||||||
|
import YearlyReports from "./YearlyReports.tsx";
|
||||||
|
|
||||||
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
type FilterType = 'all' | 'due' | 'overdue' | 'new';
|
||||||
type ViewType = 'dashboard' | 'sitePlan' | 'history';
|
type ViewType = 'dashboard' | 'sitePlan' | 'history' | 'reports';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const [zones, setZones] = useState<Zone[]>([]);
|
const [zones, setZones] = useState<Zone[]>([]);
|
||||||
@ -274,6 +291,20 @@ const Dashboard: React.FC = () => {
|
|||||||
История
|
История
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="mt-8">
|
||||||
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
@ -367,14 +398,14 @@ const Dashboard: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
{view === 'dashboard' && 'Dashboard'}
|
{view === 'dashboard' && 'Главная'}
|
||||||
{view === 'sitePlan' && 'Site Plan'}
|
{view === 'sitePlan' && 'План участка'}
|
||||||
{view === 'history' && 'History'}
|
{view === 'history' && 'История'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{view === 'dashboard' && 'Keep track of your lawn mowing schedule'}
|
{view === 'dashboard' && 'Следите за своим графиком стрижки газона'}
|
||||||
{view === 'sitePlan' && 'Visualize your property zones'}
|
{view === 'sitePlan' && 'Визуализируйте зоны вашего участка'}
|
||||||
{view === 'history' && 'Track your lawn care activities'}
|
{view === 'history' && 'Отслеживайте действия по уходу за газоном'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -386,6 +417,9 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
{view === 'history' ? (
|
{view === 'history' ? (
|
||||||
<HistoryView zones={zones} />
|
<HistoryView zones={zones} />
|
||||||
|
) : view === 'reports' ? (
|
||||||
|
<YearlyReports zones={zones} />
|
||||||
|
|
||||||
) : view === 'sitePlan' ? (
|
) : view === 'sitePlan' ? (
|
||||||
<SitePlan
|
<SitePlan
|
||||||
zones={zones}
|
zones={zones}
|
||||||
|
|||||||
@ -95,6 +95,12 @@ export const Upload: React.FC<{ className?: string }> = ({ className = "h-6 w-6"
|
|||||||
</svg>
|
</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" }) => (
|
export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 = [
|
const monthNames = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDate = (month: number, day: number) => {
|
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">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<Settings className="h-6 w-6 text-green-600" />
|
<Settings className="h-6 w-6 text-green-600" />
|
||||||
Mowing Season Settings
|
Настройки сезона покосов
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -138,11 +138,11 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-gray-900">
|
<h3 className="font-medium text-gray-900">
|
||||||
{settings.isActive && isCurrentlyInSeason() ? 'In Season' : 'Off Season'}
|
{settings.isActive && isCurrentlyInSeason() ? 'Сезон' : 'Не сезон'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{settings.isActive && isCurrentlyInSeason()
|
{settings.isActive && isCurrentlyInSeason()
|
||||||
? 'Mowing schedules are active'
|
? 'Активны графики скашивания'
|
||||||
: 'All zones are considered maintained'
|
: 'All zones are considered maintained'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@ -160,7 +160,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
className="mr-3 text-green-600 focus:ring-green-500"
|
className="mr-3 text-green-600 focus:ring-green-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
Enable mowing season (disable to treat all zones as maintained year-round)
|
Включить сезон скашивания (отключить, чтобы обеспечить круглогодичный уход за всеми зонами)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -170,11 +170,11 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
{/* Season Start */}
|
{/* Season Start */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
Season Start
|
Начало сезона
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={settings.startMonth}
|
value={settings.startMonth}
|
||||||
onChange={(e) => setSettings(prev => ({ ...prev, startMonth: parseInt(e.target.value) }))}
|
onChange={(e) => setSettings(prev => ({ ...prev, startMonth: parseInt(e.target.value) }))}
|
||||||
@ -186,7 +186,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -198,18 +198,18 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Season starts: {formatDate(settings.startMonth, settings.startDay)}
|
Сезон начинается: {formatDate(settings.startMonth, settings.startDay)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Season End */}
|
{/* Season End */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
Season End
|
Конец сезона
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={settings.endMonth}
|
value={settings.endMonth}
|
||||||
onChange={(e) => setSettings(prev => ({ ...prev, endMonth: parseInt(e.target.value) }))}
|
onChange={(e) => setSettings(prev => ({ ...prev, endMonth: parseInt(e.target.value) }))}
|
||||||
@ -221,7 +221,7 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -233,18 +233,18 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Season ends: {formatDate(settings.endMonth, settings.endDay)}
|
Сезон завершается: {formatDate(settings.endMonth, settings.endDay)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Season Info */}
|
{/* Season Info */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<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">
|
<ul className="text-sm text-blue-800 space-y-1">
|
||||||
<li>• During season: Normal mowing schedules apply</li>
|
<li>• В течение сезона: применяется обычный график скашивания</li>
|
||||||
<li>• Off season: All zones show as "maintained"</li>
|
<li>• Межсезонье: Все зоны отображаются как "обслуженные"</li>
|
||||||
<li>• Mowing/trimming can still be recorded year-round</li>
|
<li>• Запись скашивания/подравнивание по-прежнему может производиться круглый год</li>
|
||||||
<li>• Notifications only sent during active season</li>
|
<li>• Уведомления отправляются только в активный сезон</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -257,14 +257,14 @@ const SeasonSettingsComponent: React.FC<SeasonSettingsProps> = ({ onClose }) =>
|
|||||||
onClick={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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 |