lawnmowing/src/components/Dashboard.tsx

468 lines
19 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 } 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";
type FilterType = 'all' | 'due' | 'overdue' | 'new';
type ViewType = 'dashboard' | 'sitePlan' | 'history';
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 [mowingZone, setMowingZone] = useState<Zone | null>(null);
const [loading, setLoading] = useState(true);
const [mowingLoading, setMowingLoading] = 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 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 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 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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Scissors className="h-8 w-8 text-green-600" />
Менеджер по уходу за газоном
</h1>
<p className="mt-2 text-gray-600">Следите за своим графиком стрижки газона</p>
</div>
<div className="flex items-center gap-3">
{/* View Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<button
onClick={() => setView('dashboard')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'dashboard'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Calendar className="h-4 w-4 inline mr-2" />
Панель
</button>
<button
onClick={() => setView('sitePlan')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'sitePlan'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Map className="h-4 w-4 inline mr-2" />
План участка
</button>
<button
onClick={() => setView('history')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
view === 'history'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<History className="h-4 w-4 inline mr-2" />
История
</button>
</div>
<button
onClick={() => setShowForm(true)}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 transition-colors duration-200 shadow-lg hover:shadow-xl"
>
<Plus className="h-5 w-5" />
Новая зона
</button>
</div>
</div>
</div>
{view === 'history' ? (
<HistoryView 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">м2</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">обслужено зон</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">Новые зоны</p>
<p className="text-2xl font-bold text-gray-900">{newCount}</p>
<p className="text-xs text-gray-500">еще не косились</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}</p>
<p className="text-xs text-gray-500">зон для покоса</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} зон)
</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>
<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>
</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">Фильтр:</span>
<div className="flex gap-2">
{[
{ key: 'all' as FilterType, label: 'Все зоны', count: zones.length },
{ key: 'new' as FilterType, label: 'Новые зоны', count: newCount },
{ key: 'due' as FilterType, label: 'Срок - сегодня', count: dueCount },
{ key: 'overdue' as FilterType, label: 'Срок прошел', 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">Не найдено ни одной зоны</h3>
<p className="mt-1 text-sm text-gray-500">
{filter === 'all' ? 'Get started by adding your first lawn zone.' : `Нет подходящих зон для "${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}
onEdit={(zone) => {
setEditingZone(zone);
setShowForm(true);
}}
onDelete={handleDeleteZone}
/>
</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}
onSubmit={handleMowingSubmit}
onCancel={() => {
setShowMowingModal(false);
setMowingZone(null);
}}
loading={mowingLoading}
/>
)}
{/* Bulk Mowing Modal */}
{showBulkMowingModal && (
<BulkMowingModal
zones={zones}
onSubmit={handleBulkMowingSubmit}
onCancel={() => setShowBulkMowingModal(false)}
loading={mowingLoading}
/>
)}
</div>
</div>
);
};
export default Dashboard;