Initial commit - nettoyage et initialisation

This commit is contained in:
2026-05-19 22:01:18 +02:00
commit ee85d38c43
51 changed files with 2333 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Maven
**/target/
*.class
*.jar
*.war
*.ear
# IDEs
.idea/
*.iml
*.ipr
*.iws
.classpath
.project
.settings/
.nb-gradle/
# Python
venv/
.venv/
env/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
# OS/System
.DS_Store
Thumbs.db
# Environment/Local secrets
.env
.env.local
.env.*.local
+21
View File
@@ -0,0 +1,21 @@
# Build stage
FROM maven:3.9.6-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY as-talange-common/pom.xml as-talange-common/
COPY as-talange-core/pom.xml as-talange-core/
COPY as-talange-web/pom.xml as-talange-web/
# Copy source code
COPY as-talange-common/src as-talange-common/src
COPY as-talange-core/src as-talange-core/src
COPY as-talange-web/src as-talange-web/src
# Build the application
RUN mvn clean package -DskipTests
# Run stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/as-talange-web/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>as-talange-parent</artifactId>
<groupId>com.astalange</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>as-talange-common</artifactId>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>as-talange-parent</artifactId>
<groupId>com.astalange</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>as-talange-core</artifactId>
<dependencies>
<dependency>
<groupId>com.astalange</groupId>
<artifactId>as-talange-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<version>10.10.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.77</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,97 @@
package com.astalange.core.entity;
import com.astalange.core.validation.ValidRepresentantLegal;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "adherent")
@ValidRepresentantLegal
public class Adherent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String nom;
@Column(nullable = false, length = 100)
private String prenom;
@Column(name = "date_naissance", nullable = false)
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE)
private LocalDate dateNaissance;
@Column(length = 20)
private String telephone;
@Column(length = 100)
private String email;
@Column(columnDefinition = "TEXT")
private String adresse;
@Column(name = "representant_legal", length = 150)
private String representantLegal;
private boolean residentTalange = true;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public String getPrenom() { return prenom; }
public void setPrenom(String prenom) { this.prenom = prenom; }
public LocalDate getDateNaissance() { return dateNaissance; }
public void setDateNaissance(LocalDate dateNaissance) { this.dateNaissance = dateNaissance; }
public String getTelephone() { return telephone; }
public void setTelephone(String telephone) { this.telephone = telephone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getAdresse() { return adresse; }
public void setAdresse(String adresse) { this.adresse = adresse; }
public String getRepresentantLegal() { return representantLegal; }
public void setRepresentantLegal(String representantLegal) { this.representantLegal = representantLegal; }
public boolean isResidentTalange() { return residentTalange; }
public void setResidentTalange(boolean residentTalange) { this.residentTalange = residentTalange; }
@OneToMany(mappedBy = "adherent", cascade = CascadeType.ALL)
private java.util.List<Licence> licences = new java.util.ArrayList<>();
public java.util.List<Licence> getLicences() { return licences; }
public void setLicences(java.util.List<Licence> licences) { this.licences = licences; }
@Transient
public Licence getLicenceActuelle() {
if (licences == null || licences.isEmpty()) {
return null;
}
// Assuming the last added license is the current one (highest ID or latest)
return licences.get(licences.size() - 1);
}
@Transient
public String getStatutPaiement() {
Licence current = getLicenceActuelle();
if (current == null) {
return "Aucune licence";
}
java.math.BigDecimal reste = current.getResteAPayer();
if (reste.compareTo(java.math.BigDecimal.ZERO) == 0) {
return "À jour";
} else {
return "Reste : " + reste + "";
}
}
}
@@ -0,0 +1,36 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "app_user")
@Getter
@Setter
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled = true;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}
@@ -0,0 +1,48 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "categorie")
public class Categorie {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String nom;
@Column(name = "annee_min")
private Integer anneeMin;
@Column(name = "annee_max")
private Integer anneeMax;
@Column(name = "tarif_base", nullable = false, precision = 10, scale = 2)
private BigDecimal tarifBase;
@Column(name = "tarif_exterieur", nullable = false, precision = 10, scale = 2)
private BigDecimal tarifExterieur;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public Integer getAnneeMin() { return anneeMin; }
public void setAnneeMin(Integer anneeMin) { this.anneeMin = anneeMin; }
public Integer getAnneeMax() { return anneeMax; }
public void setAnneeMax(Integer anneeMax) { this.anneeMax = anneeMax; }
public BigDecimal getTarifBase() { return tarifBase; }
public void setTarifBase(BigDecimal tarifBase) { this.tarifBase = tarifBase; }
public BigDecimal getTarifExterieur() { return tarifExterieur; }
public void setTarifExterieur(BigDecimal tarifExterieur) { this.tarifExterieur = tarifExterieur; }
}
@@ -0,0 +1,49 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "dotation")
public class Dotation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "licence_id", nullable = false)
private Licence licence;
@ManyToOne(optional = false)
@JoinColumn(name = "equipement_id", nullable = false)
private Equipement equipement;
@Column(length = 10)
private String taille;
@Column(length = 50)
private String flocage;
@Column(nullable = false)
private Boolean fourni = false;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Licence getLicence() { return licence; }
public void setLicence(Licence licence) { this.licence = licence; }
public Equipement getEquipement() { return equipement; }
public void setEquipement(Equipement equipement) { this.equipement = equipement; }
public String getTaille() { return taille; }
public void setTaille(String taille) { this.taille = taille; }
public String getFlocage() { return flocage; }
public void setFlocage(String flocage) { this.flocage = flocage; }
public Boolean getFourni() { return fourni; }
public void setFourni(Boolean fourni) { this.fourni = fourni; }
}
@@ -0,0 +1,35 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "equipement")
public class Equipement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String nom;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private Boolean obligatoire = false;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Boolean getObligatoire() { return obligatoire; }
public void setObligatoire(Boolean obligatoire) { this.obligatoire = obligatoire; }
}
@@ -0,0 +1,91 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "licence")
public class Licence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "adherent_id", nullable = false)
private Adherent adherent;
@ManyToOne(optional = false)
@JoinColumn(name = "categorie_id", nullable = false)
private Categorie categorie;
@Column(nullable = false, length = 20)
private String saison;
@Column(nullable = false, length = 50)
private String etat;
@Column(name = "numero_licence", length = 50)
private String numeroLicence;
@OneToMany(mappedBy = "licence", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Paiement> paiements = new ArrayList<>();
// Logic for Reste A Payer
@Transient
public BigDecimal getResteAPayer() {
if (categorie == null) return BigDecimal.ZERO;
BigDecimal prixTotal = (adherent != null && adherent.isResidentTalange())
? categorie.getTarifBase()
: categorie.getTarifExterieur();
if (prixTotal == null) prixTotal = BigDecimal.ZERO;
if (paiements == null || paiements.isEmpty()) {
return prixTotal;
}
BigDecimal totalPaye = paiements.stream()
.map(Paiement::getMontant)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal reste = prixTotal.subtract(totalPaye);
return reste.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : reste;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Adherent getAdherent() { return adherent; }
public void setAdherent(Adherent adherent) { this.adherent = adherent; }
public Categorie getCategorie() { return categorie; }
public void setCategorie(Categorie categorie) { this.categorie = categorie; }
public String getSaison() { return saison; }
public void setSaison(String saison) { this.saison = saison; }
public String getEtat() { return etat; }
public void setEtat(String etat) { this.etat = etat; }
public List<Paiement> getPaiements() { return paiements; }
public void setPaiements(List<Paiement> paiements) { this.paiements = paiements; }
public String getNumeroLicence() { return numeroLicence; }
public void setNumeroLicence(String numeroLicence) { this.numeroLicence = numeroLicence; }
public void addPaiement(Paiement paiement) {
paiements.add(paiement);
paiement.setLicence(this);
}
public void removePaiement(Paiement paiement) {
paiements.remove(paiement);
paiement.setLicence(null);
}
}
@@ -0,0 +1,23 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "mode_paiement")
public class ModePaiement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String nom;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getNom() { return nom; }
public void setNom(String nom) { this.nom = nom; }
}
@@ -0,0 +1,45 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "paiement")
public class Paiement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "licence_id", nullable = false)
private Licence licence;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal montant;
@Column(name = "date_paiement", nullable = false)
private LocalDate datePaiement;
@ManyToOne(optional = false)
@JoinColumn(name = "mode_paiement_id", nullable = false)
private ModePaiement modePaiement;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Licence getLicence() { return licence; }
public void setLicence(Licence licence) { this.licence = licence; }
public BigDecimal getMontant() { return montant; }
public void setMontant(BigDecimal montant) { this.montant = montant; }
public LocalDate getDatePaiement() { return datePaiement; }
public void setDatePaiement(LocalDate datePaiement) { this.datePaiement = datePaiement; }
public ModePaiement getModePaiement() { return modePaiement; }
public void setModePaiement(ModePaiement modePaiement) { this.modePaiement = modePaiement; }
}
@@ -0,0 +1,19 @@
package com.astalange.core.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "role")
@Getter
@Setter
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
}
@@ -0,0 +1,12 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Adherent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AdherentRepository extends JpaRepository<Adherent, Long> {
List<Adherent> findTop5ByOrderByIdDesc();
}
@@ -0,0 +1,9 @@
package com.astalange.core.repository;
import com.astalange.core.entity.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
}
@@ -0,0 +1,9 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Categorie;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CategorieRepository extends JpaRepository<Categorie, Long> {
}
@@ -0,0 +1,9 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Dotation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DotationRepository extends JpaRepository<Dotation, Long> {
}
@@ -0,0 +1,9 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Equipement;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface EquipementRepository extends JpaRepository<Equipement, Long> {
}
@@ -0,0 +1,19 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Licence;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
@Repository
public interface LicenceRepository extends JpaRepository<Licence, Long> {
List<Licence> findByAdherentId(Long adherentId);
long countByEtat(String etat);
@Query("SELECT SUM(c.tarifBase) FROM Licence l JOIN l.categorie c")
BigDecimal sumTarifBase();
}
@@ -0,0 +1,12 @@
package com.astalange.core.repository;
import com.astalange.core.entity.ModePaiement;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ModePaiementRepository extends JpaRepository<ModePaiement, Long> {
Optional<ModePaiement> findByNom(String nom);
}
@@ -0,0 +1,14 @@
package com.astalange.core.repository;
import com.astalange.core.entity.Paiement;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
@Repository
public interface PaiementRepository extends JpaRepository<Paiement, Long> {
@Query("SELECT SUM(p.montant) FROM Paiement p")
BigDecimal sumMontant();
}
@@ -0,0 +1,38 @@
package com.astalange.core.security;
import com.astalange.core.entity.AppUser;
import com.astalange.core.repository.AppUserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final AppUserRepository userRepository;
public UserDetailsServiceImpl(AppUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser appUser = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new User(
appUser.getUsername(),
appUser.getPassword(),
appUser.isEnabled(),
true, true, true,
appUser.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList())
);
}
}
@@ -0,0 +1,25 @@
package com.astalange.core.validation;
import com.astalange.core.entity.Adherent;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.time.LocalDate;
import java.time.Period;
public class RepresentantLegalValidator implements ConstraintValidator<ValidRepresentantLegal, Adherent> {
@Override
public boolean isValid(Adherent adherent, ConstraintValidatorContext context) {
if (adherent == null || adherent.getDateNaissance() == null) {
return true; // Let @NotNull handle the null cases
}
int age = Period.between(adherent.getDateNaissance(), LocalDate.now()).getYears();
if (age < 18) {
return adherent.getRepresentantLegal() != null && !adherent.getRepresentantLegal().trim().isEmpty();
}
return true;
}
}
@@ -0,0 +1,15 @@
package com.astalange.core.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = RepresentantLegalValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidRepresentantLegal {
String message() default "Le représentant légal est obligatoire pour les adhérents mineurs.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,75 @@
CREATE TABLE role (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE app_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES app_user(id) ON DELETE CASCADE,
CONSTRAINT fk_role FOREIGN KEY (role_id) REFERENCES role(id) ON DELETE CASCADE
);
CREATE TABLE adherent (
id BIGSERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
prenom VARCHAR(100) NOT NULL,
date_naissance DATE NOT NULL,
telephone VARCHAR(20),
email VARCHAR(100),
adresse TEXT,
representant_legal VARCHAR(150)
);
CREATE TABLE categorie (
id BIGSERIAL PRIMARY KEY,
nom VARCHAR(20) UNIQUE NOT NULL,
annee_min INT,
annee_max INT,
tarif_base DECIMAL(10,2) NOT NULL
);
CREATE TABLE licence (
id BIGSERIAL PRIMARY KEY,
adherent_id BIGINT NOT NULL,
categorie_id BIGINT NOT NULL,
saison VARCHAR(20) NOT NULL,
etat VARCHAR(50) NOT NULL,
CONSTRAINT fk_licence_adherent FOREIGN KEY (adherent_id) REFERENCES adherent(id) ON DELETE CASCADE,
CONSTRAINT fk_licence_categorie FOREIGN KEY (categorie_id) REFERENCES categorie(id)
);
CREATE TABLE paiement (
id BIGSERIAL PRIMARY KEY,
licence_id BIGINT NOT NULL,
montant DECIMAL(10,2) NOT NULL,
date_paiement DATE NOT NULL,
mode_paiement VARCHAR(50) NOT NULL,
CONSTRAINT fk_paiement_licence FOREIGN KEY (licence_id) REFERENCES licence(id) ON DELETE CASCADE
);
CREATE TABLE equipement (
id BIGSERIAL PRIMARY KEY,
nom VARCHAR(100) NOT NULL,
description TEXT,
obligatoire BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE dotation (
id BIGSERIAL PRIMARY KEY,
licence_id BIGINT NOT NULL,
equipement_id BIGINT NOT NULL,
taille VARCHAR(10),
flocage VARCHAR(50),
fourni BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_dotation_licence FOREIGN KEY (licence_id) REFERENCES licence(id) ON DELETE CASCADE,
CONSTRAINT fk_dotation_equipement FOREIGN KEY (equipement_id) REFERENCES equipement(id)
);
@@ -0,0 +1,12 @@
CREATE TABLE mode_paiement (
id BIGSERIAL PRIMARY KEY,
nom VARCHAR(50) UNIQUE NOT NULL
);
INSERT INTO mode_paiement (nom) VALUES ('Espèces'), ('Chèque'), ('Pass Sport'), ('Virement');
ALTER TABLE paiement ADD COLUMN mode_paiement_id BIGINT;
ALTER TABLE paiement ADD CONSTRAINT fk_paiement_mode FOREIGN KEY (mode_paiement_id) REFERENCES mode_paiement(id);
ALTER TABLE paiement DROP COLUMN mode_paiement;
@@ -0,0 +1,23 @@
INSERT INTO role (name) VALUES ('ROLE_ADMIN'), ('ROLE_USER');
-- Password is "Talange2026!" hashed with Argon2id
INSERT INTO app_user (username, password, enabled) VALUES ('Ucef', '$argon2id$v=19$m=65536,t=3,p=4$q29E/VM1esZVxo9HufgILQ$Nzb+rwwsZUMy04BbewYum2A8L8ujs9VZj9VJ/609Am0', true);
INSERT INTO user_role (user_id, role_id) VALUES ((SELECT id FROM app_user WHERE username = 'Ucef'), (SELECT id FROM role WHERE name = 'ROLE_ADMIN'));
INSERT INTO categorie (nom, annee_min, annee_max, tarif_base) VALUES
('U6', 2020, 2020, 100.00),
('U7', 2019, 2019, 100.00),
('U8', 2018, 2018, 120.00),
('U9', 2017, 2017, 120.00),
('U10', 2016, 2016, 130.00),
('U11', 2015, 2015, 130.00),
('U12', 2014, 2014, 140.00),
('U13', 2013, 2013, 140.00),
('Senior', 1900, 2005, 180.00);
INSERT INTO equipement (nom, obligatoire) VALUES
('Maillot', true),
('Short', true),
('Chaussettes', true),
('Survêtement', false);
@@ -0,0 +1,8 @@
-- Ajouter la colonne pour savoir si l'adhérent est résident de Talange (par défaut oui pour les existants)
ALTER TABLE adherent ADD COLUMN resident_talange BOOLEAN DEFAULT TRUE;
-- Ajouter le tarif extérieur aux catégories
ALTER TABLE categorie ADD COLUMN tarif_exterieur DECIMAL(10,2);
-- Par défaut, mettre le tarif extérieur égal au tarif de base pour ne pas casser les licences existantes
UPDATE categorie SET tarif_exterieur = tarif_base;
@@ -0,0 +1 @@
ALTER TABLE licence ADD COLUMN numero_licence VARCHAR(50);
@@ -0,0 +1,11 @@
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
public class HashGen {
public static void main(String[] args) {
Argon2PasswordEncoder encoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("Talange2026!");
System.out.println("HASH_RESULT_START");
System.out.println(result);
System.out.println("HASH_RESULT_END");
}
}
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>as-talange-parent</artifactId>
<groupId>com.astalange</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>as-talange-web</artifactId>
<dependencies>
<dependency>
<groupId>com.astalange</groupId>
<artifactId>as-talange-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.astalange.web.AsTalangeApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package com.astalange.web;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "com.astalange")
@EntityScan(basePackages = "com.astalange.core.entity")
@EnableJpaRepositories(basePackages = "com.astalange.core.repository")
public class AsTalangeApplication {
public static void main(String[] args) {
SpringApplication.run(AsTalangeApplication.class, args);
}
}
@@ -0,0 +1,55 @@
package com.astalange.web.config;
import com.astalange.core.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;
public SecurityConfig(UserDetailsServiceImpl userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}
@@ -0,0 +1,74 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Adherent;
import com.astalange.core.repository.AdherentRepository;
import com.astalange.core.repository.CategorieRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/adherents")
public class AdherentController {
private final AdherentRepository adherentRepository;
private final CategorieRepository categorieRepository;
private final com.astalange.core.repository.LicenceRepository licenceRepository;
private final com.astalange.core.repository.ModePaiementRepository modePaiementRepository;
public AdherentController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, com.astalange.core.repository.LicenceRepository licenceRepository, com.astalange.core.repository.ModePaiementRepository modePaiementRepository) {
this.adherentRepository = adherentRepository;
this.categorieRepository = categorieRepository;
this.licenceRepository = licenceRepository;
this.modePaiementRepository = modePaiementRepository;
}
@GetMapping
public String listAdherents(Model model) {
List<Adherent> adherents = adherentRepository.findAll();
model.addAttribute("adherents", adherents);
return "adherents/list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("adherent", new Adherent());
return "adherents/form";
}
@org.springframework.web.bind.annotation.PostMapping
public String saveAdherent(
@jakarta.validation.Valid @org.springframework.web.bind.annotation.ModelAttribute("adherent") Adherent adherent,
org.springframework.validation.BindingResult result,
Model model) {
if (result.hasErrors()) {
return "adherents/form";
}
adherentRepository.save(adherent);
return "redirect:/adherents";
}
@GetMapping("/{id}/edit")
public String editAdherent(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) {
Adherent adherent = adherentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Adhérent invalide : " + id));
model.addAttribute("adherent", adherent);
model.addAttribute("categories", categorieRepository.findAll());
model.addAttribute("licences", licenceRepository.findByAdherentId(id));
model.addAttribute("modesPaiement", modePaiementRepository.findAll());
return "adherents/form";
}
@org.springframework.web.bind.annotation.PostMapping("/{id}/delete")
public String deleteAdherent(@org.springframework.web.bind.annotation.PathVariable Long id) {
Adherent adherent = adherentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Adhérent invalide : " + id));
adherentRepository.delete(adherent);
return "redirect:/adherents";
}
}
@@ -0,0 +1,52 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Categorie;
import com.astalange.core.repository.CategorieRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/categories")
public class CategorieController {
private final CategorieRepository categorieRepository;
public CategorieController(CategorieRepository categorieRepository) {
this.categorieRepository = categorieRepository;
}
@GetMapping
public String listCategories(Model model) {
model.addAttribute("categories", categorieRepository.findAll());
return "parametrage/categories_list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("categorie", new Categorie());
return "parametrage/categories_form";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
Categorie categorie = categorieRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid category ID: " + id));
model.addAttribute("categorie", categorie);
return "parametrage/categories_form";
}
@PostMapping
public String saveCategorie(@ModelAttribute("categorie") Categorie categorie) {
categorieRepository.save(categorie);
return "redirect:/categories";
}
@PostMapping("/{id}/delete")
public String deleteCategorie(@PathVariable Long id) {
Categorie categorie = categorieRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid category ID: " + id));
categorieRepository.delete(categorie);
return "redirect:/categories";
}
}
@@ -0,0 +1,71 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Adherent;
import com.astalange.core.repository.AdherentRepository;
import com.astalange.core.repository.LicenceRepository;
import com.astalange.core.repository.PaiementRepository;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.math.BigDecimal;
import java.util.List;
@Controller
public class HomeController {
private final AdherentRepository adherentRepository;
private final LicenceRepository licenceRepository;
private final PaiementRepository paiementRepository;
public HomeController(AdherentRepository adherentRepository, LicenceRepository licenceRepository, PaiementRepository paiementRepository) {
this.adherentRepository = adherentRepository;
this.licenceRepository = licenceRepository;
this.paiementRepository = paiementRepository;
}
@GetMapping("/")
public String home(Model model, Authentication authentication) {
if (authentication != null) {
model.addAttribute("username", authentication.getName());
}
long totalAdherents = adherentRepository.count();
long licencesBrouillon = licenceRepository.countByEtat("Brouillon");
long licencesValidees = licenceRepository.countByEtat("Validée");
long licencesPayees = licenceRepository.countByEtat("Payée");
BigDecimal totalEncaisse = paiementRepository.sumMontant();
if (totalEncaisse == null) totalEncaisse = BigDecimal.ZERO;
List<com.astalange.core.entity.Licence> allLicences = licenceRepository.findAll();
BigDecimal totalAttendu = allLicences.stream()
.map(lic -> {
if (lic.getCategorie() == null) return BigDecimal.ZERO;
return (lic.getAdherent() != null && lic.getAdherent().isResidentTalange())
? lic.getCategorie().getTarifBase()
: lic.getCategorie().getTarifExterieur();
})
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal resteARecouvrer = totalAttendu.subtract(totalEncaisse);
if (resteARecouvrer.compareTo(BigDecimal.ZERO) < 0) resteARecouvrer = BigDecimal.ZERO;
List<Adherent> derniersInscrits = adherentRepository.findTop5ByOrderByIdDesc();
model.addAttribute("totalAdherents", totalAdherents);
model.addAttribute("licencesBrouillon", licencesBrouillon);
model.addAttribute("licencesActives", licencesValidees + licencesPayees);
model.addAttribute("totalEncaisse", totalEncaisse);
model.addAttribute("resteARecouvrer", resteARecouvrer);
model.addAttribute("derniersInscrits", derniersInscrits);
return "index";
}
@GetMapping("/login")
public String login() {
return "login";
}
}
@@ -0,0 +1,49 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Adherent;
import com.astalange.core.entity.Categorie;
import com.astalange.core.entity.Licence;
import com.astalange.core.repository.AdherentRepository;
import com.astalange.core.repository.CategorieRepository;
import com.astalange.core.repository.LicenceRepository;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class LicenceController {
private final AdherentRepository adherentRepository;
private final CategorieRepository categorieRepository;
private final LicenceRepository licenceRepository;
public LicenceController(AdherentRepository adherentRepository, CategorieRepository categorieRepository, LicenceRepository licenceRepository) {
this.adherentRepository = adherentRepository;
this.categorieRepository = categorieRepository;
this.licenceRepository = licenceRepository;
}
@PostMapping("/adherents/{id}/licences")
public String addLicence(
@PathVariable Long id,
@RequestParam Long categorieId,
@RequestParam String saison,
@RequestParam String etat) {
Adherent adherent = adherentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid Adherent ID"));
Categorie categorie = categorieRepository.findById(categorieId)
.orElseThrow(() -> new IllegalArgumentException("Invalid Categorie ID"));
Licence licence = new Licence();
licence.setAdherent(adherent);
licence.setCategorie(categorie);
licence.setSaison(saison);
licence.setEtat(etat);
licenceRepository.save(licence);
return "redirect:/adherents/" + id + "/edit";
}
}
@@ -0,0 +1,52 @@
package com.astalange.web.controller;
import com.astalange.core.entity.ModePaiement;
import com.astalange.core.repository.ModePaiementRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/modespaiement")
public class ModePaiementController {
private final ModePaiementRepository modePaiementRepository;
public ModePaiementController(ModePaiementRepository modePaiementRepository) {
this.modePaiementRepository = modePaiementRepository;
}
@GetMapping
public String listModes(Model model) {
model.addAttribute("modes", modePaiementRepository.findAll());
return "parametrage/modespaiement_list";
}
@GetMapping("/new")
public String showCreateForm(Model model) {
model.addAttribute("modePaiement", new ModePaiement());
return "parametrage/modespaiement_form";
}
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model) {
ModePaiement mode = modePaiementRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid ID: " + id));
model.addAttribute("modePaiement", mode);
return "parametrage/modespaiement_form";
}
@PostMapping
public String saveMode(@ModelAttribute("modePaiement") ModePaiement modePaiement) {
modePaiementRepository.save(modePaiement);
return "redirect:/modespaiement";
}
@PostMapping("/{id}/delete")
public String deleteMode(@PathVariable Long id) {
ModePaiement mode = modePaiementRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid ID: " + id));
modePaiementRepository.delete(mode);
return "redirect:/modespaiement";
}
}
@@ -0,0 +1,60 @@
package com.astalange.web.controller;
import com.astalange.core.entity.Licence;
import com.astalange.core.entity.ModePaiement;
import com.astalange.core.entity.Paiement;
import com.astalange.core.repository.LicenceRepository;
import com.astalange.core.repository.ModePaiementRepository;
import com.astalange.core.repository.PaiementRepository;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
@Controller
public class PaiementController {
private final LicenceRepository licenceRepository;
private final ModePaiementRepository modePaiementRepository;
private final PaiementRepository paiementRepository;
public PaiementController(LicenceRepository licenceRepository, ModePaiementRepository modePaiementRepository, PaiementRepository paiementRepository) {
this.licenceRepository = licenceRepository;
this.modePaiementRepository = modePaiementRepository;
this.paiementRepository = paiementRepository;
}
@PostMapping("/licences/{id}/paiements")
public String addPaiement(
@PathVariable Long id,
@RequestParam Long adherentId,
@RequestParam BigDecimal montant,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate datePaiement,
@RequestParam Long modePaiementId) {
Licence licence = licenceRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid Licence ID"));
ModePaiement mode = modePaiementRepository.findById(modePaiementId)
.orElseThrow(() -> new IllegalArgumentException("Invalid ModePaiement ID"));
// Validation basique pour ne pas payer plus que le reste à payer
if (montant.compareTo(licence.getResteAPayer()) > 0) {
montant = licence.getResteAPayer();
}
if (montant.compareTo(BigDecimal.ZERO) > 0) {
Paiement paiement = new Paiement();
paiement.setLicence(licence);
paiement.setModePaiement(mode);
paiement.setMontant(montant);
paiement.setDatePaiement(datePaiement);
paiementRepository.save(paiement);
}
return "redirect:/adherents/" + adherentId + "/edit";
}
}
@@ -0,0 +1,21 @@
spring:
application:
name: as-talange
datasource:
url: jdbc:postgresql://localhost:5433/astalange
username: myuser
password: mypassword
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: true
properties:
hibernate:
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
server:
port: 8080
@@ -0,0 +1,328 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Nouvel Adhérent - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.hidden-block { display: none; }
</style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Adhérents</a>
<a href="#" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Paiements</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Modes de Paiement</a>
</nav>
<div class="p-4 border-t border-gray-200">
<div class="text-sm font-medium text-gray-900 mb-1" th:text="${username != null ? username : 'Admin'}">Admin</div>
<form th:action="@{/logout}" method="post">
<button type="submit" class="text-sm text-red-600 hover:text-red-700 font-medium">Déconnexion</button>
</form>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<!-- Header -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<h2 class="text-lg font-semibold text-gray-800" th:text="${adherent.id == null ? 'Ajouter un adhérent' : 'Modifier l''adhérent'}">Ajouter un adhérent</h2>
</header>
<!-- Main section -->
<div class="flex-1 overflow-auto p-6">
<div class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<form th:action="@{/adherents}" th:object="${adherent}" method="post" class="space-y-6">
<input type="hidden" th:field="*{id}" />
<div th:if="${#fields.hasErrors('global')}" class="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm mb-6">
<p th:each="err : ${#fields.errors('global')}" th:text="${err}"></p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Nom -->
<div>
<label for="nom" class="block text-sm font-medium text-gray-700 mb-1">Nom <span class="text-red-500">*</span></label>
<input type="text" id="nom" th:field="*{nom}" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('nom')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('nom')}" th:errors="*{nom}" class="mt-1 text-xs text-red-600"></p>
</div>
<!-- Prénom -->
<div>
<label for="prenom" class="block text-sm font-medium text-gray-700 mb-1">Prénom <span class="text-red-500">*</span></label>
<input type="text" id="prenom" th:field="*{prenom}" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('prenom')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('prenom')}" th:errors="*{prenom}" class="mt-1 text-xs text-red-600"></p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Date de Naissance -->
<div>
<label for="dateNaissance" class="block text-sm font-medium text-gray-700 mb-1">Date de naissance <span class="text-red-500">*</span></label>
<input type="date" id="dateNaissance" th:field="*{dateNaissance}" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('dateNaissance')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('dateNaissance')}" th:errors="*{dateNaissance}" class="mt-1 text-xs text-red-600"></p>
</div>
<!-- Téléphone -->
<div>
<label for="telephone" class="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
<input type="tel" id="telephone" th:field="*{telephone}"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('telephone')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('telephone')}" th:errors="*{telephone}" class="mt-1 text-xs text-red-600"></p>
</div>
</div>
<!-- Représentant Légal (Dynamique) -->
<div id="representantBlock" class="hidden-block bg-blue-50 p-4 rounded-lg border border-blue-100">
<label for="representantLegal" class="block text-sm font-medium text-blue-800 mb-1">
Représentant légal <span class="text-red-500">*</span>
</label>
<p class="text-xs text-blue-600 mb-2">Obligatoire car l'adhérent a moins de 18 ans.</p>
<input type="text" id="representantLegal" th:field="*{representantLegal}"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('representantLegal')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('representantLegal')}" th:errors="*{representantLegal}" class="mt-1 text-xs text-red-600"></p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="email" th:field="*{email}"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('email')} ? 'border-red-500' : ''">
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="mt-1 text-xs text-red-600"></p>
</div>
<!-- Résident Talange -->
<div class="flex items-center space-x-3 mt-7">
<input type="checkbox" id="residentTalange" th:field="*{residentTalange}" class="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<label for="residentTalange" class="text-sm font-medium text-gray-700">
Résident de la commune de Talange
</label>
</div>
</div>
<!-- Adresse -->
<div>
<label for="adresse" class="block text-sm font-medium text-gray-700 mb-1">Adresse postale</label>
<textarea id="adresse" th:field="*{adresse}" rows="3"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
th:classappend="${#fields.hasErrors('adresse')} ? 'border-red-500' : ''"></textarea>
<p th:if="${#fields.hasErrors('adresse')}" th:errors="*{adresse}" class="mt-1 text-xs text-red-600"></p>
</div>
<div class="pt-4 flex justify-end space-x-3 border-t border-gray-100">
<a th:href="@{/adherents}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">Annuler</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">Enregistrer</button>
</div>
</form>
</div>
<!-- Licences Section (Only visible for existing adherents) -->
<div th:if="${adherent.id != null}" class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8 mt-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-900">Licences de l'adhérent</h3>
<button onclick="openLicenceModal()" class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 transition-colors">
+ Ajouter une Licence
</button>
</div>
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-4 font-medium">Saison</th>
<th class="py-3 px-4 font-medium">Catégorie</th>
<th class="py-3 px-4 font-medium">N° Licence</th>
<th class="py-3 px-4 font-medium">État</th>
<th class="py-3 px-4 font-medium">Reste à Payer</th>
<th class="py-3 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(licences)}">
<td colspan="6" class="py-4 px-4 text-center text-gray-500">Aucune licence enregistrée.</td>
</tr>
<th:block th:each="lic : ${licences}">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-4 px-4 font-medium text-gray-900" th:text="${lic.saison}">2024-2025</td>
<td class="py-4 px-4 text-gray-600" th:text="${lic.categorie.nom}">U15</td>
<td class="py-4 px-4 text-gray-600 font-mono text-xs" th:text="${lic.numeroLicence != null and !lic.numeroLicence.isEmpty() ? lic.numeroLicence : '-'}">-</td>
<td class="py-4 px-4">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
th:classappend="${lic.etat == 'Validée' ? 'bg-blue-100 text-blue-800' : (lic.etat == 'Payée' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800')}"
th:text="${lic.etat}">Brouillon</span>
</td>
<td class="py-4 px-4 text-gray-600 font-medium" th:text="${lic.getResteAPayer() + ' €'}">100.00 €</td>
<td class="py-4 px-4 text-right">
<button th:if="${lic.getResteAPayer() > 0}" th:onclick="'openPaiementModal(' + ${lic.id} + ', ' + ${lic.getResteAPayer()} + ')'"
class="text-green-600 hover:text-green-800 font-medium bg-green-50 px-3 py-1 rounded-lg">Payer</button>
</td>
</tr>
<tr th:if="${!#lists.isEmpty(lic.paiements)}" class="bg-gray-50/50">
<td colspan="5" class="py-2 px-4">
<div class="text-xs text-gray-500 mb-1 font-medium uppercase tracking-wider">Historique des paiements</div>
<div class="flex flex-wrap gap-2">
<span th:each="paiement : ${lic.paiements}" class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-white border border-gray-200 text-gray-700">
<span th:text="${paiement.montant + ' €'}">50 €</span>
<span class="mx-1 text-gray-300">|</span>
<span th:text="${paiement.modePaiement.nom}">Chèque</span>
<span class="mx-1 text-gray-300">|</span>
<span th:text="${#temporals.format(paiement.datePaiement, 'dd/MM/yyyy')}">01/01/2026</span>
</span>
</div>
</td>
</tr>
</th:block>
</tbody>
</table>
</div>
</div>
</main>
<!-- Licence Modal -->
<div id="licenceModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-xl bg-white">
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Nouvelle Licence</h3>
<form th:if="${adherent.id != null}" th:action="@{/adherents/{id}/licences(id=${adherent.id})}" method="post" class="space-y-4 text-left">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Catégorie</label>
<select name="categorieId" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez une catégorie...</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.nom} + ' (' + ${cat.tarifBase} + ' €)'"></option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Numéro de licence</label>
<input type="text" name="numeroLicence" placeholder="Ex: FFF-123456" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Saison</label>
<input type="text" name="saison" required value="2024-2025" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">État</label>
<select name="etat" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="Brouillon">Brouillon</option>
<option value="Validée">Validée</option>
<option value="Payée">Payée</option>
</select>
</div>
<div class="items-center px-4 py-3 flex justify-end space-x-2">
<button type="button" onclick="closeLicenceModal()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">Annuler</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">Enregistrer</button>
</div>
</form>
</div>
</div>
</div>
<!-- Paiement Modal -->
<div id="paiementModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-xl bg-white">
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Enregistrer un Paiement</h3>
<form id="paiementForm" method="post" class="space-y-4 text-left">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<input type="hidden" name="adherentId" th:value="${adherent.id}">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Montant (€)</label>
<input type="number" step="0.01" id="paiementMontant" name="montant" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input type="date" id="paiementDate" name="datePaiement" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Mode de paiement</label>
<select name="modePaiementId" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">Sélectionnez un mode...</option>
<option th:each="mode : ${modesPaiement}" th:value="${mode.id}" th:text="${mode.nom}"></option>
</select>
</div>
<div class="items-center px-4 py-3 flex justify-end space-x-2">
<button type="button" onclick="closePaiementModal()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">Annuler</button>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">Payer</button>
</div>
</form>
</div>
</div>
</div>
<script>
function openLicenceModal() {
document.getElementById('licenceModal').classList.remove('hidden');
}
function closeLicenceModal() {
document.getElementById('licenceModal').classList.add('hidden');
}
function openPaiementModal(licenceId, maxAmount) {
document.getElementById('paiementModal').classList.remove('hidden');
document.getElementById('paiementForm').action = '/licences/' + licenceId + '/paiements';
document.getElementById('paiementMontant').value = maxAmount;
document.getElementById('paiementMontant').max = maxAmount;
document.getElementById('paiementDate').valueAsDate = new Date();
}
function closePaiementModal() {
document.getElementById('paiementModal').classList.add('hidden');
}
document.addEventListener("DOMContentLoaded", function() {
const dateInput = document.getElementById('dateNaissance');
const repBlock = document.getElementById('representantBlock');
function calculateAge(birthday) {
const ageDifMs = Date.now() - birthday.getTime();
const ageDate = new Date(ageDifMs);
return Math.abs(ageDate.getUTCFullYear() - 1970);
}
function updateRepresentantBlock() {
if (dateInput.value) {
const dob = new Date(dateInput.value);
const age = calculateAge(dob);
if (age < 18) {
repBlock.classList.remove('hidden-block');
} else {
repBlock.classList.add('hidden-block');
}
} else {
repBlock.classList.add('hidden-block');
}
}
dateInput.addEventListener('change', updateRepresentantBlock);
// Initial check in case it's a validation error reload
updateRepresentantBlock();
// But if there's already an error on representantLegal, ensure it is shown
const hasRepError = document.querySelector('p[th\\:errors="*{representantLegal}"]');
if (hasRepError || document.getElementById('representantLegal').classList.contains('border-red-500')) {
repBlock.classList.remove('hidden-block');
}
});
</script>
</body>
</html>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Adhérents - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Adhérents</a>
<a href="#" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Paiements</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Modes de Paiement</a>
</nav>
<div class="p-4 border-t border-gray-200">
<div class="text-sm font-medium text-gray-900 mb-1" th:text="${username != null ? username : 'Admin'}">Admin</div>
<form th:action="@{/logout}" method="post">
<button type="submit" class="text-sm text-red-600 hover:text-red-700 font-medium">Déconnexion</button>
</form>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<!-- Header -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<h2 class="text-lg font-semibold text-gray-800">Gestion des Adhérents</h2>
<button class="md:hidden text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
</header>
<!-- Main section -->
<div class="flex-1 overflow-auto p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-900">Liste des Adhérents</h3>
<a th:href="@{/adherents/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors inline-block">
+ Nouvel Adhérent
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<input type="text" placeholder="Rechercher un adhérent..." class="border border-gray-300 rounded-lg px-4 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-6 font-medium text-left">Nom</th>
<th class="py-3 px-6 font-medium text-left">Prénom</th>
<th class="py-3 px-6 font-medium text-center">N° Licence</th>
<th class="py-3 px-6 font-medium text-left">Email</th>
<th class="py-3 px-6 font-medium text-left">Téléphone</th>
<th class="py-3 px-6 font-medium text-center">Paiement</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(adherents)}">
<td colspan="7" class="py-8 text-center text-gray-500">Aucun adhérent enregistré.</td>
</tr>
<tr th:each="adherent : ${adherents}" class="hover:bg-gray-50 transition-colors">
<td class="py-4 px-6 font-medium text-gray-900" th:text="${adherent.nom}">Dupont</td>
<td class="py-4 px-6 font-medium text-gray-900" th:text="${adherent.prenom}">Jean</td>
<td class="py-4 px-6 text-center text-gray-600 font-mono text-xs"
th:text="${adherent.getLicenceActuelle() != null and adherent.getLicenceActuelle().numeroLicence != null and !adherent.getLicenceActuelle().numeroLicence.isEmpty() ? adherent.getLicenceActuelle().numeroLicence : '-'}">-</td>
<td class="py-4 px-6 text-gray-600" th:text="${adherent.email}">jean@example.com</td>
<td class="py-4 px-6 text-gray-600" th:text="${adherent.telephone}">0600000000</td>
<td class="py-4 px-6 text-center">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
th:classappend="${adherent.getStatutPaiement() == 'À jour' ? 'bg-green-100 text-green-800' : (adherent.getStatutPaiement() == 'Aucune licence' ? 'bg-gray-100 text-gray-800' : 'bg-red-100 text-red-800')}"
th:text="${adherent.getStatutPaiement()}">À jour</span>
</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<a th:href="@{/adherents/{id}/edit(id=${adherent.id})}" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Voir / Modifier</a>
<form th:action="@{/adherents/{id}/delete(id=${adherent.id})}" method="post" onsubmit="return confirm('Supprimer cet adhérent ?');">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium">Supprimer</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Tableau de bord - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Adhérents</a>
<a href="#" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Paiements</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors">Modes de Paiement</a>
</nav>
<div class="p-4 border-t border-gray-200">
<div class="text-sm font-medium text-gray-900 mb-1" th:text="${username}">Admin</div>
<form th:action="@{/logout}" method="post">
<button type="submit" class="text-sm text-red-600 hover:text-red-700 font-medium">Déconnexion</button>
</form>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<!-- Header -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800">Tableau de bord</h2>
</header>
<!-- Main section -->
<div class="flex-1 overflow-auto p-6 bg-gray-50/50">
<!-- KPIs -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Adhérents -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center space-x-4">
<div class="p-3 bg-blue-50 text-blue-600 rounded-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Total Adhérents</p>
<p class="text-2xl font-bold text-gray-900" th:text="${totalAdherents}">0</p>
</div>
</div>
<!-- Licences Actives -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center space-x-4">
<div class="p-3 bg-green-50 text-green-600 rounded-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Licences Actives</p>
<p class="text-2xl font-bold text-gray-900" th:text="${licencesActives}">0</p>
<p class="text-xs text-gray-400 mt-1" th:text="${licencesBrouillon} + ' en brouillon'"></p>
</div>
</div>
<!-- Total Encaissé -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center space-x-4">
<div class="p-3 bg-purple-50 text-purple-600 rounded-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Total Encaissé</p>
<p class="text-2xl font-bold text-gray-900" th:text="${totalEncaisse} + ' €'">0 €</p>
</div>
</div>
<!-- Reste à Recouvrer -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-center space-x-4">
<div class="p-3 bg-red-50 text-red-600 rounded-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"></path></svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Reste à Recouvrer</p>
<p class="text-2xl font-bold text-gray-900" th:text="${resteARecouvrer} + ' €'">0 €</p>
</div>
</div>
</div>
<!-- Derniers Inscrits -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Derniers adhérents inscrits</h3>
<a href="/adherents" class="text-sm font-medium text-blue-600 hover:text-blue-800">Voir tout &rarr;</a>
</div>
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50/50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-100">
<th class="py-3 px-6 font-medium">Nom & Prénom</th>
<th class="py-3 px-6 font-medium">Date de naissance</th>
<th class="py-3 px-6 font-medium">Téléphone</th>
<th class="py-3 px-6 font-medium text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 text-sm">
<tr th:if="${#lists.isEmpty(derniersInscrits)}">
<td colspan="4" class="py-4 px-6 text-center text-gray-500">Aucun adhérent récent.</td>
</tr>
<tr th:each="adh : ${derniersInscrits}" class="hover:bg-gray-50 transition-colors">
<td class="py-4 px-6">
<div class="font-medium text-gray-900" th:text="${adh.nom + ' ' + adh.prenom}">Dupont Jean</div>
<div class="text-xs text-gray-500" th:text="${adh.email}">jean@example.com</div>
</td>
<td class="py-4 px-6 text-gray-600" th:text="${#temporals.format(adh.dateNaissance, 'dd/MM/yyyy')}">01/01/2000</td>
<td class="py-4 px-6 text-gray-600" th:text="${adh.telephone}">0600000000</td>
<td class="py-4 px-6 text-right">
<a th:href="@{/adherents/{id}/edit(id=${adh.id})}" class="text-blue-600 hover:text-blue-800 font-medium">Voir profil</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Connexion - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen">
<div class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-md border border-gray-100">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">AS Talange</h1>
<p class="text-gray-500">Outil de gestion administrative</p>
</div>
<div th:if="${param.error}" class="bg-red-50 text-red-600 p-4 rounded-lg mb-6 text-sm">
Identifiant ou mot de passe incorrect.
</div>
<div th:if="${param.logout}" class="bg-green-50 text-green-600 p-4 rounded-lg mb-6 text-sm">
Vous avez été déconnecté avec succès.
</div>
<form th:action="@{/login}" method="post" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Nom d'utilisateur</label>
<input type="text" id="username" name="username" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" required autofocus>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Mot de passe</label>
<input type="password" id="password" name="password" class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" required>
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200">
Se connecter
</button>
</form>
</div>
</body>
</html>
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Catégorie - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Adhérents</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Modes de Paiement</a>
</nav>
</aside>
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800" th:text="${categorie.id == null ? 'Nouvelle Catégorie' : 'Modifier Catégorie'}">Catégorie</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<div class="max-w-xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<form th:action="@{/categories}" th:object="${categorie}" method="post" class="space-y-6">
<input type="hidden" th:field="*{id}" />
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nom (ex: U15)</label>
<input type="text" th:field="*{nom}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Âge Min</label>
<input type="number" th:field="*{anneeMin}" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Âge Max</label>
<input type="number" th:field="*{anneeMax}" class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="tarifBase" class="block text-sm font-medium text-gray-700 mb-1">Tarif de base (Talangeois) (€) <span class="text-red-500">*</span></label>
<input type="number" step="0.01" id="tarifBase" th:field="*{tarifBase}" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
<div>
<label for="tarifExterieur" class="block text-sm font-medium text-gray-700 mb-1">Tarif Extérieur (hors commune de Talange) (€) <span class="text-red-500">*</span></label>
<input type="number" step="0.01" id="tarifExterieur" th:field="*{tarifExterieur}" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
</div>
</div>
<div class="pt-4 flex justify-end space-x-3 border-t border-gray-100">
<a th:href="@{/categories}" class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50">Annuler</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">Enregistrer</button>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Paramétrage - Catégories</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Adhérents</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Modes de Paiement</a>
</nav>
</aside>
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800">Catégories Sportives</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-900">Liste des Catégories</h3>
<a th:href="@{/categories/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700">
+ Nouvelle Catégorie
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-6 font-medium text-left">Nom de la catégorie</th>
<th class="py-3 px-6 font-medium text-center">Année Min</th>
<th class="py-3 px-6 font-medium text-center">Année Max</th>
<th class="py-3 px-6 font-medium text-right">Tarif (Talangeois)</th>
<th class="py-3 px-6 font-medium text-right">Tarif (Hors Commune)</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(categories)}">
<td colspan="6" class="py-8 text-center text-gray-500">Aucune catégorie n'est paramétrée.</td>
</tr>
<tr th:each="cat : ${categories}" class="hover:bg-gray-50 transition-colors">
<td class="py-4 px-6 font-medium text-gray-900" th:text="${cat.nom}">U15</td>
<td class="py-4 px-6 text-center text-gray-600" th:text="${cat.anneeMin}">2010</td>
<td class="py-4 px-6 text-center text-gray-600" th:text="${cat.anneeMax}">2011</td>
<td class="py-4 px-6 text-right font-medium text-blue-600" th:text="${cat.tarifBase + ' €'}">100.00 €</td>
<td class="py-4 px-6 text-right font-medium text-purple-600" th:text="${cat.tarifExterieur != null ? cat.tarifExterieur + ' €' : '-'}">130.00 €</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<a th:href="@{/categories/{id}/edit(id=${cat.id})}" class="text-indigo-600 hover:text-indigo-900 font-medium bg-indigo-50 px-3 py-1 rounded-lg">Modifier</a>
<form th:action="@{/categories/{id}/delete(id=${cat.id})}" method="post" onsubmit="return confirm('Supprimer cette catégorie ?');">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium">Supprimer</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Mode de Paiement - AS Talange</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Adhérents</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Modes de Paiement</a>
</nav>
</aside>
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800" th:text="${modePaiement.id == null ? 'Nouveau Mode' : 'Modifier Mode'}">Mode de Paiement</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<div class="max-w-xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<form th:action="@{/modespaiement}" th:object="${modePaiement}" method="post" class="space-y-6">
<input type="hidden" th:field="*{id}" />
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nom (ex: Chèque)</label>
<input type="text" th:field="*{nom}" required class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div class="pt-4 flex justify-end space-x-3 border-t border-gray-100">
<a th:href="@{/modespaiement}" class="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50">Annuler</a>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">Enregistrer</button>
</div>
</form>
</div>
</div>
</main>
</body>
</html>
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Paramétrage - Modes de Paiement</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> body { font-family: 'Inter', sans-serif; } </style>
</head>
<body class="bg-gray-50 text-gray-900 flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col hidden md:flex">
<div class="h-16 flex items-center px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-blue-600">AS Talange</h1>
</div>
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<a href="/" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Tableau de bord</a>
<a href="/adherents" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Adhérents</a>
<div class="pt-4 pb-2 px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Paramétrage</div>
<a href="/categories" class="block px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-50 transition-colors">Catégories</a>
<a href="/modespaiement" class="block px-3 py-2 rounded-lg bg-blue-50 text-blue-700 font-medium">Modes de Paiement</a>
</nav>
</aside>
<main class="flex-1 flex flex-col h-screen overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center px-6">
<h2 class="text-lg font-semibold text-gray-800">Modes de Paiement</h2>
</header>
<div class="flex-1 overflow-auto p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-900">Liste des Modes de Paiement</h3>
<a th:href="@{/modespaiement/new}" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700">
+ Nouveau Mode
</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-sm uppercase tracking-wider border-b border-gray-200">
<th class="py-3 px-6 font-medium">ID</th>
<th class="py-3 px-6 font-medium">Nom</th>
<th class="py-3 px-6 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 text-sm">
<tr th:if="${#lists.isEmpty(modes)}">
<td colspan="3" class="py-4 px-6 text-center text-gray-500">Aucun mode de paiement trouvé.</td>
</tr>
<tr th:each="mode : ${modes}" class="hover:bg-gray-50">
<td class="py-4 px-6 text-gray-500" th:text="${mode.id}">1</td>
<td class="py-4 px-6 font-medium text-gray-900" th:text="${mode.nom}">Chèque</td>
<td class="py-4 px-6 text-right flex justify-end space-x-3 items-center">
<a th:href="@{/modespaiement/{id}/edit(id=${mode.id})}" class="text-blue-600 hover:text-blue-800 font-medium">Éditer</a>
<form th:action="@{/modespaiement/{id}/delete(id=${mode.id})}" method="post" onsubmit="return confirm('Supprimer ce mode de paiement ?');">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium">Supprimer</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
services:
db:
image: postgres:16
container_name: astalange_db
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: astalange
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4
container_name: astalange_pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@astalange.com
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
- db
volumes:
postgres_data:
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.astalange</groupId>
<artifactId>as-talange-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>as-talange-parent</name>
<description>AS Talange Management Application</description>
<properties>
<java.version>21</java.version>
</properties>
<modules>
<module>as-talange-common</module>
<module>as-talange-core</module>
<module>as-talange-web</module>
</modules>
</project>
+4
View File
@@ -0,0 +1,4 @@
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
cffi==2.0.0
pycparser==3.0