Scanner: SSO login, 2 scan modes (camera/security key), sound feedback, order details, force validation, staff/exposant badges
- Add SSO login button to scanner PWA with Keycloak redirect flow via session state - Add manual scan mode via security key (16 chars) alongside QR camera scan - Add audio feedback: good (accepted), warning (already scanned), refused sounds - Add unique scan counter per reference (no double counting same ticket) - Add order details display in scan results (order number, email, total, items) - Add force validation button for refused tickets (organizer/ROLE_ROOT only), sends email notification - Add already_scanned warning only for same-day scans, exit_definitive only same day - Staff and exposant tickets always validate regardless of state API: ROLE_ROOT access to all events, categories, billets, and scan endpoints - ROLE_ROOT bypasses ownership checks on all /api/live/* endpoints - ROLE_ROOT can login via API (email/password and SSO) - Scan API accepts securityKey parameter in addition to reference - Scan response includes billetType, buyerEmail, and full order details with items Event management: tickets tab, staff/exposant accreditations, attestation PDF - Add Tickets tab listing all sold tickets with search, download PDF, resend email, cancel actions - Add Staff/Exposant accreditation form in Invitations tab, generates dedicated non-buyable billet - Add Attestation tab to generate sales certificate PDF with category/billet selection - PDF billet template shows STAFF/EXPOSANT badge with distinct colors (black/purple) - Exclude invitations from all financial stats (event stats, admin dashboard, organizer finances) - Fix sold counts to exclude invitations in categories recap - Use actual Stripe fee parameters instead of hardcoded values in commission calculations - Add commission detail breakdown (E-Ticket + Stripe) in categories and stats tabs Admin: download tickets for orders - Add download button on admin orders page (single PDF or ZIP for multiple tickets) Scanner PWA fixes: CSP (unpkg -> jsdelivr), service worker scope (/scanner/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,13 @@ knpu_oauth2_client:
|
||||
provider_options:
|
||||
authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%'
|
||||
realm: '%env(OAUTH_KEYCLOAK_REALM)%'
|
||||
keycloak_api:
|
||||
type: generic
|
||||
provider_class: Stevenmaguire\OAuth2\Client\Provider\Keycloak
|
||||
client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%'
|
||||
client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%'
|
||||
redirect_route: app_api_auth_sso_validate
|
||||
redirect_params: {}
|
||||
provider_options:
|
||||
authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%'
|
||||
realm: '%env(OAUTH_KEYCLOAK_REALM)%'
|
||||
|
||||
@@ -442,7 +442,9 @@ class AccountController extends AbstractController
|
||||
$rows = $em->createQueryBuilder()
|
||||
->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt')
|
||||
->from(BilletOrder::class, 'bo')
|
||||
->join('bo.billetBuyer', 'bb')
|
||||
->where('bo.billet IN (:ids)')
|
||||
->andWhere('bb.isInvitation = false OR bb.isInvitation IS NULL')
|
||||
->setParameter('ids', $billetIds)
|
||||
->groupBy('bo.billet')
|
||||
->getQuery()
|
||||
@@ -459,12 +461,29 @@ class AccountController extends AbstractController
|
||||
: $em->getRepository(BilletBuyer::class)->findBy(['event' => $event], ['createdAt' => 'DESC']);
|
||||
$eventOrders = $paginator->paginate($ordersQuery, $request->query->getInt('page', 1), 20);
|
||||
|
||||
$ticketsSearchQuery = $request->query->getString('tq', '');
|
||||
$ticketsQb = $em->createQueryBuilder()
|
||||
->select('t', 'bb')
|
||||
->from(BilletOrder::class, 't')
|
||||
->join('t.billetBuyer', 'bb')
|
||||
->where('bb.event = :ticketEvent')
|
||||
->setParameter('ticketEvent', $event)
|
||||
->orderBy('t.createdAt', 'DESC');
|
||||
|
||||
if ('' !== $ticketsSearchQuery) {
|
||||
$ticketsQb->andWhere('t.reference LIKE :tq OR t.securityKey LIKE :tq OR t.billetName LIKE :tq OR bb.firstName LIKE :tq OR bb.lastName LIKE :tq OR bb.email LIKE :tq')
|
||||
->setParameter('tq', '%'.$ticketsSearchQuery.'%');
|
||||
}
|
||||
|
||||
$eventTickets = $paginator->paginate($ticketsQb->getQuery(), $request->query->getInt('tp', 1), 20);
|
||||
|
||||
$paidEventOrders = $em->createQueryBuilder()
|
||||
->select('o', 'i')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->leftJoin('o.items', 'i')
|
||||
->where('o.event = :event')
|
||||
->andWhere('o.status = :status')
|
||||
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
|
||||
->setParameter('event', $event)
|
||||
->setParameter('status', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
@@ -478,8 +497,12 @@ class AccountController extends AbstractController
|
||||
'billets' => $billets,
|
||||
'sold_counts' => $soldCounts,
|
||||
'commission_rate' => $user->getCommissionRate() ?? 0,
|
||||
'stripe_fee_rate' => (float) $this->getParameter('stripe_fee_rate'),
|
||||
'stripe_fee_fixed' => (int) $this->getParameter('stripe_fee_fixed'),
|
||||
'billet_design' => $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]),
|
||||
'event_orders' => $eventOrders,
|
||||
'event_tickets' => $eventTickets,
|
||||
'tickets_search_query' => $ticketsSearchQuery,
|
||||
'invitations' => $em->getRepository(BilletBuyer::class)->findBy(['event' => $event, 'isInvitation' => true], ['createdAt' => 'DESC']),
|
||||
'event_total_ht' => $eventStats['totalHT'] / 100,
|
||||
'event_total_sold' => $eventStats['totalSold'],
|
||||
@@ -809,6 +832,91 @@ class AccountController extends AbstractController
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/accreditation', name: 'app_account_event_create_accreditation', methods: ['POST'])]
|
||||
public function createAccreditation(Event $event, Request $request, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
|
||||
return $redirect;
|
||||
} // @codeCoverageIgnoreEnd
|
||||
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$accreditationType = $request->request->getString('accreditation_type', 'staff');
|
||||
if (!\in_array($accreditationType, ['staff', 'exposant'], true)) {
|
||||
$accreditationType = 'staff';
|
||||
}
|
||||
|
||||
$firstName = trim($request->request->getString('first_name'));
|
||||
$lastName = trim($request->request->getString('last_name'));
|
||||
$email = trim($request->request->getString('email'));
|
||||
$redirectResponse = $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
|
||||
if ('' === $firstName || '' === $lastName || '' === $email) {
|
||||
$this->addFlash('error', 'Tous les champs sont requis.');
|
||||
|
||||
return $redirectResponse;
|
||||
}
|
||||
|
||||
$categories = $em->getRepository(Category::class)->findBy(['event' => $event], ['position' => 'ASC']);
|
||||
if (empty($categories)) {
|
||||
$this->addFlash('error', 'Creez au moins une categorie avant de generer une accreditation.');
|
||||
|
||||
return $redirectResponse;
|
||||
}
|
||||
|
||||
$category = $categories[0];
|
||||
$label = 'staff' === $accreditationType ? 'Staff' : 'Exposant';
|
||||
|
||||
$billet = new Billet();
|
||||
$billet->setCategory($category);
|
||||
$billet->setName($label.' - '.$firstName.' '.$lastName);
|
||||
$billet->setType($accreditationType);
|
||||
$billet->setPriceHT(0);
|
||||
$billet->setQuantity(1);
|
||||
$billet->setIsGeneratedBillet(true);
|
||||
$billet->setNotBuyable(true);
|
||||
$billet->setPosition(9999);
|
||||
$em->persist($billet);
|
||||
$em->flush();
|
||||
|
||||
$count = $em->getRepository(BilletBuyer::class)->count([]) + 1;
|
||||
|
||||
$order = new BilletBuyer();
|
||||
$order->setEvent($event);
|
||||
$order->setFirstName($firstName);
|
||||
$order->setLastName($lastName);
|
||||
$order->setEmail($email);
|
||||
$order->setOrderNumber(date('Y-m-d').'-'.$count);
|
||||
$order->setTotalHT(0);
|
||||
$order->setIsInvitation(true);
|
||||
|
||||
$item = new BilletBuyerItem();
|
||||
$item->setBillet($billet);
|
||||
$item->setBilletName($billet->getName());
|
||||
$item->setQuantity(1);
|
||||
$item->setUnitPriceHT(0);
|
||||
$order->addItem($item);
|
||||
|
||||
$em->persist($order);
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateOrderTickets($order);
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
foreach ($tickets as $ticket) {
|
||||
$ticket->setIsInvitation(true);
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$billetOrderService->generateAndSendTickets($order);
|
||||
|
||||
$label = 'staff' === $accreditationType ? 'Staff' : 'Exposant';
|
||||
$this->addFlash('success', 'Accreditation '.$label.' envoyee a '.$order->getEmail().'.');
|
||||
|
||||
return $redirectResponse;
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/invitation/{orderId}/renvoyer', name: 'app_account_event_resend_invitation', methods: ['POST'])]
|
||||
public function resendInvitation(Event $event, int $orderId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
@@ -831,6 +939,164 @@ class AccountController extends AbstractController
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'invitations']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/telecharger', name: 'app_account_event_download_ticket', requirements: ['ticketId' => '\d+'], methods: ['GET'])]
|
||||
public function downloadTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$ticket->getReference().'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/renvoyer', name: 'app_account_event_resend_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])]
|
||||
public function resendTicket(Event $event, int $ticketId, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$billetOrderService->generateAndSendTickets($ticket->getBilletBuyer());
|
||||
|
||||
$this->addFlash('success', 'Billet renvoye a '.$ticket->getBilletBuyer()->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/ticket/{ticketId}/annuler', name: 'app_account_event_cancel_ticket', requirements: ['ticketId' => '\d+'], methods: ['POST'])]
|
||||
public function cancelTicket(Event $event, int $ticketId, EntityManagerInterface $em, AuditService $audit): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$this->requireEventOwnership($event);
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->find($ticketId);
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$ticket->setState(BilletOrder::STATE_INVALID);
|
||||
$em->flush();
|
||||
|
||||
$audit->log('ticket_cancelled', 'BilletOrder', $ticket->getId(), [
|
||||
'reference' => $ticket->getReference(),
|
||||
'event' => $event->getTitle(),
|
||||
]);
|
||||
|
||||
$this->addFlash('success', 'Billet '.$ticket->getReference().' annule.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'tickets']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/attestation', name: 'app_account_event_attestation', methods: ['POST'])]
|
||||
public function eventAttestation(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
$user = $this->requireEventOwnership($event);
|
||||
|
||||
$categoryIds = array_map('intval', $request->request->all('categories'));
|
||||
$billetIds = array_map('intval', $request->request->all('billets'));
|
||||
|
||||
$categories = [];
|
||||
if ($categoryIds) {
|
||||
$categories = $em->getRepository(Category::class)->findBy(['id' => $categoryIds, 'event' => $event]);
|
||||
}
|
||||
|
||||
$billets = [];
|
||||
if ($billetIds) {
|
||||
$billets = $em->getRepository(Billet::class)->findBy(['id' => $billetIds]);
|
||||
$billets = array_filter($billets, fn (Billet $b) => $b->getCategory()->getEvent()->getId() === $event->getId());
|
||||
}
|
||||
|
||||
if (empty($categories) && empty($billets)) {
|
||||
$this->addFlash('error', 'Selectionnez au moins une categorie ou un billet.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'attestation']);
|
||||
}
|
||||
|
||||
$allBilletIds = [];
|
||||
foreach ($billets as $b) {
|
||||
$allBilletIds[] = $b->getId();
|
||||
}
|
||||
foreach ($categories as $cat) {
|
||||
$catBillets = $em->getRepository(Billet::class)->findBy(['category' => $cat]);
|
||||
foreach ($catBillets as $b) {
|
||||
if (!\in_array($b->getId(), $allBilletIds, true)) {
|
||||
$allBilletIds[] = $b->getId();
|
||||
$billets[] = $b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$soldCounts = [];
|
||||
$revenueCounts = [];
|
||||
if ($allBilletIds) {
|
||||
$rows = $em->createQueryBuilder()
|
||||
->select('IDENTITY(bo.billet) AS billetId, COUNT(bo.id) AS cnt, SUM(bo.unitPriceHT) AS revenue')
|
||||
->from(BilletOrder::class, 'bo')
|
||||
->join('bo.billetBuyer', 'bb')
|
||||
->where('bo.billet IN (:ids)')
|
||||
->andWhere('bb.isInvitation = false OR bb.isInvitation IS NULL')
|
||||
->setParameter('ids', $allBilletIds)
|
||||
->groupBy('bo.billet')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
foreach ($rows as $row) {
|
||||
$soldCounts[(int) $row['billetId']] = (int) $row['cnt'];
|
||||
$revenueCounts[(int) $row['billetId']] = (int) $row['revenue'];
|
||||
}
|
||||
}
|
||||
|
||||
$billetLines = [];
|
||||
$totalSold = 0;
|
||||
$totalRevenue = 0;
|
||||
foreach ($billets as $b) {
|
||||
$sold = $soldCounts[$b->getId()] ?? 0;
|
||||
$revenue = $revenueCounts[$b->getId()] ?? 0;
|
||||
$billetLines[] = [
|
||||
'category' => $b->getCategory()->getName(),
|
||||
'name' => $b->getName(),
|
||||
'priceHT' => $b->getPriceHTDecimal(),
|
||||
'sold' => $sold,
|
||||
'revenue' => $revenue / 100,
|
||||
];
|
||||
$totalSold += $sold;
|
||||
$totalRevenue += $revenue;
|
||||
}
|
||||
|
||||
$html = $this->renderView('pdf/attestation_ventes.html.twig', [
|
||||
'event' => $event,
|
||||
'organizer' => $user,
|
||||
'billetLines' => $billetLines,
|
||||
'totalSold' => $totalSold,
|
||||
'totalRevenue' => $totalRevenue / 100,
|
||||
'generatedAt' => new \DateTimeImmutable(),
|
||||
'selectedCategories' => array_map(fn ($c) => $c->getName(), $categories),
|
||||
]);
|
||||
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
return new Response($dompdf->output(), 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="attestation_'.$event->getSlug().'_'.date('Y-m-d').'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/commande/{orderId}/annuler', name: 'app_account_event_cancel_order', methods: ['POST'])]
|
||||
public function cancelOrder(Event $event, int $orderId, EntityManagerInterface $em, AuditService $audit, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
@@ -1377,6 +1643,7 @@ class AccountController extends AbstractController
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->join('o.event', 'e')
|
||||
->where('e.account = :user')
|
||||
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
|
||||
->setParameter('user', $user)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
@@ -6,11 +6,13 @@ use App\Entity\AnalyticsEvent;
|
||||
use App\Entity\AnalyticsUniqId;
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\BilletBuyer;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\OrganizerInvitation;
|
||||
use App\Entity\User;
|
||||
use App\Service\AuditService;
|
||||
use App\Service\EventIndexService;
|
||||
use App\Service\BilletOrderService;
|
||||
use App\Service\ExportService;
|
||||
use App\Service\MailerService;
|
||||
use App\Service\MeilisearchService;
|
||||
@@ -48,11 +50,19 @@ class AdminController extends AbstractController
|
||||
->select('SUM(o.totalHT)')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->where(self::DQL_STATUS_PAID)
|
||||
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
|
||||
->setParameter('paid', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?? 0);
|
||||
|
||||
$nbOrders = $em->getRepository(BilletBuyer::class)->count(['status' => BilletBuyer::STATUS_PAID]);
|
||||
$nbOrders = $em->createQueryBuilder()
|
||||
->select('COUNT(o.id)')
|
||||
->from(BilletBuyer::class, 'o')
|
||||
->where(self::DQL_STATUS_PAID)
|
||||
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
|
||||
->setParameter('paid', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
$nbBillets = $em->getRepository(\App\Entity\BilletOrder::class)->count([]);
|
||||
|
||||
$commissionEticket = 0;
|
||||
@@ -63,6 +73,7 @@ class AdminController extends AbstractController
|
||||
->join('o.event', 'e')
|
||||
->join('e.account', 'a')
|
||||
->where(self::DQL_STATUS_PAID)
|
||||
->andWhere('o.isInvitation = false OR o.isInvitation IS NULL')
|
||||
->setParameter('paid', BilletBuyer::STATUS_PAID)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
@@ -569,6 +580,47 @@ class AdminController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/commandes/{id}/billets', name: 'app_admin_order_tickets', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function orderTickets(int $id, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
||||
{
|
||||
$order = $em->getRepository(BilletBuyer::class)->find($id);
|
||||
if (!$order) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
$tickets = $em->getRepository(BilletOrder::class)->findBy(['billetBuyer' => $order]);
|
||||
if (!$tickets) {
|
||||
throw $this->createNotFoundException('Aucun billet pour cette commande.');
|
||||
}
|
||||
|
||||
if (1 === \count($tickets)) {
|
||||
$pdf = $billetOrderService->generatePdf($tickets[0]);
|
||||
|
||||
return new Response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$tickets[0]->getReference().'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'tickets_');
|
||||
$zip->open($tmpFile, \ZipArchive::OVERWRITE);
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
$pdf = $billetOrderService->generatePdf($ticket);
|
||||
$zip->addFromString($ticket->getReference().'.pdf', $pdf);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
$content = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => 'attachment; filename="billets_'.$order->getOrderNumber().'.zip"',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/evenements', name: 'app_admin_events')]
|
||||
public function events(Request $request, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[Route('/api/auth')]
|
||||
@@ -42,7 +43,9 @@ class ApiAuthController extends AbstractController
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
$hasAccess = $user && (\in_array('ROLE_ORGANIZER', $user->getRoles(), true) || \in_array('ROLE_ROOT', $user->getRoles(), true));
|
||||
|
||||
if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !$hasAccess) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => !$user || !$passwordHasher->isPasswordValid($user, $password) ? 'Identifiants invalides.' : 'Acces reserve aux organisateurs.'], 401);
|
||||
}
|
||||
|
||||
@@ -105,8 +108,14 @@ class ApiAuthController extends AbstractController
|
||||
|
||||
/** @codeCoverageIgnore Requires live Keycloak */
|
||||
#[Route('/login/sso', name: 'app_api_auth_sso', methods: ['GET'])]
|
||||
public function sso(ClientRegistry $clientRegistry): RedirectResponse
|
||||
public function sso(Request $request, ClientRegistry $clientRegistry): RedirectResponse
|
||||
{
|
||||
$from = $request->query->get('from', '');
|
||||
$session = $request->getSession();
|
||||
if ('scanner' === $from) {
|
||||
$session->set('sso_from', 'scanner');
|
||||
}
|
||||
|
||||
return $clientRegistry->getClient('keycloak')->redirect(
|
||||
['openid', 'email', 'profile'],
|
||||
['redirect_uri' => $this->generateUrl('app_api_auth_sso_validate', [], UrlGeneratorInterface::ABSOLUTE_URL)],
|
||||
@@ -118,14 +127,23 @@ class ApiAuthController extends AbstractController
|
||||
*/
|
||||
#[Route('/login/sso/validate', name: 'app_api_auth_sso_validate', methods: ['GET'])]
|
||||
public function ssoValidate(
|
||||
Request $request,
|
||||
ClientRegistry $clientRegistry,
|
||||
EntityManagerInterface $em,
|
||||
): JsonResponse {
|
||||
): JsonResponse|RedirectResponse {
|
||||
$session = $request->getSession();
|
||||
$fromScanner = 'scanner' === $session->get('sso_from', '');
|
||||
$session->remove('sso_from');
|
||||
|
||||
try {
|
||||
$client = $clientRegistry->getClient('keycloak');
|
||||
$client = $clientRegistry->getClient('keycloak_api');
|
||||
$accessToken = $client->getAccessToken();
|
||||
$keycloakUser = $client->fetchUserFromToken($accessToken);
|
||||
} catch (\Throwable) {
|
||||
if ($fromScanner) {
|
||||
return new RedirectResponse('/scanner/#sso_error=auth_failed');
|
||||
}
|
||||
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Authentification SSO echouee.'], 401);
|
||||
}
|
||||
|
||||
@@ -136,10 +154,25 @@ class ApiAuthController extends AbstractController
|
||||
$user = $em->getRepository(User::class)->findOneBy(['keycloakId' => $keycloakId])
|
||||
?? $em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (!$user || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
$hasAccess = $user && (\in_array('ROLE_ORGANIZER', $user->getRoles(), true) || \in_array('ROLE_ROOT', $user->getRoles(), true));
|
||||
|
||||
if (!$user || !$hasAccess) {
|
||||
if ($fromScanner) {
|
||||
$error = !$user ? 'no_account' : 'no_access';
|
||||
|
||||
return new RedirectResponse('/scanner/#sso_error='.$error);
|
||||
}
|
||||
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => !$user ? 'Aucun compte associe a ce SSO.' : 'Acces reserve aux organisateurs.'], 403);
|
||||
}
|
||||
|
||||
if ($fromScanner) {
|
||||
$token = $this->generateJwt($user);
|
||||
$expiresAt = (new \DateTimeImmutable())->modify('+'.self::JWT_TTL.' seconds')->format(\DateTimeInterface::ATOM);
|
||||
|
||||
return new RedirectResponse('/scanner/#sso_token='.urlencode($token).'&sso_email='.urlencode($user->getEmail()).'&sso_expires='.urlencode($expiresAt));
|
||||
}
|
||||
|
||||
return $this->tokenResponse($user, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletBuyerItem;
|
||||
use App\Entity\BilletOrder;
|
||||
use App\Entity\Category;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\User;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -30,6 +33,11 @@ class ApiLiveController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
private function isRoot(User $user): bool
|
||||
{
|
||||
return \in_array('ROLE_ROOT', $user->getRoles(), true);
|
||||
}
|
||||
|
||||
#[Route('/events', name: 'app_api_live_events', methods: ['GET'])]
|
||||
public function events(Request $request, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
@@ -38,7 +46,8 @@ class ApiLiveController extends AbstractController
|
||||
return $user;
|
||||
}
|
||||
|
||||
$events = $em->getRepository(Event::class)->findBy(['account' => $user], ['startAt' => 'DESC']);
|
||||
$criteria = $this->isRoot($user) ? [] : ['account' => $user];
|
||||
$events = $em->getRepository(Event::class)->findBy($criteria, ['startAt' => 'DESC']);
|
||||
|
||||
$data = array_map(fn (Event $e) => [
|
||||
'id' => $e->getId(),
|
||||
@@ -65,7 +74,7 @@ class ApiLiveController extends AbstractController
|
||||
}
|
||||
|
||||
$event = $em->getRepository(Event::class)->find($id);
|
||||
if (!$event || $event->getAccount()->getId() !== $user->getId()) {
|
||||
if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) {
|
||||
return $this->error(self::ERR_EVENT, 404);
|
||||
}
|
||||
|
||||
@@ -93,7 +102,7 @@ class ApiLiveController extends AbstractController
|
||||
}
|
||||
|
||||
$event = $em->getRepository(Event::class)->find($id);
|
||||
if (!$event || $event->getAccount()->getId() !== $user->getId()) {
|
||||
if (!$event || (!$this->isRoot($user) && $event->getAccount()->getId() !== $user->getId())) {
|
||||
return $this->error(self::ERR_EVENT, 404);
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ class ApiLiveController extends AbstractController
|
||||
}
|
||||
|
||||
$category = $em->getRepository(Category::class)->find($id);
|
||||
if (!$category || $category->getEvent()->getAccount()->getId() !== $user->getId()) {
|
||||
if (!$category || (!$this->isRoot($user) && $category->getEvent()->getAccount()->getId() !== $user->getId())) {
|
||||
return $this->error(self::ERR_CATEGORY, 404);
|
||||
}
|
||||
|
||||
@@ -167,7 +176,7 @@ class ApiLiveController extends AbstractController
|
||||
}
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($id);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId()) {
|
||||
if (!$billet || (!$this->isRoot($user) && $billet->getCategory()->getEvent()->getAccount()->getId() !== $user->getId())) {
|
||||
return $this->error(self::ERR_BILLET, 404);
|
||||
}
|
||||
|
||||
@@ -205,37 +214,132 @@ class ApiLiveController extends AbstractController
|
||||
return $user;
|
||||
}
|
||||
|
||||
$reference = (json_decode($request->getContent(), true) ?? [])['reference'] ?? '';
|
||||
$ticket = '' !== $reference ? $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]) : null;
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$reference = $data['reference'] ?? '';
|
||||
$securityKey = $data['securityKey'] ?? '';
|
||||
|
||||
if (!$ticket || $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() !== $user->getId()) {
|
||||
return $this->error(!$ticket && '' === $reference ? 'Reference requise.' : self::ERR_BILLET, '' === $reference ? 400 : 404);
|
||||
if ('' === $reference && '' === $securityKey) {
|
||||
return $this->error('Reference ou cle de securite requise.', 400);
|
||||
}
|
||||
|
||||
return $this->success($this->processScan($ticket, $em));
|
||||
$ticket = null;
|
||||
if ('' !== $reference) {
|
||||
$ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]);
|
||||
} elseif ('' !== $securityKey) {
|
||||
$ticket = $em->getRepository(BilletOrder::class)->findOneBy(['securityKey' => strtoupper($securityKey)]);
|
||||
}
|
||||
|
||||
if (!$ticket || (!$this->isRoot($user) && $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() !== $user->getId())) {
|
||||
return $this->error(self::ERR_BILLET, 404);
|
||||
}
|
||||
|
||||
$isOwner = $ticket->getBilletBuyer()->getEvent()->getAccount()->getId() === $user->getId();
|
||||
$canForce = $this->isRoot($user) || ($isOwner && \in_array('ROLE_ORGANIZER', $user->getRoles(), true));
|
||||
|
||||
return $this->success($this->processScan($ticket, $em, $canForce));
|
||||
}
|
||||
|
||||
#[Route('/scan/force', name: 'app_api_live_scan_force', methods: ['POST'])]
|
||||
public function scanForce(Request $request, EntityManagerInterface $em, MailerService $mailerService): JsonResponse
|
||||
{
|
||||
$user = $this->authenticateRequest($request, $em, $this->appSecret);
|
||||
if ($user instanceof JsonResponse) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (!$this->isRoot($user) && !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
return $this->error('Acces reserve aux organisateurs.', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$reference = $data['reference'] ?? '';
|
||||
|
||||
if ('' === $reference) {
|
||||
return $this->error('Reference requise.', 400);
|
||||
}
|
||||
|
||||
$ticket = $em->getRepository(BilletOrder::class)->findOneBy(['reference' => $reference]);
|
||||
|
||||
if (!$ticket) {
|
||||
return $this->error(self::ERR_BILLET, 404);
|
||||
}
|
||||
|
||||
$event = $ticket->getBilletBuyer()->getEvent();
|
||||
$isOwner = $event->getAccount()->getId() === $user->getId();
|
||||
|
||||
if (!$this->isRoot($user) && !$isOwner) {
|
||||
return $this->error('Acces reserve aux organisateurs.', 403);
|
||||
}
|
||||
|
||||
$previousState = $ticket->getState();
|
||||
$ticket->setState(BilletOrder::STATE_VALID);
|
||||
$ticket->setFirstScannedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
|
||||
$organizer = $event->getAccount();
|
||||
$html = $this->renderView('email/scan_force_notification.html.twig', [
|
||||
'event_title' => $event->getTitle(),
|
||||
'billet_name' => $ticket->getBilletName(),
|
||||
'reference' => $ticket->getReference(),
|
||||
'buyer_name' => $ticket->getBilletBuyer()->getFirstName().' '.$ticket->getBilletBuyer()->getLastName(),
|
||||
'previous_state' => $previousState,
|
||||
'forced_by_name' => $user->getFirstName().' '.$user->getLastName(),
|
||||
'forced_by_email' => $user->getEmail(),
|
||||
]);
|
||||
$mailerService->sendEmail(
|
||||
$organizer->getEmail(),
|
||||
'Validation forcee d\'un billet - '.$event->getTitle(),
|
||||
$html,
|
||||
);
|
||||
|
||||
return $this->success($this->buildScanResponse('accepted', 'forced', $ticket));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function processScan(BilletOrder $ticket, EntityManagerInterface $em): array
|
||||
private function processScan(BilletOrder $ticket, EntityManagerInterface $em, bool $canForce = false): array
|
||||
{
|
||||
$reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired'];
|
||||
$billetType = $ticket->getBillet()?->getType() ?? 'billet';
|
||||
$isAlwaysValid = \in_array($billetType, ['staff', 'exposant'], true);
|
||||
|
||||
if (isset($reasonMap[$ticket->getState()])) {
|
||||
return $this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket);
|
||||
if (!$isAlwaysValid) {
|
||||
$reasonMap = [BilletOrder::STATE_INVALID => 'invalid', BilletOrder::STATE_EXPIRED => 'expired'];
|
||||
|
||||
if (isset($reasonMap[$ticket->getState()])) {
|
||||
$response = $this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket);
|
||||
$response['canForce'] = $canForce;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$scannedToday = null !== $ticket->getFirstScannedAt()
|
||||
&& $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d');
|
||||
|
||||
if ($scannedToday && ($ticket->getBillet()?->hasDefinedExit() ?? false)) {
|
||||
$response = $this->buildScanResponse('refused', 'exit_definitive', $ticket);
|
||||
$response['canForce'] = $canForce;
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $ticket->getFirstScannedAt() && ($ticket->getBillet()?->hasDefinedExit() ?? false)) {
|
||||
return $this->buildScanResponse('refused', 'exit_definitive', $ticket);
|
||||
}
|
||||
$alreadyScanned = null !== $ticket->getFirstScannedAt();
|
||||
|
||||
if (null === $ticket->getFirstScannedAt()) {
|
||||
if (!$alreadyScanned) {
|
||||
$ticket->setFirstScannedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->buildScanResponse('accepted', null, $ticket);
|
||||
if ($isAlwaysValid) {
|
||||
return $this->buildScanResponse('accepted', null, $ticket);
|
||||
}
|
||||
|
||||
$scannedToday = $alreadyScanned
|
||||
&& $ticket->getFirstScannedAt()->format('Y-m-d') === (new \DateTimeImmutable())->format('Y-m-d');
|
||||
$reason = ($alreadyScanned && $scannedToday) ? 'already_scanned' : null;
|
||||
|
||||
return $this->buildScanResponse('accepted', $reason, $ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,17 +347,33 @@ class ApiLiveController extends AbstractController
|
||||
*/
|
||||
private function buildScanResponse(string $state, ?string $reason, BilletOrder $ticket): array
|
||||
{
|
||||
$order = $ticket->getBilletBuyer();
|
||||
|
||||
$items = array_map(fn (BilletBuyerItem $i) => [
|
||||
'billetName' => $i->getBilletName(),
|
||||
'quantity' => $i->getQuantity(),
|
||||
'unitPriceHT' => $i->getUnitPriceHTDecimal(),
|
||||
], $order->getItems()->toArray());
|
||||
|
||||
return [
|
||||
'state' => $state,
|
||||
'reason' => $reason,
|
||||
'reference' => $ticket->getReference(),
|
||||
'billetName' => $ticket->getBilletName(),
|
||||
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
|
||||
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
|
||||
'buyerFirstName' => $order->getFirstName(),
|
||||
'buyerLastName' => $order->getLastName(),
|
||||
'buyerEmail' => $order->getEmail(),
|
||||
'isInvitation' => (bool) $ticket->isInvitation(),
|
||||
'billetType' => $ticket->getBillet()?->getType() ?? 'billet',
|
||||
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
|
||||
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
|
||||
'details' => 'accepted' === $state ? [] : null,
|
||||
'order' => [
|
||||
'orderNumber' => $order->getOrderNumber(),
|
||||
'status' => $order->getStatus(),
|
||||
'totalHT' => $order->getTotalHTDecimal(),
|
||||
'paidAt' => $order->getPaidAt()?->format(\DateTimeInterface::ATOM),
|
||||
'items' => $items,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ScannerController extends AbstractController
|
||||
{
|
||||
#[Route('/scanner', name: 'app_scanner', methods: ['GET'])]
|
||||
#[Route('/scanner/', name: 'app_scanner', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('scanner/index.html.twig');
|
||||
@@ -21,8 +21,8 @@ class ScannerController extends AbstractController
|
||||
'name' => 'E-Ticket Scanner',
|
||||
'short_name' => 'Scanner',
|
||||
'description' => 'Application de scan de billets pour organisateurs',
|
||||
'start_url' => '/scanner',
|
||||
'scope' => '/scanner',
|
||||
'start_url' => '/scanner/',
|
||||
'scope' => '/scanner/',
|
||||
'display' => 'standalone',
|
||||
'orientation' => 'portrait',
|
||||
'theme_color' => '#111827',
|
||||
|
||||
@@ -47,7 +47,7 @@ class BilletOrderService
|
||||
$billet->setQuantity(max(0, $newQty));
|
||||
}
|
||||
|
||||
if ('billet' !== $billet->getType() || !$billet->isGeneratedBillet()) {
|
||||
if (!\in_array($billet->getType(), ['billet', 'staff', 'exposant'], true) || !$billet->isGeneratedBillet()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'billets'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'billets' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Billets</a>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'invitations'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'invitations' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Invitations</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'tickets'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'tickets' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Tickets</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'attestation'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'attestation' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Attestation</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'stats'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 {{ current_tab == 'stats' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Statistiques</a>
|
||||
</div>
|
||||
|
||||
@@ -278,28 +280,29 @@
|
||||
|
||||
{% set total_sold = 0 %}
|
||||
{% set total_ht = 0 %}
|
||||
{% set total_commission = 0 %}
|
||||
{% set total_net = 0 %}
|
||||
{% set total_commission_eticket = 0 %}
|
||||
{% set total_commission_stripe = 0 %}
|
||||
{% for cat_billets in billets %}
|
||||
{% for billet in cat_billets %}
|
||||
{% set sold = sold_counts[billet.id] ?? 0 %}
|
||||
{% set line_ht = billet.priceHTDecimal * sold %}
|
||||
{% set eticket_fee = line_ht * (commission_rate / 100) %}
|
||||
{% set stripe_fee = sold > 0 ? (line_ht * 0.015) + (0.25 * sold) : 0 %}
|
||||
{% set line_commission = eticket_fee + stripe_fee %}
|
||||
{% set stripe_fee = sold > 0 ? (line_ht * stripe_fee_rate) + ((stripe_fee_fixed / 100) * sold) : 0 %}
|
||||
{% set total_sold = total_sold + sold %}
|
||||
{% set total_ht = total_ht + line_ht %}
|
||||
{% set total_commission = total_commission + line_commission %}
|
||||
{% set total_net = total_net + (line_ht - line_commission) %}
|
||||
{% set total_commission_eticket = total_commission_eticket + eticket_fee %}
|
||||
{% set total_commission_stripe = total_commission_stripe + stripe_fee %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% set total_commission = total_commission_eticket + total_commission_stripe %}
|
||||
{% set total_net = total_ht - total_commission %}
|
||||
|
||||
<div class="card-brutal overflow-hidden mt-4">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Recapitulatif ventes</h2>
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Recapitulatif ventes (hors invitations)</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="border-2 border-gray-900 p-4 text-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Qt vendue</div>
|
||||
<div class="text-2xl font-black">{{ total_sold }}</div>
|
||||
@@ -317,6 +320,20 @@
|
||||
<div class="text-2xl font-black text-green-600">{{ total_net|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="border-2 border-gray-900 p-3">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Commission E-Ticket ({{ commission_rate }}%)</div>
|
||||
<div class="text-lg font-black text-red-500">-{{ total_commission_eticket|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="border-2 border-gray-900 p-3">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Commission Stripe ({{ (stripe_fee_rate * 100)|number_format(1) }}% + {{ (stripe_fee_fixed / 100)|number_format(2, ',', ' ') }}€/tx)</div>
|
||||
<div class="text-lg font-black text-red-500">-{{ total_commission_stripe|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="border-2 border-gray-900 p-3 bg-gray-50">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total commissions</div>
|
||||
<div class="text-lg font-black text-red-600">-{{ total_commission|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,6 +470,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-brutal overflow-hidden mb-6">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Accreditation Staff / Exposant</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="post" action="{{ path('app_account_event_create_accreditation', {id: event.id}) }}" class="form-col">
|
||||
<div>
|
||||
<label for="accred_type" class="text-xs font-black uppercase tracking-widest form-label">Type d'accreditation</label>
|
||||
<select id="accred_type" name="accreditation_type" required class="form-input focus:border-indigo-600">
|
||||
<option value="staff">Staff</option>
|
||||
<option value="exposant">Exposant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="accred_last_name" class="text-xs font-black uppercase tracking-widest form-label">Nom</label>
|
||||
<input type="text" id="accred_last_name" name="last_name" required class="form-input focus:border-indigo-600" placeholder="Dupont">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="accred_first_name" class="text-xs font-black uppercase tracking-widest form-label">Prenom</label>
|
||||
<input type="text" id="accred_first_name" name="first_name" required class="form-input focus:border-indigo-600" placeholder="Jean">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="accred_email" class="text-xs font-black uppercase tracking-widest form-label">Email</label>
|
||||
<input type="email" id="accred_email" name="email" required class="form-input focus:border-indigo-600" placeholder="jean.dupont@exemple.fr">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Envoyer l'accreditation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invitations|length > 0 %}
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
@@ -484,6 +540,173 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elseif current_tab == 'tickets' %}
|
||||
|
||||
<div class="card-brutal mb-6">
|
||||
<h2 class="text-sm font-black uppercase tracking-widest mb-4">Rechercher un ticket</h2>
|
||||
<form method="get" action="{{ path('app_account_edit_event', {id: event.id}) }}" class="flex flex-wrap gap-4 items-end">
|
||||
<input type="hidden" name="tab" value="tickets">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="tickets-tq" class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Recherche</label>
|
||||
<input type="text" id="tickets-tq" name="tq" value="{{ tickets_search_query }}" class="form-input focus:border-indigo-600" placeholder="Reference, cle, nom, email...">
|
||||
</div>
|
||||
<button type="submit" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-white transition-all">Filtrer</button>
|
||||
{% if tickets_search_query %}
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'tickets'}) }}" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Tickets vendus ({{ event_tickets.getTotalItemCount }})</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Reference</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Cle</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Billet</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Acheteur</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Statut</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500">Scanne</th>
|
||||
<th class="px-4 py-3 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ticket in event_tickets %}
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50 transition-all">
|
||||
<td class="px-4 py-3 text-xs font-mono font-bold">{{ ticket.reference }}</td>
|
||||
<td class="px-4 py-3 text-xs font-mono text-gray-500">{{ ticket.securityKey }}</td>
|
||||
<td class="px-4 py-3 text-sm font-bold">{{ ticket.billetName }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-bold">{{ ticket.billetBuyer.firstName }} {{ ticket.billetBuyer.lastName }}</p>
|
||||
<p class="text-xs text-gray-400">{{ ticket.billetBuyer.email }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if ticket.state == 'valid' %}
|
||||
<span class="badge-green text-xs font-black uppercase">Valide</span>
|
||||
{% elseif ticket.state == 'invalid' %}
|
||||
<span class="badge-red text-xs font-black uppercase">Annule</span>
|
||||
{% elseif ticket.state == 'expired' %}
|
||||
<span class="badge-yellow text-xs font-black uppercase">Expire</span>
|
||||
{% endif %}
|
||||
{% if ticket.invitation %}
|
||||
<span class="badge-indigo text-xs font-black uppercase ml-1">Invitation</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500">
|
||||
{% if ticket.firstScannedAt %}
|
||||
{{ ticket.firstScannedAt|date('d/m/Y H:i') }}
|
||||
{% else %}
|
||||
<span class="text-gray-300">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<a href="{{ path('app_account_event_download_ticket', {id: event.id, ticketId: ticket.id}) }}" target="_blank" class="inline-block text-[10px] font-black uppercase tracking-widest text-indigo-600 hover:text-indigo-800 transition-all mr-2" title="Telecharger">PDF</a>
|
||||
<form method="post" action="{{ path('app_account_event_resend_ticket', {id: event.id, ticketId: ticket.id}) }}" class="inline">
|
||||
<button type="submit" class="text-[10px] font-black uppercase tracking-widest text-blue-600 hover:text-blue-800 transition-all mr-2 cursor-pointer" title="Renvoyer par email">Renvoyer</button>
|
||||
</form>
|
||||
{% if ticket.state == 'valid' %}
|
||||
<form method="post" action="{{ path('app_account_event_cancel_ticket', {id: event.id, ticketId: ticket.id}) }}" class="inline" onsubmit="return confirm('Annuler ce billet ?')">
|
||||
<button type="submit" class="text-[10px] font-black uppercase tracking-widest text-red-600 hover:text-red-800 transition-all cursor-pointer" title="Annuler">Annuler</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-12 text-center text-gray-400 font-bold text-sm">Aucun ticket vendu.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if event_tickets.getTotalItemCount > 20 %}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
{% for page in 1..event_tickets.getPageCount %}
|
||||
{% if page == event_tickets.getCurrentPageNumber %}
|
||||
<span class="px-3 py-1 border-2 border-gray-900 bg-gray-900 text-white text-xs font-black">{{ page }}</span>
|
||||
{% else %}
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'tickets', tp: page, tq: tickets_search_query}) }}" class="px-3 py-1 border-2 border-gray-900 text-xs font-black hover:bg-gray-100 transition-all">{{ page }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elseif current_tab == 'attestation' %}
|
||||
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Generer une attestation de ventes</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 mb-6">Selectionnez les categories et/ou billets a inclure dans l'attestation. Le document PDF certifiera le nombre de billets vendus et le chiffre d'affaires HT (hors invitations).</p>
|
||||
|
||||
<form method="post" action="{{ path('app_account_event_attestation', {id: event.id}) }}" target="_blank">
|
||||
{% if categories|length > 0 %}
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-black uppercase tracking-widest form-label mb-3">Categories</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{% for category in categories %}
|
||||
<label class="flex items-center gap-3 p-3 border-2 border-gray-200 hover:border-gray-900 transition-all cursor-pointer">
|
||||
<input type="checkbox" name="categories[]" value="{{ category.id }}" class="w-4 h-4">
|
||||
<span class="font-bold text-sm">{{ category.name }}</span>
|
||||
{% if category.active %}
|
||||
<span class="badge-green text-[10px] font-black uppercase ml-auto">Active</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set has_billets = false %}
|
||||
{% for cat_billets in billets %}
|
||||
{% if cat_billets|length > 0 %}
|
||||
{% set has_billets = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if has_billets %}
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-black uppercase tracking-widest form-label mb-3">Billets individuels</p>
|
||||
<p class="text-xs text-gray-400 mb-3">Si vous selectionnez une categorie ci-dessus, tous ses billets seront inclus. Utilisez cette section pour ajouter des billets specifiques en complement.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{% for category in categories %}
|
||||
{% set cat_billets = billets[category.id] ?? [] %}
|
||||
{% for billet in cat_billets %}
|
||||
<label class="flex items-center gap-3 p-3 border-2 border-gray-200 hover:border-gray-900 transition-all cursor-pointer">
|
||||
<input type="checkbox" name="billets[]" value="{{ billet.id }}" class="w-4 h-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-bold text-sm block">{{ billet.name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ category.name }} — {{ billet.priceHTDecimal|number_format(2, ',', ' ') }} € HT — {{ sold_counts[billet.id] ?? 0 }} vendu{{ (sold_counts[billet.id] ?? 0) > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Generer l'attestation PDF
|
||||
</button>
|
||||
<button type="button" onclick="this.closest('form').querySelectorAll('input[type=checkbox]').forEach(c => c.checked = true)" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">
|
||||
Tout selectionner
|
||||
</button>
|
||||
<button type="button" onclick="this.closest('form').querySelectorAll('input[type=checkbox]').forEach(c => c.checked = false)" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">
|
||||
Tout deselectionner
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elseif current_tab == 'stats' %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
@@ -501,8 +724,25 @@
|
||||
</div>
|
||||
<div class="card-brutal p-4 text-center bg-green-50">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total percu</div>
|
||||
{% set total_commission_event = event_total_ht * (commission_rate / 100) %}
|
||||
<div class="text-2xl font-black text-green-600">{{ (event_total_ht - total_commission_event)|number_format(2, ',', ' ') }} €</div>
|
||||
{% set commission_eticket = event_total_ht * (commission_rate / 100) %}
|
||||
{% set commission_stripe = event_total_orders > 0 ? (event_total_ht * stripe_fee_rate + event_total_orders * (stripe_fee_fixed / 100)) : 0 %}
|
||||
{% set total_net = event_total_ht - commission_eticket - commission_stripe %}
|
||||
<div class="text-2xl font-black text-green-600">{{ total_net|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="card-brutal p-4">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Commission E-Ticket ({{ commission_rate }}%)</div>
|
||||
<div class="text-lg font-black text-red-500">-{{ commission_eticket|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="card-brutal p-4">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Commission Stripe ({{ (stripe_fee_rate * 100)|number_format(1) }}% + {{ (stripe_fee_fixed / 100)|number_format(2, ',', ' ') }}€/tx)</div>
|
||||
<div class="text-lg font-black text-red-500">-{{ commission_stripe|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="card-brutal p-4 bg-gray-50">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total commissions</div>
|
||||
<div class="text-lg font-black text-red-600">-{{ (commission_eticket + commission_stripe)|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<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>
|
||||
<th class="text-[10px] font-black uppercase tracking-widest text-white text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -99,10 +100,15 @@
|
||||
<span class="admin-badge-indigo text-xs font-black uppercase ml-1">Invitation</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if order.status == 'paid' %}
|
||||
<a href="{{ path('app_admin_order_tickets', {id: order.id}) }}" target="_blank" class="inline-block text-[10px] font-black uppercase tracking-widest text-indigo-600 hover:text-indigo-800 transition-all" title="Telecharger les billets">Billets</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="!text-center !py-12 text-gray-400 font-bold text-sm">Aucune commande.</td>
|
||||
<td colspan="8" class="!text-center !py-12 text-gray-400 font-bold text-sm">Aucune commande.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
49
templates/email/scan_force_notification.html.twig
Normal file
49
templates/email/scan_force_notification.html.twig
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends 'email/base.html.twig' %}
|
||||
|
||||
{% block title %}Validation forcee d'un billet{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Validation forcee</h2>
|
||||
<p>Un billet a ete <strong>force lors du scan</strong> sur votre evenement. Voici les details :</p>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 2px solid #111827;">
|
||||
<tr style="background-color: #111827;">
|
||||
<td colspan="2" style="padding: 12px 16px; color: #ffffff; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
Details du billet
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #6b7280; border-bottom: 1px solid #e5e7eb; width: 140px;">Evenement</td>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 800; border-bottom: 1px solid #e5e7eb;">{{ event_title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #6b7280; border-bottom: 1px solid #e5e7eb;">Billet</td>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 800; border-bottom: 1px solid #e5e7eb;">{{ billet_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #6b7280; border-bottom: 1px solid #e5e7eb;">Reference</td>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 800; font-family: monospace; border-bottom: 1px solid #e5e7eb;">{{ reference }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #6b7280; border-bottom: 1px solid #e5e7eb;">Acheteur</td>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 800; border-bottom: 1px solid #e5e7eb;">{{ buyer_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700; color: #6b7280; border-bottom: 1px solid #e5e7eb;">Ancien statut</td>
|
||||
<td style="padding: 10px 16px; font-size: 13px; font-weight: 800; border-bottom: 1px solid #e5e7eb;">
|
||||
<span style="display: inline-block; padding: 2px 10px; background: #dc2626; color: #fff; font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.05em;">{{ previous_state }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 2px solid #eab308; background: #fefce8;">
|
||||
<tr>
|
||||
<td style="padding: 16px;">
|
||||
<p style="margin: 0 0 4px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em; color: #92400e;">Force par</p>
|
||||
<p style="margin: 0; font-size: 14px; font-weight: 800; color: #111827;">{{ forced_by_name }} ({{ forced_by_email }})</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-size: 13px; color: #6b7280;">Si cette action n'etait pas prevue, nous vous recommandons de verifier la situation.</p>
|
||||
{% endblock %}
|
||||
294
templates/pdf/attestation_ventes.html.twig
Normal file
294
templates/pdf/attestation_ventes.html.twig
Normal file
@@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Attestation de ventes - {{ event.title }}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 25mm 20mm; }
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #111;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
padding: 20px 24px;
|
||||
margin: -25mm -20mm 0 -20mm;
|
||||
width: calc(100% + 40mm);
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.header-sub {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #111827;
|
||||
border-bottom: 2px solid #111827;
|
||||
padding-bottom: 6px;
|
||||
margin: 24px 0 12px 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info-table td {
|
||||
padding: 5px 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.info-label {
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #999;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.data-table th {
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
padding: 8px 10px;
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-align: left;
|
||||
}
|
||||
.data-table th:last-child,
|
||||
.data-table th:nth-child(3),
|
||||
.data-table th:nth-child(4) {
|
||||
text-align: right;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 7px 10px;
|
||||
font-size: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.data-table td:last-child,
|
||||
.data-table td:nth-child(3),
|
||||
.data-table td:nth-child(4) {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: 2px solid #111827;
|
||||
}
|
||||
.data-table .total-row td {
|
||||
background: #f9fafb;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
border-bottom: 2px solid #111827;
|
||||
border-top: 2px solid #111827;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
border: 2px solid #111827;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.summary-table td {
|
||||
padding: 5px 0;
|
||||
}
|
||||
.summary-table .label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #666;
|
||||
}
|
||||
.summary-table .value {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.summary-table .value-big {
|
||||
text-align: right;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.legal {
|
||||
margin-top: 30px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.legal p {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: -15mm;
|
||||
left: -20mm;
|
||||
right: -20mm;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
font-size: 8px;
|
||||
}
|
||||
.footer-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.footer-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-title">Attestation de ventes</div>
|
||||
<div class="header-sub">{{ event.title }} — Generee le {{ generatedAt|date('d/m/Y a H:i') }}</div>
|
||||
</div>
|
||||
|
||||
<h2>Organisateur</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td style="width: 50%;">
|
||||
<div class="info-label">Raison sociale</div>
|
||||
<div class="info-value">{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="info-label">Email</div>
|
||||
<div class="info-value">{{ organizer.email }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% if organizer.siret %}
|
||||
<td>
|
||||
<div class="info-label">SIRET</div>
|
||||
<div class="info-value">{{ organizer.siret }}</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if organizer.address %}
|
||||
<td>
|
||||
<div class="info-label">Adresse</div>
|
||||
<div class="info-value">{{ organizer.address }}{% if organizer.postalCode %}, {{ organizer.postalCode }}{% endif %}{% if organizer.city %} {{ organizer.city }}{% endif %}</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Evenement</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td style="width: 50%;">
|
||||
<div class="info-label">Nom</div>
|
||||
<div class="info-value">{{ event.title }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="info-label">Date</div>
|
||||
<div class="info-value">{{ event.startAt|date('d/m/Y H:i') }} — {{ event.endAt|date('d/m/Y H:i') }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="info-label">Lieu</div>
|
||||
<div class="info-value">{{ event.address }}, {{ event.zipcode }} {{ event.city }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if selectedCategories|length > 0 %}
|
||||
<div class="info-label">Categories selectionnees</div>
|
||||
<div class="info-value">{{ selectedCategories|join(', ') }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Detail des ventes</h2>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Categorie</th>
|
||||
<th>Billet</th>
|
||||
<th>Prix unit. HT</th>
|
||||
<th>Vendus</th>
|
||||
<th>Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in billetLines %}
|
||||
<tr>
|
||||
<td>{{ line.category }}</td>
|
||||
<td style="font-weight: bold;">{{ line.name }}</td>
|
||||
<td>{{ line.priceHT|number_format(2, ',', ' ') }} €</td>
|
||||
<td>{{ line.sold }}</td>
|
||||
<td>{{ line.revenue|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td colspan="3" style="text-align: right;">TOTAL</td>
|
||||
<td style="text-align: right;">{{ totalSold }}</td>
|
||||
<td style="text-align: right;">{{ totalRevenue|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="summary-box">
|
||||
<table class="summary-table">
|
||||
<tr>
|
||||
<td class="label">Total billets vendus</td>
|
||||
<td class="value">{{ totalSold }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Chiffre d'affaires HT</td>
|
||||
<td class="value-big">{{ totalRevenue|number_format(2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="legal">
|
||||
<p><strong>Attestation :</strong> Le soussigne, {{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}{% if organizer.siret %}, SIRET {{ organizer.siret }}{% endif %}, atteste que les informations ci-dessus correspondent aux ventes realisees via la plateforme E-Ticket pour l'evenement « {{ event.title }} » a la date du {{ generatedAt|date('d/m/Y') }}.</p>
|
||||
<p>Cette attestation est generee automatiquement par la plateforme E-Ticket (ticket.e-cosplay.fr) et ne constitue pas une facture. Les montants indiques sont hors taxes et hors commissions. Les invitations, accreditations staff et exposant ne sont pas comptabilisees dans ce document.</p>
|
||||
<p>Document genere le {{ generatedAt|date('d/m/Y a H:i:s') }} — Ref: ATT-{{ event.id }}-{{ generatedAt|date('YmdHis') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<table class="footer-table">
|
||||
<tr>
|
||||
<td>E-Ticket — Plateforme de billetterie par E-Cosplay — ticket.e-cosplay.fr</td>
|
||||
<td style="text-align: right;">Page 1/1</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,10 @@
|
||||
<title>{{ ticket.reference }} - {{ event.title }}</title>
|
||||
<style>
|
||||
{% set is_inv = ticket.invitation ?? false %}
|
||||
{% set ac = is_inv ? '#d4a017' : (design ? design.accentColor : '#4f46e5') %}
|
||||
{% set billet_type = ticket.billet ? ticket.billet.type : 'billet' %}
|
||||
{% set is_staff = billet_type == 'staff' %}
|
||||
{% set is_exposant = billet_type == 'exposant' %}
|
||||
{% set ac = is_staff ? '#111827' : (is_exposant ? '#7c3aed' : (is_inv ? '#d4a017' : (design ? design.accentColor : '#4f46e5'))) %}
|
||||
{% set inv_color = design ? design.invitationColor : '#d4a017' %}
|
||||
{% set inv_title = design ? design.invitationTitle : 'Invitation' %}
|
||||
|
||||
@@ -180,6 +183,28 @@
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.badge-staff {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
border: 1px solid #111827;
|
||||
}
|
||||
.badge-exposant {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: 1px solid #7c3aed;
|
||||
}
|
||||
|
||||
/* ====== QR ====== */
|
||||
.qr-section {
|
||||
@@ -256,7 +281,8 @@
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td>
|
||||
<span class="header-org">{{ is_inv ? 'Invitation' : 'Billet Entree' }} — {{ event.title }} — {{ ticket.billetName }}</span>
|
||||
{% set header_label = is_staff ? 'STAFF' : (is_exposant ? 'EXPOSANT' : (is_inv ? 'Invitation' : 'Billet Entree')) %}
|
||||
<span class="header-org">{{ header_label }} — {{ event.title }} — {{ ticket.billetName }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -300,6 +326,12 @@
|
||||
{% if ticket.invitation %}
|
||||
<span class="badge-invitation" style="background: {{ inv_color }};">{{ inv_title }}</span>
|
||||
{% endif %}
|
||||
{% if is_staff %}
|
||||
<span class="badge-staff">STAFF</span>
|
||||
{% endif %}
|
||||
{% if is_exposant %}
|
||||
<span class="badge-exposant">EXPOSANT</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Scanner">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #111827; color: #f9fafb; min-height: 100dvh; }
|
||||
@@ -40,6 +40,8 @@
|
||||
.result-state { font-size: 20px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; }
|
||||
.result-accepted .result-state { color: #22c55e; }
|
||||
.result-refused .result-state { color: #dc2626; }
|
||||
.result-warning { border-color: #eab308; background: #713f1233; }
|
||||
.result-warning .result-state { color: #eab308; }
|
||||
.result-detail { margin-top: 16px; text-align: left; }
|
||||
.result-detail .row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #374151; font-size: 13px; }
|
||||
.result-detail .row .key { color: #9ca3af; font-weight: 700; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; }
|
||||
@@ -87,6 +89,12 @@
|
||||
<input type="password" id="login-password" class="input" placeholder="Mot de passe" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn btn-primary mt-4" id="login-btn">Se connecter</button>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-top:20px;">
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
<span style="font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;color:#6b7280;">ou</span>
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
</div>
|
||||
<a href="/api/auth/login/sso?from=scanner" class="btn mt-4" id="sso-btn" style="background:#2563eb;border-color:#2563eb;color:#fff;">Se connecter avec SSO E-Cosplay</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +120,30 @@
|
||||
</div>
|
||||
<div class="content">
|
||||
<button class="back-btn mb-4" id="back-events">← Evenements</button>
|
||||
<div id="qr-reader" class="mb-4"></div>
|
||||
|
||||
<!-- Mode selection -->
|
||||
<div id="scan-modes" class="gap-3 mb-4">
|
||||
<button class="btn btn-primary" id="btn-mode-camera">📷 Scanner un QR code</button>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
<span style="font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;color:#6b7280;">ou</span>
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="manual-reference">Cle de securite du billet</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="manual-reference" class="input" placeholder="Ex: A1B2C3D4E5F6G7H8" style="flex:1;text-transform:uppercase;font-family:monospace;letter-spacing:2px;" autocomplete="off" spellcheck="false" maxlength="16">
|
||||
<button class="btn btn-primary" id="btn-mode-manual" style="width:auto;padding:14px 20px;">Valider</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera zone (hidden by default) -->
|
||||
<div id="camera-zone" class="hidden">
|
||||
<div id="qr-reader" class="mb-4"></div>
|
||||
<button class="btn btn-sm btn-danger mb-4" id="btn-stop-camera">Arreter la camera</button>
|
||||
</div>
|
||||
|
||||
<div id="scan-result"></div>
|
||||
<div class="scan-count">
|
||||
Scans effectues : <strong id="scan-counter">0</strong>
|
||||
@@ -126,9 +157,38 @@
|
||||
let auth = JSON.parse(localStorage.getItem('scanner_auth') || 'null');
|
||||
let currentEvent = null;
|
||||
let scanner = null;
|
||||
let scanCount = parseInt(localStorage.getItem('scanner_count') || '0', 10);
|
||||
let scannedRefs = JSON.parse(localStorage.getItem('scanner_refs') || '[]');
|
||||
let scanCount = scannedRefs.length;
|
||||
let deferredPrompt = null;
|
||||
|
||||
// Audio feedback
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
function playSound(type) {
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
gain.gain.value = 0.3;
|
||||
if (type === 'good') {
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.15);
|
||||
} else if (type === 'warning') {
|
||||
osc.frequency.value = 440;
|
||||
osc.type = 'triangle';
|
||||
osc.start();
|
||||
setTimeout(() => { osc.frequency.value = 520; }, 150);
|
||||
osc.stop(audioCtx.currentTime + 0.35);
|
||||
} else {
|
||||
osc.frequency.value = 200;
|
||||
osc.type = 'square';
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// PWA Install
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -236,7 +296,7 @@
|
||||
function logout() {
|
||||
auth = null;
|
||||
localStorage.removeItem('scanner_auth');
|
||||
stopScanner();
|
||||
stopCamera();
|
||||
showScreen('login');
|
||||
}
|
||||
|
||||
@@ -273,7 +333,7 @@
|
||||
+ (ev.isOnline ? '<span style="color:#22c55e;">En ligne</span>' : '<span style="color:#dc2626;">Hors ligne</span>')
|
||||
+ (ev.isSecret ? ' · <span style="color:#eab308;">Secret</span>' : '')
|
||||
+ '</div>';
|
||||
card.addEventListener('click', () => startScanner(ev));
|
||||
card.addEventListener('click', () => openScanScreen(ev));
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -283,15 +343,27 @@
|
||||
}
|
||||
|
||||
// Scanner
|
||||
document.getElementById('back-events').addEventListener('click', () => { stopScanner(); loadEvents(); });
|
||||
document.getElementById('back-events').addEventListener('click', () => { stopCamera(); loadEvents(); });
|
||||
|
||||
function startScanner(event) {
|
||||
function openScanScreen(event) {
|
||||
currentEvent = event;
|
||||
document.getElementById('scanner-title').textContent = 'Scanner';
|
||||
document.getElementById('scanner-event-badge').textContent = event.title;
|
||||
document.getElementById('scan-result').innerHTML = '';
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
document.getElementById('manual-reference').value = '';
|
||||
document.getElementById('scan-modes').classList.remove('hidden');
|
||||
document.getElementById('camera-zone').classList.add('hidden');
|
||||
showScreen('scanner');
|
||||
}
|
||||
|
||||
// Camera mode
|
||||
document.getElementById('btn-mode-camera').addEventListener('click', startCamera);
|
||||
|
||||
function startCamera() {
|
||||
document.getElementById('scan-modes').classList.add('hidden');
|
||||
document.getElementById('camera-zone').classList.remove('hidden');
|
||||
document.getElementById('scan-result').innerHTML = '';
|
||||
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
@@ -304,70 +376,173 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-stop-camera').addEventListener('click', () => {
|
||||
stopCamera();
|
||||
document.getElementById('scan-modes').classList.remove('hidden');
|
||||
document.getElementById('camera-zone').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Manual mode
|
||||
document.getElementById('btn-mode-manual').addEventListener('click', doManualScan);
|
||||
document.getElementById('manual-reference').addEventListener('keydown', (e) => { if (e.key === 'Enter') doManualScan(); });
|
||||
|
||||
function doManualScan() {
|
||||
const key = document.getElementById('manual-reference').value.trim().toUpperCase();
|
||||
if (!key) return;
|
||||
submitScan(null, key);
|
||||
}
|
||||
|
||||
// Shared scan logic
|
||||
let scanning = false;
|
||||
async function onScanSuccess(decodedText) {
|
||||
if (scanning) return;
|
||||
scanning = true;
|
||||
|
||||
// Pause scanner
|
||||
try { await scanner.pause(true); } catch {}
|
||||
|
||||
// Vibrate
|
||||
if (navigator.vibrate) navigator.vibrate(100);
|
||||
|
||||
// Decode base64 QR -> reference
|
||||
let reference = decodedText;
|
||||
try {
|
||||
const decoded = atob(decodedText);
|
||||
if (decoded.startsWith('ETICKET-')) reference = decoded;
|
||||
} catch {}
|
||||
|
||||
const resultEl = document.getElementById('scan-result');
|
||||
resultEl.innerHTML = '<div class="spinner"></div>';
|
||||
await submitScan(reference, null);
|
||||
|
||||
try {
|
||||
const json = await api('POST', '/api/live/scan', { reference });
|
||||
if (!json.success) {
|
||||
resultEl.innerHTML = '<div class="result-box result-refused">'
|
||||
+ '<div class="result-icon">✗</div>'
|
||||
+ '<div class="result-state">' + escHtml(json.error || 'Erreur') + '</div></div>';
|
||||
} else {
|
||||
const d = json.data;
|
||||
const isOk = d.state === 'accepted';
|
||||
scanCount++;
|
||||
localStorage.setItem('scanner_count', scanCount);
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
|
||||
let reasonText = '';
|
||||
if (d.reason === 'invalid') reasonText = 'Billet invalide';
|
||||
else if (d.reason === 'expired') reasonText = 'Billet expire';
|
||||
else if (d.reason === 'exit_definitive') reasonText = 'Deja scanne (sortie definitive)';
|
||||
|
||||
resultEl.innerHTML = '<div class="result-box ' + (isOk ? 'result-accepted' : 'result-refused') + '">'
|
||||
+ '<div class="result-icon">' + (isOk ? '✓' : '✗') + '</div>'
|
||||
+ '<div class="result-state">' + (isOk ? 'Accepte' : 'Refuse') + '</div>'
|
||||
+ (reasonText ? '<div style="color:#f87171;font-weight:700;margin-top:8px;">' + reasonText + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((d.buyerFirstName || '') + ' ' + (d.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(d.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(d.reference || '') + '</span>')
|
||||
+ (d.isInvitation ? row('Type', '<span style="color:#eab308;">Invitation</span>') : '')
|
||||
+ (d.firstScannedAt ? row('Premier scan', new Date(d.firstScannedAt).toLocaleString('fr-FR')) : '')
|
||||
+ '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = '<div class="error-msg">Erreur: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
|
||||
// Resume after 2s
|
||||
setTimeout(() => {
|
||||
scanning = false;
|
||||
try { scanner.resume(); } catch {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
async function submitScan(reference, securityKey) {
|
||||
const resultEl = document.getElementById('scan-result');
|
||||
resultEl.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
const body = reference ? { reference } : { securityKey };
|
||||
try {
|
||||
const json = await api('POST', '/api/live/scan', body);
|
||||
if (!json.success) {
|
||||
playSound('refused');
|
||||
resultEl.innerHTML = '<div class="result-box result-refused">'
|
||||
+ '<div class="result-icon">✗</div>'
|
||||
+ '<div class="result-state">' + escHtml(json.error || 'Erreur') + '</div></div>';
|
||||
} else {
|
||||
const d = json.data;
|
||||
const isOk = d.state === 'accepted';
|
||||
const alreadyScanned = d.reason === 'already_scanned';
|
||||
|
||||
if (isOk && !alreadyScanned) {
|
||||
playSound('good');
|
||||
} else if (isOk && alreadyScanned) {
|
||||
playSound('warning');
|
||||
} else {
|
||||
playSound('refused');
|
||||
}
|
||||
|
||||
// Unique scan count per reference
|
||||
if (isOk && d.reference && !scannedRefs.includes(d.reference)) {
|
||||
scannedRefs.push(d.reference);
|
||||
localStorage.setItem('scanner_refs', JSON.stringify(scannedRefs));
|
||||
scanCount = scannedRefs.length;
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
}
|
||||
|
||||
let reasonText = '';
|
||||
if (d.reason === 'invalid') reasonText = 'Billet invalide';
|
||||
else if (d.reason === 'expired') reasonText = 'Billet expire';
|
||||
else if (d.reason === 'exit_definitive') reasonText = 'Deja scanne (sortie definitive)';
|
||||
else if (d.reason === 'already_scanned') reasonText = 'Billet deja scanne';
|
||||
|
||||
const boxClass = !isOk ? 'result-refused' : (alreadyScanned ? 'result-warning' : 'result-accepted');
|
||||
const icon = !isOk ? '✗' : (alreadyScanned ? '⚠' : '✓');
|
||||
const stateText = !isOk ? 'Refuse' : (alreadyScanned ? 'Accepte' : 'Accepte');
|
||||
|
||||
let orderHtml = '';
|
||||
if (d.order) {
|
||||
const o = d.order;
|
||||
orderHtml = '<div style="margin-top:12px;padding-top:12px;border-top:2px solid #374151;">'
|
||||
+ row('Commande', '<span style="font-family:monospace;">' + escHtml(o.orderNumber || '') + '</span>')
|
||||
+ row('Email', escHtml(d.buyerEmail || ''))
|
||||
+ row('Total HT', o.totalHT.toFixed(2).replace('.', ',') + ' \u20ac')
|
||||
+ (o.paidAt ? row('Payee le', new Date(o.paidAt).toLocaleString('fr-FR')) : '')
|
||||
+ '<div style="margin-top:8px;">';
|
||||
(o.items || []).forEach(function(item) {
|
||||
orderHtml += '<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:12px;">'
|
||||
+ '<span style="color:#9ca3af;">' + escHtml(item.billetName) + ' x' + item.quantity + '</span>'
|
||||
+ '<span style="font-weight:800;">' + item.unitPriceHT.toFixed(2).replace('.', ',') + ' \u20ac</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
orderHtml += '</div></div>';
|
||||
}
|
||||
|
||||
let forceHtml = '';
|
||||
if (!isOk && d.canForce) {
|
||||
forceHtml = '<div style="margin-top:12px;"><button class="btn btn-primary" id="btn-force-validate" data-ref="' + escHtml(d.reference) + '">Forcer la validation</button></div>';
|
||||
}
|
||||
|
||||
resultEl.innerHTML = '<div class="result-box ' + boxClass + '">'
|
||||
+ '<div class="result-icon">' + icon + '</div>'
|
||||
+ '<div class="result-state">' + stateText + '</div>'
|
||||
+ (reasonText ? '<div style="color:' + (alreadyScanned ? '#eab308' : '#f87171') + ';font-weight:700;margin-top:8px;">' + reasonText + '</div>' : '')
|
||||
+ forceHtml
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((d.buyerFirstName || '') + ' ' + (d.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(d.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(d.reference || '') + '</span>')
|
||||
+ (d.billetType === 'staff' ? row('Type', '<span style="display:inline-block;padding:2px 8px;background:#111827;color:#fff;font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;">STAFF</span>') : '')
|
||||
+ (d.billetType === 'exposant' ? row('Type', '<span style="display:inline-block;padding:2px 8px;background:#7c3aed;color:#fff;font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;">EXPOSANT</span>') : '')
|
||||
+ (d.isInvitation && d.billetType === 'billet' ? row('Type', '<span style="color:#eab308;">Invitation</span>') : '')
|
||||
+ (d.firstScannedAt ? row('Premier scan', new Date(d.firstScannedAt).toLocaleString('fr-FR')) : '')
|
||||
+ orderHtml
|
||||
+ '</div>';
|
||||
|
||||
const forceBtn = document.getElementById('btn-force-validate');
|
||||
if (forceBtn) {
|
||||
forceBtn.addEventListener('click', async function() {
|
||||
forceBtn.disabled = true;
|
||||
forceBtn.textContent = 'Validation...';
|
||||
try {
|
||||
const forceJson = await api('POST', '/api/live/scan/force', { reference: forceBtn.dataset.ref });
|
||||
if (forceJson.success) {
|
||||
playSound('good');
|
||||
const fd = forceJson.data;
|
||||
resultEl.innerHTML = '<div class="result-box result-accepted">'
|
||||
+ '<div class="result-icon">✓</div>'
|
||||
+ '<div class="result-state">Force</div>'
|
||||
+ '<div style="color:#22c55e;font-weight:700;margin-top:8px;">Validation forcee</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((fd.buyerFirstName || '') + ' ' + (fd.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(fd.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(fd.reference || '') + '</span>')
|
||||
+ '</div>';
|
||||
if (fd.reference && !scannedRefs.includes(fd.reference)) {
|
||||
scannedRefs.push(fd.reference);
|
||||
localStorage.setItem('scanner_refs', JSON.stringify(scannedRefs));
|
||||
scanCount = scannedRefs.length;
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
}
|
||||
} else {
|
||||
playSound('refused');
|
||||
forceBtn.textContent = forceJson.error || 'Erreur';
|
||||
}
|
||||
} catch (e) {
|
||||
playSound('refused');
|
||||
forceBtn.textContent = 'Erreur';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
document.getElementById('manual-reference').value = '';
|
||||
} catch (e) {
|
||||
playSound('refused');
|
||||
resultEl.innerHTML = '<div class="error-msg">Erreur: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (scanner) {
|
||||
try { scanner.stop(); } catch {}
|
||||
scanner = null;
|
||||
@@ -385,14 +560,48 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// SSO callback handling
|
||||
function handleSsoCallback() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (!hash) return false;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
if (params.has('sso_error')) {
|
||||
const errorMap = {
|
||||
'auth_failed': 'Echec de l\'authentification SSO.',
|
||||
'no_account': 'Aucun compte associe a ce SSO.',
|
||||
'no_access': 'Acces reserve aux organisateurs.',
|
||||
};
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.innerHTML = '<div class="error-msg">' + (errorMap[params.get('sso_error')] || 'Erreur SSO.') + '</div>';
|
||||
window.history.replaceState(null, '', '/scanner/');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.has('sso_token')) {
|
||||
auth = {
|
||||
email: params.get('sso_email'),
|
||||
token: params.get('sso_token'),
|
||||
expiresAt: params.get('sso_expires'),
|
||||
};
|
||||
localStorage.setItem('scanner_auth', JSON.stringify(auth));
|
||||
window.history.replaceState(null, '', '/scanner/');
|
||||
loadEvents();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Init
|
||||
if (auth) {
|
||||
if (!handleSsoCallback() && auth) {
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
// Service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/scanner/sw.js', { scope: '/scanner' }).catch(() => {});
|
||||
navigator.serviceWorker.register('/scanner/sw.js', { scope: '/scanner/' }).catch(() => {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@ class ScannerControllerTest extends WebTestCase
|
||||
public function testScannerPageReturnsSuccess(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/scanner');
|
||||
$client->request('GET', '/scanner/');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorTextContains('title', 'E-Ticket Scanner');
|
||||
@@ -32,7 +32,7 @@ class ScannerControllerTest extends WebTestCase
|
||||
public function testScannerPageIsAccessibleWithoutAuth(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/scanner');
|
||||
$client->request('GET', '/scanner/');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
Reference in New Issue
Block a user