← back to Freddy
components/impact/ImpactTab.tsx
177 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import { useToast } from '../ToastProvider';
import {
TrendingUp, Users, DollarSign, RefreshCw,
Building2, Flame, CheckCircle, FileText,
} from 'lucide-react';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
interface ImpactReport {
id: string;
title: string;
description: string | null;
people_helped: number;
funds_used: string | null;
outcomes: string[] | null;
report_url: string | null;
status: string;
period_start: string | null;
period_end: string | null;
created_at: string;
org_name: string;
cause_title: string | null;
match_score: number | null;
}
const SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'people_desc', label: 'Most People Helped' },
{ value: 'status', label: 'Status A-Z' },
];
const SORT_CONFIGS: Record<string, SortConfig> = {
date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
people_desc: { key: 'people_helped', direction: 'desc', type: 'number' },
status: { key: 'status', direction: 'asc', type: 'string' },
};
const fadeIn = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1, y: 0,
transition: { delay: i * 0.04, duration: 0.35 },
}),
};
export default function ImpactTab() {
const { addToast } = useToast();
const [reports, setReports] = useState<ImpactReport[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState('date_desc');
const sortedReports = useClientSort(reports, sortBy, SORT_CONFIGS);
const fetchReports = useCallback(async () => {
try {
const res = await fetch('/api/impact', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setReports(data.impact_reports || []);
}
} catch (err) {
console.error('Failed to fetch impact reports:', err);
addToast('Failed to load impact reports', 'error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchReports(); }, [fetchReports]);
const fmtMoney = (val: string | null) => {
if (!val) return 'N/A';
const n = parseFloat(val);
return `$${n.toLocaleString()}`;
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
<TrendingUp size={20} style={{ color: '#22c55e' }} />
Impact Reports
</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Tracking real-world outcomes from funded matches
</p>
</div>
<div className="flex items-center gap-2">
<SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
<button onClick={fetchReports} className="btn btn-secondary btn-sm">
<RefreshCw size={14} /> Refresh
</button>
</div>
</div>
{loading ? (
<SkeletonList count={4} />
) : sortedReports.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AnimatePresence>
{sortedReports.map((report, i) => (
<motion.div key={report.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{report.title}</h3>
<div className="flex items-center gap-2 mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Building2 size={10} /> {report.org_name}
{report.cause_title && <><Flame size={10} /> {report.cause_title}</>}
</div>
</div>
<span className={`badge ${report.status === 'published' ? 'badge-success' : 'badge-warning'}`}>
{report.status}
</span>
</div>
{report.description && (
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>{report.description}</p>
)}
<div className="grid grid-cols-2 gap-2">
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<Users size={14} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
<div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{(report.people_helped || 0).toLocaleString()}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>People Helped</div>
</div>
<div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
<DollarSign size={14} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
<div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(report.funds_used)}</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Funds Used</div>
</div>
</div>
{report.outcomes && report.outcomes.length > 0 && (
<div>
<div className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>Outcomes</div>
<ul className="space-y-1">
{report.outcomes.map((outcome, j) => (
<li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<CheckCircle size={10} style={{ color: '#22c55e', marginTop: 2, flexShrink: 0 }} />
{outcome}
</li>
))}
</ul>
</div>
)}
{report.period_start && (
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Period: {new Date(report.period_start).toLocaleDateString()} - {report.period_end ? new Date(report.period_end).toLocaleDateString() : 'Ongoing'}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
<TrendingUp size={28} style={{ color: 'var(--color-text-muted)' }} />
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No Impact Reports Yet</h3>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Impact reports will appear here as funded organizations report their outcomes.
</p>
</div>
)}
</div>
);
}