feat: implement analytics dashboard layout (task 10)
- Add analytics types (AnalyticsSummary, DateRange) - Create analyticsService for API calls and CSV export - Implement DateRangeSelector component with presets - Create StatCard component for summary statistics - Build responsive AnalyticsPage with: - Summary statistics cards (reports, events, users, litter collected) - Date range filtering with presets - CSV export functionality - Loading and error states - Responsive grid layout Requirements: 5.1, 5.5
This commit is contained in:
123
src/components/analytics/DateRangeSelector.tsx
Normal file
123
src/components/analytics/DateRangeSelector.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
import type { DateRange } from '@/types';
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
value: DateRange;
|
||||
onChange: (range: DateRange) => void;
|
||||
}
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps) {
|
||||
const [startDate, setStartDate] = useState(
|
||||
value.start.toISOString().split('T')[0]
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
value.end.toISOString().split('T')[0]
|
||||
);
|
||||
|
||||
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newStart = e.target.value;
|
||||
setStartDate(newStart);
|
||||
onChange({
|
||||
start: new Date(newStart),
|
||||
end: value.end,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEnd = e.target.value;
|
||||
setEndDate(newEnd);
|
||||
onChange({
|
||||
start: value.start,
|
||||
end: new Date(newEnd),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: 'week' | 'month' | 'quarter' | 'year') => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (preset) {
|
||||
case 'week':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
start.setMonth(end.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
start.setMonth(end.getMonth() - 3);
|
||||
break;
|
||||
case 'year':
|
||||
start.setFullYear(end.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
setStartDate(start.toISOString().split('T')[0]);
|
||||
setEndDate(end.toISOString().split('T')[0]);
|
||||
onChange({ start, end });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Date Range</h3>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handlePresetClick('week')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last 7 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('month')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last Month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('quarter')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last 3 Months
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePresetClick('year')}
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Last Year
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="start-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
value={startDate}
|
||||
onChange={handleStartChange}
|
||||
max={endDate}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="end-date" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
value={endDate}
|
||||
onChange={handleEndChange}
|
||||
min={startDate}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/analytics/StatCard.tsx
Normal file
39
src/components/analytics/StatCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon, trend, subtitle }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">{title}</h3>
|
||||
{icon && <div className="text-gray-400">{icon}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-gray-900">{value}</p>
|
||||
|
||||
{trend && (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
trend.isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/analytics/index.ts
Normal file
2
src/components/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DateRangeSelector } from './DateRangeSelector';
|
||||
export { StatCard } from './StatCard';
|
||||
Reference in New Issue
Block a user