diff --git a/db/lawn_scheduler.db b/db/lawn_scheduler.db index cfe58ae..ae628af 100644 Binary files a/db/lawn_scheduler.db and b/db/lawn_scheduler.db differ diff --git a/package-lock.json b/package-lock.json index cda0876..d2ade3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index aa1f09d..af22267 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.js b/server/index.js index 0ac683a..4a2206f 100644 --- a/server/index.js +++ b/server/index.js @@ -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 }); } }); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 56fe0be..3138251 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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([]); @@ -274,6 +291,20 @@ const Dashboard: React.FC = () => { История +

@@ -367,14 +398,14 @@ const Dashboard: React.FC = () => {

- {view === 'dashboard' && 'Dashboard'} - {view === 'sitePlan' && 'Site Plan'} - {view === 'history' && 'History'} + {view === 'dashboard' && 'Главная'} + {view === 'sitePlan' && 'План участка'} + {view === 'history' && 'История'}

- {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' && 'Отслеживайте действия по уходу за газоном'}

@@ -386,6 +417,9 @@ const Dashboard: React.FC = () => {
{view === 'history' ? ( + ) : view === 'reports' ? ( + + ) : view === 'sitePlan' ? ( = ({ className = "h-6 w-6" ); +export const Download: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( + + + +); + export const MapPin: React.FC<{ className?: string }> = ({ className = "h-6 w-6" }) => ( diff --git a/src/components/SeasonSettings.tsx b/src/components/SeasonSettings.tsx index 7a1b889..3b3f1b2 100644 --- a/src/components/SeasonSettings.tsx +++ b/src/components/SeasonSettings.tsx @@ -65,8 +65,8 @@ const SeasonSettingsComponent: React.FC = ({ 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 = ({ onClose }) =>

- Mowing Season Settings + Настройки сезона покосов

diff --git a/src/components/YearlyReports.tsx b/src/components/YearlyReports.tsx new file mode 100644 index 0000000..2f97d5a --- /dev/null +++ b/src/components/YearlyReports.tsx @@ -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 = ({ zones }) => { + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [yearlyStats, setYearlyStats] = useState(null); + const [availableYears, setAvailableYears] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedSection, setExpandedSection] = useState('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(); + 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(); + 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(); + 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(); + 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 ( +
+
+
+ ); + } + + if (!yearlyStats) { + return ( +
+ +

No data available

+

No mowing data found for {selectedYear}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + Ежегодные отчеты +

+

Всесторонний анализ мероприятий по уходу за газоном

+
+ +
+ + + +
+
+ + {/* Overview Stats */} +
+ + + {expandedSection === 'overview' && ( +
+
+
+
+
+

Всего сессий

+

{yearlyStats.totalSessions}

+

+ {yearlyStats.totalMowingSessions} покосов, {yearlyStats.totalTrimmingSessions} подравниваний +

+
+ +
+
+ +
+
+
+

Всего времени

+

+ {formatDuration(yearlyStats.totalMinutes)} +

+

+ В среднем: {formatDuration(yearlyStats.averageSessionTime)}/сессию +

+
+ +
+
+ +
+
+
+

Общая площадь

+

+ {yearlyStats.totalArea.toLocaleString()} +

+

м2 обработано

+
+ +
+
+ +
+
+
+

Активнфе зоны

+

{yearlyStats.uniqueZones}

+

зон обработано

+
+ +
+
+
+
+ )} +
+ + {/* Monthly Breakdown */} +
+ + + {expandedSection === 'monthly' && ( +
+
+ + + + + + + + + + + + + {yearlyStats.monthlyBreakdown.map((month) => ( + + + + + + + + + ))} + +
МесяцСессииСкошеноПоравненоВремяПлощадь
+ {month.monthName} + + {month.sessions} + +
+ + {month.mowingSessions} +
+
+
+ + {month.trimmingSessions} +
+
+ {formatDuration(month.minutes)} + + {month.area.toLocaleString()} м2 +
+
+
+ )} +
+ + {/* Zone Performance */} +
+ + + {expandedSection === 'zones' && ( +
+
+ + + + + + + + + + + + + + {yearlyStats.zoneBreakdown.map((zone) => ( + + + + + + + + + + ))} + +
ЗонаСессииСкошеноПодравненоВремяПлощадьСредний интервал
+ {zone.zoneName} + + {zone.sessions} + +
+ + {zone.mowingSessions} +
+
+
+ + {zone.trimmingSessions} +
+
+ {formatDuration(zone.minutes)} + + {zone.area.toLocaleString()} кв.м. + + {zone.averageInterval ? `${zone.averageInterval} дней` : 'N/A'} +
+
+
+ )} +
+ + {/* Equipment Usage */} + {yearlyStats.mowerBreakdown.length > 0 && ( +
+ + + {expandedSection === 'equipment' && ( +
+
+ {yearlyStats.mowerBreakdown.map((mower) => ( +
+
+ {getMowerIcon(mower.mowerType)} +
+

{mower.mowerName}

+

{mower.mowerType}

+
+
+
+
+ Сессии: + {mower.sessions} +
+
+ Время: + {formatDuration(mower.minutes)} +
+
+ Площадь: + {mower.area.toLocaleString()} м2 +
+
+
+ ))} +
+
+ )} +
+ )} + + {/* Weather Analysis */} + {yearlyStats.weatherBreakdown.length > 0 && ( +
+ + + {expandedSection === 'weather' && ( +
+
+ {yearlyStats.weatherBreakdown.map((weather) => ( +
+
+

{weather.weather}

+ {weather.percentage}% +
+
+
+
+

{weather.sessions} сессий

+
+ ))} +
+
+ )} +
+ )} +
+ ); +}; + +export default YearlyReports; \ No newline at end of file diff --git a/uploads/image-1752520984015-663619457.png b/uploads/image-1752520984015-663619457.png deleted file mode 100644 index a01a448..0000000 Binary files a/uploads/image-1752520984015-663619457.png and /dev/null differ diff --git a/uploads/image-1756390282554-909211750.jpg b/uploads/image-1756390282554-909211750.jpg new file mode 100644 index 0000000..56b7d68 Binary files /dev/null and b/uploads/image-1756390282554-909211750.jpg differ diff --git a/uploads/image-1756390310578-305332377.jpg b/uploads/image-1756390310578-305332377.jpg new file mode 100644 index 0000000..d2a7083 Binary files /dev/null and b/uploads/image-1756390310578-305332377.jpg differ diff --git a/uploads/image-1756390719823-218029209.jpg b/uploads/image-1756390719823-218029209.jpg new file mode 100644 index 0000000..1f4334f Binary files /dev/null and b/uploads/image-1756390719823-218029209.jpg differ diff --git a/uploads/image-1756390753486-359720367.jpg b/uploads/image-1756390753486-359720367.jpg new file mode 100644 index 0000000..e7dcf65 Binary files /dev/null and b/uploads/image-1756390753486-359720367.jpg differ diff --git a/uploads/image-1756390769487-660762043.jpg b/uploads/image-1756390769487-660762043.jpg new file mode 100644 index 0000000..e023c39 Binary files /dev/null and b/uploads/image-1756390769487-660762043.jpg differ diff --git a/uploads/image-1756390804281-192310067.jpg b/uploads/image-1756390804281-192310067.jpg new file mode 100644 index 0000000..2d17988 Binary files /dev/null and b/uploads/image-1756390804281-192310067.jpg differ diff --git a/uploads/image-1756390825972-180905785.jpg b/uploads/image-1756390825972-180905785.jpg new file mode 100644 index 0000000..2032890 Binary files /dev/null and b/uploads/image-1756390825972-180905785.jpg differ diff --git a/uploads/image-1756929231404-731431154.jpg b/uploads/image-1756929231404-731431154.jpg new file mode 100644 index 0000000..7ae546c Binary files /dev/null and b/uploads/image-1756929231404-731431154.jpg differ