diff --git a/api/frankenphp/Caddyfile b/api/frankenphp/Caddyfile
index 955cd40c0e91a9378b3da09530eb8336b5b05bd0..80566c4167a40a0fdbdce99b9271b2ebe9994688 100644
--- a/api/frankenphp/Caddyfile
+++ b/api/frankenphp/Caddyfile
@@ -34,7 +34,7 @@
# Route browser requests to Next.js PWA
@pwa expression `
(header({'Accept': '*text/html*'}) && !path('/api*', '/docs*', '/bundles*', '*.json', '*.html', '*.csv', '*.xml'))
- || path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
+ || path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*', '/team*')
|| query({'_rsc': '*'})
`
reverse_proxy @pwa http://{$PWA_UPSTREAM}
diff --git a/pwa/app/components/molecules/RegionRanking.tsx b/pwa/app/components/molecules/RegionRanking.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..297655ab6d622aa6c704671ebae41f2502e12490
--- /dev/null
+++ b/pwa/app/components/molecules/RegionRanking.tsx
@@ -0,0 +1,42 @@
+import type { RegionDistributionEntry } from '../../services/stats.services'
+
+interface RegionRankingProps {
+ shortLabel: string
+ color: string
+ regions: RegionDistributionEntry[]
+ formatValue: (value: number) => string
+}
+
+export default function RegionRanking({ shortLabel, color, regions, formatValue }: RegionRankingProps) {
+ const sorted = [...regions].sort((a, b) => b.total_amount - a.total_amount)
+ const top5 = sorted.slice(0, 5)
+ const maxAmount = top5[0]?.total_amount ?? 1
+
+ return (
+
+
+ Top 5 régions — {shortLabel}
+
+
+
+ {top5.map((entry, i) => {
+ const pct = (entry.total_amount / maxAmount) * 100
+
+ return (
+
+
+
+ {i + 1}. {entry.region}
+
+ {formatValue(entry.total_amount)}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/pwa/app/components/molecules/StatCard.tsx b/pwa/app/components/molecules/StatCard.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..283e6d72f9162c81acb25681dca4417575e84f27
--- /dev/null
+++ b/pwa/app/components/molecules/StatCard.tsx
@@ -0,0 +1,36 @@
+interface StatCardProps {
+ label: string
+ shortLabel: string
+ icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
+ color: string
+ total: number | null
+ average: number | null
+ formatValue: (value: number) => string
+}
+
+export default function StatCard({ label, shortLabel, icon: Icon, color, total, average, formatValue }: StatCardProps) {
+ return (
+
+
+
+
+
Total collecté
+
+ {total !== null ? formatValue(total) : '—'}
+
+
+
+
+
Moyenne par commune
+
{average !== null ? formatValue(average) : '—'}
+
+
+
{label}
+
+ )
+}
diff --git a/pwa/app/components/molecules/TeamAvatar.tsx b/pwa/app/components/molecules/TeamAvatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a72db8c4b1c8794cc7ff7c6764dcd368879b1dca
--- /dev/null
+++ b/pwa/app/components/molecules/TeamAvatar.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import { useState } from 'react'
+
+interface TeamAvatarProps {
+ name: string
+ img: string
+ color: string
+}
+
+export default function TeamAvatar({ name, img, color }: TeamAvatarProps) {
+ const [failed, setFailed] = useState(false)
+
+ return (
+
+
+ {failed ? name.slice(0, 2).toUpperCase() :

setFailed(true)} />}
+
+
{name}
+
+ )
+}
diff --git a/pwa/app/components/molecules/YearSelector.tsx b/pwa/app/components/molecules/YearSelector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..199a9351aec1dbea8741d66b020276cae80fa139
--- /dev/null
+++ b/pwa/app/components/molecules/YearSelector.tsx
@@ -0,0 +1,29 @@
+'use client'
+
+import { useRouter, useSearchParams } from 'next/navigation'
+import { YEARS } from '../../constants'
+
+export default function YearSelector() {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const current = Number(searchParams.get('year')) || (YEARS[YEARS.length - 1] ?? 2022)
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const params = new URLSearchParams(searchParams.toString())
+ params.set('year', e.target.value)
+ router.push(`/?${params}`)
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/pwa/app/page.tsx b/pwa/app/page.tsx
index 92b134348d2ca976aba3a68c3b9dc5ab0b83defb..f4b64d5dcc024631e2bd3b74b5d9ef3d0f9b28fc 100644
--- a/pwa/app/page.tsx
+++ b/pwa/app/page.tsx
@@ -1,3 +1,138 @@
-export default function Home() {
- return
+import { TAX_TYPES, YEARS } from './constants'
+import { getTaxSum, getTaxAverage, getRegionDistribution, type RegionDistributionEntry } from './services/stats.services'
+import StatCard from './components/molecules/StatCard'
+import RegionRanking from './components/molecules/RegionRanking'
+import TeamAvatar from './components/molecules/TeamAvatar'
+import YearSelector from './components/molecules/YearSelector'
+import ErrorDiv from './components/molecules/ErrorDiv'
+import { TrendingUp, Calculator, DollarSign, BarChart3 } from 'lucide-react'
+
+const TAX_LABELS: Record = {
+ tfpnb: 'Taxe Foncière sur les Propriétés Non Bâties',
+ tfpb: 'Taxe Foncière sur les Propriétés Bâties',
+ th: "Taxe d'Habitation",
+ cfe: 'Cotisation Foncière des Entreprises',
+}
+
+const TAX_SHORT: Record = {
+ tfpnb: 'TFPNB',
+ tfpb: 'TFPB',
+ th: 'TH',
+ cfe: 'CFE',
+}
+
+const TAX_ICONS: Record> = {
+ tfpnb: TrendingUp,
+ tfpb: DollarSign,
+ th: BarChart3,
+ cfe: Calculator,
+}
+
+const TAX_COLORS: Record = {
+ tfpnb: '#82ca9d',
+ tfpb: '#8884d8',
+ th: '#ffc658',
+ cfe: '#ff7300',
+}
+
+function formatCurrency(value: number): string {
+ if (value >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(2)} Md€`
+ }
+ if (value >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(2)} M€`
+ }
+ if (value >= 1_000) {
+ return `${(value / 1_000).toFixed(2)} k€`
+ }
+ return `${value.toFixed(2)} €`
+}
+
+interface HomeProps {
+ searchParams: Promise<{ year?: string }>
+}
+
+export default async function Home({ searchParams }: HomeProps) {
+ const { year: yearParam } = await searchParams
+ const year = Number(yearParam) || (YEARS[YEARS.length - 1] ?? 2022)
+
+ let data: { field: string; sum: number | null; average: number | null }[] = []
+ const rankings: Record = {}
+ let error: string | null = null
+
+ try {
+ const [statsResults, ...distributionResults] = await Promise.all([
+ Promise.all(
+ TAX_TYPES.map(async field => {
+ const [sumRes, avgRes] = await Promise.all([getTaxSum(field, year), getTaxAverage(field, year)])
+ return { field, sum: sumRes.sum, average: avgRes.average }
+ })
+ ),
+ ...TAX_TYPES.map(field => getRegionDistribution(field, year)),
+ ])
+ data = statsResults
+ TAX_TYPES.forEach((field, i) => {
+ rankings[field] = distributionResults[i]?.data ?? []
+ })
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Une erreur est survenue'
+ }
+
+ return (
+
+
+
+
Tableau de bord
+
Vue d'ensemble des taxes locales françaises
+
+
+
+
+
+ {error &&
}
+
+
+ {data.map(item => (
+
+ ))}
+
+
+
Classement par région
+
+
+ {TAX_TYPES.map(field => (
+
+ ))}
+
+
+
Équipe de développement
+
+
+ {[
+ { name: 'Adrien', img: '/team/marco.png', color: '#8884d8' },
+ { name: 'Clément', img: '/team/Gohmma.png', color: '#82ca9d' },
+ { name: 'Jérémy', img: '/team/Dede.png', color: '#ffc658' },
+ { name: 'Julien', img: '/team/PotiFlamby.png', color: '#ff7300' },
+ { name: 'Yoann', img: '/team/Yoann.png', color: '#00C49F' },
+ ].map(member => (
+
+ ))}
+
+
+ )
}
diff --git a/pwa/app/services/stats.services.ts b/pwa/app/services/stats.services.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ff5c66b8e09d7a2301622a6c4f2e1b10daf88dde
--- /dev/null
+++ b/pwa/app/services/stats.services.ts
@@ -0,0 +1,45 @@
+const API_URL = process.env.NEXT_PUBLIC_ENTRYPOINT || 'http://php'
+
+export interface TaxFieldStat {
+ field: string
+ sum: number | null
+ average: number | null
+ filters: Record
+}
+
+export async function getTaxSum(field: string, year?: number): Promise {
+ const params = new URLSearchParams()
+ if (year) params.set('year', String(year))
+ const query = params.toString() ? `?${params}` : ''
+ const res = await fetch(`${API_URL}/api/somme/${field}${query}`)
+ if (!res.ok) throw new Error(`Erreur lors du chargement de la somme pour ${field}`)
+ return res.json()
+}
+
+export async function getTaxAverage(field: string, year?: number): Promise {
+ const params = new URLSearchParams()
+ if (year) params.set('year', String(year))
+ const query = params.toString() ? `?${params}` : ''
+ const res = await fetch(`${API_URL}/api/average/${field}${query}`)
+ if (!res.ok) throw new Error(`Erreur lors du chargement de la moyenne pour ${field}`)
+ return res.json()
+}
+
+export interface RegionDistributionEntry {
+ region: string
+ total_amount: number
+}
+
+export interface RegionDistributionResponse {
+ tax_type: string
+ year: number | null
+ data: RegionDistributionEntry[]
+}
+
+export async function getRegionDistribution(taxType: string, year?: number): Promise {
+ const params = new URLSearchParams({ tax_type: taxType })
+ if (year) params.set('year', String(year))
+ const res = await fetch(`${API_URL}/api/regions/distribution?${params}`)
+ if (!res.ok) throw new Error(`Erreur lors du chargement de la distribution pour ${taxType}`)
+ return res.json()
+}
diff --git a/pwa/public/favicon.ico b/pwa/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7b525e92bd3d6d221fcd0d66cf6fcfd82d5103c9
Binary files /dev/null and b/pwa/public/favicon.ico differ
diff --git a/pwa/public/team/Dede.png b/pwa/public/team/Dede.png
new file mode 100644
index 0000000000000000000000000000000000000000..27e833682214437021a117bd2cb4fe7351a653eb
Binary files /dev/null and b/pwa/public/team/Dede.png differ
diff --git a/pwa/public/team/Gohmma.png b/pwa/public/team/Gohmma.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e5c6c19b2760e4128101136044c833782256ef3
Binary files /dev/null and b/pwa/public/team/Gohmma.png differ
diff --git a/pwa/public/team/PotiFlamby.png b/pwa/public/team/PotiFlamby.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f67680a71af509714edbdfbe480c0b80044f94d
Binary files /dev/null and b/pwa/public/team/PotiFlamby.png differ
diff --git a/pwa/public/team/Yoann.png b/pwa/public/team/Yoann.png
new file mode 100644
index 0000000000000000000000000000000000000000..6624ee67256c735b54c8872138fb134e7edd021e
Binary files /dev/null and b/pwa/public/team/Yoann.png differ
diff --git a/pwa/public/team/marco.png b/pwa/public/team/marco.png
new file mode 100644
index 0000000000000000000000000000000000000000..714e94bfa0e84f7d2baaa11395a2f561517a8c1d
Binary files /dev/null and b/pwa/public/team/marco.png differ