feat(templates/cota.twig): Ajoute template pour confirmation cotisation
 feat(templates/admin/dashboard.twig): Affiche stats membres et commandes
🐛 fix(src/Controller/WebhooksController.php): Gère paiement et reçu cotisation
 feat(src/Service/Payments/PaymentClient.php): Ajoute paiement cotisation
 feat(.env): Met à jour URL de dev
 feat(src/Controller/Admin/AdminController.php): Ajoute validation et lien paiement
 feat(src/Controller/DonsController.php): Ajoute route validation cotisation
 feat(assets/admin.js): Ajoute assets admin
 feat(templates/form_admin.twig): Ajoute thème formulaire admin
 feat(assets/admin.scss): Ajoute style admin
 feat(src/Service/Pdf/CotaReceiptGenerator.php): Génère reçu de cotisation
 feat(src/Form/MembersType.php): Ajoute champs et options formulaire membre
 feat(templates/admin/base.twig): Ajoute base admin
 feat(templates/admin/member/add.twig): Ajoute template ajout/édition membre
 feat(src/Entity/Members.php): Ajoute champs et relations entité Membre
 feat(templates/admin/members.twig): Affiche liste membres
 feat(templates/mails/coti_payment.twig): Ajoute template mail paiement cotisation
 feat(src/Controller/MembersController.php): Filtre membres actifs
 feat(templates/mails/cota_validation.twig): Ajoute template mail validation cota
```
This commit is contained in:
Serreau Jovann
2025-11-22 20:36:20 +01:00
parent 91d79e60d7
commit a3dc9f5801
26 changed files with 1591 additions and 254 deletions

2
.env
View File

@@ -55,7 +55,7 @@ PATH_URL=https://esyweb.local
STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE79Tr8treeHX9KMcZtvcQZ0X8VSm00Q6GQ365V
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SIGN=whsec_0DOZJAwgMwkcHl2RWXI8h8YItj9q7v3A
DEV_URL=https://3ea1cf1b1555.ngrok-free.app
DEV_URL=https://f584469e204f.ngrok-free.app
VAPID_PK=DsOg7jToRSD-VpNSV1Gt3YAhSwz4l-nqeu7yFvzbSxg
VAPID_PC=BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo

3
assets/admin.js Normal file
View File

@@ -0,0 +1,3 @@
import './admin.scss'
import * as Turbo from "@hotwired/turbo"

1
assets/admin.scss Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251122180336 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE members ADD joined_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE members ADD status VARCHAR(255) DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN members.joined_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE members DROP joined_at');
$this->addSql('ALTER TABLE members DROP status');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251122185520 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE members_cotisations (id SERIAL NOT NULL, members_id INT DEFAULT NULL, startd_ate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, amount DOUBLE PRECISION DEFAULT NULL, is_paid BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_C7D1EB64BD01F5ED ON members_cotisations (members_id)');
$this->addSql('COMMENT ON COLUMN members_cotisations.startd_ate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN members_cotisations.end_date IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE members_cotisations ADD CONSTRAINT FK_C7D1EB64BD01F5ED FOREIGN KEY (members_id) REFERENCES members (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE members_cotisations DROP CONSTRAINT FK_C7D1EB64BD01F5ED');
$this->addSql('DROP TABLE members_cotisations');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251122185748 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE members ADD email VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE members DROP email');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251122190432 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE members_cotisations ADD payment_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE members_cotisations DROP payment_id');
}
}

View File

@@ -5,13 +5,18 @@ namespace App\Controller\Admin;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Members;
use App\Entity\MembersCotisations;
use App\Entity\Products;
use App\Form\MembersType;
use App\Form\ProductsType;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\MembersCotisationsRepository;
use App\Repository\MembersRepository;
use App\Repository\ProductsRepository;
use App\Service\Mailer\Mailer;
use App\Service\Payments\PaymentClient;
use App\Service\Pdf\CotaReceiptGenerator;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
@@ -20,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
@@ -29,9 +35,10 @@ class AdminController extends AbstractController
{
#[Route(path: '/admin', name: 'admin_dashboard', options: ['sitemap' => false], methods: ['GET'])]
public function adminDashboard(): Response
public function adminDashboard(MembersRepository $membersRepository): Response
{
return $this->render('admin/dashboard.twig', [
'memberCount' => $membersRepository->count(),
]);
}
#[Route(path: '/admin/products', name: 'admin_products', options: ['sitemap' => false], methods: ['GET'])]
@@ -97,7 +104,7 @@ class AdminController extends AbstractController
]);
}
#[Route(path: '/admin/members/{id}', name: 'admin_member_edit', options: ['sitemap' => false], methods: ['GET','POST'])]
public function adminMembersEdit(?Members $members,Request $request,EntityManagerInterface $entityManager): Response
public function adminMembersEdit(Mailer $mailer,PaymentClient $paymentClient,MembersCotisationsRepository $membersCotisationsRepository,?Members $members,Request $request,EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(MembersType::class, $members);
$form->handleRequest($request);
@@ -109,6 +116,59 @@ class AdminController extends AbstractController
$entityManager->flush();
return $this->redirectToRoute('admin_member_edit',['id'=>$members->getId()]);
}
if($request->query->has('idValidateCota')) {
$cota = $membersCotisationsRepository->find($request->query->get('idValidateCota'));
$cota->setIsPaid(true);
$entityManager->persist($cota);
$entityManager->flush();
$pdfGenerator = new CotaReceiptGenerator();
$v = new \DateTime();
$donationData = [
'pseudo' => $cota->getMembers()->getPseudo(),
'email' => $cota->getMembers()->getEmail(),
'amount' => $cota->getAmount(),
'date' => $v->format('Y-m-d'),
'period' => $cota->getStartdATE()->format('d/m/Y')." - ".$cota->getEnddate()->format('d/m/Y'),
];
$files = [];
$pdfContent = $pdfGenerator->generate(
$donationData,
'recu_cotisation_E-Cosplay.pdf',
'S'
);
$files[] = new DataPart($pdfContent, 'recu_cotisation_E-Cosplay.pdf', 'application/pdf');
$mailer->send($members->getEmail(),$members->getPseudo(), '[E-Cosplay] - Confirmation de votre paiement de votre cotisation', "mails/cota_validation.twig", [
'pseudo' => $members->getPseudo(),
'start_at'=> $cota->getStartdATE(),
'end_at'=> $cota->getEnddate(),
'amount' => $cota->getAmount(),
], $files);
return $this->redirectToRoute('admin_member_edit',['id'=>$members->getId()]);
}
if($request->query->has('idLinkCota')) {
$cota = $membersCotisationsRepository->find($request->query->get('idLinkCota'));
$link = $paymentClient->paymentCota($cota);
$cota->setPaymentId($link->id);
$entityManager->persist($cota);
$entityManager->flush();
$paymentLink = $link->url;
$mailer->send($members->getEmail(),$members->getPseudo(),"[E-Cosplay] - Lien de paiement de votre cotisation","mails/coti_payment.twig",[
'pseudo'=>$members->getPseudo(),
'link'=>$paymentLink,
'amount' => $cota->getAmount(),
'start_at' => $cota->getStartdATE(),
'end_at' => $cota->getEndDate(),
]);
return $this->redirectToRoute('admin_member_edit',['id'=>$members->getId()]);
}
return $this->render('admin/member/add.twig', [
'form' => $form->createView(),
'member' => $members,
@@ -118,24 +178,42 @@ class AdminController extends AbstractController
public function adminMembersCreate(Request $request,EntityManagerInterface $entityManager): Response
{
$members = new Members();
$members->setRole('Membre');
$members->setTrans(false);
$members->setCrosscosplayer(false);
$members->setCosplayer(false);
$form = $this->createForm(MembersType::class, $members);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dateTimeStart = \DateTimeImmutable::createFromFormat('d/m/Y', $members->getJoinedAt());
$dateTimeEnd = \DateTimeImmutable::createFromFormat('d/m/Y', $members->getJoinedAt());
$dateTimeEnd = $dateTimeEnd->modify("+1 year");
$memberCota = new MembersCotisations();
$memberCota->setMembers($members);
$memberCota->setStartdATE($dateTimeStart);
$memberCota->setEndDATE($dateTimeEnd);
$memberCota->setIsPaid(false);
$memberCota->setAmount(10);
$entityManager->persist($memberCota);
$entityManager->persist($members);
$entityManager->flush();
return $this->redirectToRoute('admin_members');
}
return $this->render('admin/member/add.twig', [
'form' => $form->createView(),
'member' => $members,
]);
}
#[Route(path: '/admin/members/delete/{id}', name: 'admin_member_delete', options: ['sitemap' => false], methods: ['GET'])]
public function adminMembersDelete(): Response
public function adminMembersDelete(?Members $members,EntityManagerInterface $entityManager): Response
{
if($members instanceof Members){
$entityManager->remove($members);
$entityManager->flush();
}
return $this->redirectToRoute('admin_members');
}
#[Route(path: '/admin/events', name: 'admin_events', options: ['sitemap' => false], methods: ['GET'])]
@@ -151,4 +229,11 @@ class AdminController extends AbstractController
return $this->render('admin/dashboard.twig', [
]);
}
#[Route(path: '/admin/ag', name: 'admin_ag', options: ['sitemap' => false], methods: ['GET'])]
public function adminAg(): Response
{
return $this->render('admin/ag.twig', [
]);
}
}

View File

@@ -6,8 +6,10 @@ use App\Dto\Contact\ContactType;
use App\Dto\Contact\DtoContact;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\MembersCotisations;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\MembersCotisationsRepository;
use App\Service\Mailer\Mailer;
use App\Service\Payments\PaymentClient;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
@@ -27,6 +29,17 @@ use Twig\Environment;
class DonsController extends AbstractController
{
#[Route(path: '/cota/{id}/validation', name: 'app_cota_validate', options: ['sitemap' => false], methods: ['GET','POST'])]
public function cotaValidation(?PaymentClient $paymentClient,Request $request,?MembersCotisations $membersCotisations): Response
{
if(!$membersCotisations instanceof MembersCotisations){
return $this->redirectToRoute('app_login');
}
return $this->render('cota.twig',[
'payment' => $membersCotisations
]);
}
#[Route(path: '/dons', name: 'app_dons', options: ['sitemap' => false], methods: ['GET','POST'])]
public function index(Request $request,PaymentClient $paymentClient): Response
{

View File

@@ -30,7 +30,7 @@ class MembersController extends AbstractController
$board_members =[];
$members =[];
foreach ($membersRepository->findAll() as $member) {
foreach ($membersRepository->findBy(['status'=>'actif']) as $member) {
if($member->getRole() == "Président(e)" ||
$member->getRole() == "Trésorier(e)" ||
$member->getRole() == "Secrétaire(e)" ||
@@ -47,7 +47,7 @@ class MembersController extends AbstractController
'orientation' => $member->getOrientation(),
];
}
foreach ($membersRepository->findAll() as $member) {
foreach ($membersRepository->findBy(['status'=>'actif']) as $member) {
if($member->getRole() == "Membres(e)")
$board_members[] = [
'pseudo' => $member->getPseudo(),

View File

@@ -7,10 +7,13 @@ use App\Dto\Contact\DtoContact;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Dons;
use App\Entity\MembersCotisations;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\MembersCotisationsRepository;
use App\Service\Mailer\Mailer;
use App\Service\Payments\PaymentClient;
use App\Service\Pdf\CotaReceiptGenerator;
use App\Service\Pdf\DonReceiptGenerator;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
@@ -31,7 +34,7 @@ class WebhooksController extends AbstractController
{
#[Route(path: '/webhooks', name: 'app_webhooks', options: ['sitemap' => false], methods: ['POST'])]
public function index(DonReceiptGenerator $donReceiptGenerator,Request $request,PaymentClient $paymentClient,Mailer $mailer,EntityManagerInterface $entityManager,): Response
public function index(DonReceiptGenerator $donReceiptGenerator,MembersCotisationsRepository $membersCotisationsRepository,Request $request,PaymentClient $paymentClient,Mailer $mailer,EntityManagerInterface $entityManager,): Response
{
$content = $request->getContent();
if($paymentClient->validateWebhooks($content)) {
@@ -39,39 +42,73 @@ class WebhooksController extends AbstractController
$object = $content->data->object;
$metadata = $object->metadata;
if($object->status == "complete") {
$dons = new Dons();
$dons->setName($metadata->name);
$dons->setEmail($metadata->email);
$dons->setAmount($metadata->amount);
$dons->setMessage($metadata->message);
$entityManager->persist($dons);
$pdfGenerator = new DonReceiptGenerator();
if($metadata->id_con) {
$me= $membersCotisationsRepository->find($metadata->id_con);
if($me instanceof MembersCotisations) {
$me->setIsPaid(true);
$entityManager->persist($me);
$entityManager->flush();
$v= new \DateTime();
$donationData = [
'name' => $metadata->name,
'email' => $metadata->email,
'amount' => $metadata->amount,
'date' => $v->format('Y-m-d'),
'message' => $metadata->message ?? null,
];
$files = [];
$pdfContent = $pdfGenerator->generate(
$donationData,
'recu_don_E-Cosplay.pdf',
'S'
);
$files[] = new DataPart($pdfContent,'recu_don_E-Cosplay.pdf','application/pdf');
$pdfGenerator = new CotaReceiptGenerator();
$mailer->send($metadata->email,$metadata->name,'[E-Cosplay] - Confirmation de votre don de '.$metadata->amount."","mails/dons.twig",[
'don' => $dons,
],$files);
$v = new \DateTime();
$donationData = [
'pseudo' => $me->getMembers()->getPseudo(),
'email' => $me->getMembers()->getEmail(),
'amount' => $me->getAmount(),
'date' => $v->format('Y-m-d'),
'period' => $me->getStartdATE()->format('d/m/Y')." - ".$me->getEnddate()->format('d/m/Y'),
];
$files = [];
$pdfContent = $pdfGenerator->generate(
$donationData,
'recu_cotisation_E-Cosplay.pdf',
'S'
);
$files[] = new DataPart($pdfContent, 'recu_cotisation_E-Cosplay.pdf', 'application/pdf');
$mailer->send($metadata->email,$metadata->name,'[E-Cosplay] - Confirmation d\'un don de '.$metadata->amount."","mails/dons_new.twig",[
'don' => $dons,
]);
$mailer->send($me->getMembers()->getEmail(),$me->getMembers()->getPseudo(), '[E-Cosplay] - Confirmation de votre paiement de votre cotisation', "mails/cota_validation.twig", [
'pseudo' => $me->getMembers()->getPseudo(),
'start_at'=> $me->getStartdATE(),
'end_at'=> $me->getEnddate(),
'amount' => $me->getAmount(),
], $files);
}
} else {
$dons = new Dons();
$dons->setName($metadata->name);
$dons->setEmail($metadata->email);
$dons->setAmount($metadata->amount);
$dons->setMessage($metadata->message);
$entityManager->persist($dons);
$pdfGenerator = new DonReceiptGenerator();
$entityManager->flush();
$v = new \DateTime();
$donationData = [
'name' => $metadata->name,
'email' => $metadata->email,
'amount' => $metadata->amount,
'date' => $v->format('Y-m-d'),
'message' => $metadata->message ?? null,
];
$files = [];
$pdfContent = $pdfGenerator->generate(
$donationData,
'recu_don_E-Cosplay.pdf',
'S'
);
$files[] = new DataPart($pdfContent, 'recu_don_E-Cosplay.pdf', 'application/pdf');
$mailer->send($metadata->email, $metadata->name, '[E-Cosplay] - Confirmation de votre don de ' . $metadata->amount . "", "mails/dons.twig", [
'don' => $dons,
], $files);
$mailer->send($metadata->email, $metadata->name, '[E-Cosplay] - Confirmation d\'un don de ' . $metadata->amount . "", "mails/dons_new.twig", [
'don' => $dons,
]);
$entityManager->flush();
}
}
}
return $this->json([]);

View File

@@ -3,6 +3,8 @@
namespace App\Entity;
use App\Repository\MembersRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
@@ -50,6 +52,26 @@ class Members
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updateAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $joinedAt = null;
#[ORM\Column(length: 255,nullable: true)]
private ?string $status = null;
/**
* @var Collection<int, MembersCotisations>
*/
#[ORM\OneToMany(targetEntity: MembersCotisations::class, mappedBy: 'members')]
private Collection $membersCotisations;
#[ORM\Column(length: 255,nullable: true)]
private ?string $Email = null;
public function __construct()
{
$this->membersCotisations = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -254,4 +276,70 @@ class Members
{
$this->memberSize = $memberSize;
}
public function getJoinedAt(): ?\DateTimeImmutable
{
return $this->joinedAt;
}
public function setJoinedAt(?\DateTimeImmutable $joinedAt): static
{
$this->joinedAt = $joinedAt;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
/**
* @return Collection<int, MembersCotisations>
*/
public function getMembersCotisations(): Collection
{
return $this->membersCotisations;
}
public function addMembersCotisation(MembersCotisations $membersCotisation): static
{
if (!$this->membersCotisations->contains($membersCotisation)) {
$this->membersCotisations->add($membersCotisation);
$membersCotisation->setMembers($this);
}
return $this;
}
public function removeMembersCotisation(MembersCotisations $membersCotisation): static
{
if ($this->membersCotisations->removeElement($membersCotisation)) {
// set the owning side to null (unless already changed)
if ($membersCotisation->getMembers() === $this) {
$membersCotisation->setMembers(null);
}
}
return $this;
}
public function getEmail(): ?string
{
return $this->Email;
}
public function setEmail(string $Email): static
{
$this->Email = $Email;
return $this;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Entity;
use App\Repository\MembersCotisationsRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MembersCotisationsRepository::class)]
class MembersCotisations
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'membersCotisations')]
private ?Members $members = null;
#[ORM\Column]
private ?\DateTimeImmutable $startdATE = null;
#[ORM\Column]
private ?\DateTimeImmutable $endDate = null;
#[ORM\Column(nullable: true)]
private ?float $amount = null;
#[ORM\Column]
private ?bool $isPaid = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $paymentId = null;
public function getId(): ?int
{
return $this->id;
}
public function getMembers(): ?Members
{
return $this->members;
}
public function setMembers(?Members $members): static
{
$this->members = $members;
return $this;
}
public function getStartdATE(): ?\DateTimeImmutable
{
return $this->startdATE;
}
public function setStartdATE(\DateTimeImmutable $startdATE): static
{
$this->startdATE = $startdATE;
return $this;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(\DateTimeImmutable $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getAmount(): ?float
{
return $this->amount;
}
public function setAmount(?float $amount): static
{
$this->amount = $amount;
return $this;
}
public function isPaid(): ?bool
{
return $this->isPaid;
}
public function setIsPaid(bool $isPaid): static
{
$this->isPaid = $isPaid;
return $this;
}
public function getPaymentId(): ?string
{
return $this->paymentId;
}
public function setPaymentId(?string $paymentId): static
{
$this->paymentId = $paymentId;
return $this;
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Form;
use App\Entity\Members;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -15,10 +17,10 @@ class MembersType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('members', FileType::class, [
->add('members', FileType::class, [ // Renommé pour plus de clarté
'label' => 'Photo du membre (Max 2Mo)',
'required' => false, // Rendre facultatif
'mapped' => false, // Indiquer que ce champ n'est pas directement mappé à une propriété de l'entité
'required' => false,
'mapped' => false,
'attr' => [
'placeholder' => 'Choisir un fichier...',
]
@@ -27,16 +29,36 @@ class MembersType extends AbstractType
'label' => 'Pseudo du membre',
'required' => true,
])
->add('email', EmailType::class, [
'label' => 'Email du membre',
'required' => true,
])
->add('status',ChoiceType::class, [
'label' => 'Statut du membre',
'required' => true,
'choices' => [
'Actif' => 'actif', // Utiliser des valeurs minuscules pour la base de données
'Inactif' => 'inactif',
'Suspendu' => 'suspendu',
],
'preferred_choices' => ['actif'],
])
->add('joinedAt',DateType::class,[
'label' => 'Date de rejoint', // Correction du libellé
'required' => true,
'widget' => 'single_text', // Affiche un champ de date simple au lieu d'une liste déroulante
'input' => 'datetime',
])
->add('role', ChoiceType::class, [
'label' => 'Rôle au sein de l\'association',
'choices' => [
'Président(e)' => 'Président(e)',
'Trésorier(e)' => 'Trésorier(e)',
'Secrétaire(e)' => 'Secrétaire(e)',
'Vice-Président(e)' => 'Vice-Président(e)',
'Trésorier(e) Adjoints' => 'Trésorier(e) Adjoints',
'Secrétaire(e) Adjoints' => 'Secrétaire(e) Adjoints',
'Membres(e)' => 'Membres(e)',
'Président(e)' => 'President',
'Trésorier(e)' => 'Tresorier',
'Secrétaire(e)' => 'Secretaire',
'Vice-Président(e)' => 'VicePresident',
'Trésorier(e) Adjoints' => 'TresorierAdjoint',
'Secrétaire(e) Adjoints' => 'SecretaireAdjoint',
'Membre' => 'Membre', // Corrigé 'Membres(e)' en 'Membre'
],
])
->add('orientation', ChoiceType::class, [
@@ -54,7 +76,6 @@ class MembersType extends AbstractType
'En questionnement' => 'questioning',
'Autre' => 'other',
],
// Optionnel : affichez-le comme une liste déroulante normale (pas expanded)
])
// Les champs booléens sont mieux affichés comme des boutons radio (expanded)
@@ -64,7 +85,7 @@ class MembersType extends AbstractType
'Non' => false,
'Oui' => true,
],
'expanded' => true, // Affiche comme boutons radio
'required' => true,
'multiple' => false,
])
->add('trans', ChoiceType::class, [
@@ -73,7 +94,7 @@ class MembersType extends AbstractType
'Non' => false,
'Oui' => true,
],
'expanded' => true,
'required' => true,
'multiple' => false,
])
->add('cosplayer', ChoiceType::class, [
@@ -82,7 +103,8 @@ class MembersType extends AbstractType
'Non' => false,
'Oui' => true,
],
'expanded' => true,
'required' => true,
'empty_data' => false,
'multiple' => false,
])
;
@@ -90,6 +112,8 @@ class MembersType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', Members::class);
$resolver->setDefaults([
'data_class' => Members::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\MembersCotisations;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MembersCotisations>
*/
class MembersCotisationsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MembersCotisations::class);
}
// /**
// * @return MembersCotisations[] Returns an array of MembersCotisations objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?MembersCotisations
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -2,6 +2,7 @@
namespace App\Service\Payments;
use App\Entity\MembersCotisations;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -55,4 +56,29 @@ class PaymentClient
return false;
}
}
public function paymentCota(MembersCotisations $membersCotisations) {
$stripe = new \Stripe\StripeClient($_ENV['STRIPE_SK']);
$checkoutSession = $stripe->checkout->sessions->create([
'customer_email' => $membersCotisations->getMembers()->getEmail(),
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => "Cotisations",
'description' => "Cotisations",
],
'unit_amount' => $membersCotisations->getAmount()*100,
],
'quantity' => 1,
]],
'metadata' => [
'id_con' => $membersCotisations->getId(),
],
'mode' => 'payment',
'success_url' => $this->url.$this->urlGenerator->generate('app_cota_validate',['id'=>$membersCotisations->getId()]),
]);
return $checkoutSession;
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Service\Pdf;
use Fpdf\Fpdf;
/**
* Générateur de reçu de cotisation FPDF.
*/
class CotaReceiptGenerator extends Fpdf
{
private array $contributionData;
/**
* Initialise le générateur de reçu avec les données de la cotisation.
* @param array $data Les données de la cotisation (amount, name, date, email, etc.).
*/
public function setContributionData(array $data): void
{
// Renommage de $donationData en $contributionData
$this->contributionData = $data;
}
// --- Méthodes FPDF obligatoires ou recommandées ---
/**
* Entête du document (Logo, Nom de l'association).
*/
public function Header(): void
{
// Logo de l'association E-Cosplay
$logoPath = $_SERVER['DOCUMENT_ROOT'] . '/assets/images/logo.jpg';
if (file_exists($logoPath)) {
$this->Image($logoPath, 10, 8, 30); // Position X=10, Y=8, Largeur=30mm (Hauteur auto)
} else {
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 10, utf8_decode('E-Cosplay Logo'), 0, 0, 'L');
$this->Ln(5);
}
// Police de caractères (ex: Arial gras 15)
$this->SetFont('Arial', 'B', 15);
$this->Cell(80); // Ajuster le décalage après le logo
// --- MISE À JOUR : 'Reçu de Cotisation' ---
$this->Cell(30, 10, utf8_decode('Reçu de Cotisation'), 0, 1, 'C');
// Informations de l'association
$this->SetFont('Arial', '', 10);
$this->Cell(0, 5, utf8_decode('E-Cosplay'), 0, 1, 'C');
$this->Cell(0, 5, utf8_decode('42 RUE DE SAINT-QUENTIN 02800 BEAUTOR'), 0, 1, 'C');
$this->Cell(0, 5, utf8_decode('SIREN: 943121517 | Code NAF: 93.29Z'), 0, 1, 'C');
// Saut de ligne
$this->Ln(15);
}
/**
* Pied de page du document.
*/
public function Footer(): void
{
$this->SetY(-15);
$this->SetFont('Arial', 'I', 8);
$this->Cell(0, 10, utf8_decode('Page ') . $this->PageNo() . '/{nb}', 0, 0, 'C');
}
// --- Méthode de génération spécifique au reçu ---
/**
* Génère le corps principal du reçu de cotisation.
*/
public function generateReceiptBody(): void
{
// Utilisation de $this->contributionData
if (empty($this->contributionData)) {
$this->Cell(0, 10, utf8_decode('Erreur: Aucune donnée de cotisation n\'est définie.'), 0, 1);
return;
}
$this->SetFont('Arial', '', 12);
// Affichage des informations du Membre
$this->SetTextColor(0, 0, 0); // Noir
$this->Cell(0, 10, utf8_decode('Membre:'), 0, 1); // --- MISE À JOUR : 'Membre' ---
// Récupération et décodage des données du membre
$name = utf8_decode($this->contributionData['name'] ?? 'Non spécifié');
$email = utf8_decode($this->contributionData['email'] ?? 'Non spécifié');
$period = utf8_decode($this->contributionData['period'] ?? 'Non spécifiée'); // Ajout potentiel
$this->SetX(20);
$this->Cell(0, 7, utf8_decode('Nom/Pseudo: ') . $name, 0, 1);
$this->SetX(20);
$this->Cell(0, 7, utf8_decode('E-mail: ') . $email, 0, 1);
// Affichage de la période d'adhésion si disponible
$this->SetX(20);
$this->Cell(0, 7, utf8_decode('Période d\'adhésion: ') . $period, 0, 1);
$this->Ln(10);
// Affichage des détails de la Cotisation
$this->SetFont('Arial', 'B', 14);
$this->SetFillColor(200, 220, 255);
// --- MISE À JOUR : 'Détails de la Cotisation' ---
$this->Cell(0, 10, utf8_decode('Détails de la Cotisation'), 0, 1, 'L', true);
$this->SetFont('Arial', '', 12);
$this->Ln(3);
// Montant
$amount = $this->contributionData['amount'] ?? 0;
$formattedAmount = number_format($amount, 2, ',', '.') . ' EUR';
$this->SetX(20);
$this->Cell(50, 8, utf8_decode('Montant de la Cotisation:'), 0, 0); // --- MISE À JOUR : 'Cotisation' ---
$this->SetFont('Arial', 'B', 12);
$this->Cell(0, 8, $formattedAmount, 0, 1);
$this->SetFont('Arial', '', 12);
// Date
$date = $this->contributionData['date'] ?? date('Y-m-d');
$this->SetX(20);
$this->Cell(50, 8, utf8_decode('Date de la Transaction:'), 0, 0);
$this->Cell(0, 8, $date, 0, 1);
// Message (si présent)
if (!empty($this->contributionData['message'])) {
$message = utf8_decode($this->contributionData['message']);
$this->Ln(5);
$this->Cell(0, 8, utf8_decode('Note du Membre:'), 0, 1); // --- MISE À JOUR : 'Note du Membre' ---
$this->SetX(20);
$this->MultiCell(0, 6, $message);
}
$this->Ln(15);
// --- MISE À JOUR : Mention spécifique pour la Cotisation ---
$infoText = 'Ce document est un reçu attestant du paiement de votre cotisation d\'adhésion à l\'association E-Cosplay pour la période spécifiée. Il valide vos droits de membre.';
$this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, utf8_decode($infoText), 0, 'C');
$this->Ln(10);
// Signature
$this->SetFont('Arial', 'I', 10);
$this->Cell(0, 5, utf8_decode('Fait à BEAUTOR, le ') . date('d/m/Y'), 0, 1, 'R');
}
/**
* Méthode publique pour générer et obtenir le PDF.
* @param array $data Les données de la cotisation.
* @param string $filename Le nom du fichier de sortie.
* @param string $dest La destination (I: navigateur, D: téléchargement, F: fichier, S: string).
*/
public function generate(array $data, string $filename = 'Reçu_Cotisation.pdf', string $dest = 'I')
{
// Utilisation de setContributionData
$this->setContributionData($data);
// Initialisation du PDF
$this->AliasNbPages();
$this->AddPage();
// Génération du contenu
$this->generateReceiptBody();
// Sortie du document
// Le nom de fichier est mis à jour
return $this->Output($dest, utf8_decode($filename));
}
}

View File

@@ -1,138 +1,144 @@
{# Assurez-vous d'utiliser une version de Tailwind CSS qui supporte ces classes #}
<!DOCTYPE html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}Administration{% endblock %} - E-Cosplay</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{# 🛑 NO INDEX DIRECTIVE #}
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}Admin Dashboard{% endblock %} | Mon App</title>
<!-- Chargement de l'entrée principale de Vite pour les CSS et le JS -->
{{ vite_asset('admin.js', []) }}
{# 🎨 Tailwind CSS Integration (Utilisation du CDN pour la démo) #}
{{ vite_asset('app.js',[]) }}
<!-- Tailwind CSS est inclus via le fichier app.css/app.js géré par Vite -->
{% block stylesheets %}{% endblock %}
</head>
<!-- DARK MODE: bg-gray-900 pour le fond principal -->
<!-- La body reste un conteneur flex horizontal -->
<body class="bg-gray-900 min-h-screen flex antialiased">
{# Utiliser un layout en grille ou flex pour organiser la sidebar et le contenu #}
<body class="bg-gray-900 font-sans">
<!-- 1. Sidebar (Barre Latérale) - bg-gray-800 -->
<div id="admin-layout" class="flex h-screen">
<!-- MODIFICATION MAJEURE : Suppression de 'fixed' et 'h-screen' de la sidebar.
Elle défilera donc avec le reste du contenu de la page.
L'overflow-y-auto n'est plus nécessaire ici car c'est le <body> qui gère le scroll. -->
{# 1. BARRE LATÉRALE (SIDEBAR - FOND BLANC) #}
<aside class="w-64 bg-gray-700 shadow-xl flex-shrink-0">
<div class="h-full flex flex-col justify-between">
<aside id="sidebar" class="w-64 bg-gray-800 text-white flex-shrink-0 transition-transform duration-300 transform -translate-x-full md:translate-x-0 z-40">
<div class="p-6">
<h1 class="text-3xl font-bold tracking-wider">E-Cosplay</h1>
</div>
{# Contenu de la navigation #}
<nav class="p-4 space-y-6 flex-1 overflow-y-auto">
<nav class="mt-8">
<!-- Dashboard -->
<a href="{{ path('admin_dashboard') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>
Dashboard
</a>
{# Titre/Logo #}
<div class="text-3xl font-extrabold text-indigo-600 border-b border-gray-200 pb-4 mb-2">
E-Cosplay
</div>
<!-- Membres (Utilisateurs) -->
<a href="{{ path('admin_members') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20h5v-2a3 3 0 00-5.356-1.857M9 20H4v-2a3 3 0 015-2.236M9 20v-2a3 3 0 00-5-2.236M9 20h5m-5 0h5M12 4a4 4 0 100 8 4 4 0 000-8z"></path></svg>
Membres
</a>
{# --- Lien Principal : Dashboard --- #}
<p class="text-xs font-semibold uppercase text-white pt-4 pb-2">Général</p>
<!-- Produits -->
<a href="{{ path('admin_products') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c1.657 0 3 .895 3 2s-1.343 2-3 2-3 .895-3 2 1.343 2 3 2m0-8h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Produits
</a>
<a href="{{ path('admin_dashboard') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if app.request.attributes.get('_route') == 'admin_dashboard' %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Dashboard
</a>
<!-- Événements -->
<a href="{{ path('admin_events') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h.01M16 11h.01M9 16h.01M15 16h.01M16 12h-8m8 4H8m8 4H8m-5 4h18a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
Événements
</a>
{# --- SECTION : COMMUNAUTÉ --- #}
<p class="text-xs font-semibold uppercase text-white pt-4 pb-2">Gestion Communauté</p>
<!-- AG (Assemblée Générale) -->
<a href="{{ path('admin_ag') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.523 5.754 18 7.5 18s3.332.477 4.5 1.247m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.523 18.246 18 16.5 18s-3.332.477-4.5 1.247"></path></svg>
AG (Assemblée Générale)
</a>
{# MEMBRES #}
<a href="{{ path('admin_members') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if 'members' in app.request.attributes.get('_route') %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Membres
</a>
{# MEMBRES #}
<a href="{{ path('admin_products') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if 'product' in app.request.attributes.get('_route') %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Produits
</a>
{# ÉVÉNEMENTS #}
<a href="{{ path('admin_events') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if 'events' in app.request.attributes.get('_route') %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Événements
</a>
{# Le lien Paramètres a été supprimé #}
{# --- SECTION : ADMINISTRATION --- #}
<p class="text-xs font-semibold uppercase text-white pt-4 pb-2">Administration</p>
{# Ajoutez d'autres liens ici #}
</nav>
{# COMPTES ADMINISTRATEUR #}
<a href="{{ path('admin_accounts_list') }}"
class="block px-3 py-2 rounded-lg transition duration-150 ease-in-out text-white
{% if 'accounts_list' in app.request.attributes.get('_route') %}
bg-indigo-500 font-bold
{% else %}
hover:bg-gray-700
{% endif %}">
Comptes Administrateur
</a>
</nav>
</aside>
{# Bouton Déconnexion (pied de page de la sidebar) #}
<div class="p-4 border-t border-gray-200">
<a href="{{ path('app_logout') }}"
class="block px-3 py-2 rounded-lg text-red-500 hover:bg-red-50 transition duration-150 ease-in-out">
<!-- 2. Contenu Principal et Navigation Supérieure -->
<!-- Le conteneur principal reste flex-col et prend l'espace restant -->
<div id="main-content" class="flex flex-col flex-1">
<!-- 2.1. Navbar (Barre de Navigation Supérieure) - bg-gray-800 -->
<!-- La navbar est toujours sticky pour rester en haut de l'écran lors du défilement -->
<header class="bg-gray-800 shadow-xl p-4 flex justify-between items-center sticky top-0 z-30">
<!-- Bouton pour afficher/cacher la sidebar sur mobile -->
<button id="sidebar-toggle" class="text-gray-400 md:hidden p-2 rounded-md hover:bg-gray-700 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path></svg>
</button>
<!-- Titre de la page (Visible uniquement ici pour la cohérence) -->
<h2 class="text-xl font-semibold text-white hidden sm:block">
{% block page_title %}Tableau de Bord{% endblock %}
</h2>
<!-- Profil et Déconnexion -->
<div class="flex items-center space-x-4">
<div class="flex items-center cursor-pointer group">
<span class="text-gray-200 font-medium hidden sm:inline">
{# Assurez-vous que app.user est défini #}
{% if app.user is defined and app.user %}
{{ app.user.username }}
{% else %}
Admin
{% endif %}
</span>
<!-- Bouton Déconnexion -->
<a href="{{ path('app_logout') }}" class="ml-4 p-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition duration-150 flex items-center shadow-md">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
Déconnexion
</a>
</div>
</div>
</aside>
</header>
{# 2. CONTENU PRINCIPAL (Inclut la Topbar) #}
<div class="flex-1 flex flex-col overflow-hidden">
<!-- 2.2. Contenu de la Page (Titre et Corps) -->
<!-- Retrait de overflow-y-auto pour que le <body> défile -->
<main class="p-6 flex-1">
{# 3. TOP BAR (BARRE SUPÉRIEURE) #}
<header class="w-full bg-gray-800 shadow-md p-4 flex justify-end items-center flex-shrink-0">
<!-- Affichage du Titre de la Page (pour les écrans larges) -->
<h1 class="text-3xl font-bold text-white mb-6 hidden sm:block">
{{ block('page_title') }}
</h1>
{# Remplacer 'current_user.name' par la variable Twig de votre session #}
<div class="text-white font-medium">
Bienvenue, {{ app.user.username }}
</div>
<!-- Le contenu réel de la page s'insère ici -->
<div class="w-full">
{% block body %}{% endblock %}
</div>
</main>
{# Vous pourriez ajouter ici un bouton de profil ou un dropdown #}
</header>
<!-- 2.3. Footer (Pied de Page) - bg-gray-800 -->
<footer class="p-4 bg-gray-800 border-t border-gray-700 text-center text-sm text-gray-400 flex-shrink-0">
Développé par SARL SITECONSEIL. Tous droits réservés.
<a href="https://www.siteconseil.fr/" target="_blank" rel="noopener noreferrer" class="text-indigo-400 hover:text-indigo-300 transition duration-150 font-medium ml-1">
https://www.siteconseil.fr/
</a>
</footer>
{# ZONE DE CONTENU PRINCIPALE #}
<main class="flex-1 overflow-x-hidden overflow-y-auto p-6">
<h2 class="text-3xl font-semibold text-white mb-6">
{% block page_title %}{% endblock %}
</h2>
<div class="bg-gray-700 shadow-md rounded-lg min-h-full">
{% block body %}
{% endblock %}
</div>
</main>
</div>
</div>
<!-- Script pour la gestion du menu mobile -->
{% block javascripts %}{% endblock %}
</body>
</html>

View File

@@ -1,3 +1,77 @@
{% extends 'admin/base.twig' %}
{% block title %}Tableau de bord{% endblock %}
{% block page_title %}Tableau de bord{% endblock %}
{# Définition des titres pour le navigateur et l'affichage #}
{% block title 'Tableau de bord' %}
{% block page_title 'Tableau de bord' %}
{# Variables de données simulées (MOCK DATA) - À REMPLACER par les vraies variables passées au template #}
{% set pendingOrdersCount = 0 %}
{% block body %}
<div class="space-y-8">
{# Section des Cartes de Statistiques (KPIs) #}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{# Carte 1: Nombre de Membres #}
<div class="bg-gray-800 p-6 rounded-xl shadow-2xl border border-gray-700 hover:border-indigo-500 transition duration-300">
<div class="flex items-center justify-between">
<div class="text-indigo-400">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20h5v-2a3 3 0 00-5.356-1.857M9 20H4v-2a3 3 0 015-2.236M9 20v-2a3 3 0 00-5-2.236M9 20h5m-5 0h5M12 4a4 4 0 100 8 4 4 0 000-8z"></path></svg>
</div>
<p class="text-sm font-medium text-gray-400">
Membres de l'association
</p>
</div>
<div class="mt-4">
<p class="text-4xl font-extrabold text-white">
{{ memberCount | number_format(0, ',', ' ') }}
</p>
<p class="text-sm text-gray-500 mt-1">
Total des inscriptions actives.
</p>
</div>
</div>
{# Carte 2: Commandes en Attente #}
<div class="bg-gray-800 p-6 rounded-xl shadow-2xl border border-gray-700 hover:border-red-500 transition duration-300">
<div class="flex items-center justify-between">
<div class="text-red-400">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg>
</div>
<p class="text-sm font-medium text-gray-400">
Commandes en attente
</p>
</div>
<div class="mt-4">
<p class="text-4xl font-extrabold text-white">
{{ pendingOrdersCount }}
</p>
<p class="text-sm text-gray-500 mt-1">
Nécessitent un traitement.
</p>
</div>
</div>
{# Carte 4: Espace pour un autre KPI (ex: Nouveaux événements) #}
<div class="bg-gray-800 p-6 rounded-xl shadow-2xl border border-gray-700 hover:border-yellow-500 transition duration-300">
<div class="flex items-center justify-between">
<div class="text-yellow-400">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h.01M16 11h.01M9 16h.01M15 16h.01M16 12h-8m8 4H8m8 4H8m-5 4h18a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</div>
<p class="text-sm font-medium text-gray-400">
Événements à venir
</p>
</div>
<div class="mt-4">
<p class="text-4xl font-extrabold text-white">
0
</p>
<p class="text-sm text-gray-500 mt-1">
Planifiés pour les 3 prochains mois.
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,99 +1,138 @@
{% extends 'admin/base.twig' %}
{% block title %}Ajouter/Éditer un Membre{% endblock %}
{% block page_title %}
{{ form.vars.value.id ? 'Éditer le Membre' : 'Créer un nouveau Membre' }}
{{ form.vars.value.id ? 'Éditer le Membre: ' ~ form.vars.value.pseudo : 'Créer un nouveau Membre' }}
{% endblock %}
{% block body %}
<div class="mx-auto bg-white p-8 rounded-lg">
{# --- APPLICATION DU THÈME DE FORMULAIRE DEMANDÉ --- #}
{% form_theme form 'form_admin.twig' %}
{{ form_start(form, {'attr': {'class': 'space-y-6'}}) }}
{# Définir l'entité pour la rendre plus facile à référencer, notamment pour vich_uploader_asset #}
{% set member = form.vars.value %}
{# --- SECTION 1: Informations de base --- #}
<h3 class="text-xl font-semibold text-gray-700 border-b pb-2 mb-4">Informations Principales</h3>
{# Conteneur principal #}
<div class="w-full mx-auto bg-gray-800 p-4 sm:p-8 md:p-10 rounded-xl shadow-2xl border border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{ form_start(form, {'attr': {'class': 'space-y-8'}}) }}
{# Pseudo #}
{{ form_row(form.pseudo, {'attr': {'class': 'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500'}}) }}
{# --- SECTION 1: Informations de base (Grille de 3 colonnes sur grand écran) --- #}
<h3 class="text-2xl font-bold text-white border-b border-gray-700 pb-3 mb-6">
Informations Principales
</h3>
{# Rôle #}
{{ form_row(form.role, {'attr': {'class': 'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500'}}) }}
{# Grille de 3 colonnes sur les grands écrans (lg:grid-cols-3) #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{# Pseudo - Utilise le style défini dans form_admin.twig #}
<div>
{{ form_row(form.pseudo) }}
</div>
{# Rôle - Utilise le style défini dans form_admin.twig #}
<div>
{{ form_row(form.role) }}
</div>
{# Date de Rejoint - Utilise le style défini dans form_admin.twig #}
<div>
{{ form_row(form.joinedAt) }}
</div>
{# Statut - Utilise le style défini dans form_admin.twig #}
<div>
{{ form_row(form.status) }}
</div>
{# Orientation - Utilise le style défini dans form_admin.twig #}
<div>
{{ form_row(form.orientation) }}
</div>
<div>
{{ form_row(form.email) }}
</div>
</div>
{# L'élément LABEL est stylisé pour devenir le cercle cliquable #}
<label for="{{ form.members.vars.id }}"
class="relative cursor-pointer bg-gray-200 rounded-full w-28 h-28
flex items-center justify-center border-2 border-dashed border-gray-400
hover:border-indigo-600 transition duration-300 overflow-hidden">
{# --- SECTION IMAGE DE PROFIL / UPLOAD --- #}
<h3 class="text-2xl font-bold text-white border-b border-gray-700 pb-3 pt-6 mb-6">
Photo de Profil
</h3>
{# 1. AFFICHAGE DE L'IMAGE EXISTANTE (si présente) #}
{% set currentImageUrl = vich_uploader_asset(member,'members') %}
{% if currentImageUrl %}
<img src="{{ asset(currentImageUrl) }}"
alt="Photo de profil actuelle"
class="w-full h-full object-cover">
<div class="flex items-center space-x-6">
{# Overlay pour indiquer que c'est cliquable #}
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center
opacity-0 hover:opacity-100 transition duration-300 text-white font-bold text-xs">
{# Zone cliquable pour l'image existante, le placeholder ou la prévisualisation #}
<label for="{{ form.members.vars.id }}"
class="relative cursor-pointer bg-gray-700 rounded-full w-32 h-32
flex items-center justify-center border-2 border-dashed border-gray-600
hover:border-indigo-500 transition duration-300 overflow-hidden shadow-lg"
id="preview-container">
{% set currentImageUrl = member.memberFileName ? asset(vich_uploader_asset(member, 'members')) : '' %}
{# Image de prévisualisation ou Image actuelle #}
<img id="profile-preview-img"
src="{{ currentImageUrl }}"
alt="Photo de profil"
class="w-full h-full object-cover {{ currentImageUrl ? '' : 'hidden' }}">
{# Icône par défaut (visible seulement si aucune image actuelle) #}
<svg id="default-user-icon"
class="h-12 w-12 text-gray-500 {{ currentImageUrl ? 'hidden' : '' }}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
{# Overlay pour indiquer que c'est cliquable (s'applique à l'ensemble du label) #}
<div class="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center
opacity-0 hover:opacity-100 transition duration-300 text-white font-bold text-xs p-2 text-center">
Cliquer pour changer
</div>
{% else %}
{# 2. AFFICHAGE DE L'ICÔNE PAR DÉFAUT (si aucune image) #}
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 18m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% endif %}
</label>
</label>
{# Champ de fichier réel (caché) #}
{{ form_widget(form.members, {'attr': {'class': 'sr-only'}}) }}
<div class="flex-1">
<p class="text-sm text-gray-400">
Sélectionnez une nouvelle photo de profil. Le fichier actuel sera remplacé. (Max 2Mo).
</p>
{# Affichage des erreurs spécifiques au champ image #}
{{ form_errors(form.members) }}
</div>
{{ form_widget(form.members, {
'attr': {
'class': 'sr-only',
}
}) }}
<div class="mt-6">
{{ form_row(form.orientation, {'attr': {'class': 'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500'}}) }}
</div>
{# --- SECTION 2: Tags / Booléens (Boutons Radio) --- #}
<h3 class="text-xl font-semibold text-gray-700 border-b pb-2 pt-6 mb-4">Tags et Caractéristiques</h3>
<h3 class="text-2xl font-bold text-white border-b border-gray-700 pb-3 pt-6 mb-6">
Tags et Caractéristiques
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{# Utilisation d'une grille pour aligner les 3 groupes de boutons radio #}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{# Crosscosplayer #}
{{ form_row(form.crosscosplayer, {
'label': 'Crosscosplayer ?',
'row_attr': {'class': 'space-y-4'},
'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}
}) }}
{% set boolFields = [form.crosscosplayer, form.cosplayer, form.trans] %}
{# Cosplayer #}
{{ form_row(form.cosplayer, {
'label': 'Cosplayer ?',
'row_attr': {'class': 'space-y-4'},
'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}
}) }}
{# Trans #}
{{ form_row(form.trans, {
'label': 'Transgenre ?',
'row_attr': {'class': 'space-y-4'},
'label_attr': {'class': 'block text-sm font-medium text-gray-700 mb-2'}
}) }}
{# Utilisation de form_row() pour chaque champ booléen.
Le thème form_admin.twig gère le conteneur de carte sombre, le label et les options radio via 'choice_widget_expanded'. #}
{% for field in boolFields %}
{{ form_row(field) }}
{% endfor %}
</div>
{# --- Bouton de Soumission --- #}
<div class="pt-6 border-t border-gray-200 mt-8">
<button type="submit" class="w-full inline-flex justify-center py-3 px-6 border border-transparent
rounded-md shadow-sm text-lg font-medium text-white bg-indigo-600
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-indigo-500 transition duration-150">
{{ form.vars.value.id ? 'Sauvegarder les Modifications' : 'Créer le Membre' }}
<div class="pt-8 border-t border-gray-700 mt-8">
<button type="submit" class="w-full inline-flex justify-center py-4 px-6 border border-transparent
rounded-xl shadow-lg text-lg font-bold text-white bg-indigo-600
hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-indigo-500 focus:ring-offset-gray-800 transition duration-150 transform hover:scale-[1.01]">
{{ member.id ? 'Sauvegarder les Modifications' : 'Créer le Membre' }}
</button>
</div>
@@ -101,7 +140,118 @@
</div>
{# --- Rendu des erreurs et champs cachés --- #}
{{ form_end(form) }}
{# --- NOUVELLE SECTION: LISTE DES COTISATIONS --- #}
{% if member.membersCotisations is defined %}
<div class="w-full mx-auto bg-gray-800 p-4 sm:p-8 md:p-10 rounded-xl shadow-2xl border border-gray-700 mt-8">
<h3 class="text-2xl font-bold text-white border-b border-gray-700 pb-3 mb-6">
Liste des Cotisations
</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead>
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Période
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Montant
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Statut
</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-gray-800 divide-y divide-gray-700 text-white">
{# Itération sur la collection de cotisations #}
{% for cotisation in member.membersCotisations %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-300">
{{ cotisation.startDate|date('d/m/Y') }} - {{ cotisation.endDate|date('d/m/Y') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
{{ cotisation.amount|format_currency('EUR', locale='fr') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if cotisation.isPaid %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-700 text-green-100">
Payée
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-700 text-red-100">
Non payée
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{% if not cotisation.isPaid %}
<a href="{{ path('admin_member_edit', {'id': member.id,'idValidateCota':cotisation.id}) }}"
class="text-green-400 hover:text-green-200 transition duration-150 mr-3">
Valider le paiement
</a>
<a href="{{ path('admin_member_edit', {'id': member.id,'idLinkCota':cotisation.id}) }}"
class="text-indigo-400 hover:text-indigo-200 transition duration-150">
Envoyer lien
</a>
{% else %}
<span class="text-gray-500">Aucune action requise</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="px-6 py-4 text-center text-gray-500">
Aucune cotisation trouvée pour ce membre.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# --- SCRIPT JAVASCRIPT POUR LA PRÉVISUALISATION D'IMAGE --- #}
<script>
document.addEventListener('DOMContentLoaded', () => {
// Récupère l'ID réel du champ de fichier généré par Symfony
const fileInput = document.getElementById('{{ form.members.vars.id }}');
const previewImg = document.getElementById('profile-preview-img');
const defaultIcon = document.getElementById('default-user-icon');
if (fileInput) {
fileInput.addEventListener('change', function() {
// Vérifie si un fichier a été sélectionné
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
// 1. Met à jour l'attribut src de l'image avec la data URL du fichier
previewImg.src = e.target.result;
// 2. Assure que l'image est visible
previewImg.classList.remove('hidden');
// 3. Cache l'icône par défaut si elle est là
if (defaultIcon) {
defaultIcon.classList.add('hidden');
}
}
// Lit le contenu du fichier
reader.readAsDataURL(this.files[0]);
}
});
}
});
</script>
{% endblock %}

View File

@@ -1,63 +1,103 @@
{% extends 'admin/base.twig' %}
{% block title %}Membre(s){% endblock %}
{% block page_title %}Liste des Membres{% endblock %}
{% block title 'Membre(s)' %}
{% block page_title 'Liste des Membres' %}
{% block body %}
<style>
.dz{
display: block;
text-align: center;
width: 100%;
}
</style>
<div class="flex justify-end">
{# Définition d'un mappage pour traduire les rôles stockés vers les libellés affichés #}
{% set roleDisplay = {
'President': 'Président(e)',
'Tresorier': 'Trésorier(e)',
'Secretaire': 'Secrétaire(e)',
'VicePresident': 'Vice-Président(e)',
'TresorierAdjoint': 'Trésorier(e) Adjoint',
'SecretaireAdjoint': 'Secrétaire(e) Adjoint',
'Membre': 'Membre',
} %}
<div class="flex justify-end mb-6">
<a href="{{ path('admin_member_create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium
shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-indigo-500 transition duration-150 dz">
{# Vous pouvez ajouter ici une icône si vous le souhaitez, par exemple : [Icône] #}
class="dz items-center px-4 py-2 border border-transparent text-sm font-medium
rounded-lg shadow-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-indigo-500 transition duration-150">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
Créer un membre
</a>
</div>
<div class="overflow-x-auto bg-gray-700">
<table class="min-w-full divide-y divide-gray-200">
<div class="overflow-x-auto rounded-lg shadow-xl border border-gray-700">
<table class="min-w-full divide-y divide-gray-700">
{# --- EN-TÊTE DU TABLEAU (HEAD) --- #}
<thead class="bg-gray-800">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Pseudo
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Rôle
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-white uppercase tracking-wider">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Rejoint le
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Statut
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
{# --- CORPS DU TABLEAU (BODY) --- #}
<tbody class="bg-gray-600 divide-y divide-gray-200">
<tbody class="bg-gray-800 divide-y divide-gray-700">
{# Démonstration: Boucle sur une liste de membres (members) passée par votre contrôleur #}
{% if members is not empty %}
{% for member in members %}
<tr>
<tr class="hover:bg-gray-700 transition duration-150">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-white">{{ member.pseudo }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{# Utilisation d'un badge Tailwind pour le rôle #}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{{ member.role }}
</span>
{# Détermination de la couleur en fonction du Rôle stocké #}
{% set roleKey = member.role %}
{% set roleColor = (roleKey == 'President' or roleKey == 'VicePresident') ? 'bg-red-500' :
(roleKey == 'Secretaire' or roleKey == 'Tresorier') ? 'bg-indigo-500' :
(roleKey == 'Membre') ? 'bg-gray-500' : 'bg-green-600' %}
{# Affichage du libellé français grâce au mappage #}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full text-white {{ roleColor }}">
{{ roleDisplay[roleKey] | default(roleKey) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-300">
{# Assurez-vous que joined_at est un objet DateTime ou une chaîne Twig #}
{{ member.joinedAt | date('d/m/Y') }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{# Détermination du badge de statut (actif, inactif, suspendu) #}
{% set statusColor = (member.status == 'actif') ? 'bg-green-500' :
(member.status == 'inactif') ? 'bg-yellow-500' :
'bg-red-600' %}
{% set statusText = (member.status == 'actif') ? 'Actif' :
(member.status == 'inactif') ? 'Inactif' :
'Suspendu' %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full text-white {{ statusColor }}">
{{ statusText }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ path('admin_member_edit', {id: member.id}) }}" class="text-indigo-900 hover:text-indigo-900 mr-4">
<a href="{{ path('admin_member_edit', {id: member.id}) }}"
class="text-indigo-400 hover:text-indigo-300 mr-4 transition duration-150">
Éditer
</a>
<a href="{{ path('admin_member_delete', {id: member.id}) }}" class="text-red-900 hover:text-red-900">
<a href="{{ path('admin_member_delete', {id: member.id}) }}"
class="text-red-400 hover:text-red-300 transition duration-150">
Supprimer
</a>
</td>
@@ -66,14 +106,15 @@
{% else %}
{# Message si la liste est vide #}
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
Aucun membre trouvé.
<td colspan="5" class="px-6 py-12 text-center text-gray-500">
Aucun membre trouvé. Veuillez créer un nouveau membre.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

54
templates/cota.twig Normal file
View File

@@ -0,0 +1,54 @@
{% extends 'base.twig' %}
{% block title %}
Confirmation de Paiement | Votre Cotisation
{% endblock %}
{% block body %}
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="max-w-xl w-full bg-white shadow-2xl rounded-lg overflow-hidden border-t-4 border-green-500">
{# Header #}
<div class="p-6 bg-green-500 text-white text-center">
<svg class="w-16 h-16 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h1 class="text-3xl font-bold">Paiement Reçu !</h1>
<p class="text-green-100 mt-1">Confirmation de votre Cotisation</p>
</div>
{# Body Content #}
<div class="p-6 md:p-8 space-y-4">
<p class="text-gray-700">Cher membre,</p>
<p class="text-gray-800">
Nous vous remercions sincèrement. Le paiement de votre **cotisation** a été effectué avec succès et a été enregistré dans nos systèmes.
Votre compte est désormais à jour.
</p>
{# Transaction Details #}
<div class="bg-gray-50 border border-gray-200 p-4 rounded-lg">
<h2 class="text-lg font-semibold text-gray-800 mb-3 border-b pb-2">Détails de la transaction</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="font-medium text-gray-600">Montant payé :</dt>
<dd class="font-bold text-green-600">{{ payment.amount|number_format(2, ',', ' ') }} €</dd>
</div>
</dl>
</div>
{# Footer Text #}
<p class="pt-4 text-gray-600 border-t mt-6">
Pour toute question concernant cette transaction ou votre adhésion, n'hésitez pas à contacter notre support.
</p>
<p class="text-gray-700 font-semibold">
Cordialement,<br>
L'équipe E-Cosplay
</p>
</div>
</div>
</div>
{% endblock %}

148
templates/form_admin.twig Normal file
View File

@@ -0,0 +1,148 @@
{% use 'form_div_layout.html.twig' %}
{# ---------- FORM START / END ---------- #}
{% block form_start -%}
{{ parent() }}
{%- endblock %}
{% block form_end -%}
{{ parent() }}
{%- endblock %}
{# ---------- ROW ---------- #}
{% block form_row %}
{# La ROW est le conteneur du label, du widget et des erreurs. #}
<div class="mb-5">
{{ form_label(form) }}
<div class="mt-1">
{{ form_widget(form) }}
</div>
{% if not compound and not form.vars.valid %}
{# Affiche l'erreur en bas du champ simple #}
<p class="text-sm text-red-400 mt-1">{{ form_errors(form) }}</p>
{% else %}
{# Affiche l'erreur pour les champs composés (si form_errors n'est pas déjà dans le widget) #}
{{ form_errors(form) }}
{% endif %}
</div>
{% endblock %}
{# ---------- LABEL ---------- #}
{% block form_label %}
{% if label is not same as(false) %}
<label for="{{ id }}" class="block text-sm font-medium text-gray-300">
{{ label|trans({}, translation_domain) }}
{% if required %}
<span class="text-red-400">*</span>
{% endif %}
</label>
{% endif %}
{% endblock %}
{# ---------- ERRORS ---------- #}
{% block form_errors %}
{% if errors|length > 0 %}
{# Utilisation de red-400 pour mieux ressortir sur un fond sombre #}
<ul class="mt-1 text-sm text-red-400 list-disc list-inside">
{% for error in errors %}
<li>{{ error.message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
{# ---------- WIDGET DISPATCH ---------- #}
{% block form_widget %}
{% if compound %}
{{ block('form_widget_compound') }}
{% else %}
{{ block('form_widget_simple') }}
{% endif %}
{% endblock %}
{# --- STYLE COMMUN POUR WIDGETS (DARK MODE) --- #}
{# ---------- SIMPLE INPUTS (text, email, number...) ---------- #}
{% block form_widget_simple %}
{% set type = type|default('text') %}
<input
type="{{ type }}"
{{ block('widget_attributes') }}
value="{{ value }}"
{# DARK MODE: bg-gray-700, border-gray-600, text-white #}
class="form-input mt-1 block w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
/>
{% endblock %}
{# ---------- TEXTAREA ---------- #}
{% block textarea_widget %}
<textarea
{{ block('widget_attributes') }}
{# DARK MODE: bg-gray-700, border-gray-600, text-white #}
class="form-textarea form-input mt-1 block w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
>{{ value }}</textarea>
{% endblock %}
{# ---------- SELECT ---------- #}
{% block choice_widget_collapsed %}
<select
{{ block('widget_attributes') }}
{# DARK MODE: bg-gray-700, border-gray-600, text-white #}
class="form-select form-input mt-1 block w-full px-3 py-2 bg-gray-700 border border-gray-600 text-white rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
>
{% if placeholder is not none %}
<option value="" {% if required and value is empty %}selected{% endif %}>
{{ placeholder != '' ? (placeholder|trans({}, translation_domain)) : '' }}
</option>
{% endif %}
{% for group_label, choice in choices %}
{% if choice is iterable %}
<optgroup label="{{ group_label|trans({}, translation_domain) }}">
{% for nested_choice in choice %}
<option value="{{ nested_choice.value }}" {% if nested_choice is selectedchoice(value) %}selected{% endif %}>
{{ nested_choice.label|trans({}, translation_domain) }}
</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ choice.value }}" {% if choice is selectedchoice(value) %}selected{% endif %}>
{{ choice.label|trans({}, translation_domain) }}
</option>
{% endif %}
{% endfor %}
</select>
{% endblock %}
{# ---------- CHECKBOX ---------- #}
{% block checkbox_widget %}
<div class="flex items-center">
<input type="checkbox"
{{ block('widget_attributes') }}
{% if value not in ['', null] %} value="{{ value }}"{% endif %}
{% if checked %}checked="checked"{% endif %}
{# DARK MODE: bg-gray-700, border-gray-600 #}
class="form-checkbox h-5 w-5 text-indigo-500 border-gray-600 bg-gray-700 rounded focus:ring-indigo-500">
</div>
{% endblock %}
{# ---------- RADIO ---------- #}
{% block radio_widget %}
<input type="radio"
{{ block('widget_attributes') }}
value="{{ value }}"
{% if checked %}checked="checked"{% endif %}
{# DARK MODE: bg-gray-700, border-gray-600 #}
class="form-radio h-5 w-5 text-indigo-500 border-gray-600 bg-gray-700 focus:ring-indigo-500">
{% endblock %}
{# ---------- FILE ---------- #}
{% block file_widget %}
<input type="file"
{{ block('widget_attributes') }}
{# DARK MODE: Outer bg-gray-700, border-gray-600, text-gray-300 #}
class="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-indigo-600 file:text-white
hover:file:bg-indigo-700
bg-gray-700 border border-gray-600 rounded-md shadow-sm">
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#f3f4f6" border-radius="12px" padding="20px">
<mj-column>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#10b981" font-size="28px" font-weight="bold" align="center" padding-bottom="20px">
✅ Paiement Confirmé
</mj-text>
</mj-column>
<mj-wrapper background-color="#ffffff" border-radius="8px" padding="20px" border="1px solid #e5e7eb" padding-bottom="20px">
<mj-section padding="0">
<mj-column width="100%">
<mj-text font-family="Helvetica, Arial, sans-serif" color="#374151" font-size="16px" line-height="1.6" padding-bottom="0">
Bonjour <strong>{{ datas.pseudo }}</strong>,
</mj-text>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#374151" font-size="16px" line-height="1.6" padding-top="0" padding-bottom="20px">
Nous vous confirmons la bonne réception de votre paiement de cotisation. Merci beaucoup pour votre soutien ! Votre adhésion est maintenant active.
</mj-text>
<mj-table padding="5px 0" width="100%" color="#374151" font-size="14px">
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="font-weight: bold; padding: 10px 0;">Période d'Adhésion :</td>
<td style="text-align: right; padding: 10px 0;">Du {{ datas.start_at|date('d/m/Y') }} au {{ datas.end_at|date('d/m/Y') }}</td>
</tr>
</mj-table>
<mj-table padding="5px 0" width="100%">
<tr>
<td style="font-weight: bold; padding: 10px 0; color: #374151; font-size: 14px;">Montant Payé :</td>
<td style="text-align: right; padding: 10px 0; color: #10b981; font-size: 18px; font-weight: bold;">{{ datas.amount|format_currency('EUR', locale='fr') }}</td>
</tr>
</mj-table>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-column>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#6b7280" font-size="12px" align="center" padding-top="15px">
Une question ? Contactez-nous.
</mj-text>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#6b7280" font-size="12px" align="center" padding-top="10px">
Cet e-mail est envoyé automatiquement.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends 'mails/base.twig' %}
{% block content %}
<!-- Section principale - Fond clair (Light Gray) -->
<mj-section background-color="#f3f4f6" border-radius="12px" padding="20px">
<!-- Colonne pour le Titre -->
<mj-column>
<!-- Titre -->
<mj-text font-family="Helvetica, Arial, sans-serif" color="#4f46e5" font-size="28px" font-weight="bold" align="center" padding-bottom="20px">
Votre Cotisation Annuelle
</mj-text>
</mj-column>
<!-- Carte d'information (fond blanc) - Correction: Remplacement de <mj-group> par <mj-wrapper> -->
<mj-wrapper background-color="#ffffff" border-radius="8px" padding="20px" css-class="card-container" border="1px solid #e5e7eb" padding-bottom="20px">
<mj-section padding="0">
<mj-column width="100%">
<!-- Texte principal : couleur sombre -->
<mj-text font-family="Helvetica, Arial, sans-serif" color="#374151" font-size="16px" line-height="1.6" padding-bottom="0">
Bonjour <strong>{{ datas.pseudo }}</strong>,
</mj-text>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#374151" font-size="16px" line-height="1.6" padding-top="0" padding-bottom="20px">
Nous vous remercions de votre engagement. Veuillez trouver ci-dessous les détails de votre cotisation pour la période à venir.
</mj-text>
<!-- Détails de la Cotisation : Période -->
<mj-table padding="5px 0" width="100%" color="#374151" font-size="14px">
<!-- Bordure très claire -->
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="font-weight: bold; padding: 10px 0;">Période :</td>
<td style="text-align: right; padding: 10px 0;">Du {{ datas.start_at|date('d/m/Y') }} au {{ datas.end_at|date('d/m/Y') }}</td>
</tr>
</mj-table>
<!-- Détails de la Cotisation : Montant -->
<mj-table padding="5px 0" width="100%">
<tr>
<td style="font-weight: bold; padding: 10px 0; color: #374151; font-size: 14px;">Montant total :</td>
<!-- Couleur d'accentuation verte conservée -->
<td style="text-align: right; padding: 10px 0; color: #10b981; font-size: 18px; font-weight: bold;">{{ datas.amount|format_currency('EUR', locale='fr') }}</td>
</tr>
</mj-table>
</mj-column>
</mj-section>
</mj-wrapper>
<!-- Colonne pour le Bouton et le bas de page -->
<mj-column>
<!-- Bouton de Paiement (couleur d'accentuation conservée) -->
<mj-button href="{{ datas.link }}" background-color="#4f46e5" color="#ffffff" font-size="16px" font-weight="bold" border-radius="8px" padding-top="25px" padding-bottom="25px">
Procéder au Paiement Sécurisé
</mj-button>
<!-- Note de bas de page : couleur de texte gris moyen -->
<mj-text font-family="Helvetica, Arial, sans-serif" color="#6b7280" font-size="12px" align="center" padding-top="15px">
Si le bouton ne fonctionne pas, veuillez copier-coller ce lien dans votre navigateur :<br>
<a href="{{ datas.link }}" style="color: #4f46e5; text-decoration: underline; word-break: break-all;">{{ datas.link }}</a>
</mj-text>
<mj-text font-family="Helvetica, Arial, sans-serif" color="#6b7280" font-size="12px" align="center" padding-top="10px">
Cet e-mail est envoyé automatiquement. Merci de ne pas y répondre.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -42,6 +42,7 @@ export default defineConfig({
rollupOptions: {
input: {
app: resolve(__dirname, 'assets/app.js'),
admin: resolve(__dirname, 'assets/admin.js'),
}
},
},