#54 — Dashboard RMA (File Tickets Retours CPE)
PLANIFIÉ
Priorité: 🟠 HAUTE · Type: TYPE D · Conteneur: rgz-web · Code: web/src/pages/RmaDashboard.tsx
Dépendances: #2 rgz-web, #57 rma-ticket-system
Description
Interface web de gestion des retours matériels (RMA : Return Merchandise Authorization) pour les CPE défectueux. Support/NOC signalent panne (signal mauvais, ne démarre plus, etc.), diagnostic automatisé via tests SNMP/ping, approbation RMA générant numéro de retour, expédition vers dépôt de réparation, clôture après réception.
Workflow 5 étapes : SIGNALÉ → DIAGNOSTIC → APPROUVÉ → EXPÉDIÉ → CLÔT. Alerte si stock spare <5 unités (affiche "2 CPE en stock"). Tracking numéro de suivi courrier (DHL/TNT). Status page pour revendeur voir ses tickets.
Architecture Interne
RMA Workflow & Dataflow:
1. Ticket Signalement (Step 1 - SIGNALÉ):
├─ POST /api/v1/rma/tickets
├─ Payload:
│ {
│ "reseller_id": "uuid",
│ "cpe_serial": "LBE-HW25-12345",
│ "cpe_model": "LiteBeam AC Gen 2",
│ "problem_description": "Signal RSSI très faible (-85dBm)",
│ "problem_category": CHECK(signal|no_power|network|other),
│ "date_failure": "2026-02-19T10:30:00Z",
│ "reported_by_role": "reseller|noc"
│ }
└─ Response: {ticket_id, rma_number: null, status: "signaled", created_at}
└─ Créé en DB: rma_tickets table
2. Diagnostic Automatisé (Step 2 - DIAGNOSTIC):
├─ Celery task: app/tasks/rma.py::diagnose_cpe(ticket_id, cpe_serial)
├─ Actions:
│ • Lookup CPE en DB (ap_ip, nas_id)
│ • SNMP test : SNMPv3 vers CPE (RSSI, CPU, RAM, uptime)
│ • ICMP ping test : latency check
│ • SSH test (optionnel) : config dump
│ • Analyse résultats → decision: FIXABLE vs HARDWARE_FAILURE
│ • Insert diagnostics data → rma_diagnostics table
├─ Response:
│ {
│ "ticket_id": "uuid",
│ "diagnostics": {
│ "snmp_rssi_dbm": -68, // CPE was ok!
│ "icmp_latency_ms": 15,
│ "probable_cause": "software_config_issue",
│ "recommendation": "REBOOT_TEST",
│ "confidence_percent": 85
│ },
│ "status": "diagnostic_complete"
│ }
3. RMA Approval (Step 3 - APPROUVÉ):
├─ Admin review diagnostics
├─ POST /api/v1/rma/{ticket_id}/approve
├─ Generate RMA number: "RMA-2026-00123" (sequence DB)
├─ Allocate spare CPE: SELECT FROM cpe_inventory WHERE status='available' LIMIT 1
├─ Reserve spare: UPDATE cpe_inventory SET status='reserved', rma_ticket_id=uuid
├─ Response:
│ {
│ "rma_number": "RMA-2026-00123",
│ "spare_serial": "LBE-HW25-67890",
│ "shipping_label_url": "s3://labels/rma_2026_00123.pdf"
│ }
└─ Send SMS #61 to reseller: "RMA approuvé RMA-2026-00123, colis en route"
4. Shipping / In Transit (Step 4 - EXPÉDIÉ):
├─ Logistics print label, ship spare CPE to reseller
├─ Reseller receives spare, sends defective CPE back (prepaid)
├─ PUT /api/v1/rma/{ticket_id}/shipped
│ {
│ "shipping_provider": "DHL",
│ "tracking_number": "1Z999AA10123456784",
│ "shipped_date": "2026-02-20T14:30:00Z"
│ }
└─ Status: EXPEDITED
5. Closure / Repair Complete (Step 5 - CLÔT):
├─ Defective CPE arrives at repair depot
├─ Quality check: PASS → repair or recycle, FAIL → contact reseller
├─ PUT /api/v1/rma/{ticket_id}/close
│ {
│ "received_date": "2026-02-22T09:00:00Z",
│ "resolution": CHECK(repaired|recycled|warranty_replacement),
│ "notes": "Swapped antenna connector, unit now OK"
│ }
└─ Response: {status: "closed", rma_duration_days: 3}
6. Stock Management (Spare Inventory):
├─ Table cpe_inventory:
│ id, serial, model, status CHECK(available|reserved|shipped|returned|repair|retired),
│ rma_ticket_id FK, warehouse_location, cost_fcfa, procurement_date
├─ Alert: if COUNT(status='available') < 5 → banner NOC dashboard #52
├─ Query: GET /api/v1/rma/inventory
│ {
│ "total_units": 47,
│ "available": 3, // ⚠️ LOW STOCK
│ "reserved": 4,
│ "in_transit": 2,
│ "low_stock_alert": true
│ }
7. Dashboard Visualization:
├─ Kanban board: SIGNALÉ → DIAGNOSTIC → APPROUVÉ → EXPÉDIÉ → CLÔT
├─ Cards per column (drag-drop for manual status change)
├─ Metrics:
│ • Avg resolution time: 2.3 days
│ • Success rate: 94%
│ • Open tickets: 7
│ • Stock warning: 3 units left
8. Reporting & Compliance:
└─ Ticket history searchable by ticket_id, rma_number, reseller
└─ Export CSV for SLA reporting
└─ KPI: avg resolution time, first-time success rateConfiguration
env
# RMA Configuration
RMA_NUMBER_PREFIX=RMA
RMA_SEQUENCE_RESET_MONTHLY=true # reset counter Jan 1
# Spare inventory thresholds
SPARE_INVENTORY_LOW_THRESHOLD=5 # units
SPARE_INVENTORY_WARNING_THRESHOLD=10
# Shipping
SHIPPING_PROVIDER_DEFAULT=DHL
SHIPPING_PREPAID_RETURN=true
SHIPPING_LABEL_PRINTER=s3://shipping-labels/
# Diagnostics
RMA_SNMP_TIMEOUT=10 # seconds
RMA_ICMP_COUNT=3
RMA_SSH_ENABLED=true
# Repair depot
REPAIR_DEPOT_ADDRESS=RGZ Logistics, Douala, Cameroon
REPAIR_DEPOT_CONTACT=rma@rgz.cm
REPAIR_DEPOT_SLA_DAYS=7 # expected turnaround
# Timing
RMA_APPROVAL_SLA_HOURS=24
RMA_SHIPPING_SLA_DAYS=2
RMA_CLOSURE_SLA_DAYS=14Endpoints API
| Méthode | Route | Réponse |
|---|---|---|
| POST | /api/v1/rma/tickets | Create RMA ticket : {ticket_id, status: signaled} |
| GET | /api/v1/rma/tickets?reseller_id=&status=&limit=50 | List tickets: {items: [{id, rma_number, status, cpe_serial, created_at}], total} |
| GET | /api/v1/rma/tickets/{ticket_id} | Full ticket details |
| POST | /api/v1/rma/{ticket_id}/diagnose | Trigger SNMP/ping diagnostics (async) |
| GET | /api/v1/rma/{ticket_id}/diagnostics | Diagnostic results |
| POST | /api/v1/rma/{ticket_id}/approve | Approve RMA, allocate spare: {rma_number, spare_serial} |
| PUT | /api/v1/rma/{ticket_id}/shipped | Mark shipped with tracking: {tracking_number, provider} |
| PUT | /api/v1/rma/{ticket_id}/close | Close ticket, record resolution |
| GET | /api/v1/rma/inventory | Spare CPE stock status: {available, reserved, in_transit, low_stock_alert} |
| GET | /api/v1/rma/stats?period=30d | KPI: avg resolution time, success rate |
Composants React
typescript
// web/src/pages/RmaDashboard.tsx
import React, { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import KanbanBoard from '@/components/Boards/KanbanBoard';
import RmaForm from '@/components/Forms/RmaForm';
import InventoryCard from '@/components/Cards/InventoryCard';
import api from '@/services/api';
export default function RmaDashboard() {
const { user } = useAuth();
const queryClient = useQueryClient();
const [showNewForm, setShowNewForm] = useState(false);
// Queries
const { data: tickets, isLoading } = useQuery({
queryKey: ['rma', 'tickets'],
queryFn: () => api.get(`/api/v1/rma/tickets?reseller_id=${user?.reseller_id}`),
refetchInterval: 60000, // 1 min polling
});
const { data: inventory } = useQuery({
queryKey: ['rma', 'inventory'],
queryFn: () => api.get('/api/v1/rma/inventory'),
refetchInterval: 300000, // 5 min
});
const { data: stats } = useQuery({
queryKey: ['rma', 'stats'],
queryFn: () => api.get('/api/v1/rma/stats?period=30d'),
});
// Mutations
const createTicketMutation = useMutation({
mutationFn: (data) => api.post('/api/v1/rma/tickets', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rma', 'tickets'] });
setShowNewForm(false);
},
});
const approveTicketMutation = useMutation({
mutationFn: (ticketId) => api.post(`/api/v1/rma/${ticketId}/approve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rma', 'tickets'] }),
});
// Kanban data grouping
const getTicketsbyStatus = () => {
return {
signaled: tickets?.data?.items?.filter(t => t.status === 'signaled') || [],
diagnostic: tickets?.data?.items?.filter(t => t.status === 'diagnostic_complete') || [],
approved: tickets?.data?.items?.filter(t => t.status === 'approved') || [],
expedited: tickets?.data?.items?.filter(t => t.status === 'expedited') || [],
closed: tickets?.data?.items?.filter(t => t.status === 'closed') || [],
};
};
const statuses = getTicketsbyStatus();
return (
<div className="space-y-6">
{/* Header */}
<header className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Gestion RMA</h1>
<button
onClick={() => setShowNewForm(true)}
className="px-4 py-2 bg-access-yellow text-access-dark rounded-lg hover:bg-opacity-90"
>
+ Nouveau Ticket
</button>
</header>
{/* Low Stock Alert */}
{inventory?.data?.low_stock_alert && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<span className="text-2xl">⚠️</span>
<div>
<p className="font-semibold text-red-800">Stock de pièces détachées faible</p>
<p className="text-sm text-red-700">{inventory.data.available} CPE disponibles (seuil: 5)</p>
</div>
</div>
)}
{/* Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4 border border-gray-200">
<p className="text-gray-600 text-sm">Tickets Ouverts</p>
<p className="text-3xl font-bold">{tickets?.data?.items?.filter(t => !['closed'].includes(t.status)).length || 0}</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border border-gray-200">
<p className="text-gray-600 text-sm">Durée Moyenne</p>
<p className="text-3xl font-bold">{stats?.data?.avg_resolution_days?.toFixed(1) || 0} j</p>
</div>
<div className="bg-white rounded-lg shadow p-4 border border-gray-200">
<p className="text-gray-600 text-sm">Taux Succès</p>
<p className="text-3xl font-bold">{stats?.data?.success_rate_percent?.toFixed(0) || 0}%</p>
</div>
<InventoryCard data={inventory?.data} />
</div>
{/* New Ticket Form (Modal) */}
{showNewForm && (
<RmaForm
onSubmit={(data) => createTicketMutation.mutate(data)}
onClose={() => setShowNewForm(false)}
isLoading={createTicketMutation.isPending}
/>
)}
{/* Kanban Board */}
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
<h2 className="text-xl font-bold mb-6">Workflow Tickets</h2>
{isLoading ? (
<p className="text-gray-600">Chargement...</p>
) : (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* SIGNALÉ */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200 min-h-96">
<h3 className="font-semibold text-gray-700 mb-3">SIGNALÉ ({statuses.signaled.length})</h3>
<div className="space-y-2">
{statuses.signaled.map(ticket => (
<RmaTicketCard
key={ticket.id}
ticket={ticket}
onApprove={() => approveTicketMutation.mutate(ticket.id)}
/>
))}
</div>
</div>
{/* DIAGNOSTIC */}
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200 min-h-96">
<h3 className="font-semibold text-blue-700 mb-3">DIAGNOSTIC ({statuses.diagnostic.length})</h3>
<div className="space-y-2">
{statuses.diagnostic.map(ticket => (
<RmaTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
</div>
{/* APPROUVÉ */}
<div className="bg-yellow-50 rounded-lg p-4 border border-yellow-200 min-h-96">
<h3 className="font-semibold text-yellow-700 mb-3">APPROUVÉ ({statuses.approved.length})</h3>
<div className="space-y-2">
{statuses.approved.map(ticket => (
<RmaTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
</div>
{/* EXPÉDIÉ */}
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200 min-h-96">
<h3 className="font-semibold text-purple-700 mb-3">EXPÉDIÉ ({statuses.expedited.length})</h3>
<div className="space-y-2">
{statuses.expedited.map(ticket => (
<RmaTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
</div>
{/* CLÔT */}
<div className="bg-green-50 rounded-lg p-4 border border-green-200 min-h-96">
<h3 className="font-semibold text-green-700 mb-3">CLÔT ({statuses.closed.length})</h3>
<div className="space-y-2">
{statuses.closed.slice(0, 5).map(ticket => (
<RmaTicketCard key={ticket.id} ticket={ticket} clickable={false} />
))}
</div>
</div>
</div>
)}
</div>
</div>
);
}Commandes Utiles
bash
# Créer ticket RMA
curl -X POST https://api-rgz.duckdns.org/api/v1/rma/tickets \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"cpe_serial": "LBE-HW25-12345",
"cpe_model": "LiteBeam AC Gen 2",
"problem_description": "Signal très faible",
"problem_category": "signal"
}'
# Lancer diagnostic automatisé
curl -X POST https://api-rgz.duckdns.org/api/v1/rma/uuid/diagnose \
-H "Authorization: Bearer ${JWT_TOKEN}"
# Approuver RMA
curl -X POST https://api-rgz.duckdns.org/api/v1/rma/uuid/approve \
-H "Authorization: Bearer ${JWT_TOKEN}"
# Marquer comme expédié
curl -X PUT https://api-rgz.duckdns.org/api/v1/rma/uuid/shipped \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-d '{"shipping_provider": "DHL", "tracking_number": "1Z999AA..."}'
# Clôturer ticket
curl -X PUT https://api-rgz.duckdns.org/api/v1/rma/uuid/close \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-d '{"resolution": "repaired"}'
# Vérifier stock spare
curl https://api-rgz.duckdns.org/api/v1/rma/inventory
# Stats RMA
curl https://api-rgz.duckdns.org/api/v1/rma/stats?period=30dImplémentation TODO
- [ ] Créer composant React
RmaDashboard.tsxavec Kanban board - [ ] Créer table RMA : rma_tickets (id, rma_number, status, cpe_serial, problem_desc, created_at)
- [ ] Créer table spare inventory : cpe_inventory (serial, status, rma_ticket_id, location)
- [ ] Implémenter endpoints CRUD : POST /tickets, GET, PUT /shipped, PUT /close
- [ ] Créer Celery task : diagnose_cpe (SNMP + ping tests)
- [ ] Ajouter automatic RMA number generation (sequence)
- [ ] Implémenter spare allocation logic + stock alert (<5 units)
- [ ] Créer Kanban board component (5 columns, drag-drop status change)
- [ ] Ajouter RmaForm for new ticket creation
- [ ] Tests : workflow transitions, stock tracking, diagnostic automation
Dernière mise à jour: 2026-02-21