turash/bugulma/frontend/pages/DashboardPage.tsx

555 lines
24 KiB
TypeScript

import { MainLayout } from '@/components/layout/MainLayout.tsx';
import PageHeader from '@/components/layout/PageHeader.tsx';
import { ActivityCard } from '@/components/ui/ActivityCard.tsx';
import Badge from '@/components/ui/Badge.tsx';
import Button from '@/components/ui/Button.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import { CenteredContent } from '@/components/ui/CenteredContent.tsx';
import DateRangePicker, { type DateRange } from '@/components/ui/DateRangePicker.tsx';
import { EmptyState } from '@/components/ui/EmptyState.tsx';
import { Container, Flex, Grid, Stack } from '@/components/ui/layout';
import { LoadingState } from '@/components/ui/LoadingState.tsx';
import MetricItem from '@/components/ui/MetricItem.tsx';
import { Heading, Price, Text } from '@/components/ui/Typography.tsx';
import { useAuth } from '@/contexts/AuthContext.tsx';
import {
useDashboardStatistics,
useImpactMetrics,
useMatchingStatistics,
usePlatformStatistics
} from '@/hooks/api/useAnalyticsAPI.ts';
import { useUserOrganizations } from '@/hooks/api/useOrganizationsAPI.ts';
import { useProposals } from '@/hooks/api/useProposalsAPI.ts';
import { useTranslation } from '@/hooks/useI18n.tsx';
import { useNavigation } from '@/hooks/useNavigation.tsx';
import { useOrganizations } from '@/hooks/useOrganizations.ts';
import type { Proposal } from '@/types.ts';
import { BarChart3, Briefcase, DollarSign, Leaf, MapPin, Plus, Search, Target, TrendingUp, Users } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
type ActivityFilter = 'all' | 'match' | 'proposal' | 'organization';
const DashboardPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { handleBackNavigation, handleFooterNavigate } = useNavigation();
const { user } = useAuth();
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
const [dateRange, setDateRange] = useState<DateRange>({
startDate: null,
endDate: null,
preset: '30d',
});
// Analytics data - Zod validates these, so if data exists, it's guaranteed to be valid
const { data: dashboardStats, isLoading: isLoadingDashboard, error: dashboardError } = useDashboardStatistics();
const { data: platformStats, isLoading: isLoadingPlatform, error: platformError } = usePlatformStatistics();
const { data: matchingStats, isLoading: isLoadingMatching, error: matchingError } = useMatchingStatistics();
const { data: impactMetrics, isLoading: isLoadingImpact, error: impactError } = useImpactMetrics();
// User-specific data
const { data: proposalsData } = useProposals();
const { organizations } = useOrganizations();
const { data: userOrganizations } = useUserOrganizations();
// Proposals data - validated by API
const proposals: Proposal[] = proposalsData?.proposals ?? [];
const pendingProposals = proposals.filter((p: Proposal) => p.status === 'pending');
// Calculate derived statistics - data is validated by Zod, so we can trust it
const stats = useMemo(() => {
// If data exists, it's validated by Zod - use it directly
const dashboard = dashboardStats;
const platform = platformStats;
const matching = matchingStats;
const impact = impactMetrics;
// User-specific metrics
const myOrganizationsCount = userOrganizations?.length ?? 0;
const myProposalsCount = proposals.length;
const myPendingProposalsCount = pendingProposals.length;
return {
// Platform-wide metrics - prefer dashboard stats, fallback to platform stats
totalOrganizations: dashboard?.total_organizations ?? platform?.total_organizations ?? 0,
totalSites: dashboard?.total_sites ?? platform?.total_sites ?? 0,
totalResourceFlows: dashboard?.total_resource_flows ?? platform?.total_resource_flows ?? 0,
totalMatches: dashboard?.total_matches ?? platform?.total_matches ?? 0,
activeProposals: pendingProposals.length,
recentActivity: dashboard?.recent_activity ?? [],
// User-specific metrics
myOrganizations: myOrganizationsCount,
myProposals: myProposalsCount,
myPendingProposals: myPendingProposalsCount,
// Matching statistics - optional fields use nullish coalescing
matchSuccessRate: matching?.match_success_rate ?? 0,
avgMatchTime: matching?.avg_match_time_days ?? 0,
topResourceTypes: matching?.top_resource_types ?? [],
// Impact metrics
totalCo2Saved: impact?.total_co2_saved_tonnes ?? 0,
totalEconomicValue: impact?.total_economic_value ?? 0,
activeMatches: impact?.active_matches_count ?? 0,
};
}, [dashboardStats, platformStats, matchingStats, impactMetrics, proposalsData, userOrganizations, pendingProposals]);
// Filter activities based on selected filter
// Activity type is validated by Zod, so it's guaranteed to be a string
const filteredActivities = useMemo(() => {
if (activityFilter === 'all') {
return stats.recentActivity;
}
return stats.recentActivity.filter((activity) => {
const activityType = activity.type.toLowerCase();
switch (activityFilter) {
case 'match':
return activityType.includes('match');
case 'proposal':
return activityType.includes('proposal');
case 'organization':
return activityType.includes('organization') || activityType.includes('org');
default:
return true;
}
});
}, [stats.recentActivity, activityFilter]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number) => {
return new Intl.NumberFormat('en-US').format(value);
};
const isLoading = isLoadingDashboard || isLoadingPlatform || isLoadingMatching || isLoadingImpact;
const hasError = dashboardError || platformError || matchingError || impactError;
return (
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
<Container size="2xl" className="py-8 sm:py-12">
<Flex align="center" justify="between" className="mb-6">
<PageHeader
title={t('dashboard.title')}
subtitle={t('dashboard.subtitle', { name: user?.name || user?.email || 'User' })}
onBack={handleBackNavigation}
/>
<DateRangePicker value={dateRange} onChange={setDateRange} />
</Flex>
<Stack spacing="2xl">
{/* Your Contribution - User-Specific Metrics */}
<Card>
<CardHeader>
<CardTitle>{t('dashboard.yourContribution')}</CardTitle>
<Text variant="muted" tKey="dashboard.yourContributionDesc" className="mt-1" />
</CardHeader>
<CardContent>
<Grid cols={{ md: 2, lg: 3 }} gap="md">
<MetricItem
icon={<Briefcase className="h-4 h-5 text-current text-primary w-4 w-5" />}
label={t('dashboard.myOrganizations')}
value={formatNumber(stats.myOrganizations)}
/>
<MetricItem
icon={<Target className="h-4 h-5 text-current text-success w-4 w-5" />}
label={t('dashboard.myProposals')}
value={formatNumber(stats.myProposals)}
/>
<MetricItem
icon={<Users className="h-4 h-5 text-current text-warning w-4 w-5" />}
label={t('dashboard.myPendingProposals')}
value={formatNumber(stats.myPendingProposals)}
/>
</Grid>
</CardContent>
</Card>
{/* Platform Overview - Platform-Wide Metrics */}
<Card>
<CardHeader>
<CardTitle>{t('dashboard.platformOverview')}</CardTitle>
<Text variant="muted" tKey="dashboard.platformOverviewDesc" className="mt-1" />
</CardHeader>
<CardContent>
<Grid cols={{ md: 2, lg: 4 }} gap="md">
<MetricItem
icon={<Briefcase className="h-4 h-5 text-current text-primary w-4 w-5" />}
label={t('dashboard.organizations')}
value={formatNumber(stats.totalOrganizations)}
/>
<MetricItem
icon={<MapPin className="h-4 h-5 text-current text-warning w-4 w-5" />}
label={t('dashboard.sites')}
value={formatNumber(stats.totalSites)}
/>
<MetricItem
icon={<Target className="h-4 h-5 text-current text-success w-4 w-5" />}
label={t('dashboard.resourceFlows')}
value={formatNumber(stats.totalResourceFlows)}
/>
<MetricItem
icon={<TrendingUp className="h-4 h-5 text-current text-purple-500 w-4 w-5" />}
label={t('dashboard.matches')}
value={formatNumber(stats.totalMatches)}
/>
</Grid>
</CardContent>
</Card>
{/* Impact Metrics */}
{(stats.totalCo2Saved > 0 || stats.totalEconomicValue > 0) && (
<Grid cols={{ md: 3 }} gap="md">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Leaf className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.co2Saved')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Heading level="h3" className="text-green-600 mb-1">
{formatNumber(stats.totalCo2Saved)} t
</Heading>
<Text variant="muted" className="text-xs">
{t('dashboard.perYear')}
</Text>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<DollarSign className="h-4 text-current text-success w-4" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.economicValue')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Price value={stats.totalEconomicValue} variant="large" className="text-success" />
<Text variant="muted" className="text-xs mt-1">
{t('dashboard.created')}
</Text>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 text-blue-600 text-current w-4" />
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('dashboard.activeMatches')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<Heading level="h3" className="text-blue-600 mb-1">
{formatNumber(stats.activeMatches)}
</Heading>
<Text variant="muted" className="text-xs">
{t('dashboard.operational')}
</Text>
</CardContent>
</Card>
</Grid>
)}
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>{t('dashboard.quickActions')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ sm: 2, md: 4 }} gap="md">
<Button
variant="outline"
onClick={() => navigate('/resources')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<Plus className="h-4 h-6 text-current w-4 w-6" />
<Text variant="small" as="span">{t('dashboard.createResourceFlow')}</Text>
</Button>
<Button
variant="outline"
onClick={() => navigate('/matching')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<Search className="h-4 h-6 text-current w-4 w-6" />
<Text variant="small" as="span">{t('dashboard.findMatches')}</Text>
</Button>
<Button
variant="outline"
onClick={() => navigate('/map')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<MapPin className="h-4 h-6 text-current w-4 w-6" />
<Text variant="small" as="span">{t('dashboard.exploreMap')}</Text>
</Button>
<Button
variant="outline"
onClick={() => navigate('/analytics')}
className="h-20 flex flex-col items-center justify-center gap-2 hover:bg-primary/5"
>
<BarChart3 className="h-4 h-6 text-current w-4 w-6" />
<Text variant="small" as="span">{t('dashboard.viewAnalytics')}</Text>
</Button>
</Grid>
</CardContent>
</Card>
{/* Recent Activity & Proposals */}
<Grid cols={{ md: 2 }} gap="lg">
{/* Recent Activity */}
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.recentActivity')}</CardTitle>
<Badge variant="outline">
{filteredActivities.length}
</Badge>
</Flex>
{/* Activity Filter Buttons */}
<div className="flex flex-wrap gap-2 mt-4">
<Button
variant={activityFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('all')}
>
{t('dashboard.filterAll')}
</Button>
<Button
variant={activityFilter === 'match' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('match')}
>
{t('dashboard.filterMatches')}
</Button>
<Button
variant={activityFilter === 'proposal' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('proposal')}
>
{t('dashboard.filterProposals')}
</Button>
<Button
variant={activityFilter === 'organization' ? 'default' : 'outline'}
size="sm"
onClick={() => setActivityFilter('organization')}
>
{t('dashboard.filterOrganizations')}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingState size="md" />
) : filteredActivities.length > 0 ? (
<Stack spacing="md">
{filteredActivities.slice(0, 5).map((activity) => (
<ActivityCard
key={activity.id}
description={activity.description}
timestamp={activity.timestamp}
type={activity.type}
/>
))}
{filteredActivities.length > 5 && (
<CenteredContent padding="sm">
<Button variant="outline" size="sm">
{t('dashboard.viewAllActivity')}
</Button>
</CenteredContent>
)}
</Stack>
) : (
<EmptyState
type="no-data"
icon={<Target className="h-12 w-12 opacity-50" />}
title={
activityFilter === 'all'
? t('dashboard.noRecentActivityTitle')
: t('dashboard.noFilteredActivityTitle', { filter: activityFilter })
}
description={
activityFilter === 'all'
? t('dashboard.noRecentActivityDesc')
: t('dashboard.noFilteredActivityDesc', { filter: activityFilter })
}
/>
)}
</CardContent>
</Card>
{/* Active Proposals */}
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.activeProposals')}</CardTitle>
<Badge variant="outline">
{pendingProposals.length}
</Badge>
</Flex>
</CardHeader>
<CardContent>
{pendingProposals.length > 0 ? (
<div className="space-y-3">
{pendingProposals.slice(0, 5).map((proposal: Proposal) => (
<div
key={proposal.id}
className="p-3 rounded-lg border bg-card/50 hover:bg-card transition-colors cursor-pointer"
onClick={() => {
// Navigate to organization page or matching dashboard
if (proposal.toOrgId) {
navigate(`/organization/${proposal.toOrgId}`);
} else if (proposal.fromOrgId) {
navigate(`/organization/${proposal.fromOrgId}`);
} else {
navigate('/matching');
}
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<Text variant="small" className="font-medium line-clamp-2">
{proposal.message || t('dashboard.proposalNoMessage')}
</Text>
<Text variant="muted" className="text-xs mt-1">
{t('dashboard.proposalStatus')}:{' '}
{t(`organizationPage.status.${proposal.status}`)}
</Text>
</div>
<Badge
variant={
proposal.status === 'accepted'
? 'default'
: proposal.status === 'rejected'
? 'destructive'
: 'secondary'
}
className="shrink-0"
>
{t(`organizationPage.status.${proposal.status}`)}
</Badge>
</div>
</div>
))}
{pendingProposals.length > 5 && (
<CenteredContent padding="sm">
<Button variant="outline" size="sm" onClick={() => navigate('/matching')}>
{t('dashboard.viewAllProposals')}
</Button>
</CenteredContent>
)}
</div>
) : (
<EmptyState
type="no-data"
icon={<Users className="h-12 w-12 opacity-50" />}
title={t('dashboard.noActiveProposalsTitle')}
description={t('dashboard.noActiveProposalsDesc')}
action={{
label: t('dashboard.exploreMap'),
onClick: () => navigate('/map'),
}}
/>
)}
</CardContent>
</Card>
</Grid>
{/* My Organizations Summary */}
<Card>
<CardHeader>
<Flex align="center" justify="between">
<CardTitle>{t('dashboard.myOrganizations')}</CardTitle>
{organizations && organizations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => navigate('/organizations')}>
{t('dashboard.viewAll')}
</Button>
)}
</Flex>
</CardHeader>
<CardContent>
{organizations && organizations.length > 0 ? (
<Grid cols={{ sm: 2, md: 3 }} gap="md">
{organizations.slice(0, 3).map((org: any) => (
<div
key={org.ID}
className="p-4 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => navigate(`/organization/${org.ID}`)}
>
<Heading level="h4" className="mb-1">{org.Name}</Heading>
<Text variant="muted" className="mb-2">{org.sector}</Text>
<Badge variant="outline" className="text-xs">
{org.subtype}
</Badge>
</div>
))}
</Grid>
) : (
<EmptyState
type="no-data"
icon={<Briefcase className="h-12 w-12 opacity-50" />}
title={t('dashboard.noOrganizationsTitle')}
description={t('dashboard.noOrganizationsDesc')}
action={{
label: t('dashboard.createFirstOrganization'),
onClick: () => navigate('/map'),
}}
/>
)}
</CardContent>
</Card>
{/* Platform Health Indicators */}
{stats.matchSuccessRate > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('dashboard.platformHealth')}</CardTitle>
</CardHeader>
<CardContent>
<Grid cols={{ md: 3 }} gap="md">
<CenteredContent>
<Heading level="h3" className="text-success mb-1">
{Math.round(stats.matchSuccessRate * 100)}%
</Heading>
<Text variant="muted" tKey="dashboard.matchSuccessRate" />
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-primary mb-1">
{stats.avgMatchTime.toFixed(1)}
</Heading>
<Text variant="muted" tKey="dashboard.avgMatchTime" />
</CenteredContent>
<CenteredContent>
<Heading level="h3" className="text-warning mb-1">
{stats.topResourceTypes.length}
</Heading>
<Text variant="muted" tKey="dashboard.activeResourceTypes" />
</CenteredContent>
</Grid>
</CardContent>
</Card>
)}
</Stack>
</Container>
</MainLayout>
);
};
export default DashboardPage;