Add admin orders page with search, status filter, KPIs

- /admin/commandes: paginated list of all orders
- Search by order number, name, email
- Filter by status (pending, paid, cancelled, refunded)
- 4 KPIs: paid count, CA total HT, refunded count, cancelled count
- Table: order number, buyer, event, billets, total, date, status badges
- Navigation link 'Commandes' in admin header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-22 21:25:32 +01:00
parent 372bf46136
commit 6db0566f69
4 changed files with 174 additions and 1 deletions

View File

@@ -25,7 +25,7 @@
### Admin
- [ ] Dashboard admin : stats globales (CA global, commission E-Ticket totale, commission Stripe totale, nb commandes, nb billets, nb orgas)
- [ ] Admin : liste de toutes les commandes avec filtres
- [x] Admin : liste de toutes les commandes avec filtres (recherche, statut, KPIs)
- [x] Admin : pouvoir suspendre/réactiver un organisateur (badge, bouton toggle, redirect si suspendu, audit log)
- [x] Admin : pouvoir modifier l'offre/commission d'un orga existant
- [ ] Vérifier que les permissions des sous-comptes sont respectées (scanner, events, tickets)

View File

@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Entity\AuditLog;
use App\Entity\BilletBuyer;
use App\Entity\OrganizerInvitation;
use App\Entity\User;
use App\Service\AuditService;
@@ -450,6 +451,54 @@ class AdminController extends AbstractController
return $this->redirectToRoute('app_admin_organizers', ['tab' => 'approved']);
}
#[Route('/commandes', name: 'app_admin_orders', methods: ['GET'])]
public function orders(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response
{
$status = $request->query->getString('status', '');
$search = $request->query->getString('q', '');
$qb = $em->createQueryBuilder()
->select('o', 'i')
->from(BilletBuyer::class, 'o')
->leftJoin('o.items', 'i')
->orderBy('o.createdAt', 'DESC');
if ('' !== $status) {
$qb->andWhere('o.status = :status')->setParameter('status', $status);
}
if ('' !== $search) {
$qb->andWhere('o.orderNumber LIKE :q OR o.firstName LIKE :q OR o.lastName LIKE :q OR o.email LIKE :q')
->setParameter('q', '%'.$search.'%');
}
$orders = $paginator->paginate($qb->getQuery(), $request->query->getInt('page', 1), 20);
$totalCA = $em->createQueryBuilder()
->select('SUM(o.totalHT)')
->from(BilletBuyer::class, 'o')
->where('o.status = :paid')
->setParameter('paid', BilletBuyer::STATUS_PAID)
->getQuery()
->getSingleScalarResult() ?? 0;
$totalOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
$totalRefunded = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_REFUNDED]);
$totalCancelled = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_CANCELLED]);
$totalPending = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PENDING]);
return $this->render('admin/orders.html.twig', [
'orders' => $orders,
'status' => $status,
'search' => $search,
'totalCA' => (int) $totalCA / 100,
'totalOrders' => $totalOrders,
'totalRefunded' => $totalRefunded,
'totalCancelled' => $totalCancelled,
'totalPending' => $totalPending,
]);
}
#[Route('/evenements', name: 'app_admin_events')]
public function events(Request $request, PaginatorInterface $paginator, \App\Service\EventIndexService $eventIndex): Response
{

View File

@@ -22,6 +22,7 @@
<a href="{{ path('app_admin_buyers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_buyer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Acheteurs</a>
<a href="{{ path('app_admin_organizers') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_organizer' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Organisateurs</a>
<a href="{{ path('app_admin_events') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route starts with 'app_admin_event' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Evenements</a>
<a href="{{ path('app_admin_orders') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_orders' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Commandes</a>
<a href="{{ path('app_admin_logs') }}" class="px-3 py-1.5 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_admin_logs' ? 'admin-nav-active' : 'hover:bg-gray-100' }}">Logs</a>
</nav>
</div>

View File

@@ -0,0 +1,123 @@
{% extends 'admin/base.html.twig' %}
{% block title %}Commandes{% endblock %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Commandes</h1>
<p class="font-bold text-gray-500 italic">{{ orders.getTotalItemCount }} commande{{ orders.getTotalItemCount > 1 ? 's' : '' }}.</p>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="admin-card text-center">
<p class="admin-stat-label font-black uppercase text-gray-500">Payees</p>
<p class="text-2xl font-black text-green-600">{{ totalOrders }}</p>
</div>
<div class="admin-card text-center">
<p class="admin-stat-label font-black uppercase text-gray-500">CA Total HT</p>
<p class="text-2xl font-black text-indigo-600">{{ totalCA|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="admin-card text-center">
<p class="admin-stat-label font-black uppercase text-gray-500">Remboursees</p>
<p class="text-2xl font-black text-yellow-600">{{ totalRefunded }}</p>
</div>
<div class="admin-card text-center">
<p class="admin-stat-label font-black uppercase text-gray-500">Annulees</p>
<p class="text-2xl font-black text-red-600">{{ totalCancelled }}</p>
</div>
</div>
<div class="admin-card mb-8">
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Filtrer</h2>
<form method="get" action="{{ path('app_admin_orders') }}" class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[200px]">
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Recherche</label>
<input type="text" name="q" value="{{ search }}" class="admin-form-input" placeholder="Numero, nom, email...">
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Statut</label>
<select name="status" class="admin-form-input">
<option value="">Tous</option>
<option value="pending" {{ status == 'pending' ? 'selected' : '' }}>En attente</option>
<option value="paid" {{ status == 'paid' ? 'selected' : '' }}>Payee</option>
<option value="cancelled" {{ status == 'cancelled' ? 'selected' : '' }}>Annulee</option>
<option value="refunded" {{ status == 'refunded' ? 'selected' : '' }}>Remboursee</option>
</select>
</div>
<button type="submit" class="admin-btn-search font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-black transition-all">Filtrer</button>
{% if search or status %}
<a href="{{ path('app_admin_orders') }}" class="admin-btn-clear font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
{% endif %}
</form>
</div>
<div class="admin-card !p-0">
<table class="admin-table">
<thead>
<tr>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Commande</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Acheteur</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Evenement</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Billets</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Total HT</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white">Date</th>
<th class="text-[10px] font-black uppercase tracking-widest text-white text-right">Statut</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr class="hover:bg-gray-50 transition-all">
<td>
<p class="font-bold text-sm">{{ order.orderNumber }}</p>
{% if order.paymentMethod %}
<p class="text-[10px] text-gray-400">{{ order.paymentMethod }}{% if order.cardBrand %} {{ order.cardBrand|upper }}{% endif %}{% if order.cardLast4 %} **** {{ order.cardLast4 }}{% endif %}</p>
{% endif %}
</td>
<td>
<p class="font-bold text-sm">{{ order.firstName }} {{ order.lastName }}</p>
<p class="text-xs text-gray-400">{{ order.email }}</p>
</td>
<td class="text-sm text-gray-600">{{ order.event.title }}</td>
<td class="text-xs text-gray-500">
{% for item in order.items %}
{{ item.billetName }} x{{ item.quantity }}{% if not loop.last %}, {% endif %}
{% endfor %}
</td>
<td class="font-black text-sm text-indigo-600">{{ order.totalHTDecimal|number_format(2, ',', ' ') }} &euro;</td>
<td class="text-xs text-gray-400 whitespace-nowrap">{{ order.createdAt|date('d/m/Y H:i') }}</td>
<td class="text-right">
{% if order.status == 'paid' %}
<span class="admin-badge-green text-xs font-black uppercase">Payee</span>
{% elseif order.status == 'pending' %}
<span class="admin-badge-yellow text-xs font-black uppercase">En attente</span>
{% elseif order.status == 'refunded' %}
<span class="admin-badge-yellow text-xs font-black uppercase">Remboursee</span>
{% elseif order.status == 'cancelled' %}
<span class="admin-badge-red text-xs font-black uppercase">Annulee</span>
{% endif %}
{% if order.invitation %}
<span class="admin-badge-indigo text-xs font-black uppercase ml-1">Invitation</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="!text-center !py-12 text-gray-400 font-bold text-sm">Aucune commande.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if orders.getTotalItemCount > 20 %}
<div class="flex justify-center gap-2 mt-6">
{% for page in 1..orders.getPageCount %}
{% if page == orders.getCurrentPageNumber %}
<span class="admin-page-active text-xs font-black">{{ page }}</span>
{% else %}
<a href="{{ path('app_admin_orders', {page: page, status: status, q: search}) }}" class="admin-page-link text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %}