Spring Boot + Thymeleaf + BootStrap

Para este artículo vamos a ver como trabajar un administrador de clientes (abonados, inquilinos, miembros, etc) utilizando las siguientes tecnologías:




[

Si lo bajas y quieres probar de una vez, usa estos datos:

usuario: joe
clave: 123

]

1. Dependencias del Proyecto


En el código fuente veremos que tenemos spring boot, spring web (mvc), spring security, spring data, mysql, thymeleaf, y la integración de thymeleaf con spring security.



No tendrás ningún problema en la descarga de librerías, todas están disponibles en los repositorios de maven.


2. Estructura del Proyecto

Es la estructura típica de un proyecto Spring Boot. La parte del template y archivos HTML con Thymeleaf están en src/main/resources/templates.




3. Configuración de la Seguridad


Para manejar la autenticación y autorización usamos Spring Security. La verdad es muy sencillo. Hasta el encoder /decoder te lo brinda Spring Security gracias al soporte que tiene de BCrypt.



@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Autowired
    private PasswordEncoder passwordEncoder;

 private static final String SQL_ROLE
   = "select u.Cod_Usuario, p.permiso_value as authority "
             + "from Usuarios u "
             + "inner join Tipos_Usuario r on u.Id_Role = r.Id "
             + "inner join Role_Permisos rp on rp.Id_Role = r.id "
             + "inner join Permisos p on rp.Id_Permiso = p.id "
             + "where u.Cod_Usuario = ?";

    private static final String SQL_LOGIN
            = "select u.Cod_Usuario as username, u.Password_Usuario as password, u.active "
            + "from Usuarios u "
            + "where u.Cod_Usuario = ?";


    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    @Bean
    public AuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(userDetailsService());
        return provider;
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        JdbcDaoImpl userDetails = new JdbcDaoImpl();
        userDetails.setDataSource(dataSource);
        userDetails.setUsersByUsernameQuery(SQL_LOGIN);
        userDetails.setAuthoritiesByUsernameQuery(SQL_ROLE);
        return userDetails;
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable();

        http
                .authorizeRequests()
                .antMatchers("/js/**").permitAll()
                .antMatchers("/css/**").permitAll()
                .antMatchers("/fonts/**").permitAll()
                .antMatchers("/forgot_password/**").permitAll()
                .antMatchers("/reset_password/**").permitAll()

                .antMatchers("/clientes/**").hasAnyRole("ADMINISTRADOR")

                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
    .failureUrl("/login-error")
                .permitAll()
                .and()
                .logout()
                .permitAll();
    }
}


Para trabajar con tu modelo de datos, tienes que implementar un método (anotado con @Bean) que retorne UserDetailsService y para ello usamos dos sentencias SQL:  SQL_LOGIN (da los datos de autenticación) y SQL_ROLE (Donde obtienes los permisos por rol).

Finalmente en el método configure(HttpSecurity http) ves a que recursos ("urls") darás permiso a todos, o especificar que Roles si pueden hacer uso de él. Ahí es donde va la parte de autorización.

La última parte indica que cualquier URL que no esta marcado con "permitAll()" solo se puede acceder si se esta autenticado. De manera, que si se trata de acceder de forma directa sin haber iniciado sesión sera redireccionado al formulario "login". 


Y también podemos ver que existe la configuración para el "logout".  Al cual podemos invocar todos. 

4. Entidades

Nuestras entidades JPA son mapeadas acorde al esquema de BD.




@Entity
@Table(name = "Socios")
@NamedQueries({
  @NamedQuery(name = "Socio.findAll", query = "SELECT t FROM Socio t")})
public class Socio implements Serializable {

 private static final long serialVersionUID = 1L;
 @Id
 @GeneratedValue(strategy = GenerationType.AUTO)
 @Column(name = "Cod_Socio")
 private Integer codSocio;
 @Column(name = "Tipo_Persona")
 private String tipoPersona;
 @Column(name = "Tipo_Documento")
 private String tipoDocumento;
 @Column(name = "Nro_Documento")
 private String nroDocumento;
 @Column(name = "Ape_Paterno")
 private String apePaterno;
 @Column(name = "Ape_Materno")
 private String apeMaterno;
 @Column(name = "Nombres")
 private String nombres;
 @Column(name = "Nom_Completo")
 private String nomCompleto;

 @Column(name = "Fecha_Nacimiento")
 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaNacimiento;

 @Column(name = "Nacionalidad")
 private String nacionalidad;
 @Column(name = "Sexo")
 private String sexo;
 @Column(name = "Estado_Civil")
 private String estadoCivil;
 @Column(name = "Educacion")
 private String educacion;
 @Column(name = "Condicion_Laboral")
 private String condicionLaboral;
 @Column(name = "CIIU")
 private String ciiu;
 @Column(name = "Profesion")
 private String profesion;
 @Column(name = "Tip_Doc_Conyuge")
 private String tipDocConyuge;
 @Column(name = "Doc_Conyuge")
 private String docConyuge;
 @Column(name = "Ape_Pat_Conyuge")
 private String apePatConyuge;
 @Column(name = "Ape_Mat_Conyuge")
 private String apeMatConyuge;
 @Column(name = "Nom_Conyuge")
 private String nomConyuge;
 @Column(name = "Telefono_Fijo")
 private String telefonoFijo;
 @Column(name = "Telefono_Celular")
 private String telefonoCelular;
 @Column(name = "Correo_Electronico")
 private String correoElectronico;

 @Column(name = "Fecha_Apertura")
 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaApertura;

 @Column(name = "Carga_Familiar")
 private Integer cargaFamiliar;
 @Column(name = "Tipo_Vivienda")
 private String tipoVivienda;
 @Column(name = "Ruc_Laboral")
 private String rucLaboral;
 @Column(name = "Centro_Laboral")
 private String centroLaboral;
 @Column(name = "Cargo")
 private String cargo;

 @Column(name = "Fecha_Ingreso")
 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaIngreso;

 @Column(name = "Telefono_Laboral")
 private String telefonoLaboral;
 @Column(name = "Razon_Social")
 private String razonSocial;

 @Column(name = "Fecha_Constitucion")
 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaConstitucion;

 @Column(name = "Tipo_Empresa")
 private String tipoEmpresa;
 @Column(name = "RRPP")
 private String rrpp;
 @Column(name = "Tamano_Empresa")
 private Integer tamanoEmpresa;
 @Column(name = "Cal_Interna")
 private String calInterna;
 @Column(name = "Cal_Externa")
 private String calExterna;
 @Column(name = "Activo")
 private Integer activo;
 @Column(name = "Usuario_Registro")
 private String usuarioRegistro;

 @Column(name = "Fecha_Registro")
 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaRegistro;

 @Column(name = "Hora_Registro")
 @Temporal(TemporalType.TIME)
 private Date horaRegistro;
 @Column(name = "Usuario_Modifica")
 private String usuarioModifica;
 @Column(name = "Fecha_Modifica")

 @Temporal(TemporalType.DATE)
 @DateTimeFormat(pattern = "dd/MM/yyyy")
 private Date fechaModifica;

 @Column(name = "Hora_Modifica")
 @Temporal(TemporalType.TIME)
 private Date horaModifica;

 @Column(name = "Nom_Tipo_Persona")
 private String nomTipoPersona;
 @Column(name = "Nom_Tipo_Documento")
 private String nomTipoDocumento;

 @OneToMany(mappedBy = "socio", cascade = CascadeType.ALL, orphanRemoval = true)
 private List direccionList = new ArrayList<>();

 @OneToMany(mappedBy = "socio", cascade = CascadeType.ALL, orphanRemoval = true)
 private List representanteList = new ArrayList<>();

....



Con esta configuración podremos guardar en la tabla Socio (Los datos del cliente, sea Natural o Jurídico) , las Direcciones (para ambos tipos) y Representantes (sólo Juridicos) del cliente. Para esta demo trabajaremos la historia de Gestión de Clientes.

5. Los Repositorios


Estos los implementaremos con Spring Data. En caso necesites un query particular, lo puedes implementar usando @Query. Aquí un ejemplo para obtener Departamentos, Provincias y Distritos.



public interface UbigeoRepository extends JpaRepository {

 @Query("SELECT u FROM Ubigeo u WHERE u.codProvincia = '00' and u.codDistrito = '00'")
 List findDepartamentos();

 @Query("SELECT u FROM Ubigeo u WHERE u.codDpto= :codDpto and u.codProvincia <> '00' and u.codDistrito = '00'")
 List findProvincias(@Param("codDpto") String codDpto);

 @Query("SELECT u FROM Ubigeo u WHERE u.codDpto= :codDpto and u.codProvincia = :codProvincia and u.codDistrito <> '00'")
 List findDistritos(@Param("codDpto") String codDpto, @Param("codProvincia") String codProvincia);
}




6. Controladoras

Spring MVC es el framework para implementar el patrón Model View Controller en Spring.  El módelo es bien sencillo. 

6.1 Home



El URL que se invocará por GET, POST, PUT, DELETE ya sea por navegador, postman, link, formulario de una vista, etc. tiene que estar asociado a un método de una controladora. Por eso Spring tiene por ejemplo @GetMapping (para GET) y @PostMapping (para POST).

El return "home" indica que se debe ir a la vista con dicho nombre ubicada en src/main/resources/templates. Eso es una convención. No necesitas configurar eso en ningún lado. 

6.2 Login


La vista de Login se puede invocar directamente o al querer entrar a un URL sin habernos autenticado.

Por eso tendremos esta controladora:



Al cual podemos llamar desde un URL en el navegador (GET) o cuando haya un intento fallido de autenticación con "/login-error".

La vista Login es simple, un simple formulario que hace un POST a "/login" enviando un username y password (Recuerda la configuración de Security). Todo eso es pura convención de Spring Security. Como no quiero complicarme, sigo estas convenciones.


En esta parte ya estamos usando Thymeleaf:


En caso de error, se muestra el mensaje que existe en el messages.properties (src/main/resources):

Login.Error=El nombre de usuario o la contraseña es incorrecto. El acceso fue denegado.

6.3. Panel Principal


Luego de la autenticación, el URL a cargar es http://localhost:8088/  (el puerto 8088 esta definido en el src/main/resources/application.properties).

Como vemos el  "/" es manejado por el HomeController, que buscará cargar la vista "home".

La vista "home" es la siguiente:



Si bien parece muy simple. En realidad carga un Layout o Template que tendrá un ménu lateral izquierdo, header y body. La vista "home" solo coloca un texto en

en el fragmento denominado "content".




El Layout tiene los fragmentos que arman el template, ahí podemos apreciar el fragment "content":



El Layout declara los namespaces, incluido el que tiene integración con la seguridad (sec) y que nos permite ocultar secciones de la vista acorde a tu Rol:


En el menú lateral, por ejemplo, vemos el uso de la seguridad en la vista:


Sólo el Rol ADMINISTRADOR puede hacer uso de esta sección.

6.4. Listado Clientes



  • Todo lo relacionado a clientes lo encontraremos en templates/clientes/:





  • La primera opción a invocar es el listado ("/clientes/list"):






  • Y esto será atendido en ClientesController:



En dicho método obtenemos el listado de clientes por buscador o por el link del menú lateral que vimos anteriormente. Enviamos a la vista el listado de clientes en el objeto del modelo con nombre "clientes".


  • La vista será clientes/list.html que tiene un buscador de clientes y una grilla que mostrará los resultados. Es buscador aparece en esta sección:





  • La grilla de clientes se muestra en base al objeto "clientes" enviado desde la controladora. Contamos para cada registro mostrado la opción para eliminar y editar.

Podemos ver el uso de thymeleaf con:  th:each (para iterar), th:text (para mostrar el valor de un objeto), th:switch (if/else), th:href (para invocar links).



6.5. Nuevo Cliente o Editar Cliente


Aquí vemos una forma simple de manejar el Nuevo o Editar cliente. Veamos la controladora:


Si no viene el "id" es porque hicimos clic en el botón "Nuevo Cliente":



Caso contrario, si vamos a editar un cliente de la grilla, se envía el "id" respectivo:


Ud. dirá, y en que momento se hace la consulta findById, ahí la magia de Spring MVC. El llenará el argumento del método con una consulta findById(). Pruebelo y verá. 


6.6. Vista Cliente/Form


¿Y que hay de la vista? , pues la vista es "/clientes/form", es decir el form.html dentro del directorio clientes

  • La vista form.html es como sigue:


Pero, como quería poner un poco de complejidad, decidí manejar tabs para agregar DATOS GENERALES, DIRECCIONES, OTROS DATOS. Veamos el resultado, en la primera pestaña tenemos que ingresar datos para las Personas Jurídicas:




En la segunda pestaña ingresamos las direcciones tanto para personas naturales o jurídicas (Es válido para ambos):


Si es una persona JURIDICA podemos ingresar varios representantes legales:



Como se habrá dado cuenta, si es persona  NATURAL o JURIDICA, la primera y tercera pantalla cambian:

Para persona NATURAL se puede ingresar toda esta información en la primera pestaña:


Para la ultima pantalla, para personas NATURALES, se ingresan los datos del conyuge:



Ahí si esta mejor.  Nos permitirá ver como usar CSS, JQuery, AJAX con thymeleaf y Spring MVC para conseguir estos resultados.



  • Para cargar los combos de todas las pantallas, podemos hacerlo de la siguiente manera:




Y después simplemente lo mostramos de esta manera sencilla:


El resultado es:




  • Por suerte el template AdminLTE viene con soporte a JQuery, BootStrap, DatePicker, otros controles, estilos de botones, etc. Estos se configuran en el template o Layout.html.


  • Así que los campos fecha se configurarán de esta manera:

Y en dicha función el DatePicker es seteado:


NOTA: Sólo muestro un ejemplo, hay varios combobox a llenar de la misma forma. 

En el html no hay mucho que hacer, solo setear el campo del modelo asociado:


El resultado es:


  • ¿Cómo mostramos ciertos campos si eres PERSONA NATURAL, ocultar y mostrar otros si eres PERSONA JURIDICA?
Esto al principio pense sería super complicado, pero, es más sencillo de lo que pense. 

Sólo defines una clase (inputTipoPersona_hide) que puedas asociar a un componente del formulario y luego si escoges un valor del combobox TipoPersona (con nombre inputTipoPersona), ocultas todos los que no son de dicho valor y muestras los otros. Ahí el truco.



Y si es persona NATURAL (2) o JURIDICA(1) se mostrarán o no los componentes:



Cuando pruebes la demo, escoge el primer combo de la primera pantalla, que es Tipo Persona (Natural o Juridica) y verás como cambian sobretodo la primera y tercera pestaña. 

  • Cuando ya estaba a punto de terminar la primera pantalla. En caso de Persona NATURAL ví que un campo "Carga Familiar", sólo debe aceptar dígitos. 
El tratamiento también es sencillo.


Si intentas colocar letras, te saldrá un mensaje.


Pruebelo y me comenta (sobretodo si existe una mejor solución - espero su pull request). 

Con esto he comentado todo lo que necesita saber para cargar los campos textbox, combobox, datepickers de las 3 pestañas. La segunda y tercer pestaña si tendrán su propio apartado, porque, hay cosas interesantes por ver ahí. 


6.7.  Direcciones


En esta pestaña podemos agregar direcciones para personas NATURALES o JURIDICAS. 

La primera vez, luego de ingresar los datos de la dirección, si queremos agregar una nueva dirección hay que dar clic en el botón GUARDAR. 

Si queremos ingresar una nueva dirección, hacemos clic en el botón NUEVO. Volvemos a ingresar los datos y luego clic en GUARDAR. 



Luego de ingresar una dirección, si queremos editar algún campo, hacemos clic en el botón EDITAR. Los datos son cargados en el formulario arriba de la grilla. Haces los cambios y para actualizar la dirección haces clic en GUARDAR.

Si queremos eliminar una dirección hacemos clic en el botón ELIMINAR del registro seleccionado.



  • Entonces como se imaginará esta pestaña tendrá una estructura como la siguiente:
Cabecera
Detalle (grilla de Direcciones)

  • Para evitar un refresco total de la pantalla, haremos las llamadas AJAX respectivas al backend para agregar al objeto del formulario "cliente" las direcciones que vayamos agregando. Lo mismo para editar o eliminar una dirección.  Por tanto, veamos primero el HTML.
El formulario lo tenemos bien arriba para cubrir las 3 pestañas. 


Los botones Guardar y Cancelar cliente, van en el en el div box-footer. 
  • Los botones Nueva y Guardar dirección se trabajaran de esta manera:


No es necesario para una nueva dirección ir al backend con Ajax (fue una decisión mía). Así que lo haremos del lado del cliente con la función de JavaScript: clearDireccionNueva().


Para manejar el agregar/editar las direcciones tendremos 2 hiddens de apoyo:  Parametro (addDireccion o editDireccion) e Indice (0, 1, 2, ... , n) que es el registro seleccionado de la grilla. 

Pero, al hacer clic en el botón GUARDAR si tendremos que ir por Ajax al backend para agregar al objeto "socio" del formulario esa nueva dirección.  Esta para mi fue la parte mas dificil del proyecto. Lo anterior super fácil con JQuery

Pero, para agregar un poco más de sabor a la pantalla, los combos Departamento, Provincia y Distrito deberían ser anidados y cargados vía Ajax. Esto también es full JQuery y llamar al backend para cargar las provincias y/o distritos respectivamente. 

Solución:

1. Antes de Guardar una nueva Dirección, me preocupe por resolver la carga dinámica de combobox (Departamento, Provincia y Distrito).


Gracias a JQuery cuando escogemos el valor de un combo llamamos al backend usando Ajax. 


El truco para no refrescar toda la pantalla es solo actualizar el fragmento que tu deseas. Por ejemplo, aquí invocamos a  "/clientes/form::provincias" o "/clientes/form::distritos". 

En la vista se espera este resultado de la siguiente manera:


El th:fragment hace la magia. 

¿Qué te pareció?, Cool, ¿no?.


2. Al hacer clic en "Guardar" que tiene como nombre "addDireccion" capturamos el evento, obtenemos los textos de ciertos combobox, y en la variable data guardamos los datos del formulario con $('form').serialize(), luego verificamos si estamos agregando o editando para agregar a data dicho parámetro.  Finalmente hacemos la llamada Ajax al backend con $.post enviandole dicha data, para luego al obtener el resultado, modificar el fragmento html que se debe actualizar con la función replaceItems




En la controladora para agregar la nueva dirección hacemos lo siguiente:


El método addDireccion(socio) es como sigue:


Usted se preguntará y ¿de donde sale esa DireccionNueva?.

Mi artificio para poder guardar los datos de una nueva o existente dirección asociada al formulario es agregar un campo no persistente en la misma entidad Socio.


Por eso en la vista recibo los datos de la nueva o actual dirección así:



Al retornar del método vemos que se regresa al fragmento "/clientes/form::#direcciones" el cual será refrescado por la función de JavaScript replaceItems():


Que actualizará el html #direcciones:

Y los datos de las direcciones se mostrarán acorde a esta parte:





Es necesario los hidden, porque sino al editar la dirección, perdemos dicha información, llega a la contraladora dichos campos en null. 


3. Si hacemos clic en el botón Nuevo, que significa Nueva Dirección, sólo limpiamos los campos y actualizamos los parámetros del lado del cliente. Veamos a continuación:


Cajas de texto en blanco, combobox a sus valores por defecto.

4. Para Editar una Dirección, hacemos clic en el botón Eliminar correspondiente.


La función de JavaScript editarDireccion(button) se encargará de cargar los datos de la dirección seleccionada en el formulario de arriba.


La única parte complicada aquí es volver a cargar los valores de los combobox Departamento, Provincia y Distrito, esto lo resolvemos así:


Si hacemos clic en GUARDAR, como hemos seteado el parámetro editDireccion, el método de la controladora a llamar será:


El proceso de regresar y cambiar el fragmento es similar a lo explicado anteriormente cuando se agrega una nueva dirección. 

5. Finalmente, la eliminación de una dirección es haciendo clic en el botón ELIMINAR:

La función de JavaScript eliminarDireccion actualiza el parámetro con la acción removeDireccion y le pasa el indice seleccionado. Se llama luego al método de la controladora con dicha data


El método de la controladora encargado de eliminar el registro de la lista de direcciones es:


6.8.  Representantes

La tercera pestaña es diferente para Personas Naturales o Jurídicas. Si es Jurídica si podemos agregar varios representantes y es muy similar el funcionamiento a la ventana de Direcciones. Así que si da una revisión,  verá que se implemento muy parecido a direcciones.

6.9.  Eliminar Cliente

Hay que confirmar, antes de eliminar un cliente. Por eso seremos derivado a una ventana que solicita la confirmación. En caso de aceptar, se elimina las direcciones, representantes y datos del cliente seleccionado. 




Si tienes alguna mejora, bienvenido sea de antemano tu pull requests.   

7. ¿Que falta?


Aún faltan validaciones, el buscador y manejar los mensajes en properties, pero, ya eso para un segundo post.

Enjoy!


Joe



Share:

1 comentario:

  1. Thanks for sharing, great post!

    Áo khoác Đà Lạt chuyên sỉ và lẻ áo khoác áo khoác nam với giá ưu đãi nhất 2020. Luôn cập nhật mẫu mới chất lượng và dày ấm nhất kể cả với thời tiết lạnh giá các nước có du học sinh và xuất khẩu lao động. Aokhoacdalat.com được đồng hành cùng các bạn!!!!

    ResponderBorrar