mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
555 lines
24 KiB
TypeScript
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;
|