#51 — Dashboard Revendeur (Self-Service Revenus & Abonnés)
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE D · Conteneur: rgz-web · Code: web/src/pages/ResellerDashboard.tsx
Dépendances: #2 rgz-web, #1 rgz-api
Dashboard Revendeur
Voir la documentation interface pour détails des métriques visibles.
Description
Interface web self-service exclusive aux revendeurs ACCESS. Affiche revenus bruts FCFA par mois, split 50/50 avec RGZ, abonnés actifs/totaux, historique des 12 derniers mois, factures téléchargeables en PDF, statistiques sessions (data consommée par forfait).
Le revendeur voit uniquement ses données (IDOR sécurisé par JWT). Charts Chart.js pour tendance revenus, pie chart forfaits vendus, timeline activité abonnés. Données actualisées 1x/jour (sync avec #19 moteur facturation), interface responsive mobile-first pour consultations en terrain.
Architecture Interne
Frontend Data Flow:
1. Composant React ResellerDashboard.tsx monte
└─ useAuth() hook → JWT token
└─ useQuery("reseller/summary") → query parameter ?reseller_id=UUID
2. API Endpoint:
GET /api/v1/billing/summary?reseller_id={reseller_id}
└─ Backend: app/api/v1/endpoints/billing.py::get_summary()
└─ Filtrage: if current_user.reseller_id != reseller_id → 403
└─ Response:
{
"reseller_id": "uuid",
"reseller_name": "Tech Connect Cotonou",
"month_current": {
"gross_revenue_fcfa": 2500000,
"split_50_fcfa": 1250000,
"transactions_count": 50,
"refunds_fcfa": 25000
},
"month_previous": ,
"ytd": {
"gross_revenue_fcfa": 15000000,
"split_50_fcfa": 7500000
},
"active_subscribers": 145,
"total_subscribers_registered": 180,
"churn_rate_percent": 3.2,
"revenue_trend_12m": [
{"month": "2025-03", "revenue_fcfa": 1900000},
{"month": "2025-04", "revenue_fcfa": 2100000},
...
],
"top_plans": [
{"plan_id": "uuid", "name": "Pass 5GB", "sold_count": 120, "revenue_fcfa": 1200000},
...
],
"data_consumed_by_plan": [
{"plan_id": "uuid", "name": "Pass 5GB", "avg_usage_percent": 78},
...
]
}
3. TanStack Query cache:
└─ staleTime: 3600s (1h)
└─ refetch interval: auto (background)
└─ Manual refresh button (SuspenseQuery)
4. Charts Rendering (Chart.js):
├─ LineChart: Revenue trend 12 months
├─ PieChart: Forfaits distribution %
├─ BarChart: Data usage by plan (%)
└─ KPI Cards: current month metrics
5. Invoice Download:
└─ GET /api/v1/invoices?reseller_id={reseller_id}&month=2026-02
└─ Backend: triggerise PDF generation #24
└─ Response: PDF binary (Content-Disposition: attachment)
└─ Frontend: fetch → blob → download link click
6. Responsive Layout:
├─ Desktop (>768px): 2-column grid (KPIs + Charts)
├─ Tablet (480px-768px): 1-column stacked
└─ Mobile (<480px): full width, swipeable chartsConfiguration
# Frontend (web/.env)
VITE_API_URL=https://api-rgz.duckdns.org
VITE_API_TIMEOUT=30000 # ms
VITE_JWT_STORAGE=localStorage
VITE_REFRESH_TOKEN_KEY=access_refresh_token
# Backend API (app/config.py)
RESELLER_DASHBOARD_CACHE_TTL=3600 # seconds
BILLING_SUMMARY_QUERY_TIMEOUT=10 # seconds
INVOICE_PDF_MAX_SIZE=5242880 # 5MB
INVOICE_RETENTION_MONTHS=24 # Display last 2 years
# Tailwind responsive breakpoints (web/tailwind.config.js)
screens:
sm: 480px
md: 768px
lg: 1024px
xl: 1280pxEndpoints API
| Méthode | Route | Réponse |
|---|---|---|
| GET | /api/v1/billing/summary?reseller_id= | {month_current, month_previous, ytd, active_subscribers, revenue_trend_12m, top_plans, data_consumed} |
| GET | /api/v1/invoices?reseller_id=&month=2026-02 | Liste invoices : {items: [{id, month, gross_fcfa, split_fcfa, status}], total, pages} |
| GET | /api/v1/invoices/{invoice_id}/pdf | PDF binary (WeasyPrint #24) |
| GET | /api/v1/subscribers?reseller_id=&status=active&limit=50 | {items: [{id, subscriber_ref, name, msisdn, status, balance_fcfa, last_session}], total, pages} |
| GET | /api/v1/billing/commissions?reseller_id=&period=12m | {items: [{month, gross, commission_50_pct, refunds}], total_ytd} |
| POST | /api/v1/billing/summary/export?reseller_id=&format=csv | CSV file (historical data export) |
Composants React
// web/src/pages/ResellerDashboard.tsx
import React from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useQuery } from '@tanstack/react-query';
import { Line, Pie, Bar } from 'recharts';
import KpiCard from '@/components/Cards/KpiCard';
import ChartContainer from '@/components/Charts/ChartContainer';
import InvoiceTable from '@/components/Tables/InvoiceTable';
import api from '@/services/api';
export default function ResellerDashboard() {
const { user } = useAuth();
// Query: Summary data
const { data: summary, isLoading, error } = useQuery({
queryKey: ['billing', 'summary', user?.reseller_id],
queryFn: () => api.get(`/api/v1/billing/summary?reseller_id=${user?.reseller_id}`),
staleTime: 3600000, // 1h
refetchInterval: 3600000, // Auto refresh 1h
});
// Query: Invoices
const { data: invoices } = useQuery({
queryKey: ['invoices', user?.reseller_id],
queryFn: () => api.get(`/api/v1/invoices?reseller_id=${user?.reseller_id}&limit=24`),
staleTime: 3600000,
});
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorAlert error={error} />;
const { month_current, revenue_trend_12m, top_plans, data_consumed_by_plan } = summary.data;
return (
<div className="space-y-6">
{/* Header */}
<header className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-access-dark">Tableau de Bord Revendeur</h1>
<button className="px-4 py-2 bg-access-yellow text-access-dark rounded-lg hover:bg-opacity-90">
Actualiser
</button>
</header>
{/* KPI Cards Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<KpiCard
label="Revenu ce mois"
value={`${month_current.gross_revenue_fcfa.toLocaleString()} FCFA`}
trend="+12%"
color="blue"
/>
<KpiCard
label="Votre part (50%)"
value={`${month_current.split_50_fcfa.toLocaleString()} FCFA`}
trend="+12%"
color="yellow"
/>
<KpiCard
label="Abonnés actifs"
value={summary.data.active_subscribers}
subtext={`sur ${summary.data.total_subscribers_registered}`}
color="green"
/>
<KpiCard
label="Taux churn"
value={`${summary.data.churn_rate_percent}%`}
trend="-2%"
color="red"
/>
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend Line Chart */}
<ChartContainer title="Tendance revenus (12 mois)">
<LineChart data={revenue_trend_12m} dataKey="revenue_fcfa">
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(v) => `${(v / 1e6).toFixed(1)}M FCFA`} />
</LineChart>
</ChartContainer>
{/* Forfaits Distribution Pie */}
<ChartContainer title="Distribution forfaits">
<PieChart data={top_plans.map(p => ({ name: p.name, value: p.sold_count }))}>
<Pie dataKey="value" label={{ position: 'right' }} />
<Tooltip />
</PieChart>
</ChartContainer>
{/* Data Usage By Plan */}
<ChartContainer title="Usage données par forfait">
<BarChart data={data_consumed_by_plan}>
<XAxis dataKey="name" angle={-45} />
<YAxis label={{ value: 'Usage %', angle: -90 }} />
<Bar dataKey="avg_usage_percent" fill="#3f68ae" />
<Tooltip />
</BarChart>
</ChartContainer>
{/* Top Plans Table */}
<ChartContainer title="Top 5 forfaits vendus">
<table className="w-full text-sm">
<thead className="bg-gray-100">
<tr>
<th className="text-left p-2">Forfait</th>
<th className="text-right p-2">Vendus</th>
<th className="text-right p-2">Revenu</th>
</tr>
</thead>
<tbody>
{top_plans.map(plan => (
<tr key={plan.plan_id} className="border-b hover:bg-gray-50">
<td className="p-2">{plan.name}</td>
<td className="text-right">{plan.sold_count}</td>
<td className="text-right">{(plan.revenue_fcfa / 1e6).toFixed(1)}M FCFA</td>
</tr>
))}
</tbody>
</table>
</ChartContainer>
</div>
{/* Invoices Section */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold mb-4">Factures (derniers 12 mois)</h2>
<InvoiceTable
invoices={invoices?.data?.items || []}
onDownload={(invoiceId) => {
window.location.href = `/api/v1/invoices/${invoiceId}/pdf`;
}}
/>
</section>
</div>
);
}Commandes Utiles
# Tester endpoint résumé en curl
curl -H "Authorization: Bearer ${JWT_TOKEN}" \
https://api-rgz.duckdns.org/api/v1/billing/summary?reseller_id=uuid-revendeur
# Télécharger facture PDF
curl -H "Authorization: Bearer ${JWT_TOKEN}" \
https://api-rgz.duckdns.org/api/v1/invoices/invoice-uuid/pdf \
-o invoice_2026_02.pdf
# Build frontend
cd /home/claude-dev/RGZ/web && npm run build
# Dev mode React
cd /home/claude-dev/RGZ/web && npm run dev -- --host
# Test TanStack Query cache behavior
# (Ouvrir DevTools → React Query tab → vérifier cache, stale time, refetch)
# Vérifier audit trail changements données
docker exec rgz-db psql -U rgz -d rgz -c "
SELECT timestamp, user_id, action, resource_type, resource_id
FROM audit_trail
WHERE resource_type = 'reseller_dashboard'
ORDER BY timestamp DESC
LIMIT 20;
"Implémentation TODO
- [ ] Créer composant React
ResellerDashboard.tsxavec useAuth + useQuery hooks - [ ] Implémenter endpoint GET
/api/v1/billing/summary(app/api/v1/endpoints/billing.py) - [ ] Ajouter filtrage IDOR : if current_user.reseller_id != query_param → 403
- [ ] Créer hypertable TimescaleDB
billing_monthly_agg(precomputed pour perf) - [ ] Implémenter Charts: LineChart (trend), PieChart (forfaits), BarChart (usage)
- [ ] Ajouter endpoint GET
/api/v1/invoices/{invoice_id}/pdf→ WeasyPrint #24 - [ ] Créer composant
KpiCardréutilisable avec gradients ACCESS - [ ] Ajouter responsive design : Tailwind breakpoints sm/md/lg
- [ ] Implémenter manual refresh button + loading states
- [ ] Tests : IDOR security, TanStack Query cache behavior, PDF download
Dernière mise à jour: 2026-02-21