595 lines
26 KiB
TypeScript
595 lines
26 KiB
TypeScript
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;
|
||
|