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:
2025-11-16 18:52:34 +00:00
parent fc2fff5cb1
commit 9f7fc43fb2
8 changed files with 446 additions and 8 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,2 @@
export { DateRangeSelector } from './DateRangeSelector';
export { StatCard } from './StatCard';