Skip to content

#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 rate

Configuration

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=14

Endpoints API

MéthodeRouteRéponse
POST/api/v1/rma/ticketsCreate RMA ticket : {ticket_id, status: signaled}
GET/api/v1/rma/tickets?reseller_id=&status=&limit=50List 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}/diagnoseTrigger SNMP/ping diagnostics (async)
GET/api/v1/rma/{ticket_id}/diagnosticsDiagnostic results
POST/api/v1/rma/{ticket_id}/approveApprove RMA, allocate spare: {rma_number, spare_serial}
PUT/api/v1/rma/{ticket_id}/shippedMark shipped with tracking: {tracking_number, provider}
PUT/api/v1/rma/{ticket_id}/closeClose ticket, record resolution
GET/api/v1/rma/inventorySpare CPE stock status: {available, reserved, in_transit, low_stock_alert}
GET/api/v1/rma/stats?period=30dKPI: 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=30d

Implémentation TODO

  • [ ] Créer composant React RmaDashboard.tsx avec 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

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone