468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
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; |