lawnmowing/src/components/HistoryView.tsx

595 lines
26 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 { History, BarChart, Calendar, Timer, Cloud, FileText, Square, ChevronDown, ChevronUp, Scissors, Users, Zap } from './Icons';
import { MowingHistory, MowingHistoryResponse, MowingStats, Zone } from '../types/zone';
import { api } from '../services/api';
import Pagination from './Pagination';
interface HistoryViewProps {
zones: Zone[];
}
interface SessionGroup {
sessionId: string;
entries: MowingHistory[];
totalDuration: number;
totalArea: number;
mowedDate: string;
weather?: string;
notes?: string;
mowerName?: string;
mowerType?: string;
}
const HistoryView: React.FC<HistoryViewProps> = ({ zones }) => {
const [historyResponse, setHistoryResponse] = useState<MowingHistoryResponse | null>(null);
const [stats, setStats] = useState<MowingStats | null>(null);
const [selectedZone, setSelectedZone] = useState<number | undefined>();
const [period, setPeriod] = useState(30);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(true);
const [expandedEntry, setExpandedEntry] = useState<number | null>(null);
const [expandedSession, setExpandedSession] = useState<string | null>(null);
useEffect(() => {
loadData();
}, [selectedZone, period, currentPage, pageSize]);
const loadData = async () => {
setLoading(true);
try {
const offset = (currentPage - 1) * pageSize;
const [historyData, statsData] = await Promise.all([
api.getMowingHistory(selectedZone, pageSize, offset),
api.getMowingStats(period)
]);
setHistoryResponse(historyData);
setStats(statsData);
} catch (error) {
console.error('Failed to load history data:', error);
} finally {
setLoading(false);
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleZoneChange = (zoneId: number | undefined) => {
setSelectedZone(zoneId);
setCurrentPage(1); // Reset to first page when changing filters
};
const handlePeriodChange = (newPeriod: number) => {
setPeriod(newPeriod);
setCurrentPage(1); // Reset to first page when changing filters
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(1); // Reset to first page when changing page size
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
});
};
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 getZoneName = (zoneId: number) => {
const zone = zones.find(z => z.id === zoneId);
return zone?.name || 'Неизвестная зона';
};
const getMowerIcon = (type?: string) => {
if (!type) return '🚜';
switch (type.toLowerCase()) {
case 'battery':
return '🔋';
case 'electric':
return '⚡';
case 'gas':
case 'petrol':
return '⛽';
default:
return '🚜';
}
};
const getActivityIcon = (activityType?: string) => {
return activityType === 'trimming' ? (
<Zap className="h-4 w-4 text-purple-600" />
) : (
<Scissors className="h-4 w-4 text-green-600" />
);
};
const getActivityLabel = (activityType?: string) => {
return activityType === 'trimming' ? 'Trimming' : 'Mowing';
};
// Group history entries by session
const groupBySession = (entries: MowingHistory[]): (SessionGroup | MowingHistory)[] => {
const sessionMap = new Map<string, MowingHistory[]>();
const singleEntries: MowingHistory[] = [];
entries.forEach(entry => {
if (entry.sessionId) {
if (!sessionMap.has(entry.sessionId)) {
sessionMap.set(entry.sessionId, []);
}
sessionMap.get(entry.sessionId)!.push(entry);
} else {
singleEntries.push(entry);
}
});
const result: (SessionGroup | MowingHistory)[] = [];
// Add session groups
sessionMap.forEach((sessionEntries, sessionId) => {
if (sessionEntries.length > 1) {
// This is a bulk session
const totalDuration = sessionEntries.reduce((sum, entry) => sum + (entry.duration || 0), 0);
const totalArea = sessionEntries.reduce((sum, entry) => sum + entry.zoneArea, 0);
const firstEntry = sessionEntries[0];
result.push({
sessionId,
entries: sessionEntries.sort((a, b) => a.zoneName.localeCompare(b.zoneName)),
totalDuration,
totalArea,
mowedDate: firstEntry.mowedDate,
weather: firstEntry.weather,
notes: firstEntry.notes,
mowerName: firstEntry.mowerName,
mowerType: firstEntry.mowerType,
});
} else {
// Single entry that happens to have a session ID
result.push(sessionEntries[0]);
}
});
// Add single entries
singleEntries.forEach(entry => result.push(entry));
// Sort by date (most recent first)
return result.sort((a, b) => {
const dateA = a.mowedDate;
const dateB = b.mowedDate;
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
};
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>
);
}
const groupedHistory = historyResponse ? groupBySession(historyResponse.data) : [];
return (
<div className="space-y-6">
{/* Header and Filters */}
<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">
<History className="h-6 w-6 text-green-600" />
История покосов
</h2>
<p className="text-gray-600 mt-1">Следите за своими мероприятиями по уходу за газоном и прогрессом в их выполнении</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{/* Zone Filter */}
<select
value={selectedZone || ''}
onChange={(e) => handleZoneChange(e.target.value ? parseInt(e.target.value) : undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">Все Зоны</option>
{zones.map(zone => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
{/* Period Filter */}
<select
value={period}
onChange={(e) => handlePeriodChange(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"
>
<option value={7}>Последние 7 дней</option>
<option value={30}>Последние 30 дней</option>
<option value={90}>Последние 3 месяца</option>
<option value={365}>Последний год</option>
</select>
{/* Page Size Filter */}
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(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"
>
<option value={5}>5 на странице</option>
<option value={10}>10 на странице</option>
<option value={20}>20 на странице</option>
<option value={50}>50 на странице</option>
</select>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<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">{stats.totalSessions}</p>
</div>
<BarChart className="h-8 w-8 text-blue-500" />
</div>
</div>
<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">
{formatDuration(stats.totalMinutes)}
</p>
</div>
<Timer 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">
{stats.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-orange-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Наибольшая активность</p>
<p className="text-lg font-bold text-gray-900">
{stats.mostActiveZone?.name || 'НЕТ'}
</p>
{stats.mostActiveZone && (
<p className="text-xs text-gray-500">
{stats.mostActiveZone.sessions} сессии
</p>
)}
</div>
<Calendar className="h-8 w-8 text-orange-500" />
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 border-l-4 border-indigo-500">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Часто используется</p>
<p className="text-lg font-bold text-gray-900">
{stats.mostUsedMower?.name || 'НЕТ'}
</p>
{stats.mostUsedMower && (
<p className="text-xs text-gray-500">
{getMowerIcon(stats.mostUsedMower.type)} {stats.mostUsedMower.sessions} сессии
</p>
)}
</div>
<Scissors className="h-8 w-8 text-indigo-500" />
</div>
</div>
</div>
)}
{/* History List */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="px-6 py-4 border-b bg-gray-50">
<h3 className="text-lg font-semibold text-gray-900">Недавняя активность</h3>
<p className="text-sm text-gray-600 mt-1">
{selectedZone ? `История для ${getZoneName(selectedZone)}` : 'Все зоны'}
</p>
</div>
{!historyResponse || historyResponse.data.length === 0 ? (
<div className="text-center py-12">
<History 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">
{selectedZone
? 'В этой зоне еще нет выходов на скашивание.'
: 'Начните скашивать зоны, чтобы просмотреть свою историю здесь.'
}
</p>
</div>
) : (
<>
<div className="divide-y divide-gray-200">
{groupedHistory.map((item, index) => {
// Check if this is a session group or single entry
if ('entries' in item && item.entries.length > 1) {
// This is a bulk session
const session = item as SessionGroup;
const isExpanded = expandedSession === session.sessionId;
return (
<div key={`session-${session.sessionId}`} className="bg-gradient-to-r from-blue-50 to-indigo-50">
{/* Session Header */}
<div className="p-6 border-l-4 border-blue-500">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-2">
{session.entries[0]?.activityType === 'trimming' ? (
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-purple-600" />
<Zap className="h-4 w-4 text-purple-600" />
</div>
) : (
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-600" />
<Scissors className="h-4 w-4 text-green-600" />
</div>
)}
<h4 className="text-lg font-medium text-gray-900">
Массовая {getActivityLabel(session.entries[0]?.activityType)} сессия
</h4>
<span className={`text-xs font-medium px-2.5 py-0.5 rounded-full ${
session.entries[0]?.activityType === 'trimming'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{session.entries.length} зоны
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-3">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{formatDate(session.mowedDate)}</span>
<span className="text-gray-400">at {formatTime(session.mowedDate)}</span>
</div>
{session.mowerName && (
<div className="flex items-center gap-1">
<span className="text-base">{getMowerIcon(session.mowerType)}</span>
<span>{session.mowerName}</span>
</div>
)}
{session.totalDuration > 0 && (
<div className="flex items-center gap-1">
<Timer className="h-4 w-4" />
<span>{formatDuration(session.totalDuration)} всего</span>
</div>
)}
<div className="flex items-center gap-1">
<Square className="h-4 w-4" />
<span>{session.totalArea.toLocaleString()} м2 всего</span>
</div>
{session.weather && (
<div className="flex items-center gap-1">
<Cloud className="h-4 w-4" />
<span>{session.weather}</span>
</div>
)}
</div>
{/* Zone Summary */}
<div className="mb-3">
<p className="text-sm text-gray-700">
<strong>Зон {session.entries[0]?.activityType === 'trimming' ? 'подровнено' : 'скошено'}:</strong> {session.entries.map(e => e.zoneName).join(', ')}
</p>
</div>
{session.notes && (
<div className="mt-3">
<button
onClick={() => setExpandedEntry(
expandedEntry === -index ? null : -index
)}
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
<FileText className="h-4 w-4" />
<span>Заметки</span>
{expandedEntry === -index ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedEntry === -index && (
<div className="mt-2 p-3 bg-white rounded-md border">
<p className="text-sm text-gray-700">{session.notes}</p>
</div>
)}
</div>
)}
</div>
<button
onClick={() => setExpandedSession(
isExpanded ? null : session.sessionId
)}
className="ml-4 p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-100 rounded-md transition-colors duration-200"
>
{isExpanded ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</button>
</div>
</div>
{/* Expanded Session Details */}
{isExpanded && (
<div className="bg-white border-t border-blue-200">
<div className="px-6 py-4">
<h5 className="text-sm font-medium text-gray-900 mb-3">Детализация по зонам</h5>
<div className="space-y-3">
{session.entries.map(entry => (
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div className="flex items-center gap-3">
{getActivityIcon(entry.activityType)}
<div>
<p className="font-medium text-gray-900">{entry.zoneName}</p>
<p className="text-xs text-gray-500">
{entry.zoneArea.toLocaleString()} м2
</p>
</div>
</div>
{entry.duration && (
<div className="text-sm text-gray-600">
{formatDuration(entry.duration)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
} else {
// This is a single entry
const entry = item as MowingHistory;
return (
<div key={entry.id} className="p-6 hover:bg-gray-50 transition-colors duration-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{getActivityIcon(entry.activityType)}
<h4 className="text-lg font-medium text-gray-900">
{entry.zoneName}
</h4>
{entry.activityType === 'trimming' && (
<span className="bg-purple-100 text-purple-800 text-xs font-medium px-2 py-1 rounded-full">
Trimming
</span>
)}
<span className="text-sm text-gray-500">
{entry.zoneArea > 0 && `${entry.zoneArea.toLocaleString()} м2`}
</span>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-3">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{formatDate(entry.mowedDate)}</span>
<span className="text-gray-400">at {formatTime(entry.mowedDate)}</span>
</div>
{entry.mowerName && (
<div className="flex items-center gap-1">
<span className="text-base">{getMowerIcon(entry.mowerType)}</span>
<span>{entry.mowerName}</span>
</div>
)}
{entry.duration && (
<div className="flex items-center gap-1">
<Timer className="h-4 w-4" />
<span>{formatDuration(entry.duration)}</span>
</div>
)}
{entry.weather && (
<div className="flex items-center gap-1">
<Cloud className="h-4 w-4" />
<span>{entry.weather}</span>
</div>
)}
</div>
{entry.notes && (
<div className="mt-3">
<button
onClick={() => setExpandedEntry(
expandedEntry === entry.id ? null : entry.id
)}
className="flex items-center gap-2 text-sm text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
<FileText className="h-4 w-4" />
<span>Заметки</span>
{expandedEntry === entry.id ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{expandedEntry === entry.id && (
<div className="mt-2 p-3 bg-gray-50 rounded-md">
<p className="text-sm text-gray-700">{entry.notes}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
})}
</div>
{/* Pagination */}
<Pagination
currentPage={historyResponse.pagination.currentPage}
totalPages={historyResponse.pagination.totalPages}
hasNextPage={historyResponse.pagination.hasNextPage}
hasPrevPage={historyResponse.pagination.hasPrevPage}
onPageChange={handlePageChange}
total={historyResponse.pagination.total}
limit={historyResponse.pagination.limit}
/>
</>
)}
</div>
</div>
);
};
export default HistoryView;