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 ( +
+
+ {shortLabel} +
+ +
+
+ +
+

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() : {name} 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