lawnmowing/src/components/Dashboard.tsx

717 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
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';
import ZoneForm from './ZoneForm';
import SitePlan from './SitePlan';
import HistoryView from './HistoryView';
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' | 'reports';
const Dashboard: React.FC = () => {
const [zones, setZones] = useState<Zone[]>([]);
const [filteredZones, setFilteredZones] = useState<Zone[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
const [view, setView] = useState<ViewType>('dashboard');
const [showForm, setShowForm] = useState(false);
const [editingZone, setEditingZone] = useState<Zone | null>(null);
const [selectedZoneId, setSelectedZoneId] = useState<number | undefined>();
const [showMowingModal, setShowMowingModal] = useState(false);
const [showBulkMowingModal, setShowBulkMowingModal] = useState(false);
const [showTrimmingModal, setShowTrimmingModal] = useState(false);
const [showBulkTrimmingModal, setShowBulkTrimmingModal] = useState(false);
const [mowingZone, setMowingZone] = useState<Zone | null>(null);
const [trimmingZone, setTrimmingZone] = useState<Zone | null>(null);
const [showTelegramSettings, setShowTelegramSettings] = useState(false);
const [showSeasonSettings, setShowSeasonSettings] = useState(false);
const [loading, setLoading] = useState(true);
const [mowingLoading, setMowingLoading] = useState(false);
const [trimmingLoading, setTrimmingLoading] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
loadZones();
}, []);
useEffect(() => {
applyFilter();
}, [zones, filter]);
const loadZones = async () => {
try {
const data = await api.getZones();
setZones(data);
} catch (error) {
console.error('Failed to load zones:', error);
} finally {
setLoading(false);
}
};
const applyFilter = () => {
let filtered = zones;
switch (filter) {
case 'due':
filtered = zones.filter(zone => zone.isDueToday);
break;
case 'overdue':
filtered = zones.filter(zone => zone.isOverdue);
break;
case 'new':
filtered = zones.filter(zone => zone.isNew);
break;
default:
filtered = zones;
}
setFilteredZones(filtered);
};
const handleMarkAsMowed = (zone: Zone) => {
setMowingZone(zone);
setShowMowingModal(true);
};
const handleMarkAsTrimmed = (zone: Zone) => {
setTrimmingZone(zone);
setShowTrimmingModal(true);
};
const handleMowingSubmit = async (data: MowingFormData) => {
if (!mowingZone) return;
setMowingLoading(true);
try {
await api.markAsMowed(mowingZone.id, data);
setShowMowingModal(false);
setMowingZone(null);
loadZones();
} catch (error) {
console.error('Failed to mark as mowed:', error);
} finally {
setMowingLoading(false);
}
};
const handleTrimmingSubmit = async (data: MowingFormData) => {
if (!trimmingZone) return;
setTrimmingLoading(true);
try {
await api.markAsTrimmed(trimmingZone.id, data);
setShowTrimmingModal(false);
setTrimmingZone(null);
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
loadZones();
} catch (error) {
console.error('Failed to mark as trimmed:', error);
} finally {
setTrimmingLoading(false);
}
};
const handleBulkMowingSubmit = async (data: BulkMowingFormData) => {
setMowingLoading(true);
try {
await api.bulkMarkAsMowed(data);
setShowBulkMowingModal(false);
loadZones();
} catch (error) {
console.error('Failed to record bulk mowing session:', error);
} finally {
setMowingLoading(false);
}
};
const handleBulkTrimmingSubmit = async (data: BulkMowingFormData) => {
setTrimmingLoading(true);
try {
await api.bulkMarkAsMowed(data);
setShowBulkTrimmingModal(false);
// Перезагрузка зон, поскольку подравнивание теперь влияет на график скашивания (задержки на 1 неделю)
loadZones();
} catch (error) {
console.error('Failed to record bulk trimming session:', error);
} finally {
setTrimmingLoading(false);
}
};
const handleDeleteZone = async (id: number) => {
if (window.confirm('Are you sure you want to delete this zone?')) {
try {
await api.deleteZone(id);
loadZones();
if (selectedZoneId === id) {
setSelectedZoneId(undefined);
}
} catch (error) {
console.error('Failed to delete zone:', error);
}
}
};
const handleFormSubmit = async () => {
setShowForm(false);
setEditingZone(null);
loadZones();
};
const handleZoneSelect = (zone: Zone) => {
setSelectedZoneId(zone.id);
setView('dashboard');
// Scroll to the zone card
setTimeout(() => {
const element = document.getElementById(`zone-card-${zone.id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
};
// Calculate area statistics
const overdueCount = zones.filter(zone => zone.isOverdue).length;
const dueCount = zones.filter(zone => zone.isDueToday).length;
const newCount = zones.filter(zone => zone.isNew).length;
const okCount = zones.filter(zone => zone.status === 'ok').length;
const totalArea = zones.reduce((sum, zone) => sum + zone.area, 0);
// Calculate mowed vs remaining area based on status
const mowedArea = zones
.filter(zone => zone.status === 'ok')
.reduce((sum, zone) => sum + zone.area, 0);
const mowedPercentage = totalArea > 0 ? (mowedArea / totalArea) * 100 : 0;
// Check if there are zones that need mowing for bulk action
const zonesNeedingMowing = zones.filter(zone =>
zone.status === 'due' || zone.status === 'overdue' || zone.status === 'new'
);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="flex h-screen">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}>
<div className="flex items-center justify-between h-16 px-6 border-b">
<div className="flex items-center gap-3">
<Scissors className="h-8 w-8 text-green-600" />
<h1 className="text-xl font-bold text-gray-900">Уход за газоном</h1>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-gray-400 hover:text-gray-600"
>
<X className="h-6 w-6" />
</button>
</div>
<nav className="mt-6 px-3">
<div className="space-y-1">
{/* Navigation Items */}
<button
onClick={() => {
setView('dashboard');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'dashboard'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Calendar className="h-5 w-5 mr-3" />
Сводка
</button>
<button
onClick={() => {
setView('sitePlan');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'sitePlan'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<Map className="h-5 w-5 mr-3" />
План участка
</button>
<button
onClick={() => {
setView('history');
setSidebarOpen(false);
}}
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
view === 'history'
? 'bg-green-100 text-green-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<History className="h-5 w-5 mr-3" />
История
</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">
Действия
</h3>
<div className="mt-2 space-y-1">
<button
onClick={() => {
setShowForm(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Plus className="h-5 w-5 mr-3" />
Добавить зону
</button>
{/* Bulk Actions - only show on dashboard with zones needing mowing */}
{view === 'dashboard' && zonesNeedingMowing.length > 0 && (
<>
<button
onClick={() => {
setShowBulkMowingModal(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-orange-600 rounded-md hover:bg-orange-50 hover:text-orange-700 transition-colors duration-200"
>
<Scissors className="h-5 w-5 mr-3" />
Bulk Mow ({zonesNeedingMowing.length})
</button>
<button
onClick={() => {
setShowBulkTrimmingModal(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-purple-600 rounded-md hover:bg-purple-50 hover:text-purple-700 transition-colors duration-200"
>
<Zap className="h-5 w-5 mr-3" />
Bulk Trim
</button>
</>
)}
</div>
</div>
<div className="mt-8">
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Настройки
</h3>
<div className="mt-2 space-y-1">
<button
onClick={() => {
setShowTelegramSettings(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Send className="h-5 w-5 mr-3" />
Телеграм
</button>
<button
onClick={() => {
setShowSeasonSettings(true);
setSidebarOpen(false);
}}
className="w-full flex items-center px-3 py-2 text-sm font-medium text-gray-600 rounded-md hover:bg-gray-50 hover:text-gray-900 transition-colors duration-200"
>
<Settings className="h-5 w-5 mr-3" />
Сезон
</button>
</div>
</div>
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top bar */}
<div className="bg-white shadow-sm border-b px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden text-gray-500 hover:text-gray-700"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h2 className="text-2xl font-bold text-gray-900">
{view === 'dashboard' && 'Главная'}
{view === 'sitePlan' && 'План участка'}
{view === 'history' && 'История'}
</h2>
<p className="text-gray-600">
{view === 'dashboard' && 'Следите за своим графиком стрижки газона'}
{view === 'sitePlan' && 'Визуализируйте зоны вашего участка'}
{view === 'history' && 'Отслеживайте действия по уходу за газоном'}
</p>
</div>
</div>
</div>
</div>
{/* Main content area */}
<div className="flex-1 overflow-auto">
<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}
onZoneSelect={handleZoneSelect}
selectedZoneId={selectedZoneId}
/>
) : (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-6 mb-8">
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-green-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Зон</p>
<p className="text-2xl font-bold text-gray-900">{zones.length}</p>
</div>
<Calendar className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Площадь</p>
<p className="text-2xl font-bold text-gray-900">{totalArea.toLocaleString()}</p>
<p className="text-xs text-gray-500">sq ft</p>
</div>
<Square className="h-8 w-8 text-purple-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-emerald-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Актуально</p>
<p className="text-2xl font-bold text-gray-900">{okCount}</p>
<p className="text-xs text-gray-500">zones maintained</p>
</div>
<CheckCircle className="h-8 w-8 text-emerald-500" />
</div>
</div>
{/*
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-blue-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">New Zones</p>
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
<p className="text-xs text-gray-500">not yet mowed</p>
</div>
<Calendar className="h-8 w-8 text-blue-500" />
</div>
</div>
*/}
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-amber-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Внимание!</p>
<p className="text-2xl font-bold text-gray-900">{dueCount + overdueCount + newCount}</p>
<p className="text-xs text-gray-500">zones to mow</p>
</div>
<Clock className="h-8 w-8 text-amber-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Срок-сегодня</p>
<p className="text-2xl font-bold text-gray-900">{dueCount}</p>
</div>
<Scissors className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-red-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Срок прошел</p>
<p className="text-2xl font-bold text-gray-900">{overdueCount}</p>
</div>
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
</div>
</div>
{/* Progress Bar */}
{totalArea > 0 && (
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Ход скашивания</h3>
<span className="text-sm text-gray-600">
{mowedArea.toLocaleString()} / {totalArea.toLocaleString()} м2
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 mb-2">
<div
className="bg-gradient-to-r from-green-500 to-emerald-600 h-4 rounded-full transition-all duration-500 ease-out"
style={{ width: `${mowedPercentage}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" />
{mowedPercentage.toFixed(1)}% Завершено ({okCount} зон)
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4 text-amber-500" />
{(100 - mowedPercentage).toFixed(1)}% Осталось ({dueCount + overdueCount + newCount} зон)
</span>
</div>
</div>
)}
{/* Bulk Mowing Notice */}
{zonesNeedingMowing.length > 1 && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Scissors className="h-5 w-5 text-orange-600" />
<div>
<h4 className="text-sm font-medium text-orange-800">
Необходимо скосить или подравнять несколько зон
</h4>
<p className="text-sm text-orange-700">
Используйте массовые операции для нескольких зон, за один сеанс, с пропорциональным распределением времени
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowBulkMowingModal(true)}
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
>
Покос ({zonesNeedingMowing.length})
</button>
<button
onClick={() => setShowBulkTrimmingModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
>
Подравнование
</button>
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-6">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Filter:</span>
<div className="flex gap-2">
{[
{ key: 'all' as FilterType, label: 'All Zones', count: zones.length },
{ key: 'new' as FilterType, label: 'New Zones', count: newCount },
{ key: 'due' as FilterType, label: 'Due Today', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Overdue', count: overdueCount },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-200 ${
filter === key
? 'bg-green-600 text-white shadow-md'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
</div>
{/* Zones Grid */}
{filteredZones.length === 0 ? (
<div className="text-center py-12">
<Scissors className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No zones found</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `No zones match the "${filter}" filter.`}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredZones.map(zone => (
<div
key={zone.id}
id={`zone-card-${zone.id}`}
className={`transition-all duration-300 ${
selectedZoneId === zone.id
? 'ring-4 ring-blue-300 ring-opacity-50 scale-105'
: ''
}`}
>
<ZoneCard
zone={zone}
onMarkAsMowed={handleMarkAsMowed}
onMarkAsTrimmed={handleMarkAsTrimmed}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
}}
onDelete={handleDeleteZone}
/>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
{/* Zone Form Modal */}
{showForm && (
<ZoneForm
zone={editingZone}
onSubmit={handleFormSubmit}
onCancel={() => {
setShowForm(false);
setEditingZone(null);
}}
/>
)}
{/* Single Zone Mowing Modal */}
{showMowingModal && mowingZone && (
<MowingModal
zoneName={mowingZone.name}
activityType="mowing"
onSubmit={handleMowingSubmit}
onCancel={() => {
setShowMowingModal(false);
setMowingZone(null);
}}
loading={mowingLoading}
/>
)}
{/* Single Zone Trimming Modal */}
{showTrimmingModal && trimmingZone && (
<MowingModal
zoneName={trimmingZone.name}
activityType="trimming"
onSubmit={handleTrimmingSubmit}
onCancel={() => {
setShowTrimmingModal(false);
setTrimmingZone(null);
}}
loading={trimmingLoading}
/>
)}
{/* Bulk Mowing Modal */}
{showBulkMowingModal && (
<BulkMowingModal
zones={zones}
activityType="mowing"
onSubmit={handleBulkMowingSubmit}
onCancel={() => setShowBulkMowingModal(false)}
loading={mowingLoading}
/>
)}
{/* Bulk Trimming Modal */}
{showBulkTrimmingModal && (
<BulkMowingModal
zones={zones}
activityType="trimming"
onSubmit={handleBulkTrimmingSubmit}
onCancel={() => setShowBulkTrimmingModal(false)}
loading={trimmingLoading}
/>
)}
{/* Telegram Settings Modal */}
{showTelegramSettings && (
<TelegramSettings
onClose={() => setShowTelegramSettings(false)}
/>
)}
{/* Season Settings Modal */}
{showSeasonSettings && (
<SeasonSettingsComponent
onClose={() => setShowSeasonSettings(false)}
/>
)}
</div>
);
};
export default Dashboard;