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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
123
templates/admin/orders.html.twig
Normal file
123
templates/admin/orders.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||
Reference in New Issue
Block a user