commit 0d5c6f9512990f3357ea0af1e2a68513cdf7483c Author: root Date: Wed Aug 19 21:30:23 2026 +0000 Initial commit: proyecto ContabilidadSaPolar completo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8efefbe --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# ============================================================ +# CONFIGURACIÓN DE ENTORNO - Sa Polar +# Copia este archivo a .env y ajusta los valores. +# NUNCA subas .env al repositorio (está en .gitignore). +# ============================================================ + +# Base de datos MySQL +DB_NAME=sa_polar +DB_USER=root +DB_PASSWORD=root + +# JWT: genera un secreto con: openssl rand -base64 64 +# ⚠️ CAMBIAR en producción. Mínimo 256 bits (32 bytes). +JWT_SECRET=a2V5X3N1cGVyX3NlY3JldGFfcGFyYV9sb2dpbl9kZV9zYV9wb2xhcl9kZWJlc19zZXJfZGUzMl9jYXJhY3RlcmVz + +# SMTP (email) +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SMTP_AUTH=false +MAIL_SMTP_STARTTLS=false +RECEIPT_FROM_EMAIL=noreply@sapolar.com + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Uploads +UPLOAD_PATH=./uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2f8f29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Java +*.class +*.jar +*.war +target/ +!**/src/main/**/target/ +!**/src/test/**/target/ + +# Maven +.mvn/ + +# IDE +.idea/ +*.iml +.vscode/ +.project +opencode.json +.opencode +.classpath +.settings/ +*.swp +*.swo +*~ + +# Node +node_modules/ +frontend/dist/ + +# Docker +.env + +# Uploads +uploads/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0b9aa74 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Workflow + +After every code change: + +1. **Build backend**: `cd backend && mvn clean compile` +2. **Build frontend**: `cd frontend && cmd /c "npm run build"` + +Si alguna compilación falla, corregir antes de continuar. + +> ⚠️ **NUNCA usar `-v` en docker compose.** Borrar volúmenes elimina TODOS los datos de la BBDD. El despliegue con Docker lo gestiona el usuario manualmente. diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..c4b6ffe --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,23 @@ +FROM maven:3.9-eclipse-temurin-21-alpine AS builder +WORKDIR /app +COPY pom.xml . +COPY backend/pom.xml backend/pom.xml +RUN mvn dependency:go-offline -B -pl backend -am +COPY backend/ backend/ +RUN mvn package -B -pl backend -DskipTests +RUN java -Djarmode=layertools -jar backend/target/*.jar extract --destination extracted + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +COPY --from=builder /app/extracted/dependencies/ ./ +COPY --from=builder /app/extracted/spring-boot-loader/ ./ +COPY --from=builder /app/extracted/snapshot-dependencies/ ./ +COPY --from=builder /app/extracted/application/ ./ + +RUN mkdir -p /app/uploads && chown -R appuser:appgroup /app + +USER appuser +EXPOSE 8080 +ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..c18fa25 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,29 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM nginx:stable-alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY < sa-polar +cd sa-polar + +# 2. Copiar configuración de entorno +cp .env.example .env + +# 3. Iniciar todos los servicios +docker compose up -d + +# 4. Acceder a la aplicación +# Frontend: http://localhost:3000 +# Backend API: http://localhost:8080 +# Swagger UI: http://localhost:8080/swagger-ui.html +``` + +## Credenciales por Defecto + +| Usuario | Contraseña | Rol | +|---------|-----------|-----| +| admin | admin123 | ADMIN | + +## Desarrollo Local (sin Docker) + +### Backend + +```bash +# Requiere MySQL 8 corriendo en localhost:3306 con base de datos "sa_polar" +cd backend +mvn spring-boot:run -DskipTests +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +# Acceder en http://localhost:5173 +``` + +## Estructura del Proyecto + +``` +sa-polar/ +├── backend/ # Backend Spring Boot +│ └── src/main/java/com/sapolar/ +│ ├── auth/ # Autenticación JWT +│ ├── common/ # DTOs, excepciones, utilidades +│ ├── config/ # Configuraciones (seguridad, CORS, OpenAPI) +│ ├── contract/ # Gestión de contratos +│ ├── dashboard/ # Dashboard y resúmenes +│ ├── document/ # Gestión de documentos adjuntos +│ ├── finance/ # Módulo financiero +│ │ ├── expense/ # Gastos +│ │ ├── income/ # Ingresos +│ │ └── receipt/ # Recibos, PDF, email, reportes +│ ├── incident/ # Incidencias +│ ├── maintenance/ # Mantenimiento programado +│ ├── notification/ # Notificaciones +│ ├── property/ # Propiedades, inmuebles y conjuntos +│ ├── tenant/ # Inquilinos/arrendatarios +│ └── user/ # Usuarios y roles +├── db/ +│ └── init.sql # DDL + datos semilla +├── frontend/ # Frontend React +│ └── src/ +│ ├── api/ # Cliente Axios, funciones API +│ ├── components/ # Componentes (Layout) +│ ├── contexts/ # AuthContext +│ ├── pages/ # Páginas (Login, Dashboard, etc.) +│ └── types/ # Tipos TypeScript +├── docs/ # Documentación +├── docker-compose.yml # Orquestación de servicios +├── Dockerfile.backend # Build multi-etapa backend +└── Dockerfile.frontend # Build multi-etapa frontend +``` + +## Documentación + +La documentación completa está disponible en el directorio `docs/`: + +- [Índice de documentación](docs/INDEX.md) +- [Arquitectura del sistema](docs/tecnicas/arquitectura.md) +- [Referencia de API](docs/tecnicas/api.md) +- [Esquema de base de datos](docs/tecnicas/base-de-datos.md) +- [Planificación y roadmap](docs/planificacion/roadmap.md) +- [Manual de usuario](docs/usuario/manual.md) + +## API Endpoints Principales + +| Grupo | Base Path | Métodos | +|-------|-----------|---------| +| Autenticación | `/api/auth` | login, register, refresh | +| Usuarios | `/api/users` | CRUD (solo ADMIN) | +| Propiedades | `/api/properties` | CRUD + árbol + historial | +| Conjuntos | `/api/property-groups` | CRUD + propiedades asociadas | +| Inquilinos | `/api/tenants` | CRUD + búsqueda | +| Contratos | `/api/contracts` | CRUD + terminación | +| Ingresos | `/api/incomes` | CRUD + registro de pago | +| Gastos | `/api/expenses` | CRUD | +| Incidencias | `/api/incidents` | CRUD + asignación + programación | +| Mantenimiento | `/api/maintenance` | CRUD + programado | +| Recibos | `/api/receipts` | Generación, PDF, email, reportes | +| Notificaciones | `/api/notifications` | Listado, marcar leídas | +| Dashboard | `/api/dashboard` | Resúmenes y gráficos | +| Documentos | `/api/documents` | Subida, descarga | + +## Variables de Entorno + +| Variable | Defecto | Descripción | +|----------|---------|-------------| +| `DB_NAME` | `sa_polar` | Nombre de la base de datos | +| `DB_USER` | `root` | Usuario MySQL | +| `DB_PASSWORD` | `root` | Contraseña MySQL | +| `JWT_SECRET` | (por defecto) | Secreto para firmar JWT (base64) | +| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Orígenes CORS permitidos | +| `UPLOAD_PATH` | `./uploads` | Ruta de almacenamiento de archivos | + +## Licencia + +Uso interno. diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..1c974db --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,205 @@ + + + 4.0.0 + + + com.sapolar + contabilidad-sa-polar + 1.0.0 + + + sa-polar-backend + jar + + + 3.4.1 + 0.12.6 + 2.7.0 + 1.6.3 + 1.18.36 + 8.0.5 + 5.3.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-mail + + + + + com.mysql + mysql-connector-j + runtime + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + + + + + com.itextpdf + kernel + ${itext.version} + + + com.itextpdf + layout + ${itext.version} + + + + + org.apache.poi + poi-ooxml + ${poi.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.projectlombok + lombok + + + + + + + repackage + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + true + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + diff --git a/backend/src/main/java/com/sapolar/SaPolarApplication.java b/backend/src/main/java/com/sapolar/SaPolarApplication.java new file mode 100644 index 0000000..3899a3f --- /dev/null +++ b/backend/src/main/java/com/sapolar/SaPolarApplication.java @@ -0,0 +1,14 @@ +package com.sapolar; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class SaPolarApplication { + + public static void main(String[] args) { + SpringApplication.run(SaPolarApplication.class, args); + } +} diff --git a/backend/src/main/java/com/sapolar/auth/AuthController.java b/backend/src/main/java/com/sapolar/auth/AuthController.java new file mode 100644 index 0000000..ee0feb1 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/AuthController.java @@ -0,0 +1,39 @@ +package com.sapolar.auth; + +import com.sapolar.auth.dto.LoginRequest; +import com.sapolar.auth.dto.RegisterRequest; +import com.sapolar.auth.dto.TokenResponse; +import com.sapolar.common.dto.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + TokenResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.success("Inicio de sesión exitoso", response)); + } + + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + TokenResponse response = authService.register(request); + return ResponseEntity.ok(ApiResponse.success("Usuario registrado exitosamente", response)); + } + + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody Map request) { + String refreshToken = request.get("refreshToken"); + TokenResponse response = authService.refresh(refreshToken); + return ResponseEntity.ok(ApiResponse.success("Token refrescado exitosamente", response)); + } +} diff --git a/backend/src/main/java/com/sapolar/auth/AuthService.java b/backend/src/main/java/com/sapolar/auth/AuthService.java new file mode 100644 index 0000000..e862ddd --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/AuthService.java @@ -0,0 +1,95 @@ +package com.sapolar.auth; + +import com.sapolar.auth.dto.LoginRequest; +import com.sapolar.auth.dto.RegisterRequest; +import com.sapolar.auth.dto.TokenResponse; +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.DuplicateResourceException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.user.*; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final AuthenticationManager authenticationManager; + + @Transactional(readOnly = true) + public TokenResponse login(LoginRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) + ); + + User user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new ResourceNotFoundException("Usuario", 0L)); + + String accessToken = jwtTokenProvider.generateAccessToken( + user.getId(), user.getUsername(), user.getRole().getName()); + String refreshToken = jwtTokenProvider.generateRefreshToken( + user.getId(), user.getUsername()); + + return new TokenResponse(accessToken, refreshToken, user.getId(), + user.getUsername(), user.getRole().getName()); + } + + @Transactional + public TokenResponse register(RegisterRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new DuplicateResourceException("El usuario ya existe: " + request.getUsername()); + } + if (userRepository.existsByEmail(request.getEmail())) { + throw new DuplicateResourceException("El email ya está registrado: " + request.getEmail()); + } + + String roleName = request.getRoleName() != null ? request.getRoleName() : "VISUALIZADOR"; + Role role = roleRepository.findByName(roleName) + .orElseThrow(() -> new BadRequestException("Rol no válido: " + roleName)); + + User user = new User(); + user.setUsername(request.getUsername()); + user.setEmail(request.getEmail()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setFullName(request.getFullName()); + user.setPhone(request.getPhone()); + user.setRole(role); + user.setActive(true); + user = userRepository.save(user); + + String accessToken = jwtTokenProvider.generateAccessToken( + user.getId(), user.getUsername(), user.getRole().getName()); + String refreshToken = jwtTokenProvider.generateRefreshToken( + user.getId(), user.getUsername()); + + return new TokenResponse(accessToken, refreshToken, user.getId(), + user.getUsername(), user.getRole().getName()); + } + + @Transactional(readOnly = true) + public TokenResponse refresh(String refreshToken) { + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BadRequestException("Token de refresco inválido o expirado"); + } + + Long userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("Usuario", userId)); + + String accessToken = jwtTokenProvider.generateAccessToken( + user.getId(), user.getUsername(), user.getRole().getName()); + String newRefreshToken = jwtTokenProvider.generateRefreshToken( + user.getId(), user.getUsername()); + + return new TokenResponse(accessToken, newRefreshToken, user.getId(), + user.getUsername(), user.getRole().getName()); + } +} diff --git a/backend/src/main/java/com/sapolar/auth/AuthUserDetailsService.java b/backend/src/main/java/com/sapolar/auth/AuthUserDetailsService.java new file mode 100644 index 0000000..8c81432 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/AuthUserDetailsService.java @@ -0,0 +1,33 @@ +package com.sapolar.auth; + +import com.sapolar.user.User; +import com.sapolar.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.List; + +@Service +@RequiredArgsConstructor +public class AuthUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado: " + username)); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPasswordHash(), + user.getActive(), + true, true, true, + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().getName())) + ); + } +} diff --git a/backend/src/main/java/com/sapolar/auth/JwtAuthenticationFilter.java b/backend/src/main/java/com/sapolar/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9c0fa2d --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.sapolar.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String token = extractToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + String username = jwtTokenProvider.getUsernameFromToken(token); + Long userId = jwtTokenProvider.getUserIdFromToken(token); + String role = jwtTokenProvider.getRoleFromToken(token); + + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_" + role) + ); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + new SecurityUser(userId, username, role), + null, + authorities + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/backend/src/main/java/com/sapolar/auth/JwtTokenProvider.java b/backend/src/main/java/com/sapolar/auth/JwtTokenProvider.java new file mode 100644 index 0000000..be01d2d --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/JwtTokenProvider.java @@ -0,0 +1,80 @@ +package com.sapolar.auth; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long expirationMs; + private final long refreshExpirationMs; + + public JwtTokenProvider( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-ms}") long expirationMs, + @Value("${app.jwt.refresh-expiration-ms}") long refreshExpirationMs) { + this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + this.expirationMs = expirationMs; + this.refreshExpirationMs = refreshExpirationMs; + } + + public String generateAccessToken(Long userId, String username, String role) { + Date now = new Date(); + return Jwts.builder() + .subject(username) + .claim("userId", userId) + .claim("role", role) + .issuedAt(now) + .expiration(new Date(now.getTime() + expirationMs)) + .signWith(secretKey) + .compact(); + } + + public String generateRefreshToken(Long userId, String username) { + Date now = new Date(); + return Jwts.builder() + .subject(username) + .claim("userId", userId) + .claim("type", "refresh") + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshExpirationMs)) + .signWith(secretKey) + .compact(); + } + + public String getUsernameFromToken(String token) { + return parseClaims(token).getSubject(); + } + + public Long getUserIdFromToken(String token) { + return parseClaims(token).get("userId", Long.class); + } + + public String getRoleFromToken(String token) { + return parseClaims(token).get("role", String.class); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/sapolar/auth/SecurityUser.java b/backend/src/main/java/com/sapolar/auth/SecurityUser.java new file mode 100644 index 0000000..6fa79f9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/SecurityUser.java @@ -0,0 +1,4 @@ +package com.sapolar.auth; + +public record SecurityUser(Long userId, String username, String role) { +} diff --git a/backend/src/main/java/com/sapolar/auth/dto/LoginRequest.java b/backend/src/main/java/com/sapolar/auth/dto/LoginRequest.java new file mode 100644 index 0000000..7f01422 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/dto/LoginRequest.java @@ -0,0 +1,15 @@ +package com.sapolar.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginRequest { + @NotBlank(message = "El usuario es obligatorio") + private String username; + + @NotBlank(message = "La contraseña es obligatoria") + private String password; +} diff --git a/backend/src/main/java/com/sapolar/auth/dto/RegisterRequest.java b/backend/src/main/java/com/sapolar/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..e483fd6 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/dto/RegisterRequest.java @@ -0,0 +1,29 @@ +package com.sapolar.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RegisterRequest { + @NotBlank(message = "El usuario es obligatorio") + @Size(min = 3, max = 50, message = "El usuario debe tener entre 3 y 50 caracteres") + private String username; + + @NotBlank(message = "El email es obligatorio") + @Email(message = "Email no válido") + private String email; + + @NotBlank(message = "La contraseña es obligatoria") + @Size(min = 6, message = "La contraseña debe tener al menos 6 caracteres") + private String password; + + @NotBlank(message = "El nombre completo es obligatorio") + private String fullName; + + private String phone; + private String roleName; +} diff --git a/backend/src/main/java/com/sapolar/auth/dto/TokenResponse.java b/backend/src/main/java/com/sapolar/auth/dto/TokenResponse.java new file mode 100644 index 0000000..6c87008 --- /dev/null +++ b/backend/src/main/java/com/sapolar/auth/dto/TokenResponse.java @@ -0,0 +1,26 @@ +package com.sapolar.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; + private String tokenType = "Bearer"; + private Long userId; + private String username; + private String role; + + public TokenResponse(String accessToken, String refreshToken, Long userId, String username, String role) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenType = "Bearer"; + this.userId = userId; + this.username = username; + this.role = role; + } +} diff --git a/backend/src/main/java/com/sapolar/common/dto/ApiResponse.java b/backend/src/main/java/com/sapolar/common/dto/ApiResponse.java new file mode 100644 index 0000000..df9ff99 --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/dto/ApiResponse.java @@ -0,0 +1,35 @@ +package com.sapolar.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + private LocalDateTime timestamp = LocalDateTime.now(); + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "OK", data, LocalDateTime.now()); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data, LocalDateTime.now()); + } + + public static ApiResponse success(String message) { + return new ApiResponse<>(true, message, null, LocalDateTime.now()); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message, null, LocalDateTime.now()); + } +} diff --git a/backend/src/main/java/com/sapolar/common/dto/PagedResponse.java b/backend/src/main/java/com/sapolar/common/dto/PagedResponse.java new file mode 100644 index 0000000..a07d92b --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/dto/PagedResponse.java @@ -0,0 +1,31 @@ +package com.sapolar.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +public class PagedResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean last; + + public static PagedResponse from(Page page) { + return new PagedResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isLast() + ); + } +} diff --git a/backend/src/main/java/com/sapolar/common/exception/BadRequestException.java b/backend/src/main/java/com/sapolar/common/exception/BadRequestException.java new file mode 100644 index 0000000..de5e1fa --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package com.sapolar.common.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/sapolar/common/exception/DuplicateResourceException.java b/backend/src/main/java/com/sapolar/common/exception/DuplicateResourceException.java new file mode 100644 index 0000000..56dd9c0 --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/exception/DuplicateResourceException.java @@ -0,0 +1,7 @@ +package com.sapolar.common.exception; + +public class DuplicateResourceException extends RuntimeException { + public DuplicateResourceException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/sapolar/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/sapolar/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4d4a0d9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,59 @@ +package com.sapolar.common.exception; + +import com.sapolar.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@Slf4j + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity> handleBadRequest(BadRequestException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(DuplicateResourceException.class) + public ResponseEntity> handleDuplicate(DuplicateResourceException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error("Acceso denegado")); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String errors = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(errors)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error("Unhandled exception", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("Error interno del servidor: " + ex.getMessage())); + } +} diff --git a/backend/src/main/java/com/sapolar/common/exception/ResourceNotFoundException.java b/backend/src/main/java/com/sapolar/common/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..2ab7edd --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/exception/ResourceNotFoundException.java @@ -0,0 +1,11 @@ +package com.sapolar.common.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String resource, Long id) { + super(resource + " no encontrado con id: " + id); + } + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/sapolar/common/util/BaseEntity.java b/backend/src/main/java/com/sapolar/common/util/BaseEntity.java new file mode 100644 index 0000000..616a345 --- /dev/null +++ b/backend/src/main/java/com/sapolar/common/util/BaseEntity.java @@ -0,0 +1,34 @@ +package com.sapolar.common.util; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@MappedSuperclass +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/config/CorsConfig.java b/backend/src/main/java/com/sapolar/config/CorsConfig.java new file mode 100644 index 0000000..7789edb --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/CorsConfig.java @@ -0,0 +1,33 @@ +package com.sapolar.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Value("${app.cors.allowed-origins}") + private String allowedOrigins; + + @Bean + public CorsFilter corsFilter() { + List origins = List.of(allowedOrigins.split(",")); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(origins); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization", "Content-Disposition")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} diff --git a/backend/src/main/java/com/sapolar/config/FileStorageConfig.java b/backend/src/main/java/com/sapolar/config/FileStorageConfig.java new file mode 100644 index 0000000..8c258d5 --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/FileStorageConfig.java @@ -0,0 +1,34 @@ +package com.sapolar.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "app.upload") +public class FileStorageConfig { + + private String path; + private long maxFileSize; + + @PostConstruct + public void init() { + Path uploadPath = Paths.get(path); + if (!Files.exists(uploadPath)) { + try { + Files.createDirectories(uploadPath); + } catch (IOException e) { + throw new RuntimeException("No se pudo crear el directorio de uploads: " + path, e); + } + } + } +} diff --git a/backend/src/main/java/com/sapolar/config/FlywayRepairConfig.java b/backend/src/main/java/com/sapolar/config/FlywayRepairConfig.java new file mode 100644 index 0000000..0cd1fe4 --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/FlywayRepairConfig.java @@ -0,0 +1,69 @@ +package com.sapolar.config; + +import org.flywaydb.core.Flyway; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.env.Environment; + +/** + * Estrategia de migración de Flyway según el perfil de Spring. + * + * - Perfil "dev" (desarrollo): solo repair + migrate. NUNCA hace clean + * para preservar los datos de desarrollo. + * + * - Perfil "prod" (producción): SOLO hace repair + migrate. + * Si repair no es suficiente, falla y debe intervenir un administrador. + */ +@Configuration +public class FlywayRepairConfig { + + private static final Logger log = LoggerFactory.getLogger(FlywayRepairConfig.class); + + /** + * Estrategia para desarrollo: solo repair + migrate. + * NUNCA hace clean porque borraría datos. + * Activada cuando el perfil activo es "dev" o no hay perfil definido. + */ + @Bean + @Profile({"dev", "default"}) + public FlywayMigrationStrategy devFlywayMigrationStrategy() { + return flyway -> { + try { + log.info("Flyway [dev]: ejecutando repair + migrate"); + flyway.repair(); + flyway.migrate(); + log.info("Flyway [dev]: migraciones aplicadas correctamente"); + } catch (Exception e) { + log.error("Flyway [dev]: repair + migrate falló: {}", e.getMessage()); + log.error("Flyway [dev]: NO se ejecutará clean para preservar datos. Revise el problema manualmente."); + throw e; + } + }; + } + + /** + * Estrategia para producción: NUNCA hace clean. Solo repair + migrate. + * Si repair no es suficiente, falla y debe intervenir un administrador. + */ + @Bean + @Profile("prod") + public FlywayMigrationStrategy prodFlywayMigrationStrategy(Environment env) { + return flyway -> { + log.info("Flyway [prod]: ejecutando repair + migrate"); + try { + flyway.repair(); + flyway.migrate(); + log.info("Flyway [prod]: migraciones aplicadas correctamente"); + } catch (Exception e) { + log.error("Flyway [prod]: ERROR en migraciones. NO se ejecutará clean.", e); + log.error("Flyway [prod]: Revise manualmente el esquema y flyway_schema_history."); + log.error("Flyway [prod]: Si necesita reparar, use: mvn flyway:repair"); + throw e; + } + }; + } +} diff --git a/backend/src/main/java/com/sapolar/config/JacksonConfig.java b/backend/src/main/java/com/sapolar/config/JacksonConfig.java new file mode 100644 index 0000000..c484713 --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package com.sapolar.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public Hibernate6Module hibernate6Module() { + Hibernate6Module module = new Hibernate6Module(); + module.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION); + module.enable(Hibernate6Module.Feature.FORCE_LAZY_LOADING); + return module; + } +} diff --git a/backend/src/main/java/com/sapolar/config/OpenApiConfig.java b/backend/src/main/java/com/sapolar/config/OpenApiConfig.java new file mode 100644 index 0000000..a3ffca1 --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.sapolar.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Sa Polar API - Gestión de Alquileres") + .version("1.0.0") + .description("API REST para la gestión y contabilidad de una empresa de alquileres") + .contact(new Contact() + .name("Sa Polar") + .email("info@sapolar.com")) + .license(new License() + .name("MIT") + .url("https://opensource.org/licenses/MIT"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer")) + .components(new Components() + .addSecuritySchemes("Bearer", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Ingrese el token JWT"))); + } +} diff --git a/backend/src/main/java/com/sapolar/config/SecurityConfig.java b/backend/src/main/java/com/sapolar/config/SecurityConfig.java new file mode 100644 index 0000000..617e964 --- /dev/null +++ b/backend/src/main/java/com/sapolar/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package com.sapolar.config; + +import com.sapolar.auth.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configure(http)) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api-docs/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/users/**").hasRole("ADMIN") + .requestMatchers("/api/properties/**").hasAnyRole("ADMIN", "GERENTE") + .requestMatchers("/api/tenants/**").hasAnyRole("ADMIN", "GERENTE") + .requestMatchers("/api/contracts/**").hasAnyRole("ADMIN", "GERENTE") + .requestMatchers("/api/incomes/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/expenses/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/incidents/**").hasAnyRole("ADMIN", "GERENTE") + .requestMatchers("/api/maintenance/**").hasAnyRole("ADMIN", "GERENTE") + .requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/receipts/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE") + .requestMatchers("/api/notifications/**").authenticated() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/Contract.java b/backend/src/main/java/com/sapolar/contract/Contract.java new file mode 100644 index 0000000..f48355c --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/Contract.java @@ -0,0 +1,106 @@ +package com.sapolar.contract; + +import com.sapolar.property.Property; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Entity +@Table(name = "contracts", indexes = { + @Index(name = "idx_contracts_property", columnList = "property_id"), + @Index(name = "idx_contracts_status", columnList = "status_id"), + @Index(name = "idx_contracts_dates", columnList = "start_date, end_date") +}) +public class Contract { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id", nullable = false) + private Property property; + + @OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true) + private List contractTenants = new ArrayList<>(); + + @OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true) + private List expenseRepercussions = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private ContractStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "period_id", nullable = false) + private PaymentPeriod paymentPeriod; + + @Column(name = "contract_number", unique = true, length = 50) + private String contractNumber; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "renewal_date") + private LocalDate renewalDate; + + @Column(name = "rental_amount", nullable = false, precision = 12, scale = 2) + private BigDecimal rentalAmount; + + @Column(name = "deposit_amount", precision = 12, scale = 2) + private BigDecimal depositAmount; + + @Column(name = "payment_day", nullable = false) + private Integer paymentDay = 1; + + @Column(name = "payment_day_end") + private Integer paymentDayEnd; + + @Column(name = "iban_charge", length = 34) + private String ibanCharge; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(name = "signed_at") + private LocalDate signedAt; + + @Column(name = "terminated_at") + private LocalDate terminatedAt; + + @Column(name = "termination_cause", length = 500) + private String terminationCause; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractController.java b/backend/src/main/java/com/sapolar/contract/ContractController.java new file mode 100644 index 0000000..6b81296 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractController.java @@ -0,0 +1,70 @@ +package com.sapolar.contract; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/contracts") +@RequiredArgsConstructor +public class ContractController { + + private final ContractService contractService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) Long propertyId, + @RequestParam(required = false) Long tenantId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + if (propertyId != null) { + List list = contractService.findByProperty(propertyId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + if (tenantId != null) { + List list = contractService.findByTenant(tenantId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(contractService.findAll(pageable)))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(contractService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@RequestBody Contract contract, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Contrato creado", + contractService.create(contract, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, @RequestBody Contract contract) { + return ResponseEntity.ok(ApiResponse.success("Contrato actualizado", + contractService.update(id, contract))); + } + + @PostMapping("/{id}/terminate") + public ResponseEntity> terminate(@PathVariable Long id, + @RequestBody Map body) { + String cause = body.getOrDefault("cause", "Rescisión"); + return ResponseEntity.ok(ApiResponse.success("Contrato rescindido", + contractService.terminate(id, cause))); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractRepository.java b/backend/src/main/java/com/sapolar/contract/ContractRepository.java new file mode 100644 index 0000000..529868f --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractRepository.java @@ -0,0 +1,25 @@ +package com.sapolar.contract; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ContractRepository extends JpaRepository { + List findByPropertyId(Long propertyId); + List findByStatusId(Integer statusId); + Optional findByContractNumber(String contractNumber); + + @Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.property.id = :propertyId") + Optional findActiveByPropertyId(@Param("propertyId") Long propertyId); + + @Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.endDate BETWEEN :start AND :end") + List findContractsExpiringBetween(@Param("start") LocalDate start, @Param("end") LocalDate end); + + boolean existsByContractNumber(String contractNumber); +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractService.java b/backend/src/main/java/com/sapolar/contract/ContractService.java new file mode 100644 index 0000000..fef62ff --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractService.java @@ -0,0 +1,162 @@ +package com.sapolar.contract; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.DuplicateResourceException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyRepository; +import com.sapolar.property.PropertyStatus; +import com.sapolar.property.PropertyStatusRepository; +import com.sapolar.tenant.Tenant; +import com.sapolar.tenant.TenantRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ContractService { + + private final ContractRepository contractRepository; + private final ContractTenantRepository contractTenantRepository; + private final PropertyRepository propertyRepository; + private final TenantRepository tenantRepository; + private final PropertyStatusRepository propertyStatusRepository; + + public List findAll() { + return contractRepository.findAll(); + } + + public Page findAll(Pageable pageable) { + return contractRepository.findAll(pageable); + } + + public List findByProperty(Long propertyId) { + return contractRepository.findByPropertyId(propertyId); + } + + public List findByTenant(Long tenantId) { + List cts = contractTenantRepository.findByTenantId(tenantId); + return cts.stream().map(ContractTenant::getContract).toList(); + } + + public Contract findById(Long id) { + return contractRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Contrato", id)); + } + + void validateNoOverlap(LocalDate newStart, LocalDate newEnd, Long propertyId, Long excludeId) { + LocalDate e2 = newEnd != null ? newEnd : LocalDate.MAX; + List existing = contractRepository.findByPropertyId(propertyId); + for (Contract c : existing) { + if (c.getId().equals(excludeId)) continue; + LocalDate e1 = c.getEndDate() != null ? c.getEndDate() : LocalDate.MAX; + if (!newStart.isAfter(e1) && !c.getStartDate().isAfter(e2)) { + throw new BadRequestException( + "La propiedad ya tiene un contrato (" + c.getContractNumber() + ") vigente en esas fechas"); + } + } + } + + @Transactional + public Contract create(Contract contract, Long userId) { + if (contract.getProperty() == null || contract.getProperty().getId() == null) { + throw new BadRequestException("La propiedad es obligatoria"); + } + + if (contract.getContractNumber() != null && + contractRepository.existsByContractNumber(contract.getContractNumber())) { + throw new DuplicateResourceException("Ya existe un contrato con número: " + contract.getContractNumber()); + } + + Property property = propertyRepository.findById(contract.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", contract.getProperty().getId())); + contract.setProperty(property); + + validateNoOverlap(contract.getStartDate(), contract.getEndDate(), property.getId(), -1L); + + List resolved = new ArrayList<>(); + for (ContractTenant ct : contract.getContractTenants()) { + Tenant tenant = tenantRepository.findById(ct.getTenant().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId())); + ct.setTenant(tenant); + ct.setContract(contract); + resolved.add(ct); + } + contract.setContractTenants(resolved); + + User user = new User(); + user.setId(userId); + contract.setCreatedBy(user); + + Contract saved = contractRepository.save(contract); + + PropertyStatus rentedStatus = propertyStatusRepository.findByName("ALQUILADO") + .orElseThrow(() -> new BadRequestException("Estado ALQUILADO no encontrado")); + property.setStatus(rentedStatus); + propertyRepository.save(property); + + return saved; + } + + @Transactional + public Contract update(Long id, Contract updated) { + Contract contract = findById(id); + + LocalDate newStart = updated.getStartDate() != null ? updated.getStartDate() : contract.getStartDate(); + LocalDate newEnd = updated.getEndDate(); + validateNoOverlap(newStart, newEnd, contract.getProperty().getId(), id); + + if (updated.getStartDate() != null) contract.setStartDate(updated.getStartDate()); + contract.setEndDate(updated.getEndDate()); + contract.setRentalAmount(updated.getRentalAmount()); + contract.setDepositAmount(updated.getDepositAmount()); + contract.setPaymentDay(updated.getPaymentDay()); + contract.setIbanCharge(updated.getIbanCharge()); + contract.setNotes(updated.getNotes()); + if (updated.getStatus() != null) contract.setStatus(updated.getStatus()); + if (updated.getPaymentPeriod() != null) contract.setPaymentPeriod(updated.getPaymentPeriod()); + + if (updated.getContractTenants() != null && !updated.getContractTenants().isEmpty()) { + contractTenantRepository.deleteByContractId(contract.getId()); + List resolved = new ArrayList<>(); + for (ContractTenant ct : updated.getContractTenants()) { + Tenant tenant = tenantRepository.findById(ct.getTenant().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId())); + ContractTenant newCt = new ContractTenant(); + newCt.setContract(contract); + newCt.setTenant(tenant); + newCt.setRole(ct.getRole()); + resolved.add(newCt); + } + contract.setContractTenants(resolved); + } + + return contractRepository.save(contract); + } + + @Transactional + public Contract terminate(Long id, String cause) { + Contract contract = findById(id); + contract.setTerminatedAt(java.time.LocalDate.now()); + contract.setTerminationCause(cause); + + ContractStatus terminatedStatus = new ContractStatus(); + terminatedStatus.setId(4); + contract.setStatus(terminatedStatus); + + PropertyStatus vacantStatus = propertyStatusRepository.findByName("VACIO") + .orElseThrow(() -> new BadRequestException("Estado VACIO no encontrado")); + contract.getProperty().setStatus(vacantStatus); + propertyRepository.save(contract.getProperty()); + + return contractRepository.save(contract); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractStatus.java b/backend/src/main/java/com/sapolar/contract/ContractStatus.java new file mode 100644 index 0000000..a85e581 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractStatus.java @@ -0,0 +1,19 @@ +package com.sapolar.contract; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "contract_statuses") +public class ContractStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractTenant.java b/backend/src/main/java/com/sapolar/contract/ContractTenant.java new file mode 100644 index 0000000..9de0869 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractTenant.java @@ -0,0 +1,32 @@ +package com.sapolar.contract; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.sapolar.tenant.Tenant; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "contract_tenants", uniqueConstraints = { + @UniqueConstraint(columnNames = {"contract_id", "tenant_id"}) +}) +public class ContractTenant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contract_id", nullable = false) + @JsonIgnore + private Contract contract; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Column(nullable = false, length = 20) + private String role = "TITULAR"; +} diff --git a/backend/src/main/java/com/sapolar/contract/ContractTenantRepository.java b/backend/src/main/java/com/sapolar/contract/ContractTenantRepository.java new file mode 100644 index 0000000..5b2f54c --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ContractTenantRepository.java @@ -0,0 +1,13 @@ +package com.sapolar.contract; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ContractTenantRepository extends JpaRepository { + List findByContractId(Long contractId); + List findByTenantId(Long tenantId); + void deleteByContractId(Long contractId); +} diff --git a/backend/src/main/java/com/sapolar/contract/ExpenseRepercussion.java b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussion.java new file mode 100644 index 0000000..06ec914 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussion.java @@ -0,0 +1,55 @@ +package com.sapolar.contract; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "expense_repercussions") +public class ExpenseRepercussion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contract_id", nullable = false) + private Contract contract; + + @Column(name = "expense_type", nullable = false, length = 50) + private String expenseType; + + @Column(name = "amount", nullable = false, precision = 10, scale = 2) + private BigDecimal amount; + + @Column(name = "billing_period", length = 30) + private String billingPeriod; + + @Column(columnDefinition = "TEXT") + private String observations; + + @Column(name = "active", nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionController.java b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionController.java new file mode 100644 index 0000000..bc87fe9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionController.java @@ -0,0 +1,51 @@ +package com.sapolar.contract; + +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/expense-repercussions") +@RequiredArgsConstructor +public class ExpenseRepercussionController { + + private final ExpenseRepercussionService expenseRepercussionService; + + @GetMapping + public ResponseEntity>> findByContract( + @RequestParam Long contractId) { + return ResponseEntity.ok(ApiResponse.success( + expenseRepercussionService.findByContract(contractId))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success( + expenseRepercussionService.findById(id))); + } + + @PostMapping + public ResponseEntity> create( + @RequestParam Long contractId, + @RequestBody ExpenseRepercussion repercussion) { + return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto creada", + expenseRepercussionService.create(repercussion, contractId))); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long id, + @RequestBody ExpenseRepercussion repercussion) { + return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto actualizada", + expenseRepercussionService.update(id, repercussion))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + expenseRepercussionService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto eliminada", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionRepository.java b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionRepository.java new file mode 100644 index 0000000..64d9e8d --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionRepository.java @@ -0,0 +1,12 @@ +package com.sapolar.contract; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ExpenseRepercussionRepository extends JpaRepository { + List findByContractId(Long contractId); + List findByContractIdAndActiveTrue(Long contractId); +} diff --git a/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionService.java b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionService.java new file mode 100644 index 0000000..35a104f --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/ExpenseRepercussionService.java @@ -0,0 +1,65 @@ +package com.sapolar.contract; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ExpenseRepercussionService { + + private final ExpenseRepercussionRepository expenseRepercussionRepository; + private final ContractRepository contractRepository; + + public List findByContract(Long contractId) { + return expenseRepercussionRepository.findByContractIdAndActiveTrue(contractId); + } + + public ExpenseRepercussion findById(Long id) { + return expenseRepercussionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Repercusión de gasto", id)); + } + + @Transactional + public ExpenseRepercussion create(ExpenseRepercussion repercussion, Long contractId) { + if (repercussion.getExpenseType() == null || repercussion.getExpenseType().isBlank()) { + throw new BadRequestException("El tipo de gasto es obligatorio"); + } + if (repercussion.getAmount() == null) { + throw new BadRequestException("El importe es obligatorio"); + } + + Contract contract = contractRepository.findById(contractId) + .orElseThrow(() -> new ResourceNotFoundException("Contrato", contractId)); + repercussion.setContract(contract); + + return expenseRepercussionRepository.save(repercussion); + } + + @Transactional + public ExpenseRepercussion update(Long id, ExpenseRepercussion updated) { + ExpenseRepercussion existing = findById(id); + + if (updated.getExpenseType() != null) { + existing.setExpenseType(updated.getExpenseType()); + } + if (updated.getAmount() != null) { + existing.setAmount(updated.getAmount()); + } + existing.setBillingPeriod(updated.getBillingPeriod()); + existing.setObservations(updated.getObservations()); + + return expenseRepercussionRepository.save(existing); + } + + @Transactional + public void delete(Long id) { + ExpenseRepercussion repercussion = findById(id); + repercussion.setActive(false); + expenseRepercussionRepository.save(repercussion); + } +} diff --git a/backend/src/main/java/com/sapolar/contract/PaymentPeriod.java b/backend/src/main/java/com/sapolar/contract/PaymentPeriod.java new file mode 100644 index 0000000..d5e54c4 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/PaymentPeriod.java @@ -0,0 +1,19 @@ +package com.sapolar.contract; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "payment_periods") +public class PaymentPeriod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/contract/PaymentPeriodRepository.java b/backend/src/main/java/com/sapolar/contract/PaymentPeriodRepository.java new file mode 100644 index 0000000..dde1141 --- /dev/null +++ b/backend/src/main/java/com/sapolar/contract/PaymentPeriodRepository.java @@ -0,0 +1,8 @@ +package com.sapolar.contract; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PaymentPeriodRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/sapolar/dashboard/DashboardController.java b/backend/src/main/java/com/sapolar/dashboard/DashboardController.java new file mode 100644 index 0000000..29df86e --- /dev/null +++ b/backend/src/main/java/com/sapolar/dashboard/DashboardController.java @@ -0,0 +1,30 @@ +package com.sapolar.dashboard; + +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + @GetMapping("/summary") + public ResponseEntity>> getSummary() { + return ResponseEntity.ok(ApiResponse.success(dashboardService.getSummary())); + } + + @GetMapping("/income-expense") + public ResponseEntity>> getMonthlyIncomeExpense( + @RequestParam(required = false) Integer year) { + if (year == null) { + year = java.time.LocalDate.now().getYear(); + } + return ResponseEntity.ok(ApiResponse.success(dashboardService.getMonthlyIncomeExpense(year))); + } +} diff --git a/backend/src/main/java/com/sapolar/dashboard/DashboardService.java b/backend/src/main/java/com/sapolar/dashboard/DashboardService.java new file mode 100644 index 0000000..c6951ae --- /dev/null +++ b/backend/src/main/java/com/sapolar/dashboard/DashboardService.java @@ -0,0 +1,181 @@ +package com.sapolar.dashboard; + +import com.sapolar.contract.Contract; +import com.sapolar.contract.ContractRepository; +import com.sapolar.finance.expense.ExpenseReceipt; +import com.sapolar.finance.expense.ExpenseReceiptRepository; +import com.sapolar.finance.income.IncomeReceipt; +import com.sapolar.finance.income.IncomeReceiptRepository; +import com.sapolar.incident.IncidentRepository; +import com.sapolar.maintenance.ScheduledMaintenanceRepository; +import com.sapolar.property.PropertyRepository; +import com.sapolar.tenant.TenantRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DashboardService { + + private final IncomeReceiptRepository incomeReceiptRepository; + private final ExpenseReceiptRepository expenseReceiptRepository; + private final PropertyRepository propertyRepository; + private final IncidentRepository incidentRepository; + private final ScheduledMaintenanceRepository maintenanceRepository; + private final TenantRepository tenantRepository; + private final ContractRepository contractRepository; + + public Map getSummary() { + int year = LocalDate.now().getYear(); + int month = LocalDate.now().getMonthValue(); + LocalDate startOfYear = LocalDate.of(year, 1, 1); + LocalDate startOfMonth = LocalDate.of(year, month, 1); + LocalDate today = LocalDate.now(); + + BigDecimal incomeYear = incomeReceiptRepository.sumPaidBetween(startOfYear, today); + BigDecimal expenseYear = expenseReceiptRepository.sumPaidBetween(startOfYear, today); + BigDecimal incomeMonth = incomeReceiptRepository.sumPaidBetween(startOfMonth, today); + BigDecimal expenseMonth = expenseReceiptRepository.sumPaidBetween(startOfMonth, today); + + long pendingIncomes = incomeReceiptRepository.countByStatusId(1); + long overdueIncomes = incomeReceiptRepository.countByStatusId(3); + long pendingExpenses = expenseReceiptRepository.countByStatusId(1); + long overdueExpenses = expenseReceiptRepository.countByStatusId(3); + + Map summary = new LinkedHashMap<>(); + summary.put("totalProperties", propertyRepository.count()); + summary.put("rentedProperties", propertyRepository.findByStatusId(2).size()); + summary.put("freeProperties", propertyRepository.findByStatusId(1).size()); + summary.put("totalTenants", tenantRepository.count()); + summary.put("activeContracts", contractRepository.findByStatusId(1).size()); + summary.put("pendingIncomes", pendingIncomes); + summary.put("overdueIncomes", overdueIncomes); + summary.put("pendingExpenses", pendingExpenses); + summary.put("overdueExpenses", overdueExpenses); + summary.put("incomeThisYear", incomeYear); + summary.put("expenseThisYear", expenseYear); + summary.put("balanceThisYear", incomeYear.subtract(expenseYear)); + summary.put("incomeThisMonth", incomeMonth); + summary.put("expenseThisMonth", expenseMonth); + summary.put("balanceThisMonth", incomeMonth.subtract(expenseMonth)); + summary.put("openIncidents", incidentRepository.count() - incidentRepository.countByStatusId(4)); // sin REPARADO + summary.put("pendingMaintenance", maintenanceRepository.findByCompletedFalse().size()); + + // ── Listas de pendientes (top 8) ── + summary.put("pendingIncomeList", buildPendingIncomeList()); + summary.put("pendingExpenseList", buildPendingExpenseList()); + + // ── Contratos próximos a vencer (60 días) ── + summary.put("expiringContracts", buildExpiringContracts(today)); + + // ── Ingresos / Gastos por categoría ── + summary.put("incomeByCategory", buildIncomeByCategory(year)); + summary.put("expenseByCategory", buildExpenseByCategory(year)); + + return summary; + } + + public Map getMonthlyIncomeExpense(int year) { + Map result = new LinkedHashMap<>(); + for (int m = 1; m <= 12; m++) { + LocalDate start = LocalDate.of(year, m, 1); + LocalDate end = start.withDayOfMonth(start.lengthOfMonth()); + + BigDecimal income = incomeReceiptRepository.sumPaidBetween(start, end); + BigDecimal expense = expenseReceiptRepository.sumPaidBetween(start, end); + + Map monthData = new LinkedHashMap<>(); + monthData.put("income", income); + monthData.put("expense", expense); + result.put(java.time.Month.of(m).name(), monthData); + } + return result; + } + + // ───────────────────────────────────────────────── + // Métodos auxiliares + // ───────────────────────────────────────────────── + + private List> buildPendingIncomeList() { + List list = incomeReceiptRepository.findPendingOrderByDueDate(); + return list.stream().limit(8).map(r -> { + Map m = new LinkedHashMap<>(); + m.put("id", r.getId()); + m.put("propertyName", r.getProperty().getName()); + m.put("tenantName", r.getTenant() != null ? r.getTenant().getFullName() : null); + m.put("amount", r.getAmount()); + m.put("periodLabel", r.getPeriodLabel()); + m.put("dueDate", r.getDueDate()); + m.put("status", r.getStatus().getName()); + if (r.getDueDate() != null) { + long days = ChronoUnit.DAYS.between(r.getDueDate(), LocalDate.now()); + m.put("daysOverdue", Math.max(0, days)); + } else { + m.put("daysOverdue", 0); + } + return m; + }).collect(Collectors.toList()); + } + + private List> buildPendingExpenseList() { + List list = expenseReceiptRepository.findPendingOrderByDueDate(); + return list.stream().limit(8).map(e -> { + Map m = new LinkedHashMap<>(); + m.put("id", e.getId()); + m.put("supplierName", e.getSupplierName()); + m.put("categoryName", e.getCategory() != null ? e.getCategory().getName() : null); + m.put("description", e.getDescription()); + m.put("amount", e.getTotalAmount()); + m.put("dueDate", e.getDueDate()); + m.put("status", e.getStatus().getName()); + m.put("propertyName", e.getProperty() != null ? e.getProperty().getName() : null); + if (e.getDueDate() != null) { + long days = ChronoUnit.DAYS.between(e.getDueDate(), LocalDate.now()); + m.put("daysOverdue", Math.max(0, days)); + } else { + m.put("daysOverdue", 0); + } + return m; + }).collect(Collectors.toList()); + } + + private List> buildExpiringContracts(LocalDate today) { + LocalDate endWindow = today.plusDays(60); + List contracts = contractRepository.findContractsExpiringBetween(today, endWindow); + return contracts.stream().map(c -> { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("propertyName", c.getProperty().getName()); + m.put("contractNumber", c.getContractNumber()); + m.put("endDate", c.getEndDate()); + m.put("daysLeft", ChronoUnit.DAYS.between(today, c.getEndDate())); + return m; + }).collect(Collectors.toList()); + } + + private Map buildIncomeByCategory(int year) { + Map result = new LinkedHashMap<>(); + for (Object[] row : incomeReceiptRepository.sumByCategoryForYear(year)) { + result.put((String) row[0], (BigDecimal) row[1]); + } + return result; + } + + private Map buildExpenseByCategory(int year) { + Map result = new LinkedHashMap<>(); + for (Object[] row : expenseReceiptRepository.sumByCategoryForYear(year)) { + result.put((String) row[0], (BigDecimal) row[1]); + } + return result; + } +} diff --git a/backend/src/main/java/com/sapolar/document/Document.java b/backend/src/main/java/com/sapolar/document/Document.java new file mode 100644 index 0000000..2d4fdb2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/Document.java @@ -0,0 +1,72 @@ +package com.sapolar.document; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "documents", indexes = { + @Index(name = "idx_documents_type", columnList = "document_type_id") +}) +public class Document { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_type_id", nullable = false) + private DocumentType documentType; + + @JsonIgnore + @OneToMany(mappedBy = "document", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set entities = new HashSet<>(); + + @Column(name = "original_name", nullable = false, length = 255) + private String originalName; + + @Column(name = "stored_name", nullable = false, length = 255) + private String storedName; + + @Column(name = "mime_type", length = 100) + private String mimeType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(length = 500) + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploaded_by") + private User uploadedBy; + + @Column(name = "uploaded_at", nullable = false, updatable = false) + private LocalDateTime uploadedAt; + + @PrePersist + protected void onCreate() { + uploadedAt = LocalDateTime.now(); + } + + public void addEntity(String entityType, Long entityId) { + DocumentEntity entity = new DocumentEntity(); + entity.setDocument(this); + entity.setEntityType(entityType); + entity.setEntityId(entityId); + this.entities.add(entity); + } + + public void removeEntity(String entityType, Long entityId) { + this.entities.removeIf(e -> + e.getEntityType().equals(entityType) && e.getEntityId().equals(entityId)); + } +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentController.java b/backend/src/main/java/com/sapolar/document/DocumentController.java new file mode 100644 index 0000000..e92f598 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentController.java @@ -0,0 +1,114 @@ +package com.sapolar.document; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api/documents") +@RequiredArgsConstructor +public class DocumentController { + + private final DocumentService documentService; + + @PostMapping("/upload") + public ResponseEntity> upload( + @RequestParam("file") MultipartFile file, + @RequestParam("entityType") String entityType, + @RequestParam("entityId") Long entityId, + @RequestParam("documentTypeId") Integer documentTypeId, + @RequestParam(required = false) String description, + @AuthenticationPrincipal SecurityUser user) { + Document doc = documentService.uploadFile(file, entityType, entityId, + documentTypeId, description, user.userId()); + return ResponseEntity.ok(ApiResponse.success("Archivo subido", doc)); + } + + @GetMapping("/entity/{entityType}/{entityId}") + public ResponseEntity>> getEntityDocuments( + @PathVariable String entityType, @PathVariable Long entityId) { + return ResponseEntity.ok(ApiResponse.success( + documentService.getDocumentsForEntity(entityType, entityId))); + } + + @GetMapping("/search") + public ResponseEntity>> searchDocuments( + @RequestParam(required = false) String entityType, + @RequestParam(required = false) Long entityId, + @RequestParam(required = false) Integer documentTypeId, + @RequestParam(required = false) String originalName) { + // Convert empty strings to null for proper query handling + String entityTypeParam = (entityType != null && entityType.isBlank()) ? null : entityType; + String originalNameParam = (originalName != null && originalName.isBlank()) ? null : originalName; + return ResponseEntity.ok(ApiResponse.success( + documentService.searchDocuments(entityTypeParam, entityId, documentTypeId, originalNameParam))); + } + + @GetMapping("/{id}/download") + public ResponseEntity download(@PathVariable Long id) { + DocumentService.DocumentDownloadResult result = documentService.downloadFile(id); + String mimeType = result.mimeType(); + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + MediaType mediaType = MediaType.parseMediaType(mimeType); + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + result.filename() + "\"") + .body(result.resource()); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + documentService.deleteDocument(id); + return ResponseEntity.ok(ApiResponse.success("Documento eliminado", null)); + } + + // Associations management + @GetMapping("/{id}/entities") + public ResponseEntity>> getDocumentEntities( + @PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success( + documentService.getDocumentEntities(id))); + } + + @PostMapping("/{id}/entities") + public ResponseEntity> addEntity( + @PathVariable Long id, + @RequestParam String entityType, + @RequestParam Long entityId) { + Document doc = documentService.addEntityAssociation(id, entityType, entityId); + return ResponseEntity.ok(ApiResponse.success("Documento asociado", doc)); + } + + @DeleteMapping("/{id}/entities/{entityType}/{entityId}") + public ResponseEntity> removeEntity( + @PathVariable Long id, + @PathVariable String entityType, + @PathVariable Long entityId) { + documentService.removeEntityAssociation(id, entityType, entityId); + return ResponseEntity.ok(ApiResponse.success("Asociación eliminada", null)); + } + + @GetMapping("/types") + public ResponseEntity>> getDocumentTypes() { + return ResponseEntity.ok(ApiResponse.success(documentService.getDocumentTypes())); + } + + @GetMapping("/types/entity/{entityType}") + public ResponseEntity>> getDocumentTypesForEntity( + @PathVariable String entityType) { + return ResponseEntity.ok(ApiResponse.success( + documentService.getDocumentTypesForEntity(entityType))); + } +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentEntity.java b/backend/src/main/java/com/sapolar/document/DocumentEntity.java new file mode 100644 index 0000000..354d8b0 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentEntity.java @@ -0,0 +1,38 @@ +package com.sapolar.document; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "document_entities") +@Getter +@Setter +public class DocumentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id", nullable = false) + @JsonIgnoreProperties({"entities", "documentType", "uploadedBy"}) + private Document document; + + @Column(name = "entity_type", nullable = false, length = 30) + private String entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentEntityRepository.java b/backend/src/main/java/com/sapolar/document/DocumentEntityRepository.java new file mode 100644 index 0000000..2a06f6e --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentEntityRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.document; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DocumentEntityRepository extends JpaRepository { + Optional findByDocumentIdAndEntityTypeAndEntityId(Long documentId, String entityType, Long entityId); +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentRepository.java b/backend/src/main/java/com/sapolar/document/DocumentRepository.java new file mode 100644 index 0000000..65972e1 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentRepository.java @@ -0,0 +1,31 @@ +package com.sapolar.document; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface DocumentRepository extends JpaRepository { + + @Query("SELECT d FROM Document d JOIN d.entities e WHERE e.entityType = :entityType AND e.entityId = :entityId") + List findByEntityTypeAndEntityId(@Param("entityType") String entityType, @Param("entityId") Long entityId); + + List findByDocumentTypeId(Integer documentTypeId); + + @Query("SELECT d FROM Document d WHERE d.documentType.id = :typeId") + List findByDocumentType(@Param("typeId") Integer typeId); + + @Query("SELECT d FROM Document d JOIN d.entities e WHERE " + + "(:entityType IS NULL OR e.entityType = :entityType) AND " + + "(:entityId IS NULL OR e.entityId = :entityId) AND " + + "(:documentTypeId IS NULL OR d.documentType.id = :documentTypeId) AND " + + "(:originalName IS NULL OR LOWER(d.originalName) LIKE LOWER(CONCAT('%', :originalName, '%')))") + List searchDocuments( + @Param("entityType") String entityType, + @Param("entityId") Long entityId, + @Param("documentTypeId") Integer documentTypeId, + @Param("originalName") String originalName); +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentService.java b/backend/src/main/java/com/sapolar/document/DocumentService.java new file mode 100644 index 0000000..2a3c6d4 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentService.java @@ -0,0 +1,237 @@ +package com.sapolar.document; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.config.FileStorageConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DocumentService { + + private final FileStorageConfig fileStorageConfig; + private final DocumentRepository documentRepository; + private final DocumentEntityRepository documentEntityRepository; + private final DocumentTypeRepository documentTypeRepository; + private final DocumentTypeEntityAllowedRepository documentTypeEntityAllowedRepository; + + @Transactional + public Document uploadFile(MultipartFile file, String entityType, Long entityId, + Integer documentTypeId, String description, Long uploaderId) { + String originalName = StringUtils.cleanPath(file.getOriginalFilename()); + if (originalName.isBlank()) { + throw new BadRequestException("Nombre de archivo no válido"); + } + + // Validar que el tipo de documento está permitido para esta entidad + if (!isDocumentTypeAllowedForEntity(documentTypeId, entityType)) { + throw new BadRequestException( + "El tipo de documento no está permitido para entidades de tipo " + entityType); + } + + String extension = ""; + int dotIndex = originalName.lastIndexOf('.'); + if (dotIndex > 0) { + extension = originalName.substring(dotIndex); + } + String storedName = java.util.UUID.randomUUID().toString() + extension; + + // Guardar archivo en disco (usando la primera entidad para la ruta) + Path uploadDir = Paths.get(fileStorageConfig.getPath()) + .resolve(entityType.toLowerCase()) + .resolve(entityId.toString()); + try { + Files.createDirectories(uploadDir); + Path targetPath = uploadDir.resolve(storedName); + Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException("Error al subir el archivo: " + originalName, e); + } + + // Crear documento + Document document = new Document(); + DocumentType docType = new DocumentType(); + docType.setId(documentTypeId); + document.setDocumentType(docType); + document.setOriginalName(originalName); + document.setStoredName(storedName); + document.setMimeType(file.getContentType()); + document.setFileSize(file.getSize()); + document.setDescription(description); + + Document savedDoc = documentRepository.save(document); + + // Crear asociación con la entidad primaria + savedDoc.addEntity(entityType, entityId); + return documentRepository.save(savedDoc); + } + + /** + * Valida si un tipo de documento está permitido para una entidad. + * Si la relación no existe en la tabla de permitidos, se permite por defecto + * (comportamiento backward compatible). + */ + public boolean isDocumentTypeAllowedForEntity(Integer documentTypeId, String entityType) { + // Si no existe la tabla de permitidos, permitir (backward compatible) + if (!documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType)) { + // Verificar si existe alguna configuración para este tipo de documento + List configs = documentTypeEntityAllowedRepository + .findByDocumentTypeIdAndEntityType(documentTypeId, entityType) + .stream().toList(); + if (configs.isEmpty()) { + // No hay configuración, verificar si hay alguna para este document type + long totalConfigs = documentTypeEntityAllowedRepository.findAll().stream() + .filter(c -> c.getDocumentType().getId().equals(documentTypeId)) + .count(); + // Si no hay ninguna configuración para este tipo de documento, permitir + // (es un tipo antiguo sin restricciones) + if (totalConfigs == 0) { + return true; + } + // Hay configuraciones pero ninguna para esta entidad específica + return false; + } + } + return documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType); + } + + public DocumentDownloadResult downloadFile(Long documentId) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); + + // Usar la primera entidad para determinar la ruta del archivo + if (document.getEntities().isEmpty()) { + throw new ResourceNotFoundException("Documento", documentId); + } + String entityType = document.getEntities().iterator().next().getEntityType(); + Long entityId = document.getEntities().iterator().next().getEntityId(); + + try { + Path filePath = Paths.get(fileStorageConfig.getPath()) + .resolve(entityType.toLowerCase()) + .resolve(entityId.toString()) + .resolve(document.getStoredName()); + + Resource resource = new UrlResource(filePath.toUri()); + if (resource.exists() && resource.isReadable()) { + return new DocumentDownloadResult(resource, document.getMimeType(), document.getOriginalName()); + } + throw new RuntimeException("No se puede leer el archivo: " + document.getOriginalName()); + } catch (MalformedURLException e) { + throw new RuntimeException("Error al acceder al archivo", e); + } + } + + public List getDocumentsForEntity(String entityType, Long entityId) { + return documentRepository.findByEntityTypeAndEntityId(entityType, entityId); + } + + public List searchDocuments(String entityType, Long entityId, + Integer documentTypeId, String originalName) { + return documentRepository.searchDocuments(entityType, entityId, documentTypeId, originalName); + } + + @Transactional + public void deleteDocument(Long documentId) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); + + // Eliminar archivo físico + if (!document.getEntities().isEmpty()) { + DocumentEntity firstEntity = document.getEntities().iterator().next(); + try { + Path filePath = Paths.get(fileStorageConfig.getPath()) + .resolve(firstEntity.getEntityType().toLowerCase()) + .resolve(firstEntity.getEntityId().toString()) + .resolve(document.getStoredName()); + Files.deleteIfExists(filePath); + } catch (IOException e) { + // Log error but continue with DB deletion + } + } + + documentRepository.delete(document); + } + + @Transactional + public Document addEntityAssociation(Long documentId, String entityType, Long entityId) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); + + // Validar que el tipo de documento está permitido para esta entidad + if (!isDocumentTypeAllowedForEntity(document.getDocumentType().getId(), entityType)) { + throw new BadRequestException( + "El tipo de documento no está permitido para entidades de tipo " + entityType); + } + + // Verificar que no exista ya + if (documentEntityRepository.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId).isPresent()) { + throw new BadRequestException("El documento ya está asociado a esta entidad"); + } + + document.addEntity(entityType, entityId); + return documentRepository.save(document); + } + + @Transactional + public void removeEntityAssociation(Long documentId, String entityType, Long entityId) { + DocumentEntity entity = documentEntityRepository + .findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId) + .orElseThrow(() -> new ResourceNotFoundException("Asociación no encontrada")); + + // Verificar que no sea la última asociación (el documento debe tener al menos una) + Document document = entity.getDocument(); + if (document.getEntities().size() <= 1) { + throw new BadRequestException("No se puede eliminar la última asociación. Elimine el documento completo."); + } + + document.removeEntity(entityType, entityId); + documentEntityRepository.delete(entity); + } + + public List getDocumentEntities(Long documentId) { + Document document = documentRepository.findById(documentId) + .orElseThrow(() -> new ResourceNotFoundException("Documento", documentId)); + return List.copyOf(document.getEntities()); + } + + public List getDocumentTypes() { + return documentTypeRepository.findAll(); + } + + /** + * Obtiene los tipos de documento permitidos para una entidad específica. + * Incluye información sobre si es obligatorio o no. + */ + public List getDocumentTypesForEntity(String entityType) { + List allowed = documentTypeEntityAllowedRepository + .findAllowedTypesForEntity(entityType); + + return allowed.stream() + .map(dtea -> new DocumentTypeForEntityDto( + dtea.getDocumentType().getId(), + dtea.getDocumentType().getName(), + dtea.getEntityType(), + dtea.getCanUpload(), + dtea.getMustHave(), + dtea.getDescription())) + .toList(); + } + + // Record para devolver el archivo y su información de cabecera + public record DocumentDownloadResult(Resource resource, String mimeType, String filename) {} +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentType.java b/backend/src/main/java/com/sapolar/document/DocumentType.java new file mode 100644 index 0000000..509969e --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentType.java @@ -0,0 +1,19 @@ +package com.sapolar.document; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "document_types") +public class DocumentType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 50) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowed.java b/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowed.java new file mode 100644 index 0000000..a9d12ef --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowed.java @@ -0,0 +1,43 @@ +package com.sapolar.document; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "document_type_entity_allowed", indexes = { + @Index(name = "idx_dtea_entity", columnList = "entity_type") +}) +public class DocumentTypeEntityAllowed { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_type_id", nullable = false) + private DocumentType documentType; + + @Column(name = "entity_type", nullable = false, length = 30) + private String entityType; + + @Column(name = "can_upload", nullable = false) + private Boolean canUpload = true; + + @Column(name = "must_have", nullable = false) + private Boolean mustHave = false; + + @Column(length = 255) + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowedRepository.java b/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowedRepository.java new file mode 100644 index 0000000..3f2d92d --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentTypeEntityAllowedRepository.java @@ -0,0 +1,27 @@ +package com.sapolar.document; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DocumentTypeEntityAllowedRepository extends JpaRepository { + + List findByEntityType(String entityType); + + List findByEntityTypeAndCanUploadTrue(String entityType); + + Optional findByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType); + + boolean existsByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType); + + @Query("SELECT dtea FROM DocumentTypeEntityAllowed dtea " + + "JOIN FETCH dtea.documentType dt " + + "WHERE dtea.entityType = :entityType AND dtea.canUpload = true " + + "ORDER BY dt.name") + List findAllowedTypesForEntity(@Param("entityType") String entityType); +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentTypeForEntityDto.java b/backend/src/main/java/com/sapolar/document/DocumentTypeForEntityDto.java new file mode 100644 index 0000000..85419d8 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentTypeForEntityDto.java @@ -0,0 +1,19 @@ +package com.sapolar.document; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class DocumentTypeForEntityDto { + private Integer documentTypeId; + private String documentTypeName; + private String entityType; + private Boolean canUpload; + private Boolean mustHave; + private String description; +} diff --git a/backend/src/main/java/com/sapolar/document/DocumentTypeRepository.java b/backend/src/main/java/com/sapolar/document/DocumentTypeRepository.java new file mode 100644 index 0000000..c5b03a6 --- /dev/null +++ b/backend/src/main/java/com/sapolar/document/DocumentTypeRepository.java @@ -0,0 +1,8 @@ +package com.sapolar.document; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DocumentTypeRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/sapolar/finance/bank/BankAccount.java b/backend/src/main/java/com/sapolar/finance/bank/BankAccount.java new file mode 100644 index 0000000..706252c --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/bank/BankAccount.java @@ -0,0 +1,62 @@ +package com.sapolar.finance.bank; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "bank_accounts") +public class BankAccount { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false, length = 200) + private String holder; + + @Column(nullable = false, length = 34) + private String iban; + + @Column(name = "bank_name", length = 100) + private String bankName; + + @Column(name = "swift_bic", length = 11) + private String swiftBic; + + @Column(nullable = false, length = 3) + private String currency = "EUR"; + + @Column(name = "is_default", nullable = false) + private Boolean isDefault = false; + + @Column(nullable = false) + private Boolean active = true; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/bank/BankAccountController.java b/backend/src/main/java/com/sapolar/finance/bank/BankAccountController.java new file mode 100644 index 0000000..e59a38c --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/bank/BankAccountController.java @@ -0,0 +1,52 @@ +package com.sapolar.finance.bank; + +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/bank-accounts") +@RequiredArgsConstructor +public class BankAccountController { + + private final BankAccountService bankAccountService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(defaultValue = "false") boolean all) { + List list = all ? bankAccountService.findAll() + : bankAccountService.findAllActive(); + return ResponseEntity.ok(ApiResponse.success(list)); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(bankAccountService.findById(id))); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')") + public ResponseEntity> create(@RequestBody BankAccount account) { + return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria creada", + bankAccountService.create(account))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')") + public ResponseEntity> update(@PathVariable Long id, + @RequestBody BankAccount account) { + return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria actualizada", + bankAccountService.update(id, account))); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')") + public ResponseEntity> delete(@PathVariable Long id) { + bankAccountService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria desactivada", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/bank/BankAccountRepository.java b/backend/src/main/java/com/sapolar/finance/bank/BankAccountRepository.java new file mode 100644 index 0000000..a1854fd --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/bank/BankAccountRepository.java @@ -0,0 +1,13 @@ +package com.sapolar.finance.bank; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BankAccountRepository extends JpaRepository { + List findByActiveTrueOrderByName(); + Optional findByIsDefaultTrueAndActiveTrue(); +} diff --git a/backend/src/main/java/com/sapolar/finance/bank/BankAccountService.java b/backend/src/main/java/com/sapolar/finance/bank/BankAccountService.java new file mode 100644 index 0000000..069f410 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/bank/BankAccountService.java @@ -0,0 +1,72 @@ +package com.sapolar.finance.bank; + +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BankAccountService { + + private final BankAccountRepository repository; + + public List findAllActive() { + return repository.findByActiveTrueOrderByName(); + } + + public List findAll() { + return repository.findAll(); + } + + public BankAccount findById(Long id) { + return repository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", id)); + } + + @Transactional + public BankAccount create(BankAccount account) { + if (account.getIsDefault()) { + clearDefaultFlag(); + } + return repository.save(account); + } + + @Transactional + public BankAccount update(Long id, BankAccount updated) { + BankAccount account = findById(id); + account.setName(updated.getName()); + account.setHolder(updated.getHolder()); + account.setIban(updated.getIban()); + account.setBankName(updated.getBankName()); + account.setSwiftBic(updated.getSwiftBic()); + account.setCurrency(updated.getCurrency()); + account.setDescription(updated.getDescription()); + + if (updated.getIsDefault() && !Boolean.TRUE.equals(account.getIsDefault())) { + clearDefaultFlag(); + account.setIsDefault(true); + } else if (!Boolean.TRUE.equals(updated.getIsDefault())) { + account.setIsDefault(false); + } + + return repository.save(account); + } + + @Transactional + public void delete(Long id) { + BankAccount account = findById(id); + account.setActive(false); + repository.save(account); + } + + private void clearDefaultFlag() { + repository.findByIsDefaultTrueAndActiveTrue() + .ifPresent(a -> { + a.setIsDefault(false); + repository.save(a); + }); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategory.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategory.java new file mode 100644 index 0000000..e9b70da --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategory.java @@ -0,0 +1,25 @@ +package com.sapolar.finance.expense; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "expense_categories") +public class ExpenseCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 255) + private String description; + + @Column(nullable = false) + private Boolean active = true; +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategoryRepository.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategoryRepository.java new file mode 100644 index 0000000..94ebac8 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseCategoryRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.finance.expense; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ExpenseCategoryRepository extends JpaRepository { + List findByActiveTrueOrderByName(); +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceipt.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceipt.java new file mode 100644 index 0000000..90e361d --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceipt.java @@ -0,0 +1,125 @@ +package com.sapolar.finance.expense; + +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "expense_receipts", indexes = { + @Index(name = "idx_expense_receipts_template", columnList = "template_id"), + @Index(name = "idx_expense_receipts_property", columnList = "property_id"), + @Index(name = "idx_expense_receipts_property_group", columnList = "property_group_id"), + @Index(name = "idx_expense_receipts_category", columnList = "category_id"), + @Index(name = "idx_expense_receipts_status", columnList = "status_id"), + @Index(name = "idx_expense_receipts_issue_date", columnList = "issue_date") +}) +public class ExpenseReceipt { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id") + private ExpenseTemplate template; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_group_id") + private PropertyGroup propertyGroup; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bank_account_id") + private BankAccount bankAccount; + + @Column(name = "is_domiciled", nullable = false) + private Boolean isDomiciled = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private ExpenseCategory category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private ExpenseStatus status; + + @Column(name = "supplier_name", length = 200) + private String supplierName; + + @Column(name = "supplier_fiscal_id", length = 20) + private String supplierFiscalId; + + @Column(name = "invoice_number", length = 50) + private String invoiceNumber; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal amount = BigDecimal.ZERO; + + @Column(name = "tax_amount", precision = 12, scale = 2) + private BigDecimal taxAmount = BigDecimal.ZERO; + + @Column(name = "total_amount", precision = 12, scale = 2) + private BigDecimal totalAmount; + + @Column(name = "is_variable", nullable = false) + private Boolean isVariable = false; + + @Column(name = "previous_amount", precision = 12, scale = 2) + private BigDecimal previousAmount; + + @Column(name = "issue_date", nullable = false) + private LocalDate issueDate; + + @Column(name = "due_date") + private LocalDate dueDate; + + @Column(name = "payment_date") + private LocalDate paymentDate; + + @Column(name = "payment_method", length = 30) + private String paymentMethod; + + @Column(nullable = false, length = 500) + private String description; + + @Column(columnDefinition = "TEXT") + private String notes; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptController.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptController.java new file mode 100644 index 0000000..1f052cf --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptController.java @@ -0,0 +1,113 @@ +package com.sapolar.finance.expense; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/expense-receipts") +@RequiredArgsConstructor +public class ExpenseReceiptController { + + private final ExpenseReceiptService expenseReceiptService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) Long propertyId, + @RequestParam(required = false) Long templateId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + if (propertyId != null) { + List list = expenseReceiptService.findByProperty(propertyId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + if (templateId != null) { + List list = expenseReceiptService.findByTemplate(templateId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(expenseReceiptService.findAll(pageable)))); + } + + @GetMapping("/pending") + public ResponseEntity>> getPending() { + return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPending())); + } + + @GetMapping("/pending/count") + public ResponseEntity>> getPendingCount() { + long total = expenseReceiptService.countPending(); + long variable = expenseReceiptService.findPendingVariable().size(); + return ResponseEntity.ok(ApiResponse.success(Map.of("total", total, "variable", variable))); + } + + @GetMapping("/pending/variable") + public ResponseEntity>> getPendingVariable() { + return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPendingVariable())); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findById(id))); + } + + @PostMapping + public ResponseEntity> createManual(@Valid @RequestBody ExpenseReceipt receipt, + @AuthenticationPrincipal SecurityUser user) { + ExpenseReceipt data = receipt; + return ResponseEntity.ok(ApiResponse.success("Recibo de gasto creado", + expenseReceiptService.createManual(data, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, + @Valid @RequestBody ExpenseReceipt receipt) { + ExpenseReceipt data = receipt; + return ResponseEntity.ok(ApiResponse.success("Recibo de gasto actualizado", + expenseReceiptService.update(id, data))); + } + + @PatchMapping("/{id}/pay") + public ResponseEntity> registerPayment(@PathVariable Long id, + @RequestBody Map body) { + String paymentDateStr = body.get("paymentDate"); + if (paymentDateStr == null) { + paymentDateStr = LocalDate.now().toString(); + } + String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA"); + return ResponseEntity.ok(ApiResponse.success("Pago registrado", + expenseReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr))); + } + + @PatchMapping("/{id}/amount") + public ResponseEntity> updateAmount(@PathVariable Long id, + @RequestBody Map body) { + BigDecimal amount = body.containsKey("amount") + ? new BigDecimal(body.get("amount").toString()) + : BigDecimal.ZERO; + return ResponseEntity.ok(ApiResponse.success("Importe actualizado", + expenseReceiptService.updateAmount(id, amount))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + expenseReceiptService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Recibo de gasto eliminado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptRepository.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptRepository.java new file mode 100644 index 0000000..8e4b997 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptRepository.java @@ -0,0 +1,43 @@ +package com.sapolar.finance.expense; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ExpenseReceiptRepository extends JpaRepository { + List findByTemplateId(Long templateId); + + List findByPropertyId(Long propertyId); + + List findByStatusId(Integer statusId); + + List findByTemplateIdAndIssueDateBetween(Long templateId, LocalDate start, LocalDate end); + + @Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.property.id = :propertyId " + + "AND e.status.name = 'PAGADO' AND e.issueDate BETWEEN :start AND :end") + BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end); + + @Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.status.name = 'PAGADO' " + + "AND e.issueDate BETWEEN :start AND :end") + BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end); + + long countByStatusId(Integer statusId); + + Optional findTopByTemplateIdOrderByIssueDateDesc(Long templateId); + + @Query("SELECT e FROM ExpenseReceipt e JOIN FETCH e.category " + + "WHERE e.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY e.dueDate ASC") + List findPendingOrderByDueDate(); + + @Query("SELECT c.name, COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e JOIN e.category c " + + "WHERE YEAR(e.issueDate) = :year AND e.status.name = 'PAGADO' " + + "GROUP BY c.name ORDER BY SUM(e.totalAmount) DESC") + List sumByCategoryForYear(@Param("year") int year); +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptService.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptService.java new file mode 100644 index 0000000..7cef157 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseReceiptService.java @@ -0,0 +1,261 @@ +package com.sapolar.finance.expense; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.finance.bank.BankAccountRepository; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.property.PropertyGroupRepository; +import com.sapolar.property.PropertyRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ExpenseReceiptService { + + private final ExpenseReceiptRepository expenseReceiptRepository; + private final ExpenseTemplateRepository expenseTemplateRepository; + private final PropertyRepository propertyRepository; + private final PropertyGroupRepository propertyGroupRepository; + private final BankAccountRepository bankAccountRepository; + private final ExpenseCategoryRepository expenseCategoryRepository; + + public List findAll() { + return expenseReceiptRepository.findAll(); + } + + public Page findAll(Pageable pageable) { + return expenseReceiptRepository.findAll(pageable); + } + + public ExpenseReceipt findById(Long id) { + return expenseReceiptRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Recibo de gasto", id)); + } + + public List findByProperty(Long propertyId) { + return expenseReceiptRepository.findByPropertyId(propertyId); + } + + public List findByTemplate(Long templateId) { + return expenseReceiptRepository.findByTemplateId(templateId); + } + + public List findPending() { + return expenseReceiptRepository.findByStatusId(1); + } + + public long countPending() { + return expenseReceiptRepository.countByStatusId(1); + } + + public List findPendingVariable() { + return expenseReceiptRepository.findAll().stream() + .filter(r -> Boolean.TRUE.equals(r.getIsVariable())) + .filter(r -> r.getAmount() == null || r.getAmount().compareTo(BigDecimal.ZERO) == 0) + .collect(Collectors.toList()); + } + + @Transactional + public ExpenseReceipt createFromTemplate(ExpenseTemplate template, LocalDate issueDate, Long userId) { + ExpenseReceipt receipt = new ExpenseReceipt(); + + receipt.setTemplate(template); + receipt.setProperty(template.getProperty()); + receipt.setPropertyGroup(template.getPropertyGroup()); + receipt.setBankAccount(template.getBankAccount()); + receipt.setIsDomiciled(template.getIsDomiciled()); + receipt.setCategory(template.getCategory()); + receipt.setSupplierName(template.getSupplierName()); + receipt.setSupplierFiscalId(template.getSupplierFiscalId()); + receipt.setDescription(template.getDescription()); + receipt.setNotes(template.getNotes()); + receipt.setIssueDate(issueDate); + receipt.setDueDate(issueDate.plusDays(30)); + + if (Boolean.TRUE.equals(template.getIsVariable())) { + receipt.setIsVariable(true); + receipt.setAmount(BigDecimal.ZERO); + receipt.setTaxAmount(BigDecimal.ZERO); + receipt.setTotalAmount(BigDecimal.ZERO); + + expenseReceiptRepository.findTopByTemplateIdOrderByIssueDateDesc(template.getId()) + .ifPresent(last -> receipt.setPreviousAmount(last.getAmount())); + } else { + receipt.setIsVariable(false); + receipt.setAmount(template.getAmount()); + receipt.setTaxAmount(template.getTaxAmount() != null ? template.getTaxAmount() : BigDecimal.ZERO); + receipt.setTotalAmount(receipt.getAmount().add(receipt.getTaxAmount())); + } + + ExpenseStatus status = new ExpenseStatus(); + status.setId(1); + receipt.setStatus(status); + + User user = new User(); + user.setId(userId); + receipt.setCreatedBy(user); + + return expenseReceiptRepository.save(receipt); + } + + @Transactional + public ExpenseReceipt createManual(ExpenseReceipt receipt, Long userId) { + if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new BadRequestException("El importe debe ser mayor que cero"); + } + + if (receipt.getProperty() != null && receipt.getProperty().getId() != null) { + Property property = propertyRepository.findById(receipt.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId())); + receipt.setProperty(property); + } + if (receipt.getPropertyGroup() != null && receipt.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(receipt.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", receipt.getPropertyGroup().getId())); + receipt.setPropertyGroup(group); + } + if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId())); + receipt.setBankAccount(bank); + } + if (receipt.getCategory() != null && receipt.getCategory().getId() != null) { + ExpenseCategory category = expenseCategoryRepository.findById(receipt.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", receipt.getCategory().getId())); + receipt.setCategory(category); + } + + if (receipt.getTotalAmount() == null) { + BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO; + receipt.setTotalAmount(receipt.getAmount().add(tax)); + } + + if (receipt.getStatus() == null) { + ExpenseStatus status = new ExpenseStatus(); + status.setId(1); + receipt.setStatus(status); + } + + User user = new User(); + user.setId(userId); + receipt.setCreatedBy(user); + + return expenseReceiptRepository.save(receipt); + } + + @Transactional + public ExpenseReceipt update(Long id, ExpenseReceipt updated) { + ExpenseReceipt receipt = findById(id); + + if (updated.getTemplate() != null && updated.getTemplate().getId() != null) { + ExpenseTemplate template = expenseTemplateRepository.findById(updated.getTemplate().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", updated.getTemplate().getId())); + receipt.setTemplate(template); + } else { + receipt.setTemplate(null); + } + + if (updated.getProperty() != null && updated.getProperty().getId() != null) { + Property property = propertyRepository.findById(updated.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId())); + receipt.setProperty(property); + } else { + receipt.setProperty(null); + } + if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId())); + receipt.setPropertyGroup(group); + } else { + receipt.setPropertyGroup(null); + } + if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId())); + receipt.setBankAccount(bank); + } else { + receipt.setBankAccount(null); + } + receipt.setIsDomiciled(updated.getIsDomiciled()); + + if (updated.getCategory() != null && updated.getCategory().getId() != null) { + ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId())); + receipt.setCategory(category); + } else { + receipt.setCategory(null); + } + + if (updated.getStatus() != null && updated.getStatus().getId() != null) { + ExpenseStatus status = new ExpenseStatus(); + status.setId(updated.getStatus().getId()); + receipt.setStatus(status); + } + + receipt.setSupplierName(updated.getSupplierName()); + receipt.setSupplierFiscalId(updated.getSupplierFiscalId()); + receipt.setInvoiceNumber(updated.getInvoiceNumber()); + receipt.setAmount(updated.getAmount()); + receipt.setTaxAmount(updated.getTaxAmount()); + receipt.setTotalAmount(updated.getTotalAmount()); + receipt.setIsVariable(updated.getIsVariable()); + receipt.setPreviousAmount(updated.getPreviousAmount()); + receipt.setIssueDate(updated.getIssueDate()); + receipt.setDueDate(updated.getDueDate()); + receipt.setPaymentDate(updated.getPaymentDate()); + receipt.setPaymentMethod(updated.getPaymentMethod()); + receipt.setDescription(updated.getDescription()); + receipt.setNotes(updated.getNotes()); + + return expenseReceiptRepository.save(receipt); + } + + @Transactional + public ExpenseReceipt registerPayment(Long id, String paymentDate, String paymentMethod) { + ExpenseReceipt receipt = findById(id); + receipt.setPaymentDate(LocalDate.parse(paymentDate)); + receipt.setPaymentMethod(paymentMethod); + + ExpenseStatus status = new ExpenseStatus(); + status.setId(2); + receipt.setStatus(status); + + return expenseReceiptRepository.save(receipt); + } + + @Transactional + public ExpenseReceipt updateAmount(Long id, BigDecimal amount) { + ExpenseReceipt receipt = findById(id); + + if (!Boolean.TRUE.equals(receipt.getIsVariable())) { + throw new BadRequestException("Solo se puede actualizar el importe de recibos variables"); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new BadRequestException("El importe debe ser mayor que cero"); + } + + receipt.setAmount(amount); + BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO; + receipt.setTotalAmount(amount.add(tax)); + + return expenseReceiptRepository.save(receipt); + } + + @Transactional + public void delete(Long id) { + ExpenseReceipt receipt = findById(id); + expenseReceiptRepository.delete(receipt); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseStatus.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseStatus.java new file mode 100644 index 0000000..1828c3e --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseStatus.java @@ -0,0 +1,19 @@ +package com.sapolar.finance.expense; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "expense_statuses") +public class ExpenseStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplate.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplate.java new file mode 100644 index 0000000..3b83e0f --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplate.java @@ -0,0 +1,104 @@ +package com.sapolar.finance.expense; + +import com.sapolar.contract.PaymentPeriod; +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "expense_templates", indexes = { + @Index(name = "idx_expense_templates_property", columnList = "property_id"), + @Index(name = "idx_expense_templates_property_group", columnList = "property_group_id"), + @Index(name = "idx_expense_templates_category", columnList = "category_id"), + @Index(name = "idx_expense_templates_period", columnList = "period_id") +}) +public class ExpenseTemplate { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_group_id") + private PropertyGroup propertyGroup; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bank_account_id") + private BankAccount bankAccount; + + @Column(name = "is_domiciled", nullable = false) + private Boolean isDomiciled = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private ExpenseCategory category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "period_id", nullable = false) + private PaymentPeriod period; + + @Column(name = "payment_day", nullable = false) + private Integer paymentDay; + + @Column(name = "supplier_name", length = 200) + private String supplierName; + + @Column(name = "supplier_fiscal_id", length = 20) + private String supplierFiscalId; + + @Column(precision = 12, scale = 2) + private BigDecimal amount; + + @Column(name = "tax_amount", precision = 12, scale = 2) + private BigDecimal taxAmount = BigDecimal.ZERO; + + @Column(nullable = false, length = 500) + private String description; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(name = "is_variable", nullable = false) + private Boolean isVariable = false; + + @Column(nullable = false) + private Boolean active = true; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateController.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateController.java new file mode 100644 index 0000000..6678a60 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateController.java @@ -0,0 +1,66 @@ +package com.sapolar.finance.expense; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/expense-templates") +@RequiredArgsConstructor +public class ExpenseTemplateController { + + private final ExpenseTemplateService expenseTemplateService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + List list = expenseTemplateService.findAll(); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + + @GetMapping("/active") + public ResponseEntity>> findActive() { + return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findActive())); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody ExpenseTemplate template, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto creada", + expenseTemplateService.create(template, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, + @Valid @RequestBody ExpenseTemplate template) { + return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto actualizada", + expenseTemplateService.update(id, template))); + } + + @PatchMapping("/{id}/toggle-active") + public ResponseEntity> toggleActive(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success("Estado de plantilla cambiado", + expenseTemplateService.toggleActive(id))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + expenseTemplateService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto eliminada", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateRepository.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateRepository.java new file mode 100644 index 0000000..4c99cbd --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.finance.expense; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ExpenseTemplateRepository extends JpaRepository { + List findByActiveTrue(); +} diff --git a/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateService.java b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateService.java new file mode 100644 index 0000000..3cb2bb7 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/expense/ExpenseTemplateService.java @@ -0,0 +1,150 @@ +package com.sapolar.finance.expense; + +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.contract.PaymentPeriod; +import com.sapolar.contract.PaymentPeriodRepository; +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.finance.bank.BankAccountRepository; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.property.PropertyGroupRepository; +import com.sapolar.property.PropertyRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ExpenseTemplateService { + + private final ExpenseTemplateRepository expenseTemplateRepository; + private final PropertyRepository propertyRepository; + private final PropertyGroupRepository propertyGroupRepository; + private final BankAccountRepository bankAccountRepository; + private final ExpenseCategoryRepository expenseCategoryRepository; + private final PaymentPeriodRepository paymentPeriodRepository; + + public List findAll() { + return expenseTemplateRepository.findAll(); + } + + public ExpenseTemplate findById(Long id) { + return expenseTemplateRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", id)); + } + + public Page findAll(Pageable pageable) { + return expenseTemplateRepository.findAll(pageable); + } + + public List findActive() { + return expenseTemplateRepository.findByActiveTrue(); + } + + @Transactional + public ExpenseTemplate create(ExpenseTemplate template, Long userId) { + if (template.getProperty() != null && template.getProperty().getId() != null) { + Property property = propertyRepository.findById(template.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", template.getProperty().getId())); + template.setProperty(property); + } + if (template.getPropertyGroup() != null && template.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(template.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", template.getPropertyGroup().getId())); + template.setPropertyGroup(group); + } + if (template.getBankAccount() != null && template.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(template.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", template.getBankAccount().getId())); + template.setBankAccount(bank); + } + if (template.getCategory() != null && template.getCategory().getId() != null) { + ExpenseCategory category = expenseCategoryRepository.findById(template.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", template.getCategory().getId())); + template.setCategory(category); + } + if (template.getPeriod() != null && template.getPeriod().getId() != null) { + PaymentPeriod period = paymentPeriodRepository.findById(template.getPeriod().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + template.getPeriod().getId())); + template.setPeriod(period); + } + + User user = new User(); + user.setId(userId); + template.setCreatedBy(user); + + return expenseTemplateRepository.save(template); + } + + @Transactional + public ExpenseTemplate update(Long id, ExpenseTemplate updated) { + ExpenseTemplate template = findById(id); + + if (updated.getProperty() != null && updated.getProperty().getId() != null) { + Property property = propertyRepository.findById(updated.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId())); + template.setProperty(property); + } else { + template.setProperty(null); + } + if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId())); + template.setPropertyGroup(group); + } else { + template.setPropertyGroup(null); + } + if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId())); + template.setBankAccount(bank); + } else { + template.setBankAccount(null); + } + template.setIsDomiciled(updated.getIsDomiciled()); + + if (updated.getCategory() != null && updated.getCategory().getId() != null) { + ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId())); + template.setCategory(category); + } else { + template.setCategory(null); + } + if (updated.getPeriod() != null && updated.getPeriod().getId() != null) { + PaymentPeriod period = paymentPeriodRepository.findById(updated.getPeriod().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + updated.getPeriod().getId())); + template.setPeriod(period); + } + + template.setPaymentDay(updated.getPaymentDay()); + template.setSupplierName(updated.getSupplierName()); + template.setSupplierFiscalId(updated.getSupplierFiscalId()); + template.setAmount(updated.getAmount()); + template.setTaxAmount(updated.getTaxAmount()); + template.setDescription(updated.getDescription()); + template.setNotes(updated.getNotes()); + template.setIsVariable(updated.getIsVariable()); + template.setActive(updated.getActive()); + + return expenseTemplateRepository.save(template); + } + + @Transactional + public void delete(Long id) { + ExpenseTemplate template = findById(id); + template.setActive(false); + expenseTemplateRepository.save(template); + } + + @Transactional + public ExpenseTemplate toggleActive(Long id) { + ExpenseTemplate template = findById(id); + template.setActive(!template.getActive()); + return expenseTemplateRepository.save(template); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeCategory.java b/backend/src/main/java/com/sapolar/finance/income/IncomeCategory.java new file mode 100644 index 0000000..26d48a2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeCategory.java @@ -0,0 +1,25 @@ +package com.sapolar.finance.income; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "income_categories") +public class IncomeCategory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String name; + + @Column(length = 255) + private String description; + + @Column(nullable = false) + private Boolean active = true; +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeCategoryRepository.java b/backend/src/main/java/com/sapolar/finance/income/IncomeCategoryRepository.java new file mode 100644 index 0000000..0cc42c1 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeCategoryRepository.java @@ -0,0 +1,8 @@ +package com.sapolar.finance.income; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IncomeCategoryRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeReceipt.java b/backend/src/main/java/com/sapolar/finance/income/IncomeReceipt.java new file mode 100644 index 0000000..dea9482 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeReceipt.java @@ -0,0 +1,117 @@ +package com.sapolar.finance.income; + +import com.sapolar.contract.Contract; +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.property.Property; +import com.sapolar.tenant.Tenant; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "income_receipts", indexes = { + @Index(name = "idx_income_receipts_property", columnList = "property_id"), + @Index(name = "idx_income_receipts_contract", columnList = "contract_id"), + @Index(name = "idx_income_receipts_tenant", columnList = "tenant_id"), + @Index(name = "idx_income_receipts_status", columnList = "status_id"), + @Index(name = "idx_income_receipts_period_label", columnList = "period_label"), + @Index(name = "idx_income_receipts_issue_date", columnList = "issue_date") +}) +public class IncomeReceipt { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "contract_id") + private Contract contract; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id", nullable = false) + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_id") + private Tenant tenant; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "bank_account_id") + private BankAccount bankAccount; + + @Column(name = "is_domiciled", nullable = false) + private Boolean isDomiciled = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private IncomeCategory category; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private IncomeStatus status; + + @Column(name = "period_label", length = 20) + private String periodLabel; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal amount; + + @Column(name = "tax_withheld", precision = 12, scale = 2) + private BigDecimal taxWithheld = BigDecimal.ZERO; + + @Column(name = "net_amount", precision = 12, scale = 2) + private BigDecimal netAmount; + + @Column(name = "issue_date", nullable = false) + private LocalDate issueDate; + + @Column(name = "due_date") + private LocalDate dueDate; + + @Column(name = "payment_date") + private LocalDate paymentDate; + + @Column(name = "payment_method", length = 30) + private String paymentMethod; + + @Column(length = 500) + private String description; + + @Column(name = "receipt_number", length = 50) + private String receiptNumber; + + @Column(columnDefinition = "TEXT") + private String notes; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptController.java b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptController.java new file mode 100644 index 0000000..3e90b7c --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptController.java @@ -0,0 +1,98 @@ +package com.sapolar.finance.income; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/income-receipts") +@RequiredArgsConstructor +public class IncomeReceiptController { + + private final IncomeReceiptService incomeReceiptService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) Long contractId, + @RequestParam(required = false) Long propertyId, + @RequestParam(required = false) String periodLabel, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + if (contractId != null) { + List list = incomeReceiptService.findByContract(contractId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + if (propertyId != null) { + List list = incomeReceiptService.findByProperty(propertyId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + if (periodLabel != null) { + List list = incomeReceiptService.findByPeriodLabel(periodLabel); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incomeReceiptService.findAll(pageable)))); + } + + @GetMapping("/pending") + public ResponseEntity>> getPending() { + return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findPending())); + } + + @GetMapping("/pending/count") + public ResponseEntity> getPendingCount() { + return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.countPending())); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody IncomeReceipt receipt, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso creado", + incomeReceiptService.create(receipt, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, + @Valid @RequestBody IncomeReceipt receipt) { + return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso actualizado", + incomeReceiptService.update(id, receipt))); + } + + @PatchMapping("/{id}/pay") + public ResponseEntity> registerPayment(@PathVariable Long id, + @RequestBody Map body) { + String paymentDateStr = body.get("paymentDate"); + if (paymentDateStr == null) { + paymentDateStr = LocalDate.now().toString(); + } + String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA"); + return ResponseEntity.ok(ApiResponse.success("Pago registrado", + incomeReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + incomeReceiptService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso eliminado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptRepository.java b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptRepository.java new file mode 100644 index 0000000..9a510fa --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptRepository.java @@ -0,0 +1,43 @@ +package com.sapolar.finance.income; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface IncomeReceiptRepository extends JpaRepository { + List findByContractId(Long contractId); + + List findByPropertyId(Long propertyId); + + List findByStatusId(Integer statusId); + + Optional findByContractIdAndPeriodLabel(Long contractId, String periodLabel); + + List findByPeriodLabel(String periodLabel); + + @Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.property.id = :propertyId " + + "AND r.status.name = 'PAGADO' AND r.issueDate BETWEEN :start AND :end") + BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end); + + @Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.status.name = 'PAGADO' " + + "AND r.issueDate BETWEEN :start AND :end") + BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end); + + long countByStatusId(Integer statusId); + + @Query("SELECT r FROM IncomeReceipt r JOIN FETCH r.property p " + + "WHERE r.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY r.dueDate ASC") + List findPendingOrderByDueDate(); + + @Query("SELECT c.name, COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r JOIN r.category c " + + "WHERE YEAR(r.issueDate) = :year AND r.status.name IN ('PAGADO', 'PARCIAL') " + + "GROUP BY c.name ORDER BY SUM(r.netAmount) DESC") + List sumByCategoryForYear(@Param("year") int year); +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptService.java b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptService.java new file mode 100644 index 0000000..22e6adc --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeReceiptService.java @@ -0,0 +1,206 @@ +package com.sapolar.finance.income; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.contract.Contract; +import com.sapolar.contract.ContractRepository; +import com.sapolar.finance.bank.BankAccount; +import com.sapolar.finance.bank.BankAccountRepository; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyRepository; +import com.sapolar.tenant.Tenant; +import com.sapolar.tenant.TenantRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class IncomeReceiptService { + + private final IncomeReceiptRepository incomeReceiptRepository; + private final ContractRepository contractRepository; + private final PropertyRepository propertyRepository; + private final TenantRepository tenantRepository; + private final BankAccountRepository bankAccountRepository; + private final IncomeCategoryRepository incomeCategoryRepository; + + public List findAll() { + return incomeReceiptRepository.findAll(); + } + + public Page findAll(Pageable pageable) { + return incomeReceiptRepository.findAll(pageable); + } + + public IncomeReceipt findById(Long id) { + return incomeReceiptRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Recibo de ingreso", id)); + } + + public List findByContract(Long contractId) { + return incomeReceiptRepository.findByContractId(contractId); + } + + public List findByProperty(Long propertyId) { + return incomeReceiptRepository.findByPropertyId(propertyId); + } + + public List findByPeriodLabel(String periodLabel) { + return incomeReceiptRepository.findByPeriodLabel(periodLabel); + } + + public List findPending() { + return incomeReceiptRepository.findByStatusId(1); + } + + public long countPending() { + return incomeReceiptRepository.countByStatusId(1); + } + + @Transactional + public IncomeReceipt create(IncomeReceipt receipt, Long userId) { + if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) { + throw new BadRequestException("El importe debe ser mayor que cero"); + } + + Contract contract = null; + if (receipt.getContract() != null && receipt.getContract().getId() != null) { + contract = contractRepository.findById(receipt.getContract().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Contrato", receipt.getContract().getId())); + receipt.setContract(contract); + } + + if (receipt.getProperty() != null && receipt.getProperty().getId() != null) { + Property property = propertyRepository.findById(receipt.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId())); + receipt.setProperty(property); + } + + if (receipt.getTenant() != null && receipt.getTenant().getId() != null) { + Tenant tenant = tenantRepository.findById(receipt.getTenant().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Inquilino", receipt.getTenant().getId())); + receipt.setTenant(tenant); + } + + if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId())); + receipt.setBankAccount(bank); + } + + if (receipt.getCategory() != null && receipt.getCategory().getId() != null) { + IncomeCategory category = incomeCategoryRepository.findById(receipt.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", receipt.getCategory().getId())); + receipt.setCategory(category); + } + + if (receipt.getNetAmount() == null) { + BigDecimal tax = receipt.getTaxWithheld() != null ? receipt.getTaxWithheld() : BigDecimal.ZERO; + receipt.setNetAmount(receipt.getAmount().subtract(tax)); + } + + if (receipt.getStatus() == null) { + IncomeStatus status = new IncomeStatus(); + status.setId(1); + receipt.setStatus(status); + } + + User user = new User(); + user.setId(userId); + receipt.setCreatedBy(user); + + return incomeReceiptRepository.save(receipt); + } + + @Transactional + public IncomeReceipt update(Long id, IncomeReceipt updated) { + IncomeReceipt receipt = findById(id); + + if (updated.getContract() != null && updated.getContract().getId() != null) { + Contract contract = contractRepository.findById(updated.getContract().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Contrato", updated.getContract().getId())); + receipt.setContract(contract); + } else { + receipt.setContract(null); + } + + if (updated.getProperty() != null && updated.getProperty().getId() != null) { + Property property = propertyRepository.findById(updated.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId())); + receipt.setProperty(property); + } + + if (updated.getTenant() != null && updated.getTenant().getId() != null) { + Tenant tenant = tenantRepository.findById(updated.getTenant().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Inquilino", updated.getTenant().getId())); + receipt.setTenant(tenant); + } else { + receipt.setTenant(null); + } + + if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) { + BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId())); + receipt.setBankAccount(bank); + } else { + receipt.setBankAccount(null); + } + + receipt.setIsDomiciled(updated.getIsDomiciled()); + + if (updated.getCategory() != null && updated.getCategory().getId() != null) { + IncomeCategory category = incomeCategoryRepository.findById(updated.getCategory().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", updated.getCategory().getId())); + receipt.setCategory(category); + } else { + receipt.setCategory(null); + } + + if (updated.getStatus() != null && updated.getStatus().getId() != null) { + IncomeStatus status = new IncomeStatus(); + status.setId(updated.getStatus().getId()); + receipt.setStatus(status); + } + + receipt.setPeriodLabel(updated.getPeriodLabel()); + receipt.setAmount(updated.getAmount()); + receipt.setTaxWithheld(updated.getTaxWithheld()); + receipt.setNetAmount(updated.getNetAmount()); + receipt.setIssueDate(updated.getIssueDate()); + receipt.setDueDate(updated.getDueDate()); + receipt.setPaymentDate(updated.getPaymentDate()); + receipt.setPaymentMethod(updated.getPaymentMethod()); + receipt.setDescription(updated.getDescription()); + receipt.setReceiptNumber(updated.getReceiptNumber()); + receipt.setNotes(updated.getNotes()); + + return incomeReceiptRepository.save(receipt); + } + + @Transactional + public IncomeReceipt registerPayment(Long id, String paymentDate, String paymentMethod) { + IncomeReceipt receipt = findById(id); + receipt.setPaymentDate(LocalDate.parse(paymentDate)); + receipt.setPaymentMethod(paymentMethod); + + IncomeStatus status = new IncomeStatus(); + status.setId(2); + receipt.setStatus(status); + + return incomeReceiptRepository.save(receipt); + } + + @Transactional + public void delete(Long id) { + IncomeReceipt receipt = findById(id); + incomeReceiptRepository.delete(receipt); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/income/IncomeStatus.java b/backend/src/main/java/com/sapolar/finance/income/IncomeStatus.java new file mode 100644 index 0000000..fc76334 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/income/IncomeStatus.java @@ -0,0 +1,19 @@ +package com.sapolar.finance.income; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "income_statuses") +public class IncomeStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/EmailLog.java b/backend/src/main/java/com/sapolar/finance/receipt/EmailLog.java new file mode 100644 index 0000000..ca9a0e9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/EmailLog.java @@ -0,0 +1,44 @@ +package com.sapolar.finance.receipt; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "email_log") +public class EmailLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "income_receipt_id") + private Long incomeReceiptId; + + @Column(name = "recipient_email", nullable = false, length = 200) + private String recipientEmail; + + @Column(name = "subject", nullable = false, length = 300) + private String subject; + + @Column(name = "body", columnDefinition = "TEXT") + private String body; + + @Column(name = "success", nullable = false) + private Boolean success = false; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "sent_at", nullable = false) + private LocalDateTime sentAt; + + @PrePersist + protected void onCreate() { + sentAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/EmailLogRepository.java b/backend/src/main/java/com/sapolar/finance/receipt/EmailLogRepository.java new file mode 100644 index 0000000..1667d4b --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/EmailLogRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.finance.receipt; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EmailLogRepository extends JpaRepository { + List findByIncomeReceiptIdOrderBySentAtDesc(Long incomeReceiptId); +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/EmailReceiptService.java b/backend/src/main/java/com/sapolar/finance/receipt/EmailReceiptService.java new file mode 100644 index 0000000..4069a3d --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/EmailReceiptService.java @@ -0,0 +1,66 @@ +package com.sapolar.finance.receipt; + +import com.sapolar.finance.income.IncomeReceipt; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailReceiptService { + + private final JavaMailSender mailSender; + private final EmailLogRepository emailLogRepository; + + @Value("${app.receipt.from-email:noreply@sapolar.com}") + private String fromEmail; + + public void sendReceipt(IncomeReceipt income, byte[] pdf, String toEmail) { + EmailLog log = new EmailLog(); + log.setIncomeReceiptId(income.getId()); + log.setRecipientEmail(toEmail); + log.setSubject("Recibo de alquiler - " + income.getReceiptNumber()); + + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(fromEmail); + helper.setTo(toEmail); + helper.setSubject(log.getSubject()); + + String body = buildEmailBody(income); + helper.setText(body, true); + + String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : income.getId()) + ".pdf"; + helper.addAttachment(filename, () -> new java.io.ByteArrayInputStream(pdf)); + + mailSender.send(message); + + log.setSuccess(true); + log.setBody(body); + } catch (MessagingException e) { + log.setSuccess(false); + log.setErrorMessage(e.getMessage()); + } + + emailLogRepository.save(log); + } + + private String buildEmailBody(IncomeReceipt income) { + return "" + + "

SA POLAR - Gestión de Alquileres

" + + "

Estimado/a " + (income.getTenant() != null ? income.getTenant().getFullName() : "") + ",

" + + "

Adjuntamos el recibo de alquiler correspondiente.

" + + "

Recibo Nº: " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N") + "

" + + "

Propiedad: " + (income.getProperty() != null ? income.getProperty().getName() : "") + "

" + + "

Importe: " + income.getAmount() + " €

" + + "

Periodo: " + (income.getDescription() != null ? income.getDescription() : "") + "

" + + "

Este mensaje se ha generado automáticamente.

" + + ""; + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/PdfReceiptService.java b/backend/src/main/java/com/sapolar/finance/receipt/PdfReceiptService.java new file mode 100644 index 0000000..8111105 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/PdfReceiptService.java @@ -0,0 +1,116 @@ +package com.sapolar.finance.receipt; + +import com.itextpdf.kernel.pdf.PdfDocument; +import com.itextpdf.kernel.pdf.PdfWriter; +import com.itextpdf.layout.Document; +import com.itextpdf.layout.element.Cell; +import com.itextpdf.layout.element.Paragraph; +import com.itextpdf.layout.element.Table; +import com.itextpdf.layout.properties.UnitValue; +import com.sapolar.finance.income.IncomeReceipt; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.text.NumberFormat; +import java.util.Locale; + +@Service +public class PdfReceiptService { + + public byte[] generatePdf(IncomeReceipt income) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (PdfDocument pdfDoc = new PdfDocument(new PdfWriter(baos)); + Document document = new Document(pdfDoc)) { + + document.add(new Paragraph("SA POLAR - GESTIÓN DE ALQUILERES") + .setBold().setFontSize(18)); + document.add(new Paragraph("CIF/NIF: B-12345678")); + document.add(new Paragraph("C/ Ejemplo, 123 - 07001 Palma")); + document.add(new Paragraph("")); + document.add(new Paragraph("")); + + document.add(new Paragraph("RECIBO Nº: " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N")) + .setBold().setFontSize(14)); + document.add(new Paragraph("")); + document.add(new Paragraph("Fecha de emisión: " + income.getIssueDate())); + document.add(new Paragraph("")); + document.add(new Paragraph("")); + + Table infoTable = new Table(UnitValue.createPercentArray(new float[]{30, 70})); + infoTable.addCell(createLabelCell("Inquilino/a:")); + infoTable.addCell(createValueCell( + income.getTenant() != null ? income.getTenant().getFullName() : "")); + infoTable.addCell(createLabelCell("NIF/CIF:")); + infoTable.addCell(createValueCell( + income.getTenant() != null ? income.getTenant().getFiscalId() : "")); + infoTable.addCell(createLabelCell("Propiedad:")); + infoTable.addCell(createValueCell( + income.getProperty() != null ? income.getProperty().getName() : "")); + infoTable.addCell(createLabelCell("Dirección:")); + infoTable.addCell(createValueCell( + income.getProperty() != null ? income.getProperty().getAddressStreet() : "")); + infoTable.addCell(createLabelCell("Período:")); + infoTable.addCell(createValueCell(income.getDescription())); + document.add(infoTable); + + document.add(new Paragraph("")); + document.add(new Paragraph("")); + + Table amountTable = new Table(UnitValue.createPercentArray(new float[]{70, 30})); + amountTable.addCell(createHeaderCell("Concepto")); + amountTable.addCell(createHeaderCell("Importe")); + + String concept = income.getDescription() != null ? income.getDescription() : "Alquiler mensual"; + amountTable.addCell(createValueCell(concept)); + amountTable.addCell(createValueCell(formatCurrency(income.getAmount()))); + + if (income.getTaxWithheld() != null && income.getTaxWithheld().compareTo(BigDecimal.ZERO) > 0) { + amountTable.addCell(createLabelCell("Retención IRPF")); + amountTable.addCell(createValueCell("-" + formatCurrency(income.getTaxWithheld()))); + } + + amountTable.addCell(createHeaderCell("TOTAL")); + amountTable.addCell(createHeaderCell(formatCurrency( + income.getNetAmount() != null ? income.getNetAmount() : income.getAmount()))); + + document.add(amountTable); + document.add(new Paragraph("")); + document.add(new Paragraph("")); + + document.add(new Paragraph("Forma de pago: " + + (income.getPaymentMethod() != null ? income.getPaymentMethod() : "Pendiente"))); + if (income.getPaymentDate() != null) { + document.add(new Paragraph("Fecha de pago: " + income.getPaymentDate())); + } + } catch (Exception e) { + throw new RuntimeException("Error al generar el PDF del recibo", e); + } + + return baos.toByteArray(); + } + + private Cell createLabelCell(String text) { + return new Cell().add(new Paragraph(text).setBold()) + .setPadding(4); + } + + private Cell createValueCell(String text) { + return new Cell().add(new Paragraph(text != null ? text : "")) + .setPadding(4); + } + + private Cell createHeaderCell(String text) { + return new Cell().add(new Paragraph(text).setBold()) + .setPadding(6); + } + + private String formatCurrency(BigDecimal amount) { + if (amount == null) return "0,00 €"; + NumberFormat nf = NumberFormat.getNumberInstance(new Locale("es", "ES")); + nf.setMinimumFractionDigits(2); + nf.setMaximumFractionDigits(2); + return nf.format(amount) + " €"; + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReceiptController.java b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptController.java new file mode 100644 index 0000000..8d3a5d9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptController.java @@ -0,0 +1,88 @@ +package com.sapolar.finance.receipt; + +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.finance.income.IncomeReceipt; +import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/receipts") +@RequiredArgsConstructor +public class ReceiptController { + + private final ReceiptService receiptService; + private final ReportService reportService; + + @GetMapping + @Operation(summary = "Listar todos los recibos") + public ResponseEntity>> findAll() { + return ResponseEntity.ok(ApiResponse.success(receiptService.findAll())); + } + + @GetMapping("/{id}") + @Operation(summary = "Obtener recibo por ID") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(receiptService.findById(id))); + } + + @PostMapping("/generate") + @Operation(summary = "Generar un recibo manualmente") + public ResponseEntity> generate(@RequestBody ReceiptGenerateRequest request) { + IncomeReceipt income = receiptService.generateReceipt(request); + return ResponseEntity.ok(ApiResponse.success("Recibo generado correctamente", income)); + } + + @PostMapping("/generate-monthly") + @Operation(summary = "Generar recibos mensuales para todos los contratos activos") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> generateMonthly() { + receiptService.generateMonthlyReceipts(); + return ResponseEntity.ok(ApiResponse.success("Recibos mensuales generados")); + } + + @GetMapping("/{id}/pdf") + @Operation(summary = "Descargar PDF del recibo") + public ResponseEntity downloadPdf(@PathVariable Long id) { + byte[] pdf = receiptService.generateReceiptPdf(id); + IncomeReceipt income = receiptService.findById(id); + String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : id) + ".pdf"; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.APPLICATION_PDF) + .contentLength(pdf.length) + .body(new ByteArrayResource(pdf)); + } + + @PostMapping("/{id}/send-email") + @Operation(summary = "Enviar recibo por email") + public ResponseEntity> sendEmail(@PathVariable Long id, + @RequestParam String email) { + receiptService.sendReceiptEmail(id, email); + return ResponseEntity.ok(ApiResponse.success("Recibo enviado por email")); + } + + @GetMapping("/reports/monthly") + @Operation(summary = "Descargar informe mensual Excel") + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')") + public ResponseEntity downloadMonthlyReport( + @RequestParam int year, @RequestParam int month) { + byte[] report = reportService.generateMonthlyReport(year, month); + String filename = "informe_" + year + "_" + String.format("%02d", month) + ".xlsx"; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(report.length) + .body(new ByteArrayResource(report)); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReceiptScheduler.java b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptScheduler.java new file mode 100644 index 0000000..f771395 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptScheduler.java @@ -0,0 +1,50 @@ +package com.sapolar.finance.receipt; + +import com.sapolar.contract.Contract; +import com.sapolar.contract.ContractRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReceiptScheduler { + + private final ReceiptService receiptService; + private final ContractRepository contractRepository; + private final EmailReceiptService emailReceiptService; + + @Scheduled(cron = "0 0 6 1 * ?") + @Transactional + public void generateMonthlyReceipts() { + log.info("Iniciando generación automática de recibos mensuales..."); + receiptService.generateMonthlyReceipts(); + log.info("Generación de recibos completada."); + } + + @Scheduled(cron = "0 0 2 * * ?") + @Transactional + public void markOverdueIncomes() { + log.info("Iniciando marcado de recibos vencidos..."); + receiptService.markOverdueIncomes(); + log.info("Marcado de recibos vencidos completado."); + } + + @Scheduled(cron = "0 0 7 1 * ?") + @Transactional + public void checkExpiringContracts() { + log.info("Revisando contratos próximos a vencer..."); + LocalDate start = LocalDate.now(); + LocalDate end = start.plusMonths(1); + List expiring = contractRepository.findContractsExpiringBetween(start, end); + if (!expiring.isEmpty()) { + log.warn("Se encontraron {} contratos próximos a vencer", expiring.size()); + } + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeries.java b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeries.java new file mode 100644 index 0000000..1e15653 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeries.java @@ -0,0 +1,50 @@ +package com.sapolar.finance.receipt; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "receipt_series") +public class ReceiptSeries { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "series_name", nullable = false, length = 50) + private String seriesName; + + @Column(name = "fiscal_year", nullable = false) + private Integer fiscalYear; + + @Column(name = "last_number", nullable = false) + private Integer lastNumber = 0; + + @Column(name = "prefix", length = 20) + private String prefix; + + @Column(name = "active", nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeriesRepository.java b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeriesRepository.java new file mode 100644 index 0000000..b04b012 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptSeriesRepository.java @@ -0,0 +1,12 @@ +package com.sapolar.finance.receipt; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ReceiptSeriesRepository extends JpaRepository { + Optional findByFiscalYearAndSeriesName(Integer fiscalYear, String seriesName); + Optional findByFiscalYearAndActiveTrue(Integer fiscalYear); +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReceiptService.java b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptService.java new file mode 100644 index 0000000..7b961ba --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReceiptService.java @@ -0,0 +1,191 @@ +package com.sapolar.finance.receipt; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.contract.Contract; +import com.sapolar.contract.ContractRepository; +import com.sapolar.contract.ContractTenant; +import com.sapolar.contract.ContractTenantRepository; +import com.sapolar.finance.income.*; +import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReceiptService { + + private final IncomeReceiptRepository incomeReceiptRepository; + private final ContractRepository contractRepository; + private final ContractTenantRepository contractTenantRepository; + private final ReceiptSeriesRepository receiptSeriesRepository; + private final PdfReceiptService pdfReceiptService; + private final EmailReceiptService emailReceiptService; + + public List findAll() { + return incomeReceiptRepository.findAll(); + } + + public IncomeReceipt findById(Long id) { + return incomeReceiptRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Recibo", id)); + } + + @Transactional + public IncomeReceipt generateReceipt(ReceiptGenerateRequest request) { + Contract contract = contractRepository.findById(request.getContractId()) + .orElseThrow(() -> new ResourceNotFoundException("Contrato", request.getContractId())); + + if (!"ACTIVO".equals(contract.getStatus().getName())) { + throw new BadRequestException("El contrato no está activo"); + } + + IncomeReceipt income = new IncomeReceipt(); + income.setContract(contract); + income.setProperty(contract.getProperty()); + List cts = contractTenantRepository.findByContractId(contract.getId()); + income.setTenant(cts.stream().filter(ct -> "TITULAR".equals(ct.getRole())) + .findFirst().map(ContractTenant::getTenant) + .orElseGet(() -> cts.isEmpty() ? null : cts.get(0).getTenant())); + + IncomeCategory defaultCategory = new IncomeCategory(); + defaultCategory.setId(1L); + income.setCategory(defaultCategory); + + IncomeStatus pendingStatus = new IncomeStatus(); + pendingStatus.setId(1); + income.setStatus(pendingStatus); + + income.setAmount(contract.getRentalAmount()); + income.setNetAmount(contract.getRentalAmount()); + income.setIssueDate(request.getIssueDate() != null ? request.getIssueDate() : LocalDate.now()); + income.setDueDate(request.getDueDate() != null ? request.getDueDate() + : income.getIssueDate().plusDays(30)); + income.setReceiptNumber(generateReceiptNumber(income.getIssueDate().getYear())); + + // Period label based on issue date + String periodLabel = income.getIssueDate().getYear() + "-" + + String.format("%02d", income.getIssueDate().getMonthValue()); + income.setPeriodLabel(periodLabel); + + income.setDescription(request.getDescription() != null ? request.getDescription() + : "Recibo mensual alquiler - " + contract.getProperty().getName()); + + return incomeReceiptRepository.save(income); + } + + @Transactional + public void generateMonthlyReceipts() { + List activeContracts = contractRepository.findByStatusId(1); + LocalDate today = LocalDate.now(); + int currentMonth = today.getMonthValue(); + int currentYear = today.getYear(); + String periodLabel = currentYear + "-" + String.format("%02d", currentMonth); + + for (Contract contract : activeContracts) { + Integer periodId = contract.getPaymentPeriod() != null + ? contract.getPaymentPeriod().getId() : 1; + + // Determinar si este mes toca generar según la periodicidad + if (!shouldGenerateForPeriod(periodId, contract.getStartDate(), currentMonth, currentYear)) { + continue; + } + + // Evitar duplicados por contrato+periodo + if (incomeReceiptRepository.findByContractIdAndPeriodLabel(contract.getId(), periodLabel).isPresent()) { + log.debug("Ya existe recibo para contrato {} en periodo {}", contract.getId(), periodLabel); + continue; + } + + // Calcular día de emisión: usa paymentDay, sin pasarse de los días del mes + int day = contract.getPaymentDay() != null + ? Math.min(contract.getPaymentDay(), today.lengthOfMonth()) + : today.lengthOfMonth(); + LocalDate issueDate = LocalDate.of(currentYear, currentMonth, day); + LocalDate dueDate = issueDate.plusDays(30); + + ReceiptGenerateRequest req = new ReceiptGenerateRequest(); + req.setContractId(contract.getId()); + req.setIssueDate(issueDate); + req.setDueDate(dueDate); + generateReceipt(req); + + log.info("Recibo generado para contrato {} - periodo {}", contract.getId(), periodLabel); + } + } + + /** + * Determina si se debe generar recibo este mes según la periodicidad del contrato. + * periodId: 1=MENSUAL, 2=TRIMESTRAL, 3=SEMESTRAL, 4=ANUAL + */ + private boolean shouldGenerateForPeriod(Integer periodId, LocalDate startDate, + int currentMonth, int currentYear) { + if (periodId == null || periodId == 1) { + return true; // MENSUAL: siempre generar + } + // Para períodos >1 mes, calcular los meses transcurridos desde el inicio + int monthsSinceStart = (currentYear - startDate.getYear()) * 12 + + (currentMonth - startDate.getMonthValue()); + if (monthsSinceStart < 0) return false; // contrato aún no ha empezado + + return switch (periodId) { + case 2 -> monthsSinceStart % 3 == 0; // TRIMESTRAL: cada 3 meses + case 3 -> monthsSinceStart % 6 == 0; // SEMESTRAL: cada 6 meses + case 4 -> monthsSinceStart % 12 == 0; // ANUAL: cada 12 meses + default -> true; + }; + } + + @Transactional + public void markOverdueIncomes() { + IncomeStatus overdueStatus = new IncomeStatus(); + overdueStatus.setId(3); + + List pending = incomeReceiptRepository.findByStatusId(1); + LocalDate today = LocalDate.now(); + + for (IncomeReceipt income : pending) { + if (income.getDueDate() != null && income.getDueDate().isBefore(today)) { + income.setStatus(overdueStatus); + incomeReceiptRepository.save(income); + } + } + } + + public byte[] generateReceiptPdf(Long incomeId) { + IncomeReceipt income = findById(incomeId); + return pdfReceiptService.generatePdf(income); + } + + public void sendReceiptEmail(Long incomeId, String toEmail) { + IncomeReceipt income = findById(incomeId); + byte[] pdf = pdfReceiptService.generatePdf(income); + emailReceiptService.sendReceipt(income, pdf, toEmail); + } + + private String generateReceiptNumber(int fiscalYear) { + ReceiptSeries series = receiptSeriesRepository + .findByFiscalYearAndActiveTrue(fiscalYear) + .orElseGet(() -> { + ReceiptSeries newSeries = new ReceiptSeries(); + newSeries.setSeriesName("RECIBOS"); + newSeries.setFiscalYear(fiscalYear); + newSeries.setPrefix("R-" + fiscalYear + "-"); + newSeries.setLastNumber(0); + newSeries.setActive(true); + return receiptSeriesRepository.save(newSeries); + }); + + series.setLastNumber(series.getLastNumber() + 1); + receiptSeriesRepository.save(series); + return series.getPrefix() + String.format("%05d", series.getLastNumber()); + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/ReportService.java b/backend/src/main/java/com/sapolar/finance/receipt/ReportService.java new file mode 100644 index 0000000..cf88f7e --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/ReportService.java @@ -0,0 +1,78 @@ +package com.sapolar.finance.receipt; + +import com.sapolar.finance.expense.ExpenseReceiptRepository; +import com.sapolar.finance.income.IncomeReceiptRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final IncomeReceiptRepository incomeReceiptRepository; + private final ExpenseReceiptRepository expenseReceiptRepository; + + public byte[] generateMonthlyReport(int year, int month) { + LocalDate start = LocalDate.of(year, month, 1); + LocalDate end = start.withDayOfMonth(start.lengthOfMonth()); + + BigDecimal totalIncome = incomeReceiptRepository.sumPaidBetween(start, end); + BigDecimal totalExpense = expenseReceiptRepository.sumPaidBetween(start, end); + + totalIncome = totalIncome != null ? totalIncome : BigDecimal.ZERO; + totalExpense = totalExpense != null ? totalExpense : BigDecimal.ZERO; + + try (Workbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Informe " + month + "-" + year); + + CellStyle headerStyle = workbook.createCellStyle(); + Font headerFont = workbook.createFont(); + headerFont.setBold(true); + headerFont.setFontHeightInPoints((short) 12); + headerStyle.setFont(headerFont); + headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex()); + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + + Row titleRow = sheet.createRow(0); + Cell titleCell = titleRow.createCell(0); + titleCell.setCellValue("Informe mensual - " + month + "/" + year); + titleCell.setCellStyle(headerStyle); + + Row incomeRow = sheet.createRow(2); + incomeRow.createCell(0).setCellValue("Total Ingresos"); + incomeRow.createCell(1).setCellValue(totalIncome.doubleValue()); + + Row expenseRow = sheet.createRow(3); + expenseRow.createCell(0).setCellValue("Total Gastos"); + expenseRow.createCell(1).setCellValue(totalExpense.doubleValue()); + + Row balanceRow = sheet.createRow(5); + balanceRow.createCell(0).setCellValue("Saldo Neto"); + CellStyle boldStyle = workbook.createCellStyle(); + Font boldFont = workbook.createFont(); + boldFont.setBold(true); + boldStyle.setFont(boldFont); + balanceRow.createCell(0).setCellStyle(boldStyle); + balanceRow.createCell(1).setCellValue(totalIncome.subtract(totalExpense).doubleValue()); + + sheet.autoSizeColumn(0); + sheet.autoSizeColumn(1); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + workbook.write(baos); + return baos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Error al generar informe Excel", e); + } + } +} diff --git a/backend/src/main/java/com/sapolar/finance/receipt/dto/ReceiptGenerateRequest.java b/backend/src/main/java/com/sapolar/finance/receipt/dto/ReceiptGenerateRequest.java new file mode 100644 index 0000000..662a7d2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/finance/receipt/dto/ReceiptGenerateRequest.java @@ -0,0 +1,15 @@ +package com.sapolar.finance.receipt.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Getter +@Setter +public class ReceiptGenerateRequest { + private Long contractId; + private LocalDate issueDate; + private LocalDate dueDate; + private String description; +} diff --git a/backend/src/main/java/com/sapolar/incident/Incident.java b/backend/src/main/java/com/sapolar/incident/Incident.java new file mode 100644 index 0000000..9fa15c2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/Incident.java @@ -0,0 +1,96 @@ +package com.sapolar.incident; + +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "incidents", indexes = { + @Index(name = "idx_incidents_property", columnList = "property_id"), + @Index(name = "idx_incidents_status", columnList = "status_id"), + @Index(name = "idx_incidents_priority", columnList = "priority_id"), + @Index(name = "idx_incidents_reported", columnList = "reported_at") +}) +public class Incident { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_group_id") + private PropertyGroup propertyGroup; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private IncidentStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "priority_id", nullable = false) + private IncidentPriority priority; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_by") + private User reportedBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_to") + private User assignedTo; + + @Column(name = "reported_at", nullable = false) + private LocalDateTime reportedAt; + + @Column(name = "scheduled_date") + private LocalDate scheduledDate; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "resolution_notes", columnDefinition = "TEXT") + private String resolutionNotes; + + @Column(name = "cost_estimate", precision = 12, scale = 2) + private BigDecimal costEstimate; + + @Column(name = "final_cost", precision = 12, scale = 2) + private BigDecimal finalCost; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + if (reportedAt == null) { + reportedAt = LocalDateTime.now(); + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/incident/IncidentController.java b/backend/src/main/java/com/sapolar/incident/IncidentController.java new file mode 100644 index 0000000..8730101 --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/IncidentController.java @@ -0,0 +1,91 @@ +package com.sapolar.incident; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/incidents") +@RequiredArgsConstructor +public class IncidentController { + + private final IncidentService incidentService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) Long propertyId, + @RequestParam(required = false) Integer statusId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + if (propertyId != null) { + List list = incidentService.findByProperty(propertyId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + if (statusId != null) { + List list = incidentService.findByStatus(statusId); + return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true))); + } + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incidentService.findAll(pageable)))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(incidentService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@RequestBody Incident incident, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Incidencia creada", + incidentService.create(incident, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, @RequestBody Incident incident) { + return ResponseEntity.ok(ApiResponse.success("Incidencia actualizada", + incidentService.update(id, incident))); + } + + @PatchMapping("/{id}/status") + public ResponseEntity> updateStatus(@PathVariable Long id, + @RequestBody Map body) { + Integer statusId = (Integer) body.get("statusId"); + String notes = (String) body.get("resolutionNotes"); + return ResponseEntity.ok(ApiResponse.success("Estado actualizado", + incidentService.updateStatus(id, statusId, notes))); + } + + @PatchMapping("/{id}/assign") + public ResponseEntity> assign(@PathVariable Long id, + @RequestBody Map body) { + return ResponseEntity.ok(ApiResponse.success("Técnico asignado", + incidentService.assignTechnician(id, body.get("technicianId")))); + } + + @PatchMapping("/{id}/schedule") + public ResponseEntity> schedule(@PathVariable Long id, + @RequestBody Map body) { + return ResponseEntity.ok(ApiResponse.success("Reparación programada", + incidentService.scheduleRepair(id, java.time.LocalDate.parse(body.get("scheduledDate"))))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + incidentService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Incidencia eliminada", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/incident/IncidentPriority.java b/backend/src/main/java/com/sapolar/incident/IncidentPriority.java new file mode 100644 index 0000000..70c9e96 --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/IncidentPriority.java @@ -0,0 +1,19 @@ +package com.sapolar.incident; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "incident_priorities") +public class IncidentPriority { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 20) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/incident/IncidentRepository.java b/backend/src/main/java/com/sapolar/incident/IncidentRepository.java new file mode 100644 index 0000000..9874bf4 --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/IncidentRepository.java @@ -0,0 +1,16 @@ +package com.sapolar.incident; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface IncidentRepository extends JpaRepository { + List findByPropertyId(Long propertyId); + List findByStatusId(Integer statusId); + List findByPriorityId(Integer priorityId); + List findByAssignedToId(Long userId); + List findByReportedById(Long userId); + long countByStatusId(Integer statusId); +} diff --git a/backend/src/main/java/com/sapolar/incident/IncidentService.java b/backend/src/main/java/com/sapolar/incident/IncidentService.java new file mode 100644 index 0000000..5d157cb --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/IncidentService.java @@ -0,0 +1,139 @@ +package com.sapolar.incident; + +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.property.PropertyGroupRepository; +import com.sapolar.property.PropertyRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class IncidentService { + + private final IncidentRepository incidentRepository; + private final PropertyRepository propertyRepository; + private final PropertyGroupRepository propertyGroupRepository; + + public List findAll() { + return incidentRepository.findAll(); + } + + public Page findAll(Pageable pageable) { + return incidentRepository.findAll(pageable); + } + + public List findByProperty(Long propertyId) { + return incidentRepository.findByPropertyId(propertyId); + } + + public List findByStatus(Integer statusId) { + return incidentRepository.findByStatusId(statusId); + } + + public Incident findById(Long id) { + return incidentRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Incidencia", id)); + } + + @Transactional + public Incident create(Incident incident, Long userId) { + // Asociar propiedad o conjunto + if (incident.getProperty() != null && incident.getProperty().getId() != null) { + Property property = propertyRepository.findById(incident.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", incident.getProperty().getId())); + incident.setProperty(property); + } + if (incident.getPropertyGroup() != null && incident.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(incident.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", incident.getPropertyGroup().getId())); + incident.setPropertyGroup(group); + } + + User user = new User(); + user.setId(userId); + incident.setReportedBy(user); + + return incidentRepository.save(incident); + } + + @Transactional + public Incident updateStatus(Long id, Integer statusId, String resolutionNotes) { + Incident incident = findById(id); + IncidentStatus status = new IncidentStatus(); + status.setId(statusId); + incident.setStatus(status); + + if (statusId == 4) { + incident.setResolvedAt(LocalDateTime.now()); + } + if (resolutionNotes != null) { + incident.setResolutionNotes(resolutionNotes); + } + + return incidentRepository.save(incident); + } + + @Transactional + public Incident assignTechnician(Long id, Long technicianId) { + Incident incident = findById(id); + User technician = new User(); + technician.setId(technicianId); + incident.setAssignedTo(technician); + + IncidentStatus status = new IncidentStatus(); + status.setId(2); + incident.setStatus(status); + + return incidentRepository.save(incident); + } + + @Transactional + public Incident scheduleRepair(Long id, java.time.LocalDate scheduledDate) { + Incident incident = findById(id); + incident.setScheduledDate(scheduledDate); + + IncidentStatus status = new IncidentStatus(); + status.setId(3); + incident.setStatus(status); + + return incidentRepository.save(incident); + } + + @Transactional + public Incident update(Long id, Incident updated) { + Incident incident = findById(id); + incident.setTitle(updated.getTitle()); + incident.setDescription(updated.getDescription()); + if (updated.getPriority() != null) incident.setPriority(updated.getPriority()); + if (updated.getStatus() != null) incident.setStatus(updated.getStatus()); + if (updated.getCostEstimate() != null) incident.setCostEstimate(updated.getCostEstimate()); + if (updated.getAssignedTo() != null) { + User technician = new User(); + technician.setId(updated.getAssignedTo().getId()); + incident.setAssignedTo(technician); + } + incident.setScheduledDate(updated.getScheduledDate()); + incident.setResolutionNotes(updated.getResolutionNotes()); + return incidentRepository.save(incident); + } + + @Transactional + public void delete(Long id) { + incidentRepository.deleteById(id); + } + + public long countOpen() { + return incidentRepository.countByStatusId(1) + + incidentRepository.countByStatusId(2) + + incidentRepository.countByStatusId(3); + } +} diff --git a/backend/src/main/java/com/sapolar/incident/IncidentStatus.java b/backend/src/main/java/com/sapolar/incident/IncidentStatus.java new file mode 100644 index 0000000..5d2e59f --- /dev/null +++ b/backend/src/main/java/com/sapolar/incident/IncidentStatus.java @@ -0,0 +1,19 @@ +package com.sapolar.incident; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "incident_statuses") +public class IncidentStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 50) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/maintenance/MaintenanceController.java b/backend/src/main/java/com/sapolar/maintenance/MaintenanceController.java new file mode 100644 index 0000000..15a71a2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/maintenance/MaintenanceController.java @@ -0,0 +1,87 @@ +package com.sapolar.maintenance; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.finance.expense.ExpenseCategory; +import com.sapolar.finance.expense.ExpenseCategoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/maintenance") +@RequiredArgsConstructor +public class MaintenanceController { + + private final MaintenanceService maintenanceService; + private final ExpenseCategoryRepository expenseCategoryRepository; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) Long propertyId) { + if (propertyId != null) { + return ResponseEntity.ok(ApiResponse.success(maintenanceService.findByProperty(propertyId))); + } + return ResponseEntity.ok(ApiResponse.success(maintenanceService.findAll())); + } + + @GetMapping("/pending") + public ResponseEntity>> getPending() { + return ResponseEntity.ok(ApiResponse.success(maintenanceService.findPending())); + } + + @GetMapping("/upcoming") + public ResponseEntity>> getUpcoming( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return ResponseEntity.ok(ApiResponse.success(maintenanceService.findUpcoming(from, to))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(maintenanceService.findById(id))); + } + + @GetMapping("/expense-categories") + public ResponseEntity>> getExpenseCategories() { + return ResponseEntity.ok(ApiResponse.success(expenseCategoryRepository.findAll())); + } + + @PostMapping + public ResponseEntity> create(@RequestBody ScheduledMaintenance maintenance, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Mantenimiento creado", + maintenanceService.create(maintenance, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, + @RequestBody ScheduledMaintenance maintenance) { + return ResponseEntity.ok(ApiResponse.success("Mantenimiento actualizado", + maintenanceService.update(id, maintenance))); + } + + @PatchMapping("/{id}/complete") + public ResponseEntity> complete(@PathVariable Long id, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Mantenimiento completado", + maintenanceService.markCompleted(id, user.userId()))); + } + + @PatchMapping("/{id}/reopen") + public ResponseEntity> reopen(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success("Mantenimiento reabierto", + maintenanceService.reopen(id))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + maintenanceService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Mantenimiento eliminado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/maintenance/MaintenancePeriod.java b/backend/src/main/java/com/sapolar/maintenance/MaintenancePeriod.java new file mode 100644 index 0000000..65df6a9 --- /dev/null +++ b/backend/src/main/java/com/sapolar/maintenance/MaintenancePeriod.java @@ -0,0 +1,19 @@ +package com.sapolar.maintenance; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "maintenance_periods") +public class MaintenancePeriod { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/maintenance/MaintenanceService.java b/backend/src/main/java/com/sapolar/maintenance/MaintenanceService.java new file mode 100644 index 0000000..81b25f8 --- /dev/null +++ b/backend/src/main/java/com/sapolar/maintenance/MaintenanceService.java @@ -0,0 +1,190 @@ +package com.sapolar.maintenance; + +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.finance.expense.ExpenseCategory; +import com.sapolar.finance.expense.ExpenseReceipt; +import com.sapolar.finance.expense.ExpenseReceiptRepository; +import com.sapolar.finance.expense.ExpenseStatus; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.property.PropertyGroupRepository; +import com.sapolar.property.PropertyRepository; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MaintenanceService { + + private final ScheduledMaintenanceRepository maintenanceRepository; + private final PropertyRepository propertyRepository; + private final PropertyGroupRepository propertyGroupRepository; + private final ExpenseReceiptRepository expenseReceiptRepository; + + public List findAll() { + return maintenanceRepository.findAll(); + } + + public List findByProperty(Long propertyId) { + return maintenanceRepository.findByPropertyId(propertyId); + } + + public List findPending() { + return maintenanceRepository.findByCompletedFalse(); + } + + public List findUpcoming(LocalDate start, LocalDate end) { + return maintenanceRepository.findByCompletedFalseAndNextExecutionBetween(start, end); + } + + public ScheduledMaintenance findById(Long id) { + return maintenanceRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Mantenimiento", id)); + } + + @Transactional + public ScheduledMaintenance create(ScheduledMaintenance maintenance, Long userId) { + // Asociar propiedad o conjunto + if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) { + Property property = propertyRepository.findById(maintenance.getProperty().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", maintenance.getProperty().getId())); + maintenance.setProperty(property); + } + if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(maintenance.getPropertyGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", maintenance.getPropertyGroup().getId())); + maintenance.setPropertyGroup(group); + } + + User user = new User(); + user.setId(userId); + maintenance.setCreatedBy(user); + maintenance.setCompleted(false); + + ScheduledMaintenance saved = maintenanceRepository.save(maintenance); + + // Auto-generar gasto si la opción está activa + if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) { + createExpenseFromMaintenance(saved, userId); + } + + return saved; + } + + @Transactional + public ScheduledMaintenance update(Long id, ScheduledMaintenance updated) { + ScheduledMaintenance maintenance = findById(id); + maintenance.setTitle(updated.getTitle()); + maintenance.setDescription(updated.getDescription()); + maintenance.setEstimatedCost(updated.getEstimatedCost()); + maintenance.setNextExecution(updated.getNextExecution()); + maintenance.setReminderDaysBefore(updated.getReminderDaysBefore()); + maintenance.setResponsible(updated.getResponsible()); + maintenance.setNotes(updated.getNotes()); + maintenance.setGenerateExpense(updated.getGenerateExpense()); + if (updated.getPeriod() != null) maintenance.setPeriod(updated.getPeriod()); + if (updated.getExpenseCategory() != null) maintenance.setExpenseCategory(updated.getExpenseCategory()); + return maintenanceRepository.save(maintenance); + } + + @Transactional + public ScheduledMaintenance markCompleted(Long id, Long userId) { + ScheduledMaintenance maintenance = findById(id); + maintenance.setCompleted(true); + maintenance.setCompletedAt(LocalDate.now()); + + User user = new User(); + user.setId(userId); + maintenance.setCompletedBy(user); + + ScheduledMaintenance saved = maintenanceRepository.save(maintenance); + + // Auto-generar gasto al completar si la opción está activa y es periódico + if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) { + // Para periódicos (no única vez): generar gasto cada vez que se completa + if (saved.getPeriod() != null && saved.getPeriod().getId() != 1) { + createExpenseFromMaintenance(saved, userId); + } + } + + return saved; + } + + @Transactional + public ScheduledMaintenance reopen(Long id) { + ScheduledMaintenance maintenance = findById(id); + maintenance.setCompleted(false); + maintenance.setCompletedAt(null); + maintenance.setCompletedBy(null); + return maintenanceRepository.save(maintenance); + } + + @Transactional + public void delete(Long id) { + maintenanceRepository.deleteById(id); + } + + /** + * Crea un gasto a partir de los datos de una tarea de mantenimiento. + */ + private ExpenseReceipt createExpenseFromMaintenance(ScheduledMaintenance maintenance, Long userId) { + ExpenseReceipt expense = new ExpenseReceipt(); + + // Propiedad o conjunto asociado + if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) { + Property propertyRef = new Property(); + propertyRef.setId(maintenance.getProperty().getId()); + expense.setProperty(propertyRef); + } + if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) { + PropertyGroup groupRef = new PropertyGroup(); + groupRef.setId(maintenance.getPropertyGroup().getId()); + expense.setPropertyGroup(groupRef); + } + + // Categoría de gasto + expense.setCategory(maintenance.getExpenseCategory()); + + // Estado PENDIENTE (id=1) + ExpenseStatus status = new ExpenseStatus(); + status.setId(1); + expense.setStatus(status); + + // Importes + BigDecimal amount = maintenance.getEstimatedCost() != null + ? maintenance.getEstimatedCost() + : BigDecimal.ZERO; + expense.setAmount(amount); + expense.setTaxAmount(BigDecimal.ZERO); + expense.setTotalAmount(amount); + + // Fechas + expense.setIssueDate(LocalDate.now()); + if (maintenance.getNextExecution() != null) { + expense.setDueDate(maintenance.getNextExecution()); + } + + // Descripción + String desc = "Mantenimiento: " + maintenance.getTitle(); + if (maintenance.getProperty() != null && maintenance.getProperty().getName() != null) { + desc += " - " + maintenance.getProperty().getName(); + } + expense.setDescription(desc); + + // Responsable como proveedor + expense.setSupplierName(maintenance.getResponsible()); + + // Creador + User user = new User(); + user.setId(userId); + expense.setCreatedBy(user); + + return expenseReceiptRepository.save(expense); + } +} diff --git a/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenance.java b/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenance.java new file mode 100644 index 0000000..320880b --- /dev/null +++ b/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenance.java @@ -0,0 +1,102 @@ +package com.sapolar.maintenance; + +import com.sapolar.finance.expense.ExpenseCategory; +import com.sapolar.property.Property; +import com.sapolar.property.PropertyGroup; +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "scheduled_maintenance", indexes = { + @Index(name = "idx_maint_property", columnList = "property_id"), + @Index(name = "idx_maint_next_exec", columnList = "next_execution"), + @Index(name = "idx_maint_completed", columnList = "completed") +}) +public class ScheduledMaintenance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id") + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_group_id") + private PropertyGroup propertyGroup; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "period_id", nullable = false) + private MaintenancePeriod period; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "estimated_cost", precision = 12, scale = 2) + private BigDecimal estimatedCost; + + @Column(name = "last_execution") + private LocalDate lastExecution; + + @Column(name = "next_execution", nullable = false) + private LocalDate nextExecution; + + @Column(name = "reminder_days_before", nullable = false) + private Integer reminderDaysBefore = 30; + + @Column(length = 200) + private String responsible; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(name = "generate_expense", nullable = false) + private Boolean generateExpense = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "expense_category_id") + private ExpenseCategory expenseCategory; + + @Column(nullable = false) + private Boolean completed = false; + + @Column(name = "completed_at") + private LocalDate completedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "completed_by") + private User completedBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenanceRepository.java b/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenanceRepository.java new file mode 100644 index 0000000..0205665 --- /dev/null +++ b/backend/src/main/java/com/sapolar/maintenance/ScheduledMaintenanceRepository.java @@ -0,0 +1,15 @@ +package com.sapolar.maintenance; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface ScheduledMaintenanceRepository extends JpaRepository { + List findByPropertyId(Long propertyId); + List findByCompletedFalse(); + List findByCompletedFalseAndNextExecutionBetween(LocalDate start, LocalDate end); + List findByCompletedFalseAndNextExecutionBefore(LocalDate date); +} diff --git a/backend/src/main/java/com/sapolar/notification/Notification.java b/backend/src/main/java/com/sapolar/notification/Notification.java new file mode 100644 index 0000000..d5f86ce --- /dev/null +++ b/backend/src/main/java/com/sapolar/notification/Notification.java @@ -0,0 +1,59 @@ +package com.sapolar.notification; + +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "notifications", indexes = { + @Index(name = "idx_notifications_user", columnList = "user_id, `read`"), + @Index(name = "idx_notifications_created", columnList = "created_at") +}) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "type_id", nullable = false) + private NotificationType type; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "TEXT") + private String body; + + @Column(name = "entity_type", length = 30) + private String entityType; + + @Column(name = "entity_id") + private Long entityId; + + @Column(name = "sent_by_email", nullable = false) + private Boolean sentByEmail = false; + + @Column(name = "`read`", nullable = false) + private Boolean read = false; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/notification/NotificationController.java b/backend/src/main/java/com/sapolar/notification/NotificationController.java new file mode 100644 index 0000000..b9b99a8 --- /dev/null +++ b/backend/src/main/java/com/sapolar/notification/NotificationController.java @@ -0,0 +1,48 @@ +package com.sapolar.notification; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity>> findAll( + @AuthenticationPrincipal SecurityUser user, + @RequestParam(required = false) Boolean unreadOnly) { + if (Boolean.TRUE.equals(unreadOnly)) { + return ResponseEntity.ok(ApiResponse.success( + notificationService.findUnreadByUser(user.userId()))); + } + return ResponseEntity.ok(ApiResponse.success( + notificationService.findByUser(user.userId()))); + } + + @GetMapping("/unread-count") + public ResponseEntity> countUnread(@AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success(notificationService.countUnread(user.userId()))); + } + + @PatchMapping("/{id}/read") + public ResponseEntity> markAsRead( + @PathVariable Long id, @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Notificación marcada como leída", + notificationService.markAsRead(id, user.userId()))); + } + + @PatchMapping("/read-all") + public ResponseEntity> markAllAsRead(@AuthenticationPrincipal SecurityUser user) { + notificationService.markAllAsRead(user.userId()); + return ResponseEntity.ok(ApiResponse.success("Todas las notificaciones marcadas como leídas", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/notification/NotificationRepository.java b/backend/src/main/java/com/sapolar/notification/NotificationRepository.java new file mode 100644 index 0000000..4c84b2f --- /dev/null +++ b/backend/src/main/java/com/sapolar/notification/NotificationRepository.java @@ -0,0 +1,13 @@ +package com.sapolar.notification; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotificationRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(Long userId); + List findByUserIdAndReadFalseOrderByCreatedAtDesc(Long userId); + long countByUserIdAndReadFalse(Long userId); +} diff --git a/backend/src/main/java/com/sapolar/notification/NotificationService.java b/backend/src/main/java/com/sapolar/notification/NotificationService.java new file mode 100644 index 0000000..bd7ba2a --- /dev/null +++ b/backend/src/main/java/com/sapolar/notification/NotificationService.java @@ -0,0 +1,72 @@ +package com.sapolar.notification; + +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.user.User; +import com.sapolar.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + public List findByUser(Long userId) { + return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + public List findUnreadByUser(Long userId) { + return notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId); + } + + public long countUnread(Long userId) { + return notificationRepository.countByUserIdAndReadFalse(userId); + } + + @Transactional + public Notification create(Long userId, Integer typeId, String title, String body, + String entityType, Long entityId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("Usuario", userId)); + + Notification notification = new Notification(); + notification.setUser(user); + + NotificationType type = new NotificationType(); + type.setId(typeId); + notification.setType(type); + + notification.setTitle(title); + notification.setBody(body); + notification.setEntityType(entityType); + notification.setEntityId(entityId); + notification.setRead(false); + + return notificationRepository.save(notification); + } + + @Transactional + public Notification markAsRead(Long notificationId, Long userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new ResourceNotFoundException("Notificación", notificationId)); + notification.setRead(true); + notification.setReadAt(LocalDateTime.now()); + return notificationRepository.save(notification); + } + + @Transactional + public void markAllAsRead(Long userId) { + List unread = notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId); + unread.forEach(n -> { + n.setRead(true); + n.setReadAt(LocalDateTime.now()); + }); + notificationRepository.saveAll(unread); + } +} diff --git a/backend/src/main/java/com/sapolar/notification/NotificationType.java b/backend/src/main/java/com/sapolar/notification/NotificationType.java new file mode 100644 index 0000000..6ba9100 --- /dev/null +++ b/backend/src/main/java/com/sapolar/notification/NotificationType.java @@ -0,0 +1,19 @@ +package com.sapolar.notification; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "notification_types") +public class NotificationType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 50) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/property/Property.java b/backend/src/main/java/com/sapolar/property/Property.java new file mode 100644 index 0000000..164d685 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/Property.java @@ -0,0 +1,125 @@ +package com.sapolar.property; + +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "properties", indexes = { + @Index(name = "idx_properties_parent", columnList = "parent_id"), + @Index(name = "idx_properties_type", columnList = "type_id"), + @Index(name = "idx_properties_status", columnList = "status_id"), + @Index(name = "idx_properties_active", columnList = "active") +}) +public class Property { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Property parent; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id") + private PropertyGroup group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "type_id", nullable = false) + private PropertyType type; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private PropertyStatus status; + + @Column(length = 50) + private String reference; + + @Column(nullable = false, length = 200) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "address_street", length = 200) + private String addressStreet; + + @Column(name = "address_number", length = 20) + private String addressNumber; + + @Column(name = "address_city", length = 100) + private String addressCity; + + @Column(name = "address_postal_code", length = 10) + private String addressPostalCode; + + @Column(name = "address_province", length = 100) + private String addressProvince; + + @Column(name = "cadastral_ref", length = 30) + private String cadastralRef; + + @Column(name = "surface_m2", precision = 10, scale = 2) + private BigDecimal surfaceM2; + + @Column(length = 50) + private String floor; + + @Column(length = 50) + private String door; + + @Column(name = "rental_amount", precision = 12, scale = 2) + private BigDecimal rentalAmount; + + @Column(name = "rented_since") + private LocalDate rentedSince; + + @Column(name = "vacant_since") + private LocalDate vacantSince; + + @Column(name = "occupied_since") + private LocalDate occupiedSince; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(nullable = false) + private Boolean active = true; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + // Convertir reference vacío a null para evitar errores de clave única + if (reference != null && reference.trim().isEmpty()) { + reference = null; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + // Convertir reference vacío a null para evitar errores de clave única + if (reference != null && reference.trim().isEmpty()) { + reference = null; + } + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyController.java b/backend/src/main/java/com/sapolar/property/PropertyController.java new file mode 100644 index 0000000..15d1d60 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyController.java @@ -0,0 +1,95 @@ +package com.sapolar.property; + +import com.sapolar.auth.SecurityUser; +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/properties") +@RequiredArgsConstructor +public class PropertyController { + + private final PropertyService propertyService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(propertyService.findAll(pageable)))); + } + + @GetMapping("/tree") + public ResponseEntity>> getTree() { + return ResponseEntity.ok(ApiResponse.success(propertyService.findRootProperties())); + } + + @GetMapping("/{id}/children") + public ResponseEntity>> getChildren(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(propertyService.findChildren(id))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(propertyService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@RequestBody Property property, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Propiedad creada", + propertyService.create(property, user.userId()))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, + @RequestBody Property property, + @AuthenticationPrincipal SecurityUser user) { + return ResponseEntity.ok(ApiResponse.success("Propiedad actualizada", + propertyService.update(id, property, user.userId()))); + } + + @PatchMapping("/{id}/status") + public ResponseEntity> changeStatus(@PathVariable Long id, + @RequestBody Map body, + @AuthenticationPrincipal SecurityUser user) { + Integer statusId = (Integer) body.get("statusId"); + String notes = (String) body.get("notes"); + return ResponseEntity.ok(ApiResponse.success("Estado actualizado", + propertyService.changeStatus(id, statusId, user.userId(), notes))); + } + + @GetMapping("/{id}/history") + public ResponseEntity>> getHistory(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(propertyService.getStatusHistory(id))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + propertyService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Propiedad desactivada", null)); + } + + @GetMapping("/types") + public ResponseEntity>> getTypes() { + return ResponseEntity.ok(ApiResponse.success(propertyService.getAllTypes())); + } + + @GetMapping("/statuses") + public ResponseEntity>> getStatuses() { + return ResponseEntity.ok(ApiResponse.success(propertyService.getAllStatuses())); + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyGroup.java b/backend/src/main/java/com/sapolar/property/PropertyGroup.java new file mode 100644 index 0000000..552c06e --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyGroup.java @@ -0,0 +1,59 @@ +package com.sapolar.property; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "property_groups") +public class PropertyGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "address_street", length = 200) + private String addressStreet; + + @Column(name = "address_number", length = 20) + private String addressNumber; + + @Column(name = "address_city", length = 100) + private String addressCity; + + @Column(name = "address_postal_code", length = 10) + private String addressPostalCode; + + @Column(name = "address_province", length = 100) + private String addressProvince; + + @Column(nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyGroupController.java b/backend/src/main/java/com/sapolar/property/PropertyGroupController.java new file mode 100644 index 0000000..d1ace65 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyGroupController.java @@ -0,0 +1,61 @@ +package com.sapolar.property; + +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/property-groups") +@RequiredArgsConstructor +public class PropertyGroupController { + + private final PropertyGroupService propertyGroupService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(propertyGroupService.findAll(pageable)))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(propertyGroupService.findById(id))); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')") + public ResponseEntity> create(@RequestBody PropertyGroup group) { + return ResponseEntity.ok(ApiResponse.success("Conjunto creado", propertyGroupService.create(group))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')") + public ResponseEntity> update(@PathVariable Long id, @RequestBody PropertyGroup group) { + return ResponseEntity.ok(ApiResponse.success("Conjunto actualizado", propertyGroupService.update(id, group))); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')") + public ResponseEntity> delete(@PathVariable Long id) { + propertyGroupService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Conjunto desactivado", null)); + } + + @GetMapping("/{id}/properties") + public ResponseEntity>> getProperties(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(propertyGroupService.findProperties(id))); + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyGroupRepository.java b/backend/src/main/java/com/sapolar/property/PropertyGroupRepository.java new file mode 100644 index 0000000..e6021f7 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyGroupRepository.java @@ -0,0 +1,14 @@ +package com.sapolar.property; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PropertyGroupRepository extends JpaRepository { + List findByActiveTrue(); + Page findByActiveTrue(Pageable pageable); +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyGroupService.java b/backend/src/main/java/com/sapolar/property/PropertyGroupService.java new file mode 100644 index 0000000..28476b6 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyGroupService.java @@ -0,0 +1,61 @@ +package com.sapolar.property; + +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PropertyGroupService { + + private final PropertyGroupRepository propertyGroupRepository; + private final PropertyRepository propertyRepository; + + public List findAll() { + return propertyGroupRepository.findByActiveTrue(); + } + + public Page findAll(Pageable pageable) { + return propertyGroupRepository.findByActiveTrue(pageable); + } + + public PropertyGroup findById(Long id) { + return propertyGroupRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", id)); + } + + @Transactional + public PropertyGroup create(PropertyGroup group) { + group.setActive(true); + return propertyGroupRepository.save(group); + } + + @Transactional + public PropertyGroup update(Long id, PropertyGroup updated) { + PropertyGroup group = findById(id); + group.setName(updated.getName()); + group.setDescription(updated.getDescription()); + group.setAddressStreet(updated.getAddressStreet()); + group.setAddressNumber(updated.getAddressNumber()); + group.setAddressCity(updated.getAddressCity()); + group.setAddressPostalCode(updated.getAddressPostalCode()); + group.setAddressProvince(updated.getAddressProvince()); + return propertyGroupRepository.save(group); + } + + @Transactional + public void delete(Long id) { + PropertyGroup group = findById(id); + group.setActive(false); + propertyGroupRepository.save(group); + } + + public List findProperties(Long groupId) { + return propertyRepository.findActiveByGroupId(groupId); + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyRepository.java b/backend/src/main/java/com/sapolar/property/PropertyRepository.java new file mode 100644 index 0000000..d1dad17 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyRepository.java @@ -0,0 +1,29 @@ +package com.sapolar.property; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PropertyRepository extends JpaRepository { + List findByParentId(Long parentId); + List findByActiveTrue(); + Page findByActiveTrue(Pageable pageable); + List findByTypeId(Integer typeId); + List findByStatusId(Integer statusId); + List findByParentIsNull(); + + @Query("SELECT p FROM Property p WHERE p.parent.id = :parentId AND p.active = true") + List findActiveChildrenByParentId(@Param("parentId") Long parentId); + + @Query("SELECT p FROM Property p WHERE p.parent IS NULL AND p.active = true") + List findRootProperties(); + + @Query("SELECT p FROM Property p WHERE p.group.id = :groupId AND p.active = true") + List findActiveByGroupId(@Param("groupId") Long groupId); +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyService.java b/backend/src/main/java/com/sapolar/property/PropertyService.java new file mode 100644 index 0000000..7170c1c --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyService.java @@ -0,0 +1,165 @@ +package com.sapolar.property; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import com.sapolar.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PropertyService { + + private final PropertyRepository propertyRepository; + private final PropertyGroupRepository propertyGroupRepository; + private final PropertyStatusHistoryRepository statusHistoryRepository; + private final PropertyStatusRepository statusRepository; + private final PropertyTypeRepository typeRepository; + + public List findAll() { + return propertyRepository.findByActiveTrue(); + } + + public Page findAll(Pageable pageable) { + return propertyRepository.findByActiveTrue(pageable); + } + + public List findRootProperties() { + return propertyRepository.findRootProperties(); + } + + public List findChildren(Long parentId) { + return propertyRepository.findActiveChildrenByParentId(parentId); + } + + public Property findById(Long id) { + return propertyRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad", id)); + } + + @Transactional + public Property create(Property property, Long userId) { + if (property.getParent() != null && property.getParent().getId() != null) { + Property parent = propertyRepository.findById(property.getParent().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Propiedad padre", property.getParent().getId())); + property.setParent(parent); + } + + if (property.getGroup() != null && property.getGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(property.getGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", property.getGroup().getId())); + property.setGroup(group); + } else { + property.setGroup(null); + } + + User user = new User(); + user.setId(userId); + property.setCreatedBy(user); + property.setActive(true); + + Property saved = propertyRepository.save(property); + + PropertyStatusHistory history = new PropertyStatusHistory(); + history.setProperty(saved); + history.setStatus(saved.getStatus()); + history.setChangedBy(user); + history.setNotes("Estado inicial"); + statusHistoryRepository.save(history); + + return saved; + } + + @Transactional + public Property update(Long id, Property updated, Long userId) { + Property property = findById(id); + property.setName(updated.getName()); + property.setDescription(updated.getDescription()); + property.setAddressStreet(updated.getAddressStreet()); + property.setAddressNumber(updated.getAddressNumber()); + property.setAddressCity(updated.getAddressCity()); + property.setAddressPostalCode(updated.getAddressPostalCode()); + property.setAddressProvince(updated.getAddressProvince()); + property.setCadastralRef(updated.getCadastralRef()); + property.setSurfaceM2(updated.getSurfaceM2()); + property.setFloor(updated.getFloor()); + property.setDoor(updated.getDoor()); + property.setRentalAmount(updated.getRentalAmount()); + property.setNotes(updated.getNotes()); + if (updated.getType() != null) property.setType(updated.getType()); + if (updated.getGroup() != null && updated.getGroup().getId() != null) { + PropertyGroup group = propertyGroupRepository.findById(updated.getGroup().getId()) + .orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getGroup().getId())); + property.setGroup(group); + } else { + property.setGroup(null); + } + + // Actualizar estado si cambia + if (updated.getStatus() != null && updated.getStatus().getId() != null) { + PropertyStatus oldStatus = property.getStatus(); + Integer newStatusId = updated.getStatus().getId(); + PropertyStatus newStatus = statusRepository.findById(newStatusId) + .orElseThrow(() -> new ResourceNotFoundException("Estado de propiedad", newStatusId.longValue())); + if (!newStatus.equals(oldStatus)) { + property.setStatus(newStatus); + User user = new User(); + user.setId(userId); + PropertyStatusHistory history = new PropertyStatusHistory(); + history.setProperty(property); + history.setStatus(newStatus); + history.setChangedBy(user); + history.setNotes("Cambio de estado desde edición: " + oldStatus.getName() + " → " + newStatus.getName()); + statusHistoryRepository.save(history); + } + } + + return propertyRepository.save(property); + } + + @Transactional + public Property changeStatus(Long id, Integer statusId, Long userId, String notes) { + Property property = findById(id); + PropertyStatus newStatus = statusRepository.findById(statusId) + .orElseThrow(() -> new ResourceNotFoundException("Estado de propiedad", statusId.longValue())); + + PropertyStatus oldStatus = property.getStatus(); + property.setStatus(newStatus); + + User user = new User(); + user.setId(userId); + + PropertyStatusHistory history = new PropertyStatusHistory(); + history.setProperty(property); + history.setStatus(newStatus); + history.setChangedBy(user); + history.setNotes(notes != null ? notes : "Cambio de " + oldStatus.getName() + " a " + newStatus.getName()); + statusHistoryRepository.save(history); + + return propertyRepository.save(property); + } + + @Transactional + public void delete(Long id) { + Property property = findById(id); + property.setActive(false); + propertyRepository.save(property); + } + + public List getStatusHistory(Long propertyId) { + return statusHistoryRepository.findByPropertyIdOrderByChangedAtDesc(propertyId); + } + + public List getAllTypes() { + return typeRepository.findAll(); + } + + public List getAllStatuses() { + return statusRepository.findAll(); + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyStatus.java b/backend/src/main/java/com/sapolar/property/PropertyStatus.java new file mode 100644 index 0000000..c19106a --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyStatus.java @@ -0,0 +1,22 @@ +package com.sapolar.property; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "property_statuses") +public class PropertyStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(length = 255) + private String description; +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyStatusHistory.java b/backend/src/main/java/com/sapolar/property/PropertyStatusHistory.java new file mode 100644 index 0000000..b0b892b --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyStatusHistory.java @@ -0,0 +1,46 @@ +package com.sapolar.property; + +import com.sapolar.user.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "property_status_history", indexes = { + @Index(name = "idx_psh_property_date", columnList = "property_id, changed_at") +}) +public class PropertyStatusHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "property_id", nullable = false) + private Property property; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id", nullable = false) + private PropertyStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "changed_by") + private User changedBy; + + @Column(name = "changed_at", nullable = false) + private LocalDateTime changedAt; + + @Column(length = 500) + private String notes; + + @PrePersist + protected void onCreate() { + if (changedAt == null) { + changedAt = LocalDateTime.now(); + } + } +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyStatusHistoryRepository.java b/backend/src/main/java/com/sapolar/property/PropertyStatusHistoryRepository.java new file mode 100644 index 0000000..2e857e5 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyStatusHistoryRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.property; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PropertyStatusHistoryRepository extends JpaRepository { + List findByPropertyIdOrderByChangedAtDesc(Long propertyId); +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyStatusRepository.java b/backend/src/main/java/com/sapolar/property/PropertyStatusRepository.java new file mode 100644 index 0000000..7ab6459 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyStatusRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.property; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PropertyStatusRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyType.java b/backend/src/main/java/com/sapolar/property/PropertyType.java new file mode 100644 index 0000000..08f42c6 --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyType.java @@ -0,0 +1,22 @@ +package com.sapolar.property; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "property_types") +public class PropertyType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(length = 255) + private String description; +} diff --git a/backend/src/main/java/com/sapolar/property/PropertyTypeRepository.java b/backend/src/main/java/com/sapolar/property/PropertyTypeRepository.java new file mode 100644 index 0000000..a87d84e --- /dev/null +++ b/backend/src/main/java/com/sapolar/property/PropertyTypeRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.property; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PropertyTypeRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/sapolar/scheduler/ExpenseScheduler.java b/backend/src/main/java/com/sapolar/scheduler/ExpenseScheduler.java new file mode 100644 index 0000000..d7f18b3 --- /dev/null +++ b/backend/src/main/java/com/sapolar/scheduler/ExpenseScheduler.java @@ -0,0 +1,48 @@ +package com.sapolar.scheduler; + +import com.sapolar.finance.expense.ExpenseReceiptService; +import com.sapolar.finance.expense.ExpenseTemplate; +import com.sapolar.finance.expense.ExpenseTemplateService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpenseScheduler { + + private final ExpenseTemplateService expenseTemplateService; + private final ExpenseReceiptService expenseReceiptService; + + /** + * Generate expense receipts from active templates on the 1st of each month. + * Cron: "0 0 5 1 * ?" — at 05:00 on day-of-month 1. + */ + @Scheduled(cron = "0 0 5 1 * ?") + @Transactional + public void generateExpenseReceipts() { + log.info("Iniciando generación automática de recibos de gasto..."); + List templates = expenseTemplateService.findActive(); + LocalDate today = LocalDate.now(); + int generated = 0; + + for (ExpenseTemplate template : templates) { + try { + // Generate receipt for current month + expenseReceiptService.createFromTemplate(template, today, null); + generated++; + log.debug("Recibo de gasto generado desde template {}: {}", template.getId(), template.getDescription()); + } catch (Exception e) { + log.error("Error generando recibo de gasto desde template {}: {}", template.getId(), e.getMessage()); + } + } + + log.info("Generación de recibos de gasto completada. {} recibos creados.", generated); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/Tenant.java b/backend/src/main/java/com/sapolar/tenant/Tenant.java new file mode 100644 index 0000000..4cfa77a --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/Tenant.java @@ -0,0 +1,87 @@ +package com.sapolar.tenant; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "tenants", indexes = { + @Index(name = "idx_tenants_fiscal_id", columnList = "fiscal_id"), + @Index(name = "idx_tenants_name", columnList = "first_name, last_name"), + @Index(name = "idx_tenants_active", columnList = "active") +}) +public class Tenant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tenant_type_id", nullable = false) + private TenantType tenantType; + + @Column(name = "fiscal_id", nullable = false, unique = true, length = 20) + private String fiscalId; + + @Column(name = "first_name", nullable = false, length = 100) + private String firstName; + + @Column(name = "last_name", nullable = false, length = 100) + private String lastName; + + @Column(name = "document_type", length = 20) + private String documentType = "DNI"; + + @Column(name = "business_name", length = 200) + private String businessName; + + @Column(length = 100) + private String email; + + @Column(length = 20) + private String phone; + + @Column(length = 300) + private String address; + + @Column(length = 34) + private String iban; + + @Column(columnDefinition = "TEXT") + private String notes; + + @JsonIgnore + @OneToMany(mappedBy = "tenant", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Set bankData = new HashSet<>(); + + @Column(nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public String getFullName() { + return (firstName != null ? firstName : "") + " " + (lastName != null ? lastName : ""); + } + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantBankData.java b/backend/src/main/java/com/sapolar/tenant/TenantBankData.java new file mode 100644 index 0000000..d1b6fda --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantBankData.java @@ -0,0 +1,66 @@ +package com.sapolar.tenant; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "tenant_bank_data", indexes = { + @Index(name = "idx_tenant_bank_data_tenant", columnList = "tenant_id"), + @Index(name = "idx_tenant_bank_data_principal", columnList = "tenant_id, is_principal") +}) +public class TenantBankData { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "tenant_id", nullable = false) + private Tenant tenant; + + @Column(name = "alias", length = 100) + private String alias; + + @Column(name = "iban", nullable = false, length = 34) + private String iban; + + @Column(name = "bic", length = 11) + private String bic; + + @Column(name = "bank_name", length = 200) + private String bankName; + + @Column(name = "account_holder", length = 200) + private String accountHolder; + + @Column(name = "is_principal", nullable = false) + private Boolean isPrincipal = false; + + @Column(name = "active", nullable = false) + private Boolean active = true; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantBankDataController.java b/backend/src/main/java/com/sapolar/tenant/TenantBankDataController.java new file mode 100644 index 0000000..8701ba1 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantBankDataController.java @@ -0,0 +1,63 @@ +package com.sapolar.tenant; + +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/tenants/{tenantId}/bank-data") +@RequiredArgsConstructor +public class TenantBankDataController { + + private final TenantBankDataService bankDataService; + + @GetMapping + public ResponseEntity>> findAll(@PathVariable Long tenantId) { + return ResponseEntity.ok(ApiResponse.success(bankDataService.findByTenantId(tenantId))); + } + + @GetMapping("/banks") + public ResponseEntity>> findAllBanks(@PathVariable Long tenantId) { + return ResponseEntity.ok(ApiResponse.success(bankDataService.findDistinctBankNames())); + } + + @PostMapping + public ResponseEntity> create( + @PathVariable Long tenantId, + @RequestBody TenantBankData data) { + // Validar IBAN + data.setIban(bankDataService.validateIban(data.getIban())); + TenantBankData created = bankDataService.create(tenantId, data); + return ResponseEntity.ok(ApiResponse.success("Dato bancario creado", created)); + } + + @PutMapping("/{id}") + public ResponseEntity> update( + @PathVariable Long tenantId, + @PathVariable Long id, + @RequestBody TenantBankData data) { + if (data.getIban() != null) { + data.setIban(bankDataService.validateIban(data.getIban())); + } + return ResponseEntity.ok(ApiResponse.success("Dato bancario actualizado", bankDataService.update(id, data))); + } + + @PatchMapping("/{id}/principal") + public ResponseEntity> setPrincipal( + @PathVariable Long tenantId, + @PathVariable Long id) { + bankDataService.setPrincipal(id); + return ResponseEntity.ok(ApiResponse.success("Marcado como principal", bankDataService.findById(id))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable Long tenantId, + @PathVariable Long id) { + bankDataService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Dato bancario eliminado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantBankDataRepository.java b/backend/src/main/java/com/sapolar/tenant/TenantBankDataRepository.java new file mode 100644 index 0000000..9feaeb2 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantBankDataRepository.java @@ -0,0 +1,31 @@ +package com.sapolar.tenant; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TenantBankDataRepository extends JpaRepository { + + List findByTenantIdAndActiveTrueOrderByIsPrincipalDesc(Long tenantId); + + List findByTenantIdOrderByIsPrincipalDesc(Long tenantId); + + Optional findByTenantIdAndIsPrincipalTrue(Long tenantId); + + @Modifying + @Query("UPDATE TenantBankData t SET t.isPrincipal = false WHERE t.tenant.id = :tenantId AND t.id != :excludeId") + void unsetOthersAsPrincipal(@Param("tenantId") Long tenantId, @Param("excludeId") Long excludeId); + + @Modifying + @Query("UPDATE TenantBankData t SET t.isPrincipal = false WHERE t.tenant.id = :tenantId") + void unsetAllAsPrincipal(@Param("tenantId") Long tenantId); + + @Query("SELECT DISTINCT t.bankName FROM TenantBankData t WHERE t.bankName IS NOT NULL AND t.bankName <> '' ORDER BY t.bankName") + List findDistinctBankNames(); +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantBankDataService.java b/backend/src/main/java/com/sapolar/tenant/TenantBankDataService.java new file mode 100644 index 0000000..2017b03 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantBankDataService.java @@ -0,0 +1,113 @@ +package com.sapolar.tenant; + +import com.sapolar.common.exception.BadRequestException; +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TenantBankDataService { + + private final TenantBankDataRepository bankDataRepository; + private final TenantRepository tenantRepository; + + public List findByTenantId(Long tenantId) { + return bankDataRepository.findByTenantIdAndActiveTrueOrderByIsPrincipalDesc(tenantId); + } + + public TenantBankData findById(Long id) { + return bankDataRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Dato bancario", id)); + } + + @Transactional + public TenantBankData create(Long tenantId, TenantBankData data) { + Tenant tenant = tenantRepository.findById(tenantId) + .orElseThrow(() -> new ResourceNotFoundException("Arrendatario", tenantId)); + + // Si no hay datos bancarios aún, el primero será principal por defecto + long existingCount = bankDataRepository.findByTenantIdOrderByIsPrincipalDesc(tenantId).size(); + if (existingCount == 0) { + data.setIsPrincipal(true); + } else if (data.getIsPrincipal() != null && data.getIsPrincipal()) { + // Si el nuevo dato es principal, desmarcar los demás + bankDataRepository.unsetAllAsPrincipal(tenantId); + } + + data.setTenant(tenant); + if (data.getActive() == null) { + data.setActive(true); + } + if (data.getIsPrincipal() == null) { + data.setIsPrincipal(false); + } + return bankDataRepository.save(data); + } + + @Transactional + public TenantBankData update(Long id, TenantBankData updated) { + TenantBankData existing = findById(id); + existing.setAlias(updated.getAlias()); + existing.setIban(updated.getIban()); + existing.setBic(updated.getBic()); + existing.setBankName(updated.getBankName()); + existing.setAccountHolder(updated.getAccountHolder()); + existing.setNotes(updated.getNotes()); + existing.setActive(updated.getActive() != null ? updated.getActive() : existing.getActive()); + + if (updated.getIsPrincipal() != null && updated.getIsPrincipal() && !existing.getIsPrincipal()) { + bankDataRepository.unsetAllAsPrincipal(existing.getTenant().getId()); + existing.setIsPrincipal(true); + } else if (updated.getIsPrincipal() != null) { + existing.setIsPrincipal(updated.getIsPrincipal()); + } + + return bankDataRepository.save(existing); + } + + @Transactional + public void setPrincipal(Long id) { + TenantBankData data = findById(id); + bankDataRepository.unsetAllAsPrincipal(data.getTenant().getId()); + data.setIsPrincipal(true); + bankDataRepository.save(data); + } + + @Transactional + public void delete(Long id) { + TenantBankData data = findById(id); + boolean wasPrincipal = data.getIsPrincipal(); + Long tenantId = data.getTenant().getId(); + bankDataRepository.delete(data); + + // Si era el principal, marcar otro como principal si queda alguno + if (wasPrincipal) { + List remaining = bankDataRepository.findByTenantIdAndActiveTrueOrderByIsPrincipalDesc(tenantId); + if (!remaining.isEmpty()) { + TenantBankData newPrincipal = remaining.get(0); + newPrincipal.setIsPrincipal(true); + bankDataRepository.save(newPrincipal); + } + } + } + + public String validateIban(String iban) { + if (iban == null || iban.isBlank()) { + throw new BadRequestException("El IBAN es obligatorio"); + } + String normalized = iban.replaceAll("\\s+", "").toUpperCase(); + // Validar formato básico: 2 letras + 2 dígitos + hasta 30 caracteres alfanuméricos + if (!normalized.matches("^[A-Z]{2}\\d{2}[A-Z0-9]{1,30}$")) { + throw new BadRequestException("Formato de IBAN no válido"); + } + return normalized; + } + + public List findDistinctBankNames() { + return bankDataRepository.findDistinctBankNames(); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantController.java b/backend/src/main/java/com/sapolar/tenant/TenantController.java new file mode 100644 index 0000000..c0f9178 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantController.java @@ -0,0 +1,64 @@ +package com.sapolar.tenant; + +import com.sapolar.common.dto.ApiResponse; +import com.sapolar.common.dto.PagedResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/tenants") +@RequiredArgsConstructor +public class TenantController { + + private final TenantService tenantService; + + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) String search, + @RequestParam(required = false) Long propertyId, + @RequestParam(required = false) Long groupId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "id") String sort, + @RequestParam(defaultValue = "asc") String dir) { + Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending(); + Pageable pageable = PageRequest.of(page, size, sorting); + if (search != null && !search.isBlank()) { + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(tenantService.search(search, pageable)))); + } + if (propertyId != null) { + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(tenantService.findByPropertyId(propertyId, pageable)))); + } + if (groupId != null) { + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(tenantService.findByGroupId(groupId, pageable)))); + } + return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(tenantService.findAll(pageable)))); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(tenantService.findById(id))); + } + + @PostMapping + public ResponseEntity> create(@RequestBody Tenant tenant) { + return ResponseEntity.ok(ApiResponse.success("Arrendatario creado", tenantService.create(tenant))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, @RequestBody Tenant tenant) { + return ResponseEntity.ok(ApiResponse.success("Arrendatario actualizado", tenantService.update(id, tenant))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + tenantService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Arrendatario desactivado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantRepository.java b/backend/src/main/java/com/sapolar/tenant/TenantRepository.java new file mode 100644 index 0000000..c6a0044 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantRepository.java @@ -0,0 +1,32 @@ +package com.sapolar.tenant; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TenantRepository extends JpaRepository { + Optional findByFiscalId(String fiscalId); + + @Query("SELECT t FROM Tenant t WHERE LOWER(CONCAT(t.firstName, ' ', t.lastName)) LIKE LOWER(CONCAT('%', :name, '%'))") + List searchByName(@Param("name") String name); + + @Query("SELECT t FROM Tenant t WHERE LOWER(CONCAT(t.firstName, ' ', t.lastName)) LIKE LOWER(CONCAT('%', :name, '%'))") + Page searchByName(@Param("name") String name, Pageable pageable); + + List findByActiveTrue(); + Page findByActiveTrue(Pageable pageable); + boolean existsByFiscalId(String fiscalId); + + @Query("SELECT DISTINCT ct.tenant FROM ContractTenant ct WHERE ct.contract.property.id = :propertyId AND ct.tenant.active = true") + Page findByPropertyId(@Param("propertyId") Long propertyId, Pageable pageable); + + @Query("SELECT DISTINCT ct.tenant FROM ContractTenant ct WHERE ct.contract.property.group.id = :groupId AND ct.tenant.active = true") + Page findByGroupId(@Param("groupId") Long groupId, Pageable pageable); +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantService.java b/backend/src/main/java/com/sapolar/tenant/TenantService.java new file mode 100644 index 0000000..5cfa1b0 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantService.java @@ -0,0 +1,79 @@ +package com.sapolar.tenant; + +import com.sapolar.common.exception.DuplicateResourceException; +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TenantService { + + private final TenantRepository tenantRepository; + + public List findAll() { + return tenantRepository.findByActiveTrue(); + } + + public Page findAll(Pageable pageable) { + return tenantRepository.findByActiveTrue(pageable); + } + + public List search(String query) { + return tenantRepository.searchByName(query); + } + + public Page search(String query, Pageable pageable) { + return tenantRepository.searchByName(query, pageable); + } + + public Page findByPropertyId(Long propertyId, Pageable pageable) { + return tenantRepository.findByPropertyId(propertyId, pageable); + } + + public Page findByGroupId(Long groupId, Pageable pageable) { + return tenantRepository.findByGroupId(groupId, pageable); + } + + public Tenant findById(Long id) { + return tenantRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Arrendatario", id)); + } + + @Transactional + public Tenant create(Tenant tenant) { + if (tenantRepository.existsByFiscalId(tenant.getFiscalId())) { + throw new DuplicateResourceException("Ya existe un arrendatario con ese documento: " + tenant.getFiscalId()); + } + tenant.setActive(true); + return tenantRepository.save(tenant); + } + + @Transactional + public Tenant update(Long id, Tenant updated) { + Tenant tenant = findById(id); + tenant.setFirstName(updated.getFirstName()); + tenant.setLastName(updated.getLastName()); + tenant.setDocumentType(updated.getDocumentType()); + tenant.setBusinessName(updated.getBusinessName()); + tenant.setEmail(updated.getEmail()); + tenant.setPhone(updated.getPhone()); + tenant.setAddress(updated.getAddress()); + tenant.setIban(updated.getIban()); + tenant.setNotes(updated.getNotes()); + tenant.setActive(updated.getActive()); + return tenantRepository.save(tenant); + } + + @Transactional + public void delete(Long id) { + Tenant tenant = findById(id); + tenant.setActive(false); + tenantRepository.save(tenant); + } +} diff --git a/backend/src/main/java/com/sapolar/tenant/TenantType.java b/backend/src/main/java/com/sapolar/tenant/TenantType.java new file mode 100644 index 0000000..f842fb5 --- /dev/null +++ b/backend/src/main/java/com/sapolar/tenant/TenantType.java @@ -0,0 +1,19 @@ +package com.sapolar.tenant; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "tenant_types") +public class TenantType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; +} diff --git a/backend/src/main/java/com/sapolar/user/Role.java b/backend/src/main/java/com/sapolar/user/Role.java new file mode 100644 index 0000000..952deaf --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/Role.java @@ -0,0 +1,22 @@ +package com.sapolar.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "roles") +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true, length = 30) + private String name; + + @Column(length = 255) + private String description; +} diff --git a/backend/src/main/java/com/sapolar/user/RoleRepository.java b/backend/src/main/java/com/sapolar/user/RoleRepository.java new file mode 100644 index 0000000..e48038c --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/RoleRepository.java @@ -0,0 +1,11 @@ +package com.sapolar.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RoleRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/sapolar/user/User.java b/backend/src/main/java/com/sapolar/user/User.java new file mode 100644 index 0000000..265d6a8 --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/User.java @@ -0,0 +1,60 @@ +package com.sapolar.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @Column(name = "full_name", nullable = false, length = 150) + private String fullName; + + @Column(length = 20) + private String phone; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(nullable = false) + private Boolean active = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "last_login") + private LocalDateTime lastLogin; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/sapolar/user/UserController.java b/backend/src/main/java/com/sapolar/user/UserController.java new file mode 100644 index 0000000..bfe07a0 --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/UserController.java @@ -0,0 +1,37 @@ +package com.sapolar.user; + +import com.sapolar.common.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping + public ResponseEntity>> findAll() { + return ResponseEntity.ok(ApiResponse.success(userService.findAll())); + } + + @GetMapping("/{id}") + public ResponseEntity> findById(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(userService.findById(id))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable Long id, @RequestBody User user) { + return ResponseEntity.ok(ApiResponse.success("Usuario actualizado", userService.update(id, user))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete(@PathVariable Long id) { + userService.delete(id); + return ResponseEntity.ok(ApiResponse.success("Usuario desactivado", null)); + } +} diff --git a/backend/src/main/java/com/sapolar/user/UserPermission.java b/backend/src/main/java/com/sapolar/user/UserPermission.java new file mode 100644 index 0000000..0477ccb --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/UserPermission.java @@ -0,0 +1,27 @@ +package com.sapolar.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "user_permissions", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "permission"})) +public class UserPermission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 50) + private String permission; + + @Column(nullable = false) + private Boolean granted = true; +} diff --git a/backend/src/main/java/com/sapolar/user/UserRepository.java b/backend/src/main/java/com/sapolar/user/UserRepository.java new file mode 100644 index 0000000..8d81a29 --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/UserRepository.java @@ -0,0 +1,14 @@ +package com.sapolar.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); +} diff --git a/backend/src/main/java/com/sapolar/user/UserService.java b/backend/src/main/java/com/sapolar/user/UserService.java new file mode 100644 index 0000000..a94e140 --- /dev/null +++ b/backend/src/main/java/com/sapolar/user/UserService.java @@ -0,0 +1,52 @@ +package com.sapolar.user; + +import com.sapolar.common.exception.DuplicateResourceException; +import com.sapolar.common.exception.ResourceNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public List findAll() { + return userRepository.findAll(); + } + + public User findById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Usuario", id)); + } + + @Transactional + public User update(Long id, User updated) { + User user = findById(id); + user.setFullName(updated.getFullName()); + user.setEmail(updated.getEmail()); + user.setPhone(updated.getPhone()); + user.setActive(updated.getActive()); + if (updated.getRole() != null) { + user.setRole(updated.getRole()); + } + return userRepository.save(user); + } + + @Transactional + public void toggleActive(Long id) { + User user = findById(id); + user.setActive(!user.getActive()); + userRepository.save(user); + } + + @Transactional + public void delete(Long id) { + User user = findById(id); + user.setActive(false); + userRepository.save(user); + } +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..d919f0c --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,32 @@ +# ============================================================ +# Perfil DEV (Desarrollo) +# ============================================================ +# Configuración específica para entorno de desarrollo. +# +# Características: +# - Flyway clean habilitado (permite resetear la BD) +# - Logs detallados +# - Validación de JPA más estricta +# ============================================================ + +spring: + flyway: + # En desarrollo, permitimos clean para resetear la BD + clean-disabled: false + # Validar al migrar para detectar cambios de checksum + validate-on-migrate: true + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: true + +logging: + level: + com.sapolar: DEBUG + org.springframework.security: DEBUG + org.flywaydb: INFO + org.hibernate.SQL: DEBUG diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..b6c2bf5 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,36 @@ +# ============================================================ +# Perfil PROD (Producción) +# ============================================================ +# Configuración específica para entorno de producción. +# +# Características: +# - Flyway clean DESHABILITADO (nunca se borrarán datos) +# - Solo repair + migrate en migraciones fallidas +# - Logs menos detallados +# ============================================================ + +spring: + flyway: + # En producción, clean está COMPLETAMENTE deshabilitado + # para proteger los datos de la empresa + clean-disabled: true + # Validar al migrar para detectar cualquier inconsistencia + validate-on-migrate: true + # Deshabilitar reparaciones automáticas para máxima seguridad + # El administrador debe revisar y reparar manualmente si es necesario + repair-on-migrate: false + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: false + +logging: + level: + com.sapolar: INFO + org.springframework.security: WARN + org.flywaydb: WARN + org.hibernate.SQL: WARN diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5595665 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,100 @@ +server: + port: 8080 + servlet: + encoding: + charset: UTF-8 + force: true + enabled: true + +spring: + application: + name: sa-polar-backend + + # Perfil por defecto: dev. En producción, establecer SPRING_PROFILES_ACTIVE=prod + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + + datasource: + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:sa_polar}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci + username: ${DB_USER:root} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 300000 + connection-timeout: 20000 + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + open-in-view: true + properties: + hibernate: + format_sql: true + + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 1 + # La estrategia de reparación está en FlywayRepairConfig (varía por perfil) + # La configuración específica de cada perfil está en application-{profile}.yml + locations: classpath:db/migration + validate-on-migrate: true + # outOfOrder permite ejecutar migraciones que no están en orden + out-of-order: false + + servlet: + multipart: + enabled: true + max-file-size: 20MB + max-request-size: 25MB + + jackson: + serialization: + write-dates-as-timestamps: false + date-format: yyyy-MM-dd'T'HH:mm:ss + time-zone: Europe/Madrid + + mail: + host: ${MAIL_HOST:localhost} + port: ${MAIL_PORT:1025} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: ${MAIL_SMTP_AUTH:false} + starttls: + enable: ${MAIL_SMTP_STARTTLS:false} + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + +app: + jwt: + secret: ${JWT_SECRET} + expiration-ms: 86400000 + refresh-expiration-ms: 2592000000 + + upload: + path: ${UPLOAD_PATH:./uploads} + max-file-size: 20971520 + + receipt: + from-email: ${RECEIPT_FROM_EMAIL:noreply@sapolar.com} + scheduler-enabled: ${RECEIPT_SCHEDULER_ENABLED:true} + + cors: + allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000} + +logging: + level: + com.sapolar: DEBUG + org.springframework.security: INFO + org.hibernate.SQL: WARN diff --git a/backend/src/main/resources/db/migration/V10__expense_periodicity.sql b/backend/src/main/resources/db/migration/V10__expense_periodicity.sql new file mode 100644 index 0000000..301d16f --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__expense_periodicity.sql @@ -0,0 +1,48 @@ +-- V10: Periodicidad en gastos - period_id y payment_day +-- NOTA: MySQL 8.0 < 8.0.29 no soporta ADD COLUMN IF NOT EXISTS. +-- Se usan bloques condicionales para idempotencia. + +-- 1. Añadir columna period_id (nullable = gasto único) si no existe +SET @dbname = DATABASE(); +SET @exists = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'expenses' + AND COLUMN_NAME = 'period_id'); +SET @sql = IF(@exists = 0, + 'ALTER TABLE expenses ADD COLUMN period_id INT DEFAULT NULL AFTER is_planned', + 'SELECT "period_id already exists"'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. Añadir columna payment_day si no existe +SET @exists2 = (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'expenses' + AND COLUMN_NAME = 'payment_day'); +SET @sql2 = IF(@exists2 = 0, + 'ALTER TABLE expenses ADD COLUMN payment_day INT DEFAULT NULL AFTER period_id', + 'SELECT "payment_day already exists"'); +PREPARE stmt2 FROM @sql2; +EXECUTE stmt2; +DEALLOCATE PREPARE stmt2; + +-- 3. Añadir FK si no existe +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'expenses' + AND CONSTRAINT_NAME = 'fk_expense_period'); +SET @sql3 = IF(@fk_exists = 0, + 'ALTER TABLE expenses ADD CONSTRAINT fk_expense_period FOREIGN KEY (period_id) REFERENCES payment_periods(id) ON DELETE SET NULL', + 'SELECT "fk_expense_period already exists"'); +PREPARE stmt3 FROM @sql3; +EXECUTE stmt3; +DEALLOCATE PREPARE stmt3; + +-- 4. Índice para búsquedas por periodicidad (si no existe) +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'expenses' + AND INDEX_NAME = 'idx_expenses_period'); +SET @sql4 = IF(@idx_exists = 0, + 'CREATE INDEX idx_expenses_period ON expenses(period_id)', + 'SELECT "idx_expenses_period already exists"'); +PREPARE stmt4 FROM @sql4; +EXECUTE stmt4; +DEALLOCATE PREPARE stmt4; diff --git a/backend/src/main/resources/db/migration/V11__refactor_auto_receipts.sql b/backend/src/main/resources/db/migration/V11__refactor_auto_receipts.sql new file mode 100644 index 0000000..ba92eb8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__refactor_auto_receipts.sql @@ -0,0 +1,136 @@ +-- ============================================================ +-- V11: Refactor a sistema de recibos automáticos +-- - income_receipts (generados desde contratos) +-- - expense_templates (configuración de gastos recurrentes) +-- - expense_receipts (instancias generadas desde templates) +-- ============================================================ + +-- 1. Limpiar datos antiguos +DELETE FROM document_entities WHERE entity_type IN ('INCOME', 'EXPENSE'); +DELETE FROM email_log; +DELETE FROM incomes; +DELETE FROM expenses; + +-- 2. Eliminar tablas antiguas +DROP TABLE IF EXISTS incomes; +DROP TABLE IF EXISTS expenses; + +-- 3. Renombrar columna en email_log (income_id → income_receipt_id) +ALTER TABLE email_log CHANGE COLUMN income_id income_receipt_id BIGINT; + +-- 4. Crear income_receipts +CREATE TABLE income_receipts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT, + property_id BIGINT NOT NULL, + tenant_id BIGINT, + bank_account_id BIGINT, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + status_id INT NOT NULL, + period_label VARCHAR(20) COMMENT 'ej: 2026-07', + amount DECIMAL(12,2) NOT NULL, + tax_withheld DECIMAL(12,2) DEFAULT 0.00, + net_amount DECIMAL(12,2), + issue_date DATE NOT NULL, + due_date DATE, + payment_date DATE, + payment_method VARCHAR(30), + description VARCHAR(500), + receipt_number VARCHAR(50), + notes TEXT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_income_receipt_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_income_receipt_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_category FOREIGN KEY (category_id) REFERENCES income_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_status FOREIGN KEY (status_id) REFERENCES income_statuses(id), + CONSTRAINT fk_income_receipt_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_income_receipts_property ON income_receipts(property_id); +CREATE INDEX idx_income_receipts_contract ON income_receipts(contract_id); +CREATE INDEX idx_income_receipts_tenant ON income_receipts(tenant_id); +CREATE INDEX idx_income_receipts_status ON income_receipts(status_id); +CREATE INDEX idx_income_receipts_period ON income_receipts(period_label); +CREATE INDEX idx_income_receipts_issue_date ON income_receipts(issue_date); + +-- 4. Crear expense_templates (configuraciones de gastos recurrentes) +CREATE TABLE expense_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT, + property_group_id BIGINT, + bank_account_id BIGINT, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + period_id INT NOT NULL DEFAULT 1, + payment_day INT NOT NULL DEFAULT 1, + supplier_name VARCHAR(200), + supplier_fiscal_id VARCHAR(20), + amount DECIMAL(12,2) COMMENT 'NULL = variable, usuario rellena importe', + tax_amount DECIMAL(12,2), + description VARCHAR(500) NOT NULL, + notes TEXT, + is_variable BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_expense_template_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_category FOREIGN KEY (category_id) REFERENCES expense_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_period FOREIGN KEY (period_id) REFERENCES payment_periods(id), + CONSTRAINT fk_expense_template_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_expense_templates_property ON expense_templates(property_id); +CREATE INDEX idx_expense_templates_group ON expense_templates(property_group_id); +CREATE INDEX idx_expense_templates_category ON expense_templates(category_id); +CREATE INDEX idx_expense_templates_period ON expense_templates(period_id); + +-- 5. Crear expense_receipts (instancias de gasto generadas automáticamente) +CREATE TABLE expense_receipts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_id BIGINT, + property_id BIGINT, + property_group_id BIGINT, + bank_account_id BIGINT, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + status_id INT NOT NULL, + supplier_name VARCHAR(200), + supplier_fiscal_id VARCHAR(20), + invoice_number VARCHAR(50), + amount DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) DEFAULT 0.00, + total_amount DECIMAL(12,2), + is_variable BOOLEAN NOT NULL DEFAULT FALSE, + previous_amount DECIMAL(12,2) COMMENT 'Importe del periodo anterior (para variables)', + issue_date DATE NOT NULL, + due_date DATE, + payment_date DATE, + payment_method VARCHAR(30), + description VARCHAR(500) NOT NULL, + notes TEXT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_expense_receipt_template FOREIGN KEY (template_id) REFERENCES expense_templates(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_category FOREIGN KEY (category_id) REFERENCES expense_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_status FOREIGN KEY (status_id) REFERENCES expense_statuses(id), + CONSTRAINT fk_expense_receipt_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_expense_receipts_template ON expense_receipts(template_id); +CREATE INDEX idx_expense_receipts_property ON expense_receipts(property_id); +CREATE INDEX idx_expense_receipts_group ON expense_receipts(property_group_id); +CREATE INDEX idx_expense_receipts_category ON expense_receipts(category_id); +CREATE INDEX idx_expense_receipts_status ON expense_receipts(status_id); +CREATE INDEX idx_expense_receipts_issue_date ON expense_receipts(issue_date); diff --git a/backend/src/main/resources/db/migration/V12__split_suministros_category.sql b/backend/src/main/resources/db/migration/V12__split_suministros_category.sql new file mode 100644 index 0000000..dee2bc8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__split_suministros_category.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- V12: Dividir categoría SUMINISTROS en tres +-- ============================================================ +-- La categoría "SUMINISTROS" se divide en: +-- - SUMINISTROS_LUZ: Electricidad +-- - SUMINISTROS_GAS: Gas natural/butano +-- - SUMINISTROS_OTROS: Agua, internet y otros suministros +-- ============================================================ + +-- Desactivar la categoría original SUMINISTROS (soft-delete) +UPDATE expense_categories SET active = FALSE WHERE name = 'SUMINISTROS'; + +-- Insertar las tres nuevas categorías (verificar que no existan para ser idempotente) +INSERT INTO expense_categories (id, name, description, active) +SELECT 13, 'SUMINISTROS_LUZ', 'Electricidad y suministro eléctrico', TRUE +WHERE NOT EXISTS (SELECT 1 FROM expense_categories WHERE name = 'SUMINISTROS_LUZ'); + +INSERT INTO expense_categories (id, name, description, active) +SELECT 14, 'SUMINISTROS_GAS', 'Gas natural, butano, propano', TRUE +WHERE NOT EXISTS (SELECT 1 FROM expense_categories WHERE name = 'SUMINISTROS_GAS'); + +INSERT INTO expense_categories (id, name, description, active) +SELECT 15, 'SUMINISTROS_OTROS', 'Agua, internet, telefonía y otros suministros', TRUE +WHERE NOT EXISTS (SELECT 1 FROM expense_categories WHERE name = 'SUMINISTROS_OTROS'); diff --git a/backend/src/main/resources/db/migration/V13__increase_floor_door_size.sql b/backend/src/main/resources/db/migration/V13__increase_floor_door_size.sql new file mode 100644 index 0000000..4fd2879 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__increase_floor_door_size.sql @@ -0,0 +1,10 @@ +-- ============================================================ +-- Migration V13: Increase floor and door column sizes to VARCHAR(50) +-- ============================================================ +-- Motivo: Los datos de piso/puerta pueden contener valores +-- como "1º A", "Bajo B", "Entresuelo 3", etc., que exceden +-- el límite anterior de 10 caracteres. +-- ============================================================ + +ALTER TABLE properties MODIFY COLUMN floor VARCHAR(50); +ALTER TABLE properties MODIFY COLUMN door VARCHAR(50); diff --git a/backend/src/main/resources/db/migration/V15__payment_day_range.sql b/backend/src/main/resources/db/migration/V15__payment_day_range.sql new file mode 100644 index 0000000..b2b74b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__payment_day_range.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- V15: Añadir columna payment_day_end para rangos de pago +-- Permite especificar un rango de días de pago (ej: 1-7) +-- Si payment_day_end es NULL, se usa solo payment_day +-- ============================================================ + +DROP PROCEDURE IF EXISTS add_payment_day_end_column; + +DELIMITER // + +CREATE PROCEDURE add_payment_day_end_column() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'contracts' + AND COLUMN_NAME = 'payment_day_end' + ) THEN + ALTER TABLE contracts ADD COLUMN payment_day_end INT DEFAULT NULL COMMENT 'Día final del rango de pago (NULL = día único)'; + END IF; +END // + +DELIMITER ; + +CALL add_payment_day_end_column(); + +DROP PROCEDURE IF EXISTS add_payment_day_end_column; diff --git a/backend/src/main/resources/db/migration/V16__expense_repercussions.sql b/backend/src/main/resources/db/migration/V16__expense_repercussions.sql new file mode 100644 index 0000000..c8a1859 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__expense_repercussions.sql @@ -0,0 +1,19 @@ +-- V16__expense_repercussions.sql +-- Tabla para almacenar las repercusiones de gastos de los contratos +-- Las repercusiones son gastos como luz, agua o gas que el casero paga +-- y luego se los pasa al inquilino + +CREATE TABLE IF NOT EXISTS expense_repercussions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT NOT NULL, + expense_type VARCHAR(50) NOT NULL, + amount DECIMAL(10,2) NOT NULL, + billing_period VARCHAR(30), + observations TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_repercussion_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE, + INDEX idx_repercussion_contract (contract_id), + INDEX idx_repercussion_expense_type (expense_type) +) ENGINE=InnoDB; diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..b035974 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,555 @@ +-- ============================================================ +-- SISTEMA DE GESTIÓN DE ALQUILERES "SA POLAR" +-- Migración V1: Esquema inicial (estructura original) +-- ============================================================ + +CREATE TABLE roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(150) NOT NULL, + phone VARCHAR(20), + role_id INT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_login DATETIME, + CONSTRAINT fk_user_role FOREIGN KEY (role_id) REFERENCES roles(id) +) ENGINE=InnoDB; + +CREATE TABLE user_permissions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + permission VARCHAR(50) NOT NULL, + granted BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_up_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_user_permission (user_id, permission) +) ENGINE=InnoDB; + +CREATE TABLE property_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE property_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE property_groups ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + address_street VARCHAR(200), + address_number VARCHAR(20), + address_city VARCHAR(100), + address_postal_code VARCHAR(10), + address_province VARCHAR(100), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE properties ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + parent_id BIGINT, + group_id BIGINT, + type_id INT NOT NULL, + status_id INT NOT NULL, + reference VARCHAR(50) UNIQUE, + name VARCHAR(200) NOT NULL, + description TEXT, + address_street VARCHAR(200), + address_number VARCHAR(20), + address_city VARCHAR(100), + address_postal_code VARCHAR(10), + address_province VARCHAR(100), + cadastral_ref VARCHAR(30), + surface_m2 DECIMAL(10,2), + floor VARCHAR(10), + door VARCHAR(10), + rental_amount DECIMAL(12,2), + rented_since DATE, + vacant_since DATE, + occupied_since DATE, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_prop_parent FOREIGN KEY (parent_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_prop_group FOREIGN KEY (group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_prop_type FOREIGN KEY (type_id) REFERENCES property_types(id), + CONSTRAINT fk_prop_status FOREIGN KEY (status_id) REFERENCES property_statuses(id), + CONSTRAINT fk_prop_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE property_status_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + status_id INT NOT NULL, + changed_by BIGINT, + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + notes VARCHAR(500), + CONSTRAINT fk_psh_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE, + CONSTRAINT fk_psh_status FOREIGN KEY (status_id) REFERENCES property_statuses(id), + CONSTRAINT fk_psh_user FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_properties_parent ON properties(parent_id); +CREATE INDEX idx_properties_group ON properties(group_id); +CREATE INDEX idx_properties_type ON properties(type_id); +CREATE INDEX idx_properties_status ON properties(status_id); +CREATE INDEX idx_properties_active ON properties(active); +CREATE INDEX idx_psh_property_date ON property_status_history(property_id, changed_at); + +CREATE TABLE tenant_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_type_id INT NOT NULL, + fiscal_id VARCHAR(20) NOT NULL UNIQUE COMMENT 'DNI / NIF / CIF', + full_name VARCHAR(200) NOT NULL COMMENT 'Nombre completo o razón social', + business_name VARCHAR(200) COMMENT 'Solo para personas jurídicas', + role VARCHAR(20) NOT NULL DEFAULT 'TITULAR' COMMENT 'TITULAR o CONVIVIENTE', + email VARCHAR(100), + phone VARCHAR(20), + address VARCHAR(300), + iban VARCHAR(34), + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_tenant_type FOREIGN KEY (tenant_type_id) REFERENCES tenant_types(id) +) ENGINE=InnoDB; + +CREATE INDEX idx_tenants_fiscal_id ON tenants(fiscal_id); +CREATE INDEX idx_tenants_name ON tenants(full_name); +CREATE INDEX idx_tenants_active ON tenants(active); + +CREATE TABLE contract_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE payment_periods ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE contracts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + status_id INT NOT NULL, + period_id INT NOT NULL DEFAULT 1, + contract_number VARCHAR(50) UNIQUE, + start_date DATE NOT NULL, + end_date DATE, + renewal_date DATE, + rental_amount DECIMAL(12,2) NOT NULL, + deposit_amount DECIMAL(12,2), + payment_day INT NOT NULL DEFAULT 1 COMMENT 'Día de mes para el pago', + iban_charge VARCHAR(34) COMMENT 'IBAN para domiciliación', + notes TEXT, + signed_at DATE, + terminated_at DATE, + termination_cause VARCHAR(500), + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_contract_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_contract_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id), + CONSTRAINT fk_contract_status FOREIGN KEY (status_id) REFERENCES contract_statuses(id), + CONSTRAINT fk_contract_period FOREIGN KEY (period_id) REFERENCES payment_periods(id), + CONSTRAINT fk_contract_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_contracts_property ON contracts(property_id); +CREATE INDEX idx_contracts_tenant ON contracts(tenant_id); +CREATE INDEX idx_contracts_status ON contracts(status_id); +CREATE INDEX idx_contracts_dates ON contracts(start_date, end_date); + +CREATE TABLE document_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE documents ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + document_type_id INT NOT NULL, + entity_type VARCHAR(30) NOT NULL COMMENT 'PROPERTY / CONTRACT / TENANT / INCIDENT / INCOME / EXPENSE', + entity_id BIGINT NOT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100), + file_size BIGINT, + description VARCHAR(500), + uploaded_by BIGINT, + uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_doc_type FOREIGN KEY (document_type_id) REFERENCES document_types(id), + CONSTRAINT fk_doc_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_documents_entity ON documents(entity_type, entity_id); +CREATE INDEX idx_documents_type ON documents(document_type_id); + +CREATE TABLE income_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + active BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE=InnoDB; + +CREATE TABLE income_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE incomes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT, + property_id BIGINT NOT NULL, + tenant_id BIGINT, + category_id BIGINT, + status_id INT NOT NULL, + amount DECIMAL(12,2) NOT NULL, + tax_withheld DECIMAL(12,2) DEFAULT 0.00, + net_amount DECIMAL(12,2), + issue_date DATE NOT NULL, + due_date DATE, + payment_date DATE, + payment_method VARCHAR(30), + description VARCHAR(500), + receipt_number VARCHAR(50), + notes TEXT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_income_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE SET NULL, + CONSTRAINT fk_income_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_income_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL, + CONSTRAINT fk_income_category FOREIGN KEY (category_id) REFERENCES income_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_income_status FOREIGN KEY (status_id) REFERENCES income_statuses(id), + CONSTRAINT fk_income_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_incomes_property ON incomes(property_id); +CREATE INDEX idx_incomes_contract ON incomes(contract_id); +CREATE INDEX idx_incomes_tenant ON incomes(tenant_id); +CREATE INDEX idx_incomes_status ON incomes(status_id); +CREATE INDEX idx_incomes_issue_date ON incomes(issue_date); + +CREATE TABLE expense_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + active BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE=InnoDB; + +CREATE TABLE expense_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE expenses ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + category_id BIGINT, + status_id INT NOT NULL, + supplier_name VARCHAR(200), + supplier_fiscal_id VARCHAR(20), + invoice_number VARCHAR(50), + amount DECIMAL(12,2) NOT NULL, + tax_amount DECIMAL(12,2) DEFAULT 0.00, + total_amount DECIMAL(12,2), + issue_date DATE NOT NULL, + payment_date DATE, + due_date DATE, + payment_method VARCHAR(30), + description VARCHAR(500) NOT NULL, + notes TEXT, + is_planned BOOLEAN NOT NULL DEFAULT FALSE, + planned_date DATE, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_expense_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_expense_category FOREIGN KEY (category_id) REFERENCES expense_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_status FOREIGN KEY (status_id) REFERENCES expense_statuses(id), + CONSTRAINT fk_expense_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_expenses_property ON expenses(property_id); +CREATE INDEX idx_expenses_category ON expenses(category_id); +CREATE INDEX idx_expenses_status ON expenses(status_id); +CREATE INDEX idx_expenses_issue_date ON expenses(issue_date); + +CREATE TABLE incident_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE incident_priorities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE incidents ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + status_id INT NOT NULL, + priority_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + reported_by BIGINT, + assigned_to BIGINT, + reported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + scheduled_date DATE, + resolved_at DATETIME, + resolution_notes TEXT, + cost_estimate DECIMAL(12,2), + final_cost DECIMAL(12,2), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_incident_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_incident_status FOREIGN KEY (status_id) REFERENCES incident_statuses(id), + CONSTRAINT fk_incident_priority FOREIGN KEY (priority_id) REFERENCES incident_priorities(id), + CONSTRAINT fk_incident_reporter FOREIGN KEY (reported_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_incident_assigned FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_incidents_property ON incidents(property_id); +CREATE INDEX idx_incidents_status ON incidents(status_id); +CREATE INDEX idx_incidents_priority ON incidents(priority_id); +CREATE INDEX idx_incidents_reported ON incidents(reported_at); + +CREATE TABLE maintenance_periods ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE scheduled_maintenance ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + period_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT, + estimated_cost DECIMAL(12,2), + last_execution DATE, + next_execution DATE NOT NULL, + reminder_days_before INT NOT NULL DEFAULT 30, + responsible VARCHAR(200), + notes TEXT, + completed BOOLEAN NOT NULL DEFAULT FALSE, + completed_at DATE, + completed_by BIGINT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_maint_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_maint_period FOREIGN KEY (period_id) REFERENCES maintenance_periods(id), + CONSTRAINT fk_maint_completed FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_maint_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_maint_property ON scheduled_maintenance(property_id); +CREATE INDEX idx_maint_next_exec ON scheduled_maintenance(next_execution); +CREATE INDEX idx_maint_completed ON scheduled_maintenance(completed); + +CREATE TABLE notification_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + type_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + body TEXT, + entity_type VARCHAR(30), + entity_id BIGINT, + sent_by_email BOOLEAN NOT NULL DEFAULT FALSE, + `read` BOOLEAN NOT NULL DEFAULT FALSE, + read_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_notif_type FOREIGN KEY (type_id) REFERENCES notification_types(id) +) ENGINE=InnoDB; + +CREATE INDEX idx_notifications_user ON notifications(user_id, `read`); +CREATE INDEX idx_notifications_created ON notifications(created_at); + +CREATE TABLE receipt_series ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + series_name VARCHAR(50) NOT NULL, + fiscal_year INT NOT NULL, + last_number INT NOT NULL DEFAULT 0, + prefix VARCHAR(20) NOT NULL DEFAULT 'R-', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_series_year (series_name, fiscal_year) +) ENGINE=InnoDB; + +CREATE TABLE email_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + income_id BIGINT, + recipient_email VARCHAR(200) NOT NULL, + subject VARCHAR(300) NOT NULL, + body TEXT, + success BOOLEAN NOT NULL DEFAULT FALSE, + error_message TEXT, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_email_income (income_id) +) ENGINE=InnoDB; + +-- Seed data + +INSERT INTO roles (id, name, description) VALUES +(1, 'ADMIN', 'Acceso total al sistema'), +(2, 'GERENTE', 'Gestión de propiedades, contratos, inquilinos y finanzas'), +(3, 'CONTABLE', 'Gestión de ingresos, gastos y reportes'), +(4, 'VISUALIZADOR','Solo lectura de la información'); + +INSERT INTO property_types (id, name, description) VALUES +(1, 'EDIFICIO', 'Edificio completo con varias plantas'), +(2, 'PISO', 'Vivienda en un edificio de pisos'), +(3, 'LOCAL_COMERCIAL','Local comercial'), +(4, 'BAR', 'Bar o restaurante'), +(5, 'NAVE', 'Nave industrial o almacén'), +(6, 'GARAJE', 'Plaza de garaje'), +(7, 'TRASTERO', 'Trastero'), +(8, 'OFICINA', 'Oficina o despacho'), +(9, 'ADOSADO', 'Vivienda unifamiliar adosada'), +(10, 'CHALET', 'Vivienda unifamiliar independiente'); + +INSERT INTO property_statuses (id, name, description) VALUES +(1, 'DISPONIBLE', 'Propiedad disponible para alquilar'), +(2, 'ALQUILADO', 'Actualmente alquilado'), +(3, 'VACIO', 'Vacío, sin inquilino'), +(4, 'ANUNCIADO', 'Anunciado para alquiler'), +(5, 'VENTA', 'Puesto a la venta'), +(6, 'VENDIDO', 'Vendido'), +(7, 'TRASPASADO', 'Traspasado a otro propietario'), +(8, 'OCUPADO', 'Ocupado sin contrato vigente'), +(9, 'MANTENIMIENTO', 'En obras o mantenimiento'); + +INSERT INTO tenant_types (id, name) VALUES +(1, 'PERSONA_FISICA'), +(2, 'PERSONA_JURIDICA'); + +INSERT INTO contract_statuses (id, name) VALUES +(1, 'ACTIVO'), +(2, 'VENCIDO'), +(3, 'RENOVADO'), +(4, 'RESCINDIDO'), +(5, 'ANULADO'); + +INSERT INTO payment_periods (id, name) VALUES +(1, 'MENSUAL'), +(2, 'TRIMESTRAL'), +(3, 'SEMESTRAL'), +(4, 'ANUAL'); + +INSERT INTO document_types (id, name) VALUES +(1, 'CONTRATO'), +(2, 'ANEXO_CONTRATO'), +(3, 'DNI_ARRENDATARIO'), +(4, 'CIF_EMPRESA'), +(5, 'FOTO_PROPIEDAD'), +(6, 'FOTO_INCIDENCIA'), +(7, 'FACTURA'), +(8, 'JUSTIFICANTE_PAGO'), +(9, 'CERTIFICADO'), +(10, 'OTRO'), +(11, 'IMAGEN'), +(12, 'PRESUPUESTO'); + +INSERT INTO income_statuses (id, name) VALUES +(1, 'PENDIENTE'), +(2, 'PAGADO'), +(3, 'VENCIDO'), +(4, 'PARCIAL'), +(5, 'ANULADO'); + +INSERT INTO income_categories (id, name, description) VALUES +(1, 'ALQUILER', 'Pago de renta mensual o periódica'), +(2, 'FIANZA', 'Depósito de garantía'), +(3, 'GASTOS_COMUNIDAD', 'Repercusión de gastos de comunidad'), +(4, 'INTERESES_DEMORA', 'Intereses por pago fuera de plazo'), +(5, 'INDEMNIZACION', 'Indemnización por daños o rescisión'), +(6, 'OTROS_INGRESOS', 'Otros ingresos no clasificados'); + +INSERT INTO expense_statuses (id, name) VALUES +(1, 'PENDIENTE'), +(2, 'PAGADO'), +(3, 'VENCIDO'), +(4, 'ANULADO'); + +INSERT INTO expense_categories (id, name, description) VALUES +(1, 'REPARACION', 'Reparaciones y arreglos'), +(2, 'MANTENIMIENTO', 'Mantenimiento preventivo'), +(3, 'COMUNIDAD', 'Gastos de comunidad de propietarios'), +(4, 'IBI', 'Impuesto de Bienes Inmuebles'), +(5, 'BASURA', 'Tasa de basura'), +(6, 'SUMINISTROS', 'Agua, luz, gas, internet'), +(7, 'SEGURO', 'Seguro del inmueble o multirriesgo'), +(8, 'REFORMA', 'Obras de reforma o mejora'), +(9, 'GESTION', 'Gestión de gastos inmobiliaria'), +(10, 'NOTARIA_REGISTRO', 'Gastos notariales y de registro'), +(11, 'PUBLICIDAD', 'Anuncios y marketing'), +(12, 'OTROS_GASTOS', 'Otros gastos no clasificados'); + +INSERT INTO incident_statuses (id, name) VALUES +(1, 'SIN_REVISAR'), +(2, 'TECNICO_AVISADO'), +(3, 'REPARACION_PREVISTA'), +(4, 'REPARADO'), +(5, 'IGNORADO'), +(6, 'ANULADO'); + +INSERT INTO incident_priorities (id, name) VALUES +(1, 'BAJA'), +(2, 'MEDIA'), +(3, 'ALTA'), +(4, 'URGENTE'); + +INSERT INTO maintenance_periods (id, name) VALUES +(1, 'UNICA_VEZ'), +(2, 'MENSUAL'), +(3, 'TRIMESTRAL'), +(4, 'SEMESTRAL'), +(5, 'ANUAL'), +(6, 'BIENAL'), +(7, 'TRIENAL'), +(8, 'QUINQUENAL'); + +INSERT INTO notification_types (id, name) VALUES +(1, 'INCIDENCIA_ABIERTA'), +(2, 'MANTENIMIENTO_PROXIMO'), +(3, 'RECIBO_VENCIDO'), +(4, 'CONTRATO_PROXIMO_VENCER'), +(5, 'CONTRATO_VENCIDO'), +(6, 'SISTEMA'); + +INSERT INTO receipt_series (series_name, fiscal_year, last_number, prefix) VALUES +('RECIBOS', YEAR(CURDATE()), 0, CONCAT('R-', YEAR(CURDATE()), '-')); + +INSERT INTO users (username, email, password_hash, full_name, role_id, active) +VALUES ('admin', 'admin@sapolar.com', + '$2a$10$I9VbpnnqwttwAiKlWAgxRuyQF6IC02wnMO1YoBF/u2QcTJKcZnJBe', + 'Administrador del Sistema', 1, TRUE); diff --git a/backend/src/main/resources/db/migration/V2__contract_tenants.sql b/backend/src/main/resources/db/migration/V2__contract_tenants.sql new file mode 100644 index 0000000..e53ec04 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__contract_tenants.sql @@ -0,0 +1,81 @@ +-- ============================================================ +-- Migración V2: Refactor contrato-inquilino +-- Crea contract_tenants, migra datos existentes, +-- elimina tenant_id de contracts y role de tenants +-- ============================================================ +-- Migración idempotente: puede ejecutarse múltiples veces sin error. + +-- 1. Crear tabla intermedia contract_tenants (si no existe) +CREATE TABLE IF NOT EXISTS contract_tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'TITULAR' COMMENT 'TITULAR o CONVIVIENTE', + CONSTRAINT fk_ct_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE, + CONSTRAINT fk_ct_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uq_contract_tenant (contract_id, tenant_id) +) ENGINE=InnoDB; + +-- Crear índices solo si no existen +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'contract_tenants' + AND index_name = 'idx_ct_contract'); +SET @sql = IF(@idx_exists = 0, 'CREATE INDEX idx_ct_contract ON contract_tenants(contract_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'contract_tenants' + AND index_name = 'idx_ct_tenant'); +SET @sql = IF(@idx_exists = 0, 'CREATE INDEX idx_ct_tenant ON contract_tenants(tenant_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. Migrar datos existentes: cada contrato → un contract_tenant como TITULAR +-- Solo se migran los que no existan ya +INSERT IGNORE INTO contract_tenants (contract_id, tenant_id, role) +SELECT id, tenant_id, 'TITULAR' FROM contracts +WHERE tenant_id IS NOT NULL; + +-- 3. Eliminar tenant_id de contracts (si existe) +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() + AND table_name = 'contracts' + AND constraint_name = 'fk_contract_tenant' + AND constraint_type = 'FOREIGN KEY'); +SET @sql = IF(@fk_exists > 0, 'ALTER TABLE contracts DROP FOREIGN KEY fk_contract_tenant', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'contracts' + AND index_name = 'idx_contracts_tenant'); +SET @sql = IF(@idx_exists > 0, 'DROP INDEX idx_contracts_tenant ON contracts', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'contracts' + AND column_name = 'tenant_id'); +SET @sql = IF(@col_exists > 0, 'ALTER TABLE contracts DROP COLUMN tenant_id', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Eliminar role de tenants (si existe) +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'role'); +SET @sql = IF(@col_exists > 0, 'ALTER TABLE tenants DROP COLUMN role', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V3__tenant_name_split.sql b/backend/src/main/resources/db/migration/V3__tenant_name_split.sql new file mode 100644 index 0000000..dc2e835 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__tenant_name_split.sql @@ -0,0 +1,91 @@ +-- ============================================================ +-- Migración V3: Split full_name → first_name + last_name +-- Agrega document_type +-- ============================================================ +-- Migración idempotente: puede ejecutarse múltiples veces sin error. + +-- 1. Agregar nuevas columnas como nullable primero (si no existen) +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'first_name'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE tenants ADD COLUMN first_name VARCHAR(100)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'last_name'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE tenants ADD COLUMN last_name VARCHAR(100)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'document_type'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE tenants ADD COLUMN document_type VARCHAR(20) DEFAULT ''DNI''', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. Migrar datos existentes solo si first_name o last_name están vacíos +UPDATE tenants SET + first_name = SUBSTRING_INDEX(full_name, ' ', 1), + last_name = TRIM(SUBSTRING(full_name, LENGTH(SUBSTRING_INDEX(full_name, ' ', 1)) + 2)) +WHERE full_name IS NOT NULL + AND full_name != '' + AND (first_name IS NULL OR first_name = ''); + +-- 3. Poner NOT NULL después de migrar datos +SET @col_nullable = (SELECT IS_NULLABLE FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'first_name'); +SET @sql = IF(@col_nullable = 'YES', 'ALTER TABLE tenants MODIFY COLUMN first_name VARCHAR(100) NOT NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_nullable = (SELECT IS_NULLABLE FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'last_name'); +SET @sql = IF(@col_nullable = 'YES', 'ALTER TABLE tenants MODIFY COLUMN last_name VARCHAR(100) NOT NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Eliminar columna e índice antiguos (si existen) +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND index_name = 'idx_tenants_name' + AND column_name = 'full_name'); +SET @sql = IF(@idx_exists > 0, 'DROP INDEX idx_tenants_name ON tenants', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND column_name = 'full_name'); +SET @sql = IF(@col_exists > 0, 'ALTER TABLE tenants DROP COLUMN full_name', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 5. Crear nuevo índice compuesto (si no existe) +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tenants' + AND index_name = 'idx_tenants_name' + AND column_name = 'first_name'); +SET @sql = IF(@idx_exists = 0, 'CREATE INDEX idx_tenants_name ON tenants(first_name, last_name)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V4__document_entities.sql b/backend/src/main/resources/db/migration/V4__document_entities.sql new file mode 100644 index 0000000..d553896 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__document_entities.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- Migración V4: Tabla pivote document_entities +-- Permite asociar un documento a múltiples entidades +-- (ej: una fianza asociada a INCOME y EXPENSE) +-- ============================================================ +-- Esta migración es idempotente: puede ejecutarse múltiples veces +-- sin causar errores. Si las tablas/columnas ya existen, las omite. + +-- 1. Crear tabla pivote (si no existe) +CREATE TABLE IF NOT EXISTS document_entities ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + document_id BIGINT NOT NULL, + entity_type VARCHAR(30) NOT NULL COMMENT 'PROPERTY / CONTRACT / TENANT / INCIDENT / INCOME / EXPENSE', + entity_id BIGINT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_doc_entity_doc FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + CONSTRAINT uk_doc_entity UNIQUE (document_id, entity_type, entity_id) +) ENGINE=InnoDB; + +-- Crear índice solo si no existe +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'document_entities' + AND index_name = 'idx_doc_entity_lookup'); +SET @sql = IF(@idx_exists = 0, 'CREATE INDEX idx_doc_entity_lookup ON document_entities(entity_type, entity_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. Migrar datos existentes de documents a document_entities (solo si hay datos sin migrar) +INSERT IGNORE INTO document_entities (document_id, entity_type, entity_id, created_at) +SELECT d.id, d.entity_type, d.entity_id, d.uploaded_at +FROM documents d +LEFT JOIN document_entities de ON de.document_id = d.id +WHERE d.entity_type IS NOT NULL + AND d.entity_id IS NOT NULL + AND de.id IS NULL; + +-- 3. Eliminar columnas de documents (si existen) +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'documents' + AND column_name = 'entity_type'); +SET @sql = IF(@col_exists > 0, 'ALTER TABLE documents DROP COLUMN entity_type', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'documents' + AND column_name = 'entity_id'); +SET @sql = IF(@col_exists > 0, 'ALTER TABLE documents DROP COLUMN entity_id', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. Eliminar índice que ya no aplica (si existe) +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'documents' + AND index_name = 'idx_documents_entity'); +SET @sql = IF(@idx_exists > 0, 'DROP INDEX idx_documents_entity ON documents', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V5__document_type_entity_allowed.sql b/backend/src/main/resources/db/migration/V5__document_type_entity_allowed.sql new file mode 100644 index 0000000..6ce22cf --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__document_type_entity_allowed.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- Migración V5: Tipos de documento permitidos por entidad +-- Define qué tipos de documento pueden asociarse a qué +-- tipos de entidades del sistema +-- ============================================================ + +CREATE TABLE document_type_entity_allowed ( + id INT AUTO_INCREMENT PRIMARY KEY, + document_type_id INT NOT NULL, + entity_type VARCHAR(30) NOT NULL COMMENT 'PROPERTY / TENANT / CONTRACT / INCOME / EXPENSE / INCIDENT / MAINTENANCE', + can_upload BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'Si el usuario puede subir este tipo en esta entidad', + must_have BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Si es obligatorio al crear/editar la entidad', + description VARCHAR(255) COMMENT 'Descripción de para qué se usa este documento en esta entidad', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_dtea_doc_type FOREIGN KEY (document_type_id) REFERENCES document_types(id) ON DELETE CASCADE, + UNIQUE KEY uq_doc_entity (document_type_id, entity_type) +) ENGINE=InnoDB; + +CREATE INDEX idx_dtea_entity ON document_type_entity_allowed(entity_type); + +-- ============================================================ +-- RELACIONES PERMITIDAS +-- ============================================================ +-- CONTRATO puede asociarse con: CONTRACT, TENANT, PROPERTY +INSERT INTO document_type_entity_allowed (document_type_id, entity_type, can_upload, must_have, description) VALUES +(1, 'CONTRACT', TRUE, TRUE, 'Documento principal del contrato de alquiler'), +(1, 'TENANT', TRUE, FALSE, 'Copia del contrato firmada por el inquilino'), +(1, 'PROPERTY', TRUE, FALSE, 'Contrato asociado al inmueble'), + +-- ANEXO_CONTRATO -> CONTRACT +(2, 'CONTRACT', TRUE, FALSE, 'Anexos y modificaciones del contrato'), + +-- DNI_ARRENDATARIO -> TENANT (obligatorio) +(3, 'TENANT', TRUE, TRUE, 'DNI o documento de identidad del inquilino'), + +-- FOTO_PROPIEDAD -> PROPERTY (no obligatorio) +(5, 'PROPERTY', TRUE, FALSE, 'Fotos del inmueble para publicidad o documentación'), + +-- FOTO_INCIDENCIA -> INCIDENT +(6, 'INCIDENT', TRUE, FALSE, 'Fotos de la incidencia reportada'), + +-- FACTURA -> EXPENSE (obligatorio) +(7, 'EXPENSE', TRUE, TRUE, 'Factura o justificante del gasto'), + +-- JUSTIFICANTE_PAGO -> INCOME (obligatorio) +(8, 'INCOME', TRUE, TRUE, 'Justificante de pago del recibo'), + +-- CERTIFICADO -> TENANT, CONTRACT +(9, 'TENANT', TRUE, FALSE, 'Certificados varios (empadronamiento, vida laboral, etc.)'), +(9, 'CONTRACT', TRUE, FALSE, 'Certificados asociados al contrato'), + +-- IMAGEN -> INCIDENT, EXPENSE +(11, 'INCIDENT', TRUE, FALSE, 'Imágenes de la incidencia'), +(11, 'EXPENSE', TRUE, FALSE, 'Imágenes del gasto'), + +-- PRESUPUESTO -> EXPENSE +(12, 'EXPENSE', TRUE, FALSE, 'Presupuesto del gasto'), + +-- OTRO -> Cualquiera +(10, 'PROPERTY', TRUE, FALSE, 'Otros documentos del inmueble'), +(10, 'TENANT', TRUE, FALSE, 'Otros documentos del inquilino'), +(10, 'CONTRACT', TRUE, FALSE, 'Otros documentos del contrato'), +(10, 'INCOME', TRUE, FALSE, 'Otros documentos relacionados con ingresos'), +(10, 'EXPENSE', TRUE, FALSE, 'Otros documentos relacionados con gastos'), +(10, 'INCIDENT', TRUE, FALSE, 'Otros documentos de incidencias'), +(10, 'MAINTENANCE', TRUE, FALSE, 'Otros documentos de mantenimiento'); diff --git a/backend/src/main/resources/db/migration/V6__tenant_bank_data.sql b/backend/src/main/resources/db/migration/V6__tenant_bank_data.sql new file mode 100644 index 0000000..537aabd --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__tenant_bank_data.sql @@ -0,0 +1,41 @@ +-- ============================================================ +-- Migración V6: Tenant bank data +-- Crea la tabla tenant_bank_data para datos bancarios del inquilino +-- ============================================================ +-- Esta migración es idempotente: puede ejecutarse múltiples veces. + +CREATE TABLE IF NOT EXISTS tenant_bank_data ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + alias VARCHAR(100), + iban VARCHAR(34) NOT NULL, + bic VARCHAR(11), + bank_name VARCHAR(200), + account_holder VARCHAR(200), + is_principal BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_bank_data_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + INDEX idx_tenant_bank_data_tenant (tenant_id), + INDEX idx_tenant_bank_data_principal (tenant_id, is_principal) +); + +-- Crear índices solo si no existen +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tenant_bank_data' + AND index_name = 'idx_tenant_bank_data_tenant'); +SET @sql = IF(@idx_exists = 0, 'CREATE INDEX idx_tenant_bank_data_tenant ON tenant_bank_data(tenant_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Migrar el IBAN actual del inquilino a la nueva tabla como dato principal +-- Solo se migran los inquilinos que aún no tienen datos bancarios +INSERT IGNORE INTO tenant_bank_data (tenant_id, iban, is_principal, active, created_at, updated_at) +SELECT t.id, t.iban, TRUE, TRUE, NOW(), NOW() +FROM tenants t +LEFT JOIN tenant_bank_data bd ON bd.tenant_id = t.id +WHERE t.iban IS NOT NULL AND t.iban != '' AND bd.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V7__maintenance_auto_expense.sql b/backend/src/main/resources/db/migration/V7__maintenance_auto_expense.sql new file mode 100644 index 0000000..e72a487 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__maintenance_auto_expense.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- Migración V7: Auto-generación de gastos desde mantenimiento +-- Añade campos generate_expense y expense_category_id +-- ============================================================ +-- Esta migración es idempotente: puede ejecutarse múltiples veces. + +-- Añadir columna generate_expense si no existe +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'scheduled_maintenance' + AND column_name = 'generate_expense'); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE scheduled_maintenance ADD COLUMN generate_expense BOOLEAN NOT NULL DEFAULT FALSE AFTER notes', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Añadir columna expense_category_id si no existe +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'scheduled_maintenance' + AND column_name = 'expense_category_id'); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE scheduled_maintenance ADD COLUMN expense_category_id BIGINT AFTER generate_expense', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Añadir FK solo si la columna existe y la FK no existe +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() + AND table_name = 'scheduled_maintenance' + AND constraint_name = 'fk_maint_expense_category'); +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'scheduled_maintenance' + AND column_name = 'expense_category_id'); +SET @sql = IF(@fk_exists = 0 AND @col_exists > 0, + 'ALTER TABLE scheduled_maintenance + ADD CONSTRAINT fk_maint_expense_category + FOREIGN KEY (expense_category_id) REFERENCES expense_categories(id) ON DELETE SET NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V8__property_group_relations.sql b/backend/src/main/resources/db/migration/V8__property_group_relations.sql new file mode 100644 index 0000000..2b6a20b --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__property_group_relations.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- Migración V8: Asociar gastos, incidencias y mantenimientos +-- a conjuntos (property_groups) además de propiedades +-- ============================================================ +-- ADD COLUMN works with IF NOT EXISTS emulation. + +-- ============ EXPENSES ============ + +-- Añadir property_group_id +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'expenses' AND column_name = 'property_group_id'); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE expenses ADD COLUMN property_group_id BIGINT AFTER property_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Hacer property_id nullable +SET @nullable = (SELECT IS_NULLABLE FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'expenses' AND column_name = 'property_id'); +SET @sql = IF(@nullable = 'NO', + 'ALTER TABLE expenses MODIFY COLUMN property_id BIGINT NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- FK y FK index +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() AND table_name = 'expenses' AND constraint_name = 'fk_expense_property_group'); +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE expenses ADD CONSTRAINT fk_expense_property_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Index +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'expenses' AND index_name = 'idx_expenses_property_group'); +SET @sql = IF(@idx_exists = 0, + 'CREATE INDEX idx_expenses_property_group ON expenses(property_group_id)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ============ INCIDENTS ============ + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'incidents' AND column_name = 'property_group_id'); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE incidents ADD COLUMN property_group_id BIGINT AFTER property_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @nullable = (SELECT IS_NULLABLE FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'incidents' AND column_name = 'property_id'); +SET @sql = IF(@nullable = 'NO', + 'ALTER TABLE incidents MODIFY COLUMN property_id BIGINT NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() AND table_name = 'incidents' AND constraint_name = 'fk_incident_property_group'); +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE incidents ADD CONSTRAINT fk_incident_property_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'incidents' AND index_name = 'idx_incidents_property_group'); +SET @sql = IF(@idx_exists = 0, + 'CREATE INDEX idx_incidents_property_group ON incidents(property_group_id)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ============ SCHEDULED MAINTENANCE ============ + +SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'scheduled_maintenance' AND column_name = 'property_group_id'); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE scheduled_maintenance ADD COLUMN property_group_id BIGINT AFTER property_id', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @nullable = (SELECT IS_NULLABLE FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'scheduled_maintenance' AND column_name = 'property_id'); +SET @sql = IF(@nullable = 'NO', + 'ALTER TABLE scheduled_maintenance MODIFY COLUMN property_id BIGINT NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @fk_exists = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema = DATABASE() AND table_name = 'scheduled_maintenance' AND constraint_name = 'fk_maint_property_group'); +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE scheduled_maintenance ADD CONSTRAINT fk_maint_property_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'scheduled_maintenance' AND index_name = 'idx_maint_property_group'); +SET @sql = IF(@idx_exists = 0, + 'CREATE INDEX idx_maint_property_group ON scheduled_maintenance(property_group_id)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V9__bank_accounts.sql b/backend/src/main/resources/db/migration/V9__bank_accounts.sql new file mode 100644 index 0000000..6168524 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__bank_accounts.sql @@ -0,0 +1,37 @@ +-- V9: Módulo bancario - Cuentas bancarias y domiciliación + +-- 1. Crear tabla de cuentas bancarias +CREATE TABLE IF NOT EXISTS bank_accounts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + holder VARCHAR(200) NOT NULL, + iban VARCHAR(34) NOT NULL, + bank_name VARCHAR(100) DEFAULT NULL, + swift_bic VARCHAR(11) DEFAULT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'EUR', + is_default TINYINT(1) NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + description TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Añadir columna bank_account_id a expenses +ALTER TABLE expenses + ADD COLUMN bank_account_id BIGINT DEFAULT NULL AFTER property_group_id, + ADD COLUMN is_domiciled TINYINT(1) NOT NULL DEFAULT 0 AFTER bank_account_id, + ADD CONSTRAINT fk_expense_bank_account + FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) + ON DELETE SET NULL; + +-- 3. Añadir columna bank_account_id a incomes +ALTER TABLE incomes + ADD COLUMN bank_account_id BIGINT DEFAULT NULL AFTER tenant_id, + ADD COLUMN is_domiciled TINYINT(1) NOT NULL DEFAULT 0 AFTER bank_account_id, + ADD CONSTRAINT fk_income_bank_account + FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) + ON DELETE SET NULL; + +-- 4. Índices para las nuevas FK +CREATE INDEX idx_expenses_bank_account ON expenses(bank_account_id); +CREATE INDEX idx_incomes_bank_account ON incomes(bank_account_id); diff --git a/backend/src/main/resources/db/seed.sql b/backend/src/main/resources/db/seed.sql new file mode 100644 index 0000000..61590b8 --- /dev/null +++ b/backend/src/main/resources/db/seed.sql @@ -0,0 +1,282 @@ +-- ============================================================ +-- DATOS DE PRUEBA - Sa Polar +-- Basado en datos reales insertados por el usuario. +-- Ejecutar a mano (NO es migración Flyway) +-- ============================================================ + +-- Forzar UTF-8 en la conexión para evitar problemas con acentos +SET NAMES utf8mb4; +SET CHARACTER SET utf8mb4; + +-- ============================================================ +-- 1. USUARIOS adicionales +-- ============================================================ +INSERT IGNORE INTO users (id, username, email, password_hash, full_name, phone, role_id, active) VALUES +(2, 'gerente', 'gerente@sapolar.com', '$2a$10$I9VbpnnqwttwAiKlWAgxRuyQF6IC02wnMO1YoBF/u2QcTJKcZnJBe', 'María García López', '600111222', 2, TRUE), +(3, 'contable', 'contable@sapolar.com', '$2a$10$I9VbpnnqwttwAiKlWAgxRuyQF6IC02wnMO1YoBF/u2QcTJKcZnJBe', 'Carlos Ruiz Pérez', '600333444', 3, TRUE); + +-- ============================================================ +-- 2. CONJUNTOS (property_groups) +-- Datos reales insertados por el usuario +-- ============================================================ +INSERT IGNORE INTO property_groups (id, name, description, address_street, address_number, address_city, address_postal_code, address_province) VALUES +(1, 'Edificio Sa Polar', 'Edificio de pisos en el centro de Sant Antoni de Portmany', 'Carrer Menéndez Pidal', '17', 'Sant Antoni de Portmany', '07820', 'Illes Balears'), +(2, 'Ses Parres', 'Edificio residencial en la misma calle', 'Carrer Menéndez Pidal', '26', 'Sant Antoni de Portmany', '07820', 'Illes Balears'); + +-- ============================================================ +-- 3. PROPIEDADES (properties) +-- Datos reales insertados por el usuario, enriquecidos con +-- superficies, alquileres y descripciones +-- ============================================================ + +-- Nota: Como las propiedades ya existen con esos IDs, usamos UPDATE +-- para enriquecerlas con datos adicionales (superficie, alquiler, etc.) + +-- PISOS Edificio Sa Polar (group_id=1) +UPDATE properties SET surface_m2=55.00, rental_amount=650.00, notes='Piso exterior con balcón pequeño', cadastral_ref='12345A001' WHERE id=1; +UPDATE properties SET surface_m2=50.00, rental_amount=600.00, notes='Piso interior luminoso', cadastral_ref='12345A002' WHERE id=2; +UPDATE properties SET surface_m2=60.00, rental_amount=700.00, notes='Piso exterior reformado', cadastral_ref='12345A003' WHERE id=3; +UPDATE properties SET surface_m2=58.00, rental_amount=680.00, notes='Piso exterior con vistas', cadastral_ref='12345A004' WHERE id=4; +UPDATE properties SET surface_m2=45.00, rental_amount=550.00, notes='Piso pequeño interior', cadastral_ref='12345A005' WHERE id=5; +UPDATE properties SET surface_m2=48.00, rental_amount=580.00, notes='Piso interior reformado', cadastral_ref='12345A006' WHERE id=6; +UPDATE properties SET surface_m2=65.00, rental_amount=750.00, notes='Piso grande exterior', cadastral_ref='12345A007' WHERE id=7; +UPDATE properties SET surface_m2=62.00, rental_amount=720.00, notes='Piso exterior con terraza', cadastral_ref='12345A008' WHERE id=8; +UPDATE properties SET surface_m2=70.00, rental_amount=800.00, notes='Piso grande con balcón', cadastral_ref='12345A009' WHERE id=9; +UPDATE properties SET surface_m2=68.00, rental_amount=780.00, notes='Piso exterior reformado', cadastral_ref='12345A010' WHERE id=10; +UPDATE properties SET surface_m2=72.00, rental_amount=820.00, notes='Piso grande con vistas al mar', cadastral_ref='12345A011' WHERE id=11; +UPDATE properties SET surface_m2=55.00, rental_amount=650.00, notes='Piso interior reformado', cadastral_ref='12345A012' WHERE id=12; +UPDATE properties SET surface_m2=60.00, rental_amount=700.00, notes='Piso exterior luminoso', cadastral_ref='12345A013' WHERE id=13; +UPDATE properties SET surface_m2=58.00, rental_amount=680.00, notes='Piso exterior con balcón', cadastral_ref='12345A014' WHERE id=14; +UPDATE properties SET surface_m2=75.00, rental_amount=850.00, notes='Piso grande exterior', cadastral_ref='12345A015' WHERE id=15; +UPDATE properties SET surface_m2=70.00, rental_amount=800.00, notes='Piso exterior reformado', cadastral_ref='12345A016' WHERE id=16; +UPDATE properties SET surface_m2=90.00, rental_amount=1200.00, notes='Ático dúplex con terraza 30m2', cadastral_ref='12345A017' WHERE id=17; +UPDATE properties SET surface_m2=15.00, rental_amount=80.00, notes='Trastero en sótano', cadastral_ref='12345A018' WHERE id=18; + +-- LOCALES Edificio Sa Polar +UPDATE properties SET description='Bar/Restaurante Sa Polar - terraza exterior', surface_m2=120.00, rental_amount=1800.00, notes='Bar con terraza en calle Valencia', cadastral_ref='12345A019' WHERE id=19; +UPDATE properties SET description='Local comercial esquinero', surface_m2=85.00, rental_amount=1200.00, notes='Local comercial con escaparate doble', cadastral_ref='12345A020' WHERE id=20; +UPDATE properties SET description='Tienda de lámparas y decoración', surface_m2=70.00, rental_amount=900.00, notes='Tienda en planta baja del edificio', cadastral_ref='12345A021' WHERE id=21; + +-- SES PARRERS (group_id=2) +UPDATE properties SET description='Edificio completo', surface_m2=300.00, rental_amount=2500.00, notes='Edificio completo - varias plantas', cadastral_ref='67890B001' WHERE id=22; + +-- INDEPENDIENTES +UPDATE properties SET description='Terreno con casa rural en zona de Sant Mateu', surface_m2=5000.00,rental_amount=1500.00, notes='Finca rústica con casa - ideal explotación',cadastral_ref='34567C001', address_postal_code='07816' WHERE id=23; +UPDATE properties SET description='Bar céntrico en el corazón de Ibiza', surface_m2=90.00, rental_amount=2000.00, notes='Bar con licencia de música', cadastral_ref='89012D001' WHERE id=24; + +-- Cambiar estado de algunas propiedades a ALQUILADO (2) / OCUPADO (8) +-- Dejamos VACIO (3) las que no están alquiladas +UPDATE properties SET status_id=2 WHERE id IN (1, 4, 9, 11, 16, 17, 19, 20, 21, 22, 23, 24); +UPDATE properties SET status_id=8 WHERE id IN (9); -- 3-3 ocupado pero sin contrato formal aún + +-- ============================================================ +-- 4. INQUILINOS (tenants) +-- ============================================================ +INSERT IGNORE INTO tenants (id, tenant_type_id, fiscal_id, first_name, last_name, document_type, business_name, email, phone, address, iban, notes, active) VALUES +(1, 1, '45678901A', 'Marco', 'Rossi Bianchi', 'NIE', NULL, 'marco.rossi@email.com', '611111111', 'Calle de la Mar 5, Sant Antoni', 'ES9121000418450200051332', 'Restaurador italiano - regenta Sa Polar', TRUE), +(2, 1, '56789012B', 'María', 'Torres Ferrer', 'DNI', NULL, 'maria.torres@email.com', '622222222', 'Calle Sol 12, Sant Antoni', 'ES8730001234567890123456', 'Comerciante - regenta Local Esquina', TRUE), +(3, 1, '67890123C', 'Carlos', 'García Martínez', 'DNI', NULL, 'carlos.garcia@email.com', '633333333', 'Carrer Bilbao 3, Sant Antoni', 'ES6621000418450200051333', 'Profesional - alquila piso 1-1', TRUE), +(4, 1, '78901234D', 'Ana', 'Martínez López', 'DNI', NULL, 'ana.martinez@email.com', '644444444', 'Calle Mayor 8, Sant Antoni', 'ES7621000418450200051334', 'Alquila piso 2-2 con su pareja', TRUE), +(5, 2, 'B12345678', 'Distribuciones', 'Noguera SL', 'CIF', 'Distribuciones Noguera SL', 'info@noguera-dist.com', '677777777', 'Polígon Industrial, Sant Antoni', 'ES4400800012345678901234', 'Tienda de lámparas - alquila local', TRUE), +(6, 1, '89012345E', 'Elena', 'Costa Vidal', 'DNI', NULL, 'elena.costa@email.com', '655555555', 'Calle Virgen del Carmen 7, Sant Antoni', 'ES2421000418450200051335', 'Alquila piso 5-2', TRUE), +(7, 1, '90123456F', 'Markus', 'Weber Schmidt', 'NIE', NULL, 'markus.weber@email.com', '666666666', 'Calle de la Luz 22, Sant Antoni', 'ES4901234567890123456789', 'Empresario alemán - alquila Ses Parres', TRUE), +(8, 1, '01234567G', 'Antonio', 'Pujol Marí', 'DNI', NULL, 'antonio.pujol@email.com', '688888888', 'Camino de Santa Agnès 15, Sant Antoni', 'ES7620900001234567890123', 'Payés - alquila Sant Mateu para almacén', TRUE), +(9, 2, 'B98765432', 'Riera', 'Gourmet SL', 'CIF', 'Riera Gourmet SL', 'info@rieragourmet.com', '699999999', 'Calle Aragón 137, Ibiza', 'ES4901234567890123456700', 'Restauración - regenta Bar Ibiza', TRUE), +(10, 1, '11223344H', 'Sophie', 'Laurent Dubois', 'NIE', NULL, 'sophie.laurent@email.com', '600000000', 'Calle Granada 2, Sant Antoni', 'ES8730001234567890123458', 'Diseñadora francesa - alquila Ático', TRUE), +(11, 1, '22334455I', 'Pablo', 'Díaz Romero', 'DNI', NULL, 'pablo.diaz@email.com', '611222333', 'Calle Luarca 10, Sant Antoni', 'ES6621000418450200051337', 'Alquila piso 2-2 con Ana Martínez', TRUE); + +-- ============================================================ +-- 5. CONTRATOS (contracts) +-- ============================================================ +INSERT IGNORE INTO contracts (id, property_id, status_id, period_id, contract_number, start_date, end_date, rental_amount, deposit_amount, payment_day, iban_charge, signed_at, notes, created_by) VALUES +-- Contrato activo: Sa Polar (bar) +(1, 19, 1, 1, 'CTR-2025-001', '2025-01-01', '2027-12-31', 1800.00, 3600.00, 5, 'ES9121000418450200051332', '2024-12-20', 'Contrato mercantil Bar Sa Polar - restauración', 1), +-- Contrato activo: Local Esquina +(2, 20, 1, 1, 'CTR-2025-002', '2025-03-01', '2028-02-29', 1200.00, 2400.00, 5, 'ES8730001234567890123456', '2025-02-15', 'Local comercial - tienda moda', 1), +-- Contrato activo: Piso 1-1 +(3, 1, 1, 1, 'CTR-2026-001', '2026-01-01', '2027-12-31', 650.00, 1300.00, 1, 'ES6621000418450200051333', '2025-12-15', 'Contrato vivienda habitual', 1), +-- Contrato activo: Piso 2-2 +(4, 4, 1, 1, 'CTR-2026-002', '2026-02-01', '2027-01-31', 680.00, 1360.00, 5, 'ES7621000418450200051334', '2026-01-20', 'Contrato vivienda pareja', 1), +-- Contrato activo: Ses Parres +(5, 22, 1, 1, 'CTR-2025-003', '2025-06-01', '2030-05-31', 2500.00, 7500.00, 10, 'ES4901234567890123456789', '2025-05-15', 'Contrato plurianual edificio completo', 1), +-- Contrato activo: Tienda de Lámparas +(6, 21, 1, 1, 'CTR-2024-001', '2024-09-01', '2027-08-31', 900.00, 1800.00, 5, 'ES4400800012345678901234', '2024-08-15', 'Contrato mercantil tienda decoración', 1), +-- Contrato activo: Piso 5-2 +(7, 16, 1, 1, 'CTR-2026-003', '2026-06-01', '2027-05-31', 800.00, 1600.00, 5, 'ES2421000418450200051335', '2026-05-20', 'Contrato vivienda temporal', 1), +-- Contrato activo: Ático +(8, 17, 1, 1, 'CTR-2026-004', '2026-07-01', '2028-06-30', 1200.00, 2400.00, 5, 'ES8730001234567890123458', '2026-06-25', 'Ático dúplex - alquiler premium', 1), +-- Contrato vencido: Piso 4-1 (inquilino anterior) +(9, 11, 4, 1, 'CTR-2023-001', '2023-01-01', '2025-12-31', 750.00, 1500.00, 5, 'ES9121000418450200051332', '2022-12-20', 'Contrato finalizado - inquilino se mudó', 1), +-- Contrato activo: Sant Mateu +(10, 23, 1, 1, 'CTR-2026-005', '2026-01-01', '2028-12-31', 1500.00, 3000.00, 10, 'ES7620900001234567890123', '2025-12-20', 'Finca rústica - almacén agrícola', 1), +-- Contrato activo: Bar Ibiza +(11, 24, 1, 1, 'CTR-2025-004', '2025-07-01', '2030-06-30', 2000.00, 4000.00, 5, 'ES4901234567890123456700', '2025-06-15', 'Contrato mercantil Bar Ibiza', 1); + +-- ============================================================ +-- 6. CONTRACT_TENANTS +-- ============================================================ +INSERT IGNORE INTO contract_tenants (contract_id, tenant_id, role) VALUES +(1, 1, 'TITULAR'), +(2, 2, 'TITULAR'), +(3, 3, 'TITULAR'), +(4, 4, 'TITULAR'), +(4, 11, 'CONVIVIENTE'), +(5, 7, 'TITULAR'), +(6, 5, 'TITULAR'), +(7, 6, 'TITULAR'), +(8, 10, 'TITULAR'), +(9, 3, 'TITULAR'), -- contrato vencido pero mismo inquilino renovó en 3-1 +(10, 8, 'TITULAR'), +(11, 9, 'TITULAR'); + +-- ============================================================ +-- 7. RECIBOS DE INGRESOS (income_receipts) +-- ============================================================ +INSERT IGNORE INTO income_receipts (id, contract_id, property_id, tenant_id, category_id, status_id, amount, tax_withheld, net_amount, issue_date, due_date, payment_date, payment_method, description, receipt_number, period_label, created_by) VALUES +-- Sa Polar (bar): últimos 3 meses al corriente +(1, 1, 19, 1, 1, 2, 1800.00, 0.00, 1800.00, '2026-05-01', '2026-05-05', '2026-05-03', 'TRANSFERENCIA', 'Recibo alquiler mayo 2026 - Bar Sa Polar', 'R-2026-0001', '2026-05', 1), +(2, 1, 19, 1, 1, 2, 1800.00, 0.00, 1800.00, '2026-06-01', '2026-06-05', '2026-06-04', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Bar Sa Polar', 'R-2026-0002', '2026-06', 1), +(3, 1, 19, 1, 1, 1, 1800.00, 0.00, 1800.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Bar Sa Polar', 'R-2026-0003', '2026-07', 1), + +-- Local Esquina: pagado hasta julio +(4, 2, 20, 2, 1, 2, 1200.00, 0.00, 1200.00, '2026-06-01', '2026-06-05', '2026-06-03', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Local Esquina', 'R-2026-0004', '2026-06', 1), +(5, 2, 20, 2, 1, 1, 1200.00, 0.00, 1200.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Local Esquina', 'R-2026-0005', '2026-07', 1), + +-- Piso 1-1: pagado +(6, 3, 1, 3, 1, 2, 650.00, 0.00, 650.00, '2026-06-01', '2026-06-01', '2026-05-28', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Piso 1-1', 'R-2026-0006', '2026-06', 1), +(7, 3, 1, 3, 1, 2, 650.00, 0.00, 650.00, '2026-07-01', '2026-07-01', '2026-06-30', 'TRANSFERENCIA', 'Recibo alquiler julio 2026 - Piso 1-1', 'R-2026-0007', '2026-07', 1), + +-- Piso 2-2: pagado hasta junio, julio pendiente +(8, 4, 4, 4, 1, 2, 680.00, 0.00, 680.00, '2026-06-01', '2026-06-05', '2026-06-03', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Piso 2-2', 'R-2026-0008', '2026-06', 1), +(9, 4, 4, 4, 1, 1, 680.00, 0.00, 680.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Piso 2-2', 'R-2026-0009', '2026-07', 1), + +-- Ses Parres: pagado +(10, 5, 22, 7, 1, 2, 2500.00, 0.00, 2500.00, '2026-06-01', '2026-06-10', '2026-06-08', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Ses Parres', 'R-2026-0010', '2026-06', 1), +(11, 5, 22, 7, 1, 1, 2500.00, 0.00, 2500.00, '2026-07-01', '2026-07-10', NULL, NULL, 'Recibo alquiler julio 2026 - Ses Parres', 'R-2026-0011', '2026-07', 1), + +-- Tienda de Lámparas: pagado +(12, 6, 21, 5, 1, 2, 900.00, 0.00, 900.00, '2026-06-01', '2026-06-05', '2026-06-02', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Tienda Lámparas', 'R-2026-0012', '2026-06', 1), +(13, 6, 21, 5, 1, 1, 900.00, 0.00, 900.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Tienda Lámparas', 'R-2026-0013', '2026-07', 1), + +-- Piso 5-2: primer recibo pagado +(14, 7, 16, 6, 1, 2, 800.00, 0.00, 800.00, '2026-06-01', '2026-06-05', '2026-06-03', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Piso 5-2', 'R-2026-0014', '2026-06', 1), +(15, 7, 16, 6, 1, 1, 800.00, 0.00, 800.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Piso 5-2', 'R-2026-0015', '2026-07', 1), + +-- Ático: primer recibo pendiente (contrato nuevo desde julio) +(16, 8, 17, 10, 1, 1, 1200.00, 0.00, 1200.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Ático', 'R-2026-0016', '2026-07', 1), + +-- Sant Mateu: pagado +(17, 10, 23, 8, 1, 2, 1500.00, 0.00, 1500.00, '2026-06-01', '2026-06-10', '2026-06-09', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Sant Mateu', 'R-2026-0017', '2026-06', 1), +(18, 10, 23, 8, 1, 1, 1500.00, 0.00, 1500.00, '2026-07-01', '2026-07-10', NULL, NULL, 'Recibo alquiler julio 2026 - Sant Mateu', 'R-2026-0018', '2026-07', 1), + +-- Bar Ibiza: pagado hasta junio, julio pendiente +(19, 11, 24, 9, 1, 2, 2000.00, 0.00, 2000.00, '2026-05-01', '2026-05-05', '2026-05-04', 'TRANSFERENCIA', 'Recibo alquiler mayo 2026 - Bar Ibiza', 'R-2026-0019', '2026-05', 1), +(20, 11, 24, 9, 1, 2, 2000.00, 0.00, 2000.00, '2026-06-01', '2026-06-05', '2026-06-03', 'TRANSFERENCIA', 'Recibo alquiler junio 2026 - Bar Ibiza', 'R-2026-0020', '2026-06', 1), +(21, 11, 24, 9, 1, 1, 2000.00, 0.00, 2000.00, '2026-07-01', '2026-07-05', NULL, NULL, 'Recibo alquiler julio 2026 - Bar Ibiza', 'R-2026-0021', '2026-07', 1), + +-- Repercusión gastos comunidad (para locales en el edificio) +(22, 1, 19, 1, 3, 2, 150.00, 0.00, 150.00, '2026-06-15', '2026-06-20', '2026-06-19', 'TRANSFERENCIA', 'Repercusión comunidad junio - Bar Sa Polar', 'R-2026-0022', '2026-06', 1), +(23, 2, 20, 2, 3, 2, 100.00, 0.00, 100.00, '2026-06-15', '2026-06-20', '2026-06-18', 'TRANSFERENCIA', 'Repercusión comunidad junio - Local Esquina', 'R-2026-0023', '2026-06', 1), + +-- Recibo vencido (contrato 9 - vencido, piso 4-1) +(24, 9, 11, 3, 1, 3, 750.00, 0.00, 750.00, '2025-12-01', '2025-12-05', '2026-01-10', 'TRANSFERENCIA', 'Recibo alquiler diciembre 2025 - Piso 4-1 (VENCIDO)', 'R-2026-0024', '2025-12', 1), + +-- Gastos comunidad repercutidos varios +(25, 6, 21, 5, 3, 2, 80.00, 0.00, 80.00, '2026-06-15', '2026-06-20', '2026-06-17', 'TRANSFERENCIA', 'Repercusión comunidad junio - Tienda Lámparas', 'R-2026-0025', '2026-06', 1); + +-- ============================================================ +-- 8. PLANTILLAS DE GASTOS (expense_templates) +-- ============================================================ +INSERT IGNORE INTO expense_templates (id, property_id, category_id, period_id, payment_day, supplier_name, amount, tax_amount, description, is_variable, active, created_by) VALUES +(1, 1, 3, 1, 5, 'Comunidad Edificio Sa Polar', 450.00, 0.00, 'Cuota comunidad mensual Edificio Sa Polar', FALSE, TRUE, 1), +(2, 1, 4, 4, 15, 'Ayuntamiento Sant Antoni', 1800.00, 0.00, 'IBI Edificio Sa Polar', FALSE, TRUE, 1), +(3, 22, 3, 1, 5, 'Comunidad Ses Parres', 200.00, 0.00, 'Cuota comunidad mensual Ses Parres', FALSE, TRUE, 1), +(4, 1, 13, 1, 10, 'Endesa', NULL, 0.00, 'Electricidad zonas comunes Edificio Sa Polar', TRUE, TRUE, 1), +(5, 1, 7, 4, 1, 'Mapfre Seguros', 1200.00, 0.00, 'Seguro multirriesgo Edificio Sa Polar', FALSE, TRUE, 1), +(6, 1, 5, 4, 20, 'Ayuntamiento Sant Antoni', 240.00, 0.00, 'Tasa basuras Edificio Sa Polar', FALSE, TRUE, 1), +(7, 1, 9, 1, 1, 'Gestoría Contable SL', 250.00, 52.50, 'Gestión fiscal mensual', FALSE, TRUE, 1), +(8, 19, 13, 1, 15, 'Endesa', NULL, 0.00, 'Electricidad Bar Sa Polar', TRUE, TRUE, 1), +(9, 24, 13, 1, 15, 'Endesa', NULL, 0.00, 'Electricidad Bar Ibiza', TRUE, TRUE, 1); + +-- ============================================================ +-- 9. RECIBOS DE GASTOS (expense_receipts) +-- ============================================================ +INSERT IGNORE INTO expense_receipts (id, template_id, property_id, category_id, status_id, supplier_name, amount, tax_amount, total_amount, issue_date, due_date, payment_date, description, created_by) VALUES +-- Comunidad Edificio Sa Polar pagada +(1, 1, 1, 3, 2, 'Comunidad Edificio Sa Polar', 450.00, 0.00, 450.00, '2026-06-01', '2026-06-05', '2026-06-04', 'Cuota comunidad junio 2026', 1), +(2, 1, 1, 3, 1, 'Comunidad Edificio Sa Polar', 450.00, 0.00, 450.00, '2026-07-01', '2026-07-05', NULL, 'Cuota comunidad julio 2026', 1), + +-- Comunidad Ses Parres pagada +(3, 3, 22, 3, 2, 'Comunidad Ses Parres', 200.00, 0.00, 200.00, '2026-06-01', '2026-06-05', '2026-06-03', 'Cuota comunidad junio 2026', 1), + +-- Electricidad zonas comunes +(4, 4, 1, 13, 2, 'Endesa', 180.00, 37.80, 217.80, '2026-06-10', '2026-06-25', '2026-06-20', 'Factura electricidad mayo 2026 - zonas comunes', 1), +(5, 4, 1, 13, 1, 'Endesa', 165.00, 34.65, 199.65, '2026-07-10', '2026-07-25', NULL, 'Factura electricidad junio 2026 - zonas comunes', 1), + +-- Seguro edificio +(6, 5, 1, 7, 2, 'Mapfre Seguros', 1200.00, 0.00, 1200.00, '2026-06-01', '2026-06-15', '2026-06-10', 'Seguro anual Edificio Sa Polar 2026', 1), + +-- Gestoría +(7, 7, 1, 9, 2, 'Gestoría Contable SL', 250.00, 52.50, 302.50, '2026-06-01', '2026-06-15', '2026-06-10', 'Gestión fiscal junio 2026', 1), +(8, 7, 1, 9, 1, 'Gestoría Contable SL', 250.00, 52.50, 302.50, '2026-07-01', '2026-07-15', NULL, 'Gestión fiscal julio 2026', 1), + +-- Electricidad Bar Sa Polar +(9, 8, 19, 13, 2, 'Endesa', 350.00, 73.50, 423.50, '2026-06-10', '2026-06-25', '2026-06-22', 'Factura electricidad mayo 2026 - Bar Sa Polar', 2), +(10, 8, 19, 13, 1, 'Endesa', 420.00, 88.20, 508.20, '2026-07-10', '2026-07-25', NULL, 'Factura electricidad junio 2026 - Bar Sa Polar', 2), + +-- Electricidad Bar Ibiza +(11, 9, 24, 13, 2, 'Endesa', 280.00, 58.80, 338.80, '2026-06-10', '2026-06-25', '2026-06-21', 'Factura electricidad mayo 2026 - Bar Ibiza', 2), + +-- Reparaciones varias +(12, NULL, 4, 1, 2, 'Fontanero Express', 120.00, 25.20, 145.20, '2026-06-15', '2026-06-25', '2026-06-18', 'Reparación grifo cocina - Piso 2-2', 1), +(13, NULL, 22, 1, 1, 'Mantenimientos Ibiza SL', 350.00, 73.50, 423.50, '2026-07-01', '2026-07-15', NULL, 'Reparación persiana - Ses Parres', 1), + +-- IBI +(14, 2, 1, 4, 1, 'Ayuntamiento Sant Antoni', 1800.00, 0.00, 1800.00, '2026-07-01', '2026-07-31', NULL, 'IBI 2026 - Edificio Sa Polar', 1), + +-- Tasa basuras +(15, 6, 1, 5, 1, 'Ayuntamiento Sant Antoni', 240.00, 0.00, 240.00, '2026-07-01', '2026-07-31', NULL, 'Tasa basuras 2026 - Edificio Sa Polar', 1), + +-- Publicidad +(16, NULL, 17, 11, 2, 'Idealista', 69.00, 0.00, 69.00, '2026-06-15', '2026-06-25', '2026-06-17', 'Publicidad alquiler Ático', 1); + +-- ============================================================ +-- 10. INCIDENCIAS (incidents) +-- ============================================================ +INSERT IGNORE INTO incidents (id, property_id, status_id, priority_id, title, description, reported_by, assigned_to, reported_at, scheduled_date, resolved_at, resolution_notes, cost_estimate, final_cost) VALUES +(1, 4, 4, 2, 'Filtración agua baño', 'El inquilino del piso 2-2 reporta una pequeña filtración en la ducha', 1, 1, '2026-06-10 10:00:00', '2026-06-12', '2026-06-11 14:00:00', 'Reparada junta silicona y sellado', 80.00, 75.00), +(2, 19, 4, 4, 'Fuga de agua en cocina', 'Fuga importante en tubería de la cocina del Bar Sa Polar - urgencia', 1, 1, '2026-06-20 08:30:00', '2026-06-20', '2026-06-20 12:00:00', 'Tubería sustituida y prueba de presión OK', 350.00, 320.00), +(3, 22, 1, 3, 'Persiana atascada', 'La persiana del portal de Ses Parres no sube correctamente', 2, 1, '2026-06-25 15:30:00', '2026-06-28', NULL, NULL, 200.00, NULL), +(4, 23, 1, 2, 'Valla perimetral caída', 'Temporal ha derribado parte de la valla de la finca Sant Mateu', 1, 2, '2026-06-28 09:00:00', '2026-07-05', NULL, NULL, 500.00, NULL), +(5, 17, 1, 3, 'Aire acondicionado no enfría', 'El split del salón del Ático no enfría lo suficiente', 10, 1, '2026-07-02 16:45:00', '2026-07-07', NULL, NULL, 250.00, NULL), +(6, 1, 2, 1, 'Bombilla fundida pasillo', 'Varias bombillas LED del pasillo del piso 1-1 no funcionan', 3, NULL, '2026-07-04 09:00:00', '2026-07-08', NULL, NULL, 30.00, NULL), +(7, 24, 3, 3, 'Nevera bar estropeada', 'La nevera de bebidas del Bar Ibiza no enfría - necesitan reparación urgente', 2, 1, '2026-07-03 11:00:00', '2026-07-06', NULL, NULL, 400.00, NULL); + +-- ============================================================ +-- 11. MANTENIMIENTO PROGRAMADO (scheduled_maintenance) +-- ============================================================ +INSERT IGNORE INTO scheduled_maintenance (id, property_id, period_id, title, description, estimated_cost, last_execution, next_execution, reminder_days_before, responsible, notes, created_by) VALUES +(1, 1, 5, 'Revisión caldera', 'Revisión anual de la caldera del Edificio Sa Polar', 180.00, '2025-10-15', '2026-10-15', 30, 'Técnico Gasific SL', 'Cambio de piezas programado', 1), +(2, 19, 2, 'Limpieza campana extractora', 'Limpieza profesional de campana y conductos del Bar Sa Polar', 120.00, '2026-05-01', '2026-07-01', 7, 'Clima Total Ibiza', NULL, 1), +(3, 24, 2, 'Mantenimiento climatización', 'Revisión y limpieza de splits del Bar Ibiza', 150.00, '2026-03-01', '2026-09-01', 15, 'Clima Total Ibiza', NULL, 1), +(4, 1, 3, 'Revisión ascensor', 'Mantenimiento trimestral del ascensor del edificio', 250.00, '2026-04-01', '2026-07-01', 7, 'Ascensores Pitiusas', 'Contrato mantenimiento anual', 1), +(5, 1, 4, 'Limpieza depósito agua', 'Limpieza y desinfección del depósito de agua del edificio', 200.00, '2026-01-15', '2026-07-15', 15, 'Aguas de Ibiza', NULL, 1), +(6, 22, 5, 'Revisión instalación eléctrica', 'Revisión anual del cuadro eléctrico de Ses Parres', 300.00, '2025-12-01', '2026-12-01', 30, 'Electricidad Ibiza SL', NULL, 1); + +-- ============================================================ +-- 12. NOTIFICACIONES +-- ============================================================ +INSERT IGNORE INTO notifications (id, user_id, type_id, title, body, entity_type, entity_id, sent_by_email, `read`) VALUES +(1, 1, 3, 'Recibos pendientes julio', 'Varios recibos de julio están pendientes de pago (Bar Sa Polar, Local Esquina, etc.)', 'INCOME_RECEIPT', 3, FALSE, FALSE), +(2, 1, 1, 'Incidencia urgente', 'Fuga de agua en cocina del Bar Sa Polar resuelta', 'INCIDENT', 2, TRUE, TRUE), +(3, 1, 2, 'Mantenimiento próximo', 'Limpieza de campana del Bar Sa Polar programada para julio', 'MAINTENANCE', 2, FALSE, FALSE), +(4, 1, 4, 'Contrato próximo a vencer', 'El contrato del Piso 2-2 vence el 31/01/2027 - preparar renovación', 'CONTRACT', 4, FALSE, FALSE), +(5, 2, 3, 'Recibo vencido', 'El recibo de diciembre 2025 del Piso 4-1 sigue pendiente de pago', 'INCOME_RECEIPT', 24, TRUE, FALSE), +(6, 1, 1, 'Avería aire acondicionado', 'El inquilino del Ático reporta avería en el aire acondicionado', 'INCIDENT', 5, FALSE, FALSE), +(7, 1, 6, 'Informe mensual disponible', 'El informe de ingresos y gastos de junio 2026 está disponible para descarga', 'SYSTEM', 0, FALSE, TRUE), +(8, 1, 2, 'Mantenimiento trimestral', 'Revisión del ascensor programada para julio - Edificio Sa Polar', 'MAINTENANCE', 4, FALSE, FALSE); + +-- ============================================================ +-- 13. ACTUALIZAR CONTADOR SERIE RECIBOS +-- ============================================================ +UPDATE receipt_series SET last_number = 25 WHERE series_name = 'RECIBOS' AND fiscal_year = YEAR(CURDATE()); diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..539ab0e --- /dev/null +++ b/db/init.sql @@ -0,0 +1,709 @@ +-- ============================================================ +-- SISTEMA DE GESTIÓN DE ALQUILERES "SA POLAR" +-- Script de inicialización de base de datos (MySQL 8) +-- ⚠ NOTA: La gestión de esquemas ahora la realiza Flyway. +-- Este script se mantiene solo como referencia. +-- Para despliegues nuevos, las migraciones están en: +-- backend/src/main/resources/db/migration/ +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS sa_polar + DEFAULT CHARACTER SET utf8mb4 + DEFAULT COLLATE utf8mb4_unicode_ci; + +USE sa_polar; + +-- ============================================================ +-- 1. TABLAS DE USUARIOS Y AUTENTICACIÓN +-- ============================================================ + +CREATE TABLE roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(150) NOT NULL, + phone VARCHAR(20), + role_id INT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_login DATETIME, + CONSTRAINT fk_user_role FOREIGN KEY (role_id) REFERENCES roles(id) +) ENGINE=InnoDB; + +CREATE TABLE user_permissions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + permission VARCHAR(50) NOT NULL, + granted BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_up_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_user_permission (user_id, permission) +) ENGINE=InnoDB; + +-- ============================================================ +-- 2. TABLAS DE INMUEBLES +-- ============================================================ + +CREATE TABLE property_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE property_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255) +) ENGINE=InnoDB; + +CREATE TABLE property_groups ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + address_street VARCHAR(200), + address_number VARCHAR(20), + address_city VARCHAR(100), + address_postal_code VARCHAR(10), + address_province VARCHAR(100), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB; + +CREATE TABLE properties ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + parent_id BIGINT, + group_id BIGINT, + type_id INT NOT NULL, + status_id INT NOT NULL, + reference VARCHAR(50) UNIQUE, + name VARCHAR(200) NOT NULL, + description TEXT, + address_street VARCHAR(200), + address_number VARCHAR(20), + address_city VARCHAR(100), + address_postal_code VARCHAR(10), + address_province VARCHAR(100), + cadastral_ref VARCHAR(30), + surface_m2 DECIMAL(10,2), + floor VARCHAR(50), + door VARCHAR(50), + rental_amount DECIMAL(12,2), + rented_since DATE, + vacant_since DATE, + occupied_since DATE, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_prop_parent FOREIGN KEY (parent_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_prop_group FOREIGN KEY (group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_prop_type FOREIGN KEY (type_id) REFERENCES property_types(id), + CONSTRAINT fk_prop_status FOREIGN KEY (status_id) REFERENCES property_statuses(id), + CONSTRAINT fk_prop_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE TABLE property_status_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + status_id INT NOT NULL, + changed_by BIGINT, + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + notes VARCHAR(500), + CONSTRAINT fk_psh_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE, + CONSTRAINT fk_psh_status FOREIGN KEY (status_id) REFERENCES property_statuses(id), + CONSTRAINT fk_psh_user FOREIGN KEY (changed_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_properties_parent ON properties(parent_id); +CREATE INDEX idx_properties_group ON properties(group_id); +CREATE INDEX idx_properties_type ON properties(type_id); +CREATE INDEX idx_properties_status ON properties(status_id); +CREATE INDEX idx_properties_active ON properties(active); +CREATE INDEX idx_psh_property_date ON property_status_history(property_id, changed_at); + +-- ============================================================ +-- 3. TABLAS DE ARRENDATARIOS +-- ============================================================ + +CREATE TABLE tenant_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_type_id INT NOT NULL, + fiscal_id VARCHAR(20) NOT NULL UNIQUE COMMENT 'DNI / NIF / CIF', + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + document_type VARCHAR(20) DEFAULT 'DNI', + business_name VARCHAR(200) COMMENT 'Solo para personas jurídicas', + email VARCHAR(100), + phone VARCHAR(20), + address VARCHAR(300), + iban VARCHAR(34), + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_tenant_type FOREIGN KEY (tenant_type_id) REFERENCES tenant_types(id) +) ENGINE=InnoDB; + +CREATE INDEX idx_tenants_fiscal_id ON tenants(fiscal_id); +CREATE INDEX idx_tenants_name ON tenants(first_name, last_name); +CREATE INDEX idx_tenants_active ON tenants(active); + +-- Tabla de datos bancarios del inquilino +CREATE TABLE tenant_bank_data ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + alias VARCHAR(100), + iban VARCHAR(34) NOT NULL, + bic VARCHAR(11), + bank_name VARCHAR(200), + account_holder VARCHAR(200), + is_principal BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_bank_data_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE INDEX idx_tenant_bank_data_tenant ON tenant_bank_data(tenant_id); +CREATE INDEX idx_tenant_bank_data_principal ON tenant_bank_data(tenant_id, is_principal); + +-- ============================================================ +-- 4. TABLAS DE CONTRATOS +-- ============================================================ + +CREATE TABLE contract_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE payment_periods ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE contracts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + status_id INT NOT NULL, + period_id INT NOT NULL DEFAULT 1, + contract_number VARCHAR(50) UNIQUE, + start_date DATE NOT NULL, + end_date DATE, + renewal_date DATE, + rental_amount DECIMAL(12,2) NOT NULL, + deposit_amount DECIMAL(12,2), + payment_day INT NOT NULL DEFAULT 1 COMMENT 'Día de mes para el pago', + payment_day_end INT DEFAULT NULL COMMENT 'Día final del rango de pago (NULL = día único)', + iban_charge VARCHAR(34) COMMENT 'IBAN para domiciliación', + notes TEXT, + signed_at DATE, + terminated_at DATE, + termination_cause VARCHAR(500), + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_contract_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_contract_status FOREIGN KEY (status_id) REFERENCES contract_statuses(id), + CONSTRAINT fk_contract_period FOREIGN KEY (period_id) REFERENCES payment_periods(id), + CONSTRAINT fk_contract_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_contracts_property ON contracts(property_id); +CREATE INDEX idx_contracts_status ON contracts(status_id); +CREATE INDEX idx_contracts_dates ON contracts(start_date, end_date); + +CREATE TABLE contract_tenants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'TITULAR' COMMENT 'TITULAR o CONVIVIENTE', + CONSTRAINT fk_ct_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE, + CONSTRAINT fk_ct_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uq_contract_tenant (contract_id, tenant_id) +) ENGINE=InnoDB; + +CREATE INDEX idx_ct_contract ON contract_tenants(contract_id); +CREATE INDEX idx_ct_tenant ON contract_tenants(tenant_id); + +-- ============================================================ +-- 5. TABLAS DE DOCUMENTOS +-- ============================================================ + +CREATE TABLE document_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE documents ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + document_type_id INT NOT NULL, + entity_type VARCHAR(30) NOT NULL COMMENT 'PROPERTY / CONTRACT / TENANT / INCIDENT / INCOME / EXPENSE', + entity_id BIGINT NOT NULL, + original_name VARCHAR(255) NOT NULL, + stored_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100), + file_size BIGINT, + description VARCHAR(500), + uploaded_by BIGINT, + uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_doc_type FOREIGN KEY (document_type_id) REFERENCES document_types(id), + CONSTRAINT fk_doc_uploader FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_documents_entity ON documents(entity_type, entity_id); +CREATE INDEX idx_documents_type ON documents(document_type_id); + +-- ============================================================ +-- 6. TABLAS DE INGRESOS +-- ============================================================ + +CREATE TABLE income_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + active BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE=InnoDB; + +CREATE TABLE income_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE income_receipts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + contract_id BIGINT, + property_id BIGINT NOT NULL, + tenant_id BIGINT, + bank_account_id BIGINT, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + status_id INT NOT NULL, + period_label VARCHAR(20) COMMENT 'ej: 2026-07', + amount DECIMAL(12,2) NOT NULL, + tax_withheld DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Retención IRPF aplicada', + net_amount DECIMAL(12,2) COMMENT 'Importe neto después de retención', + issue_date DATE NOT NULL, + due_date DATE, + payment_date DATE, + payment_method VARCHAR(30) COMMENT 'TRANSFERENCIA / EFECTIVO / BIZUM / RECIBO / TARJETA', + description VARCHAR(500), + receipt_number VARCHAR(50), + notes TEXT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_income_receipt_contract FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_income_receipt_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_category FOREIGN KEY (category_id) REFERENCES income_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_income_receipt_status FOREIGN KEY (status_id) REFERENCES income_statuses(id), + CONSTRAINT fk_income_receipt_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_income_receipts_property ON income_receipts(property_id); +CREATE INDEX idx_income_receipts_contract ON income_receipts(contract_id); +CREATE INDEX idx_income_receipts_tenant ON income_receipts(tenant_id); +CREATE INDEX idx_income_receipts_status ON income_receipts(status_id); +CREATE INDEX idx_income_receipts_period ON income_receipts(period_label); +CREATE INDEX idx_income_receipts_issue_date ON income_receipts(issue_date); + +-- ============================================================ +-- 7. TABLAS DE GASTOS +-- ============================================================ + +CREATE TABLE expense_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + active BOOLEAN NOT NULL DEFAULT TRUE +) ENGINE=InnoDB; + +CREATE TABLE expense_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE expense_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT DEFAULT NULL, + property_group_id BIGINT DEFAULT NULL, + bank_account_id BIGINT DEFAULT NULL, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + period_id INT NOT NULL DEFAULT 1, + payment_day INT NOT NULL DEFAULT 1, + supplier_name VARCHAR(200), + supplier_fiscal_id VARCHAR(20), + amount DECIMAL(12,2) COMMENT 'NULL = variable, usuario rellena importe', + tax_amount DECIMAL(12,2), + description VARCHAR(500) NOT NULL, + notes TEXT, + is_variable BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_expense_template_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_category FOREIGN KEY (category_id) REFERENCES expense_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_template_period FOREIGN KEY (period_id) REFERENCES payment_periods(id), + CONSTRAINT fk_expense_template_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_expense_templates_property ON expense_templates(property_id); +CREATE INDEX idx_expense_templates_group ON expense_templates(property_group_id); +CREATE INDEX idx_expense_templates_category ON expense_templates(category_id); +CREATE INDEX idx_expense_templates_period ON expense_templates(period_id); + +CREATE TABLE expense_receipts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_id BIGINT DEFAULT NULL, + property_id BIGINT DEFAULT NULL, + property_group_id BIGINT DEFAULT NULL, + bank_account_id BIGINT DEFAULT NULL, + is_domiciled BOOLEAN NOT NULL DEFAULT FALSE, + category_id BIGINT, + status_id INT NOT NULL, + supplier_name VARCHAR(200), + supplier_fiscal_id VARCHAR(20), + invoice_number VARCHAR(50), + amount DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) DEFAULT 0.00, + total_amount DECIMAL(12,2) COMMENT 'Importe total con impuestos', + is_variable BOOLEAN NOT NULL DEFAULT FALSE, + previous_amount DECIMAL(12,2) COMMENT 'Importe del periodo anterior (para variables)', + issue_date DATE NOT NULL, + due_date DATE, + payment_date DATE, + payment_method VARCHAR(30), + description VARCHAR(500) NOT NULL, + notes TEXT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_expense_receipt_template FOREIGN KEY (template_id) REFERENCES expense_templates(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_property FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_group FOREIGN KEY (property_group_id) REFERENCES property_groups(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_bank_account FOREIGN KEY (bank_account_id) REFERENCES bank_accounts(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_category FOREIGN KEY (category_id) REFERENCES expense_categories(id) ON DELETE SET NULL, + CONSTRAINT fk_expense_receipt_status FOREIGN KEY (status_id) REFERENCES expense_statuses(id), + CONSTRAINT fk_expense_receipt_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_expense_receipts_template ON expense_receipts(template_id); +CREATE INDEX idx_expense_receipts_property ON expense_receipts(property_id); +CREATE INDEX idx_expense_receipts_group ON expense_receipts(property_group_id); +CREATE INDEX idx_expense_receipts_category ON expense_receipts(category_id); +CREATE INDEX idx_expense_receipts_status ON expense_receipts(status_id); +CREATE INDEX idx_expense_receipts_issue_date ON expense_receipts(issue_date); + +-- ============================================================ +-- 8. TABLAS DE INCIDENCIAS (Fase 2) +-- ============================================================ + +CREATE TABLE incident_statuses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE incident_priorities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(20) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE incidents ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + status_id INT NOT NULL, + priority_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + reported_by BIGINT, + assigned_to BIGINT COMMENT 'Técnico o usuario asignado', + reported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + scheduled_date DATE COMMENT 'Fecha prevista de reparación', + resolved_at DATETIME, + resolution_notes TEXT, + cost_estimate DECIMAL(12,2), + final_cost DECIMAL(12,2), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_incident_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_incident_status FOREIGN KEY (status_id) REFERENCES incident_statuses(id), + CONSTRAINT fk_incident_priority FOREIGN KEY (priority_id) REFERENCES incident_priorities(id), + CONSTRAINT fk_incident_reporter FOREIGN KEY (reported_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_incident_assigned FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_incidents_property ON incidents(property_id); +CREATE INDEX idx_incidents_status ON incidents(status_id); +CREATE INDEX idx_incidents_priority ON incidents(priority_id); +CREATE INDEX idx_incidents_reported ON incidents(reported_at); + +-- ============================================================ +-- 9. TABLAS DE MANTENIMIENTO PROGRAMADO (Fase 2) +-- ============================================================ + +CREATE TABLE maintenance_periods ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE scheduled_maintenance ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + property_id BIGINT NOT NULL, + period_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT, + estimated_cost DECIMAL(12,2), + last_execution DATE, + next_execution DATE NOT NULL, + reminder_days_before INT NOT NULL DEFAULT 30, + responsible VARCHAR(200), + notes TEXT, + completed BOOLEAN NOT NULL DEFAULT FALSE, + completed_at DATE, + completed_by BIGINT, + created_by BIGINT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_maint_property FOREIGN KEY (property_id) REFERENCES properties(id), + CONSTRAINT fk_maint_period FOREIGN KEY (period_id) REFERENCES maintenance_periods(id), + CONSTRAINT fk_maint_completed FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_maint_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB; + +CREATE INDEX idx_maint_property ON scheduled_maintenance(property_id); +CREATE INDEX idx_maint_next_exec ON scheduled_maintenance(next_execution); +CREATE INDEX idx_maint_completed ON scheduled_maintenance(completed); + +-- ============================================================ +-- 10. TABLAS DE NOTIFICACIONES (Fase 3) +-- ============================================================ + +CREATE TABLE notification_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE notifications ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + type_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + body TEXT, + entity_type VARCHAR(30) COMMENT 'INCIDENT / MAINTENANCE / INCOME / CONTRACT', + entity_id BIGINT, + sent_by_email BOOLEAN NOT NULL DEFAULT FALSE, + `read` BOOLEAN NOT NULL DEFAULT FALSE, + read_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_notif_type FOREIGN KEY (type_id) REFERENCES notification_types(id) +) ENGINE=InnoDB; + +CREATE INDEX idx_notifications_user ON notifications(user_id, `read`); +CREATE INDEX idx_notifications_created ON notifications(created_at); + +-- ============================================================ +-- DATOS SEMILLA (Seed Data) +-- ============================================================ + +-- Roles +INSERT INTO roles (id, name, description) VALUES +(1, 'ADMIN', 'Acceso total al sistema'), +(2, 'GERENTE', 'Gestión de propiedades, contratos, inquilinos y finanzas'), +(3, 'CONTABLE', 'Gestión de ingresos, gastos y reportes'), +(4, 'VISUALIZADOR','Solo lectura de la información'); + +-- Tipos de propiedad +INSERT INTO property_types (id, name, description) VALUES +(1, 'EDIFICIO', 'Edificio completo con varias plantas'), +(2, 'PISO', 'Vivienda en un edificio de pisos'), +(3, 'LOCAL_COMERCIAL','Local comercial'), +(4, 'BAR', 'Bar o restaurante'), +(5, 'NAVE', 'Nave industrial o almacén'), +(6, 'GARAJE', 'Plaza de garaje'), +(7, 'TRASTERO', 'Trastero'), +(8, 'OFICINA', 'Oficina o despacho'), +(9, 'ADOSADO', 'Vivienda unifamiliar adosada'), +(10, 'CHALET', 'Vivienda unifamiliar independiente'); + +-- Estados de propiedad +INSERT INTO property_statuses (id, name, description) VALUES +(1, 'DISPONIBLE', 'Propiedad disponible para alquilar'), +(2, 'ALQUILADO', 'Actualmente alquilado'), +(3, 'VACIO', 'Vacío, sin inquilino'), +(4, 'ANUNCIADO', 'Anunciado para alquiler'), +(5, 'VENTA', 'Puesto a la venta'), +(6, 'VENDIDO', 'Vendido'), +(7, 'TRASPASADO', 'Traspasado a otro propietario'), +(8, 'OCUPADO', 'Ocupado sin contrato vigente'), +(9, 'MANTENIMIENTO', 'En obras o mantenimiento'); + +-- Tipos de arrendatario +INSERT INTO tenant_types (id, name) VALUES +(1, 'PERSONA_FISICA'), +(2, 'PERSONA_JURIDICA'); + +-- Estados de contrato +INSERT INTO contract_statuses (id, name) VALUES +(1, 'ACTIVO'), +(2, 'VENCIDO'), +(3, 'RENOVADO'), +(4, 'RESCINDIDO'), +(5, 'ANULADO'); + +-- Períodos de pago +INSERT INTO payment_periods (id, name) VALUES +(1, 'MENSUAL'), +(2, 'TRIMESTRAL'), +(3, 'SEMESTRAL'), +(4, 'ANUAL'); + +-- Tipos de documento +INSERT INTO document_types (id, name) VALUES +(1, 'CONTRATO'), +(2, 'ANEXO_CONTRATO'), +(3, 'DNI_ARRENDATARIO'), +(5, 'FOTO_PROPIEDAD'), +(6, 'FOTO_INCIDENCIA'), +(7, 'FACTURA'), +(8, 'JUSTIFICANTE_PAGO'), +(9, 'CERTIFICADO'), +(10, 'OTRO'), +(11, 'IMAGEN'), +(12, 'PRESUPUESTO'); + +-- Estados de ingreso +INSERT INTO income_statuses (id, name) VALUES +(1, 'PENDIENTE'), +(2, 'PAGADO'), +(3, 'VENCIDO'), +(4, 'PARCIAL'), +(5, 'ANULADO'); + +-- Categorías de ingreso +INSERT INTO income_categories (id, name, description) VALUES +(1, 'ALQUILER', 'Pago de renta mensual o periódica'), +(2, 'FIANZA', 'Depósito de garantía'), +(3, 'GASTOS_COMUNIDAD', 'Repercusión de gastos de comunidad'), +(4, 'INTERESES_DEMORA', 'Intereses por pago fuera de plazo'), +(5, 'INDEMNIZACION', 'Indemnización por daños o rescisión'), +(6, 'OTROS_INGRESOS', 'Otros ingresos no clasificados'); + +-- Estados de gasto +INSERT INTO expense_statuses (id, name) VALUES +(1, 'PENDIENTE'), +(2, 'PAGADO'), +(3, 'VENCIDO'), +(4, 'ANULADO'); + +-- Categorías de gasto +INSERT INTO expense_categories (id, name, description) VALUES +(1, 'REPARACION', 'Reparaciones y arreglos'), +(2, 'MANTENIMIENTO', 'Mantenimiento preventivo'), +(3, 'COMUNIDAD', 'Gastos de comunidad de propietarios'), +(4, 'IBI', 'Impuesto de Bienes Inmuebles'), +(5, 'BASURA', 'Tasa de basura'), +(6, 'SUMINISTROS', 'Agua, luz, gas, internet'), +(7, 'SEGURO', 'Seguro del inmueble o multirriesgo'), +(8, 'REFORMA', 'Obras de reforma o mejora'), +(9, 'GESTION', 'Gastos de gestión inmobiliaria'), +(10, 'NOTARIA_REGISTRO', 'Gastos notariales y de registro'), +(11, 'PUBLICIDAD', 'Anuncios y marketing'), +(12, 'OTROS_GASTOS', 'Otros gastos no clasificados'); + +-- Estados de incidencia (Fase 2) +INSERT INTO incident_statuses (id, name) VALUES +(1, 'SIN_REVISAR'), +(2, 'TECNICO_AVISADO'), +(3, 'REPARACION_PREVISTA'), +(4, 'REPARADO'), +(5, 'IGNORADO'), +(6, 'ANULADO'); + +-- Prioridades de incidencia (Fase 2) +INSERT INTO incident_priorities (id, name) VALUES +(1, 'BAJA'), +(2, 'MEDIA'), +(3, 'ALTA'), +(4, 'URGENTE'); + +-- Períodos de mantenimiento (Fase 2) +INSERT INTO maintenance_periods (id, name) VALUES +(1, 'UNICA_VEZ'), +(2, 'MENSUAL'), +(3, 'TRIMESTRAL'), +(4, 'SEMESTRAL'), +(5, 'ANUAL'), +(6, 'BIENAL'), +(7, 'TRIENAL'), +(8, 'QUINQUENAL'); + +-- Tipos de notificación (Fase 3) +INSERT INTO notification_types (id, name) VALUES +(1, 'INCIDENCIA_ABIERTA'), +(2, 'MANTENIMIENTO_PROXIMO'), +(3, 'RECIBO_VENCIDO'), +(4, 'CONTRATO_PROXIMO_VENCER'), +(5, 'CONTRATO_VENCIDO'), +(6, 'SISTEMA'); + +-- ============================================================ +-- 11. TABLAS DE RECIBOS Y FACTURACIÓN (Fase 2) +-- ============================================================ + +CREATE TABLE receipt_series ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + series_name VARCHAR(50) NOT NULL, + fiscal_year INT NOT NULL, + last_number INT NOT NULL DEFAULT 0, + prefix VARCHAR(20) NOT NULL DEFAULT 'R-', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_series_year (series_name, fiscal_year) +) ENGINE=InnoDB; + +CREATE TABLE email_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + income_receipt_id BIGINT, + recipient_email VARCHAR(200) NOT NULL, + subject VARCHAR(300) NOT NULL, + body TEXT, + success BOOLEAN NOT NULL DEFAULT FALSE, + error_message TEXT, + sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_email_income (income_receipt_id) +) ENGINE=InnoDB; + +-- Serie de recibos para el año actual +INSERT INTO receipt_series (series_name, fiscal_year, last_number, prefix) VALUES +('RECIBOS', YEAR(CURDATE()), 0, CONCAT('R-', YEAR(CURDATE()), '-')); + +-- ============================================================ +-- USUARIO ADMIN POR DEFECTO +-- Contraseña: admin123 (BCrypt hash) +-- ============================================================ +INSERT INTO users (username, email, password_hash, full_name, role_id, active) +VALUES ('admin', 'admin@sapolar.com', + '$2a$10$I9VbpnnqwttwAiKlWAgxRuyQF6IC02wnMO1YoBF/u2QcTJKcZnJBe', + 'Administrador del Sistema', 1, TRUE); diff --git a/db/load-seed.sh b/db/load-seed.sh new file mode 100644 index 0000000..012c304 --- /dev/null +++ b/db/load-seed.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Script para cargar datos seed después de que Flyway haya migrado la BBDD +# Uso: docker compose exec backend /docker-entrypoint-initdb.d/load-seed.sh +# O directamente: ./db/load-seed.sh + +set -e + +echo "==> Esperando a que MySQL esté disponible..." +for i in {1..30}; do + if mysql -uroot -p"$DB_PASSWORD" -h mysql -e "SELECT 1" >/dev/null 2>&1; then + echo "==> MySQL disponible!" + break + fi + sleep 2 +done + +echo "==> Verificando si ya hay datos..." +USER_COUNT=$(mysql -uroot -p"$DB_PASSWORD" -h mysql "$DB_NAME" -sN -e "SELECT COUNT(*) FROM users WHERE username != 'admin';" 2>/dev/null || echo "0") + +if [ "$USER_COUNT" -gt 0 ]; then + echo "==> La BBDD ya tiene datos ($USER_COUNT usuarios extra). Saltando seed." + exit 0 +fi + +echo "==> Cargando seed.sql..." +mysql -uroot -p"$DB_PASSWORD" -h mysql "$DB_NAME" --default-character-set=utf8mb4 < /tmp/seed.sql 2>/dev/null || \ +mysql -uroot -p"$DB_PASSWORD" -h mysql "$DB_NAME" < /docker-entrypoint-initdb.d/seed.sql + +if [ $? -eq 0 ]; then + echo "==> Seed cargado correctamente!" +else + echo "==> Error al cargar seed. Intentando desde ubicación alternativa..." + # Intentar copiar seed a la ubicación del contenedor + docker compose cp backend/src/main/resources/db/seed.sql backend:/tmp/seed.sql + docker compose exec backend bash -c "mysql -uroot -p\$DB_PASSWORD -h mysql \$DB_NAME --default-character-set=utf8mb4 < /tmp/seed.sql" +fi + +echo "==> Seed completado!" diff --git a/db/mysql-entrypoint.sh b/db/mysql-entrypoint.sh new file mode 100644 index 0000000..2c9cbcd --- /dev/null +++ b/db/mysql-entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Script wrapper para MySQL +# Solo carga seed si DEBUG=true y la BD está vacía + +# Ejecutar el entrypoint de MySQL y capturar el PID +/docker-entrypoint.sh "$@" & +MAIN_PID=$! + +# Esperar a que MySQL esté realmente disponible +echo "==> Esperando a MySQL..." +for i in {1..60}; do + if mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT 1" >/dev/null 2>&1; then + echo "==> MySQL disponible!" + break + fi + if ! kill -0 $MAIN_PID 2>/dev/null; then + echo "==> MySQL terminó inesperadamente" + exit 1 + fi + sleep 1 +done + +# Si DEBUG=true y la BD está vacía (menos de 5 tablas), cargar seed +if [ "$DEBUG" = "true" ]; then + echo "==> DEBUG=true - Verificando datos..." + TABLE_COUNT=$(mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" -sN -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$MYSQL_DATABASE' AND table_name NOT LIKE 'flyway%' AND table_name NOT LIKE 'sys%';" 2>/dev/null) + + if [ "$TABLE_COUNT" -lt 5 ]; then + echo "==> BD casi vacía ($TABLE_COUNT tablas). Ejecutando seed.sql..." + mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" < /docker-entrypoint-initdb.d/seed.sql + echo "==> Seed completado!" + else + echo "==> BD ya tiene datos ($TABLE_COUNT tablas). Saltando seed." + fi +fi + +# Esperar a que termine el proceso principal +wait $MAIN_PID diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4a2b10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +name: sa-polar + +services: + mysql: + image: mysql:8.0 + container_name: sa-polar-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root} + MYSQL_DATABASE: ${DB_NAME:-sa_polar} + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 5s + retries: 20 + interval: 5s + + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: sa-polar-backend + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + environment: + DB_HOST: mysql + DB_PORT: 3306 + DB_NAME: ${DB_NAME:-sa_polar} + DB_USER: root + DB_PASSWORD: ${DB_PASSWORD:-root} + JWT_SECRET: ${JWT_SECRET:-a2V5X3N1cGVyX3NlY3JldGFfcGFyYV9sb2dpbl9kZV9zYV9wb2xhcl9kZWJlc19zZXJfZGUzMl9jYXJhY3RlcmVz} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:5173} + UPLOAD_PATH: /app/uploads + # Perfil por defecto: dev. Para producción usar: SPRING_PROFILES_ACTIVE=prod + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-dev} + ports: + - "8080:8080" + volumes: + - uploads_data:/app/uploads + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: sa-polar-frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "3000:3000" + +volumes: + mysql_data: + uploads_data: diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..37b8300 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,15 @@ +# Documentación de Sa Polar - Sistema de Gestión de Alquileres + +## Guías técnicas + +- [Arquitectura del sistema](tecnicas/arquitectura.md) +- [Referencia de la API REST](tecnicas/api.md) +- [Esquema de base de datos](tecnicas/base-de-datos.md) + +## Planificación + +- [Roadmap y planificación del proyecto](planificacion/roadmap.md) + +## Manuales de usuario + +- [Manual de usuario](usuario/manual.md) diff --git a/docs/planificacion/roadmap.md b/docs/planificacion/roadmap.md new file mode 100644 index 0000000..66ea6c4 --- /dev/null +++ b/docs/planificacion/roadmap.md @@ -0,0 +1,193 @@ +# Planificación del Proyecto - Sa Polar + +## Visión General + +Sistema de gestión de alquileres desarrollado por fases incrementales. Cada fase añade funcionalidades completas y autónomas. + +## Fases Completadas + +### Fase 1 - MVP (Base del Sistema) + +**Estado:** COMPLETADO + +**Objetivo:** Sistema base funcional con operaciones CRUD esenciales y autenticación. + +#### Módulos implementados + +| Módulo | Funcionalidades | +|--------|----------------| +| **Autenticación** | Login con JWT, registro de usuarios, refresh token, roles (ADMIN, GERENTE, CONTABLE, VISUALIZADOR) | +| **Usuarios** | CRUD de usuarios, asignación de roles, activación/desactivación | +| **Propiedades** | CRUD con jerarquía (edificio → pisos), tipos y estados, historial de cambios de estado | +| **Inquilinos** | CRUD, búsqueda, personas físicas y jurídicas | +| **Contratos** | CRUD, asociación propiedad+inquilino, cambio automático de estado de propiedad al crear/terminar | +| **Recibos de Ingresos** | CRUD, categorías, registro de pagos, cálculo automático de retención IRPF, periodo | +| **Plantillas de Gastos** | CRUD, categorías, periodicidad, generación automática de recibos | +| **Recibos de Gastos** | CRUD, origen desde plantilla o manual, registro de pagos | +| **Documentos** | Subida/descarga polimórfica, validación de tipos por entidad, documentos obligatorios, componente reutilizable en todas las páginas | +| **Dashboard** | Resumen general con contadores y agregaciones financieras | +| **Notificaciones** | Sistema de notificaciones por usuario con marcado de lectura | +| **Infraestructura** | Docker compose (mysql + backend), Swagger/OpenAPI, script init.sql completo | + +#### Tareas técnicas realizadas + +- [x] Creación del proyecto Spring Boot multi-módulo +- [x] Configuración de Spring Security con JWT +- [x] Mapeo JPA de todas las entidades del dominio +- [x] Script init.sql con DDL y datos semilla +- [x] Configuración Docker con healthcheck de MySQL +- [x] Corrección de tipos de columna (TINYINT UNSIGNED → INT) +- [x] Corrección de palabra reservada `read` en MySQL +- [x] Corrección de LazyInitializationException con @Transactional +- [x] Generación correcta de hash BCrypt para admin +- [x] Configuración CORS para frontend + +### Fase 2 - Recibos Automáticos e Incidencias + +**Estado:** COMPLETADO + +**Objetivo:** Automatizar la generación de recibos, gestión de incidencias y mantenimiento programado. + +#### Módulos implementados + +| Módulo | Funcionalidades | +|--------|----------------| +| **Incidencias** | CRUD completo, flujo de estados (SIN_REVISAR → TECNICO_AVISADO → REPARACION_PREVISTA → REPARADO), asignación de técnico, programación de reparación, prioridades | +| **Mantenimiento Programado** | CRUD, periodicidad configurable, cálculo de próxima ejecución, recordatorios | +| **Recibos** | Generación individual y masiva, numeración automática por serie fiscal, PDF con iText, envío por email con adjunto, log de envíos | +| **Reportes** | Informe mensual Excel (ingresos - gastos = balance) con Apache POI | +| **Tareas Programadas** | Generación mensual de recibos (día 1 a las 06:00), marcado de vencidos (diario 02:00), revisión de contratos próximos a vencer (día 1 a las 07:00) | + +#### Tareas técnicas realizadas + +- [x] Entidades ReceiptSeries y EmailLog +- [x] Servicios ReceiptService, PdfReceiptService, EmailReceiptService, ReportService +- [x] ReceiptScheduler con 3 tareas cron +- [x] ReceiptController con 7 endpoints +- [x] Configuración SMTP en application.yml +- [x] Tablas receipt_series y email_log en init.sql +- [x] Endpoints de reportes Excel +- [x] Frontend React + Vite + TypeScript completo +- [x] Páginas: Login, Dashboard, Properties, Tenants, Contracts, Incomes, Expenses, Incidents, Documents +- [x] Capa API con Axios e interceptor JWT +- [x] AuthContext con persistencia en localStorage +- [x] Layout con sidebar y navegación +- [x] Docker compose con servicio frontend (Nginx) +- [x] Proxy reverso en Nginx para /api/* +- [x] Compilación y build exitosos +- [x] ID visible en todas las tablas, detalles y formularios +- [x] Property Groups (Conjuntos) — entidad, CRUD backend, página frontend con propiedades asociadas +- [x] Sistema de documentos con validación tipo-entidad (V5 migration) +- [x] Componente DocumentUploader integrado en Contracts, Tenants, Properties, Incidents, Incomes, Expenses +- [x] Endpoint getDocumentTypesForEntity para filtrar tipos permitidos por entidad +- [x] CRUD completo de Inquilinos con validación de documentos (DNI/NIE/CIF) +- [x] Gestión dinámica de múltiples inquilinos en Contratos con creación inline +- [x] Acciones especiales: "Cobrar" en Ingresos, "Pagar" en Gastos, "Terminar contrato" +- [x] Componentes reutilizables: Modal, ConfirmDialog, Pagination, SortableHeader, Toast, EntityLink +- [x] Hook useSort para ordenación client-side con claves anidadas +- [x] Hook useEntityNavigation para navegación programática entre entidades +- [x] BankDataManager para gestión de datos bancarios de inquilinos (CRUD, validación IBAN) +- [x] Previsualización de documentos (PDF en iframe, imágenes JPEG/PNG/GIF/WebP) +- [x] Autocompletado de direcciones via datalist en formularios +- [x] Badges de estado y prioridad con colores en todas las tablas +- [x] Recepción de filtros desde Dashboard via location.state +- [x] Flyway configurado con 6 migraciones (V1-V6) +- [x] FlywayRepairConfig con estrategia por perfil (dev vs prod) +- [x] Perfiles application-dev.yml y application-prod.yml +- [x] TenantBankData: entidad, controller, repository, service, migración V6 + +## Fase 3 - Funcionalidades Avanzadas + +**Estado:** PARCIALMENTE COMPLETADA + +**Objetivo:** Mejoras en la experiencia de usuario y funcionalidades complementarias. + +### Completado + +| Módulo | Funcionalidades | +|--------|----------------| +| **Frontend Avanzado** | CRUD completo en 8 páginas (Properties, PropertyGroups, Tenants, Contracts, IncomeReceipts, ExpenseTemplates, ExpenseReceipts, Incidents, Documents) con patrón consistente ViewMode (list/detail/edit/create) | +| **Filtros y búsqueda** | Filtros desplegables + búsqueda por texto libre en todas las páginas de listado | +| **Paginación** | Paginación client-side con componente Pagination reutilizable (PAGE_SIZE = 20) | +| **Ordenación** | Cabeceras ordenables con hook useSort en todas las tablas | +| **Formularios** | Formularios de creación/edición completos con validación en todas las entidades | +| **Navegación cruzada** | Componente EntityLink para navegar entre entidades relacionadas | +| **Documentos adjuntos** | Componente DocumentUploader con drag & drop, previsualización (PDF/imágenes), descarga | +| **Datos bancarios** | Componente BankDataManager para gestión de IBAN de inquilinos con validación | +| **Notificaciones UI** | Sistema de Toast para feedback de acciones | +| **Flyway** | Configurado y funcionando con 6 migraciones (V1-V6), perfiles dev/prod | +| **Repositorio Git** | Inicializado con .gitignore completo, 5 commits | + +### V11 — Refactor Financiero (COMPLETADO) + +| Módulo | Funcionalidades | +|--------|----------------| +| **IncomeReceipt** | Nueva entidad con soporte de período, cuenta bancaria, domiciliación | +| **ExpenseTemplate** | Plantillas de gastos con periodicidad, importe fijo/variable | +| **ExpenseReceipt** | Recibos de gastos con origen desde plantilla o manual | +| **ExpenseScheduler** | Generación automática de recibos desde plantillas activas | +| **Refactor recibos** | PdfReceiptService, EmailReceiptService, ReceiptService, ReportService adaptados a nuevo modelo | +| **Frontend** | Páginas IncomeReceipts, ExpenseTemplates, ExpenseReceipts creadas | +| **Seed data** | Actualizado seed.sql con datos de demostración | + +### Pendiente + +| Módulo | Funcionalidades | Prioridad | +|--------|----------------|-----------| +| **Página Mantenimiento** | CRUD de mantenimiento programado ✅ COMPLETADO | Alta | +| **Mejoras Mantenimiento** | Reapertura de tareas, generación automática de gastos, diálogo de documentos al completar ✅ COMPLETADO | Alta | +| **Página Reportes** | Generación de informes Excel y gestión de recibos automáticos (backend existe, falta frontend) | Alta | +| **Página Notificaciones** | Gestión de notificaciones del usuario (backend existe, falta frontend) | Media | +| **Página Usuarios** | CRUD de usuarios y asignación de roles (backend existe, falta frontend) | Media | +| **Exportación** | Exportar listados a PDF/Excel desde el frontend | Media | +| **Funcionalidad avanzada frontend** | Terminar páginas IncomeReceipts, ExpenseTemplates, ExpenseReceipts con filtros y acciones completas | Alta | +| **Inventario** | Gestión de mobiliario y equipamiento por propiedad | Baja | +| **Candidatos** | Registro de interesados antes del contrato | Baja | +| **Temporada** | Alquileres por temporada con precios dinámicos | Baja | + +## Fase 4 - Producción y Calidad + +**Estado:** PARCIALMENTE COMPLETADA + +**Objetivo:** Preparar el sistema para uso en producción con garantías de calidad. + +### Completado + +| Tarea | Descripción | +|-------|-------------| +| **Flyway** | Configurado con 6 migraciones SQL, perfiles dev (clean+repair+migrate) y prod (solo repair+migrate), FlywayRepairConfig | +| **Repositorio Git** | Inicializado, .gitignore completo (raíz + frontend), 5 commits | + +### Pendiente + +| Tarea | Descripción | Prioridad | +|-------|-------------|-----------| +| **Tests unitarios** | Tests para AuthService, ReceiptService, PdfReceiptService, ContractService, etc. | Alta | +| **Tests de integración** | Tests con H2 (ya incluido en pom.xml) o Testcontainers | Alta | +| **Pipeline CI/CD** | GitHub Actions para build y tests automáticos | Media | +| **Logs centralizados** | Estructura de logging consistente (SLF4J + Logback) | Baja | +| **Monitorización** | Health checks, métricas con Actuator | Baja | +| **SSL/TLS** | Certificados HTTPS para producción | Media | +| **Backups** | Script de backup automático de BD | Media | +| **Auditoría** | Tabla de auditoría para cambios sensibles | Baja | + +## Notas sobre la Planificación + +### Decisiones de arquitectura + +- Se eligió **monolito modular** frente a microservicios por la simplicidad del dominio y para evitar complejidad operativa innecesaria. +- Se usa **init.sql + ddl-auto: validate** en lugar de Flyway para la fase inicial porque el esquema se define completamente desde el principio. +- El **frontend se separó del backend** desde el inicio para permitir desarrollo independiente y despliegue con Nginx. +- Los **recibos de ingresos** (tabla `income_receipts`) y **recibos de gastos** (tabla `expense_receipts`) se separan de las plantillas de gastos (tabla `expense_templates`) para mayor flexibilidad. +- Las **plantillas de gastos** permiten definir gastos recurrentes con periodicidad y generación automática mediante scheduler. +- Se usa **BCrypt** con Spring Security para contraseñas, con hash pre-generado para el usuario admin por defecto. + +### Convenciones de código + +- Nombres de tablas en **plural** y **snake_case**. +- Nombres de columnas en **snake_case**. +- Entidades JPA con **Lombok** (`@Getter`, `@Setter`, `@NoArgsConstructor`). +- Servicios con **inyección por constructor** (no `@Autowired` directo). +- Controladores con **inyección por constructor** y `@Valid` en request bodies. +- Paquetes organizados por **dominio de negocio** (no por capa técnica). +- URLs RESTful con **sustantivos en plural** y verbs HTTP semánticos. diff --git a/docs/tecnicas/api.md b/docs/tecnicas/api.md new file mode 100644 index 0000000..e1c1993 --- /dev/null +++ b/docs/tecnicas/api.md @@ -0,0 +1,370 @@ +# Referencia de la API REST + +## Formato General + +Todas las respuestas siguen el formato `ApiResponse`: + +```json +{ + "success": true, + "message": "OK", + "data": { ... }, + "timestamp": "2026-07-04T12:00:00" +} +``` + +Los errores usan el mismo formato con `success: false` y mensaje descriptivo. + +## Autenticación + +### `POST /api/auth/login` + +Iniciar sesión. + +**Request body:** +```json +{ + "username": "admin", + "password": "admin123" +} +``` + +**Response (200):** +```json +{ + "success": true, + "message": "OK", + "data": { + "accessToken": "eyJhbGciOi...", + "refreshToken": "eyJhbGciOi...", + "tokenType": "Bearer", + "userId": 1, + "username": "admin", + "role": "ADMIN" + } +} +``` + +### `POST /api/auth/register` + +Registrar nuevo usuario. + +**Request body:** +```json +{ + "username": "usuario1", + "email": "user@example.com", + "password": "password123", + "fullName": "Usuario Ejemplo", + "phone": "600123456", + "roleName": "GERENTE" +} +``` + +### `POST /api/auth/refresh` + +Renovar token de acceso. + +**Request body:** +```json +{ + "refreshToken": "eyJhbGciOi..." +} +``` + +## Usuarios (solo ADMIN) + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/users` | Listar todos los usuarios | +| `GET` | `/api/users/{id}` | Obtener usuario por ID | +| `PUT` | `/api/users/{id}` | Actualizar usuario | +| `DELETE` | `/api/users/{id}` | Desactivar usuario (soft-delete) | + +## Propiedades + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/properties` | Listar propiedades activas | +| `GET` | `/api/properties/tree` | Obtener propiedades raíz (sin padre) | +| `GET` | `/api/properties/{id}/children` | Obtener hijos de una propiedad | +| `GET` | `/api/properties/{id}` | Obtener propiedad por ID | +| `POST` | `/api/properties` | Crear propiedad | +| `PUT` | `/api/properties/{id}` | Actualizar propiedad | +| `PATCH` | `/api/properties/{id}/status` | Cambiar estado de propiedad | +| `GET` | `/api/properties/{id}/history` | Historial de cambios de estado | +| `DELETE` | `/api/properties/{id}` | Eliminar propiedad (soft-delete) | +| `GET` | `/api/properties/types` | Listar tipos de propiedad | +| `GET` | `/api/properties/statuses` | Listar estados de propiedad | + +**Estructura de Property:** +```json +{ + "id": 1, + "parent": null, + "group": { "id": 1, "name": "Residencial Centro" }, + "type": { "id": 2, "name": "PISO", "description": "Vivienda..." }, + "status": { "id": 2, "name": "ALQUILADO", "description": "..." }, + "reference": "PIS-001", + "name": "Piso Centro", + "addressStreet": "Calle Mayor", + "addressNumber": "12", + "addressCity": "Madrid", + "addressPostalCode": "28001", + "addressProvince": "Madrid", + "cadastralRef": "1234567VK1234A", + "surfaceM2": 85.50, + "floor": "3", + "door": "A", + "rentalAmount": 850.00, + "active": true +} +``` + +## Conjuntos (Property Groups) + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/property-groups` | Listar todos los conjuntos | +| `GET` | `/api/property-groups/{id}` | Obtener conjunto por ID | +| `POST` | `/api/property-groups` | Crear conjunto | +| `PUT` | `/api/property-groups/{id}` | Actualizar conjunto | +| `DELETE` | `/api/property-groups/{id}` | Eliminar conjunto | +| `GET` | `/api/property-groups/{id}/properties` | Propiedades pertenecientes al conjunto | + +**Estructura de PropertyGroup:** +```json +{ + "id": 1, + "name": "Residencial Centro", + "addressStreet": "Calle Mayor", + "addressNumber": "10", + "addressCity": "Madrid", + "addressPostalCode": "28001", + "isActive": true +} +``` + +## Inquilinos + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/tenants?search=` | Listar/buscar inquilinos | +| `GET` | `/api/tenants/{id}` | Obtener inquilino por ID | +| `POST` | `/api/tenants` | Crear inquilino | +| `PUT` | `/api/tenants/{id}` | Actualizar inquilino | +| `DELETE` | `/api/tenants/{id}` | Eliminar inquilino (soft-delete) | + +## Contratos + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/contracts?propertyId=&tenantId=` | Listar/filtrar contratos | +| `GET` | `/api/contracts/{id}` | Obtener contrato por ID | +| `POST` | `/api/contracts` | Crear contrato (auto: propiedad → ALQUILADO) | +| `PUT` | `/api/contracts/{id}` | Actualizar contrato | +| `POST` | `/api/contracts/{id}/terminate` | Terminar contrato (auto: propiedad → VACIO) | + +## Recibos de Ingresos (Income Receipts) + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/income-receipts?propertyId=&contractId=&from=&to=&statusId=` | Listar/filtrar recibos de ingresos | +| `GET` | `/api/income-receipts/pending` | Total pendiente de cobro | +| `GET` | `/api/income-receipts/{id}` | Obtener recibo por ID | +| `POST` | `/api/income-receipts` | Crear recibo | +| `PUT` | `/api/income-receipts/{id}` | Actualizar recibo | +| `PATCH` | `/api/income-receipts/{id}/pay` | Registrar pago | +| `DELETE` | `/api/income-receipts/{id}` | Eliminar recibo | + +## Plantillas de Gastos (Expense Templates) + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/expense-templates?propertyId=&categoryId=` | Listar/filtrar plantillas | +| `GET` | `/api/expense-templates/active` | Plantillas activas | +| `GET` | `/api/expense-templates/{id}` | Obtener plantilla por ID | +| `POST` | `/api/expense-templates` | Crear plantilla | +| `PUT` | `/api/expense-templates/{id}` | Actualizar plantilla | +| `PATCH` | `/api/expense-templates/{id}/toggle` | Activar/desactivar plantilla | +| `POST` | `/api/expense-templates/{id}/generate` | Generar recibo de gasto desde plantilla | +| `DELETE` | `/api/expense-templates/{id}` | Eliminar plantilla | + +## Recibos de Gastos (Expense Receipts) + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/expense-receipts?propertyId=&templateId=&from=&to=&statusId=` | Listar/filtrar recibos de gastos | +| `GET` | `/api/expense-receipts/{id}` | Obtener recibo por ID | +| `POST` | `/api/expense-receipts` | Crear recibo manual | +| `PUT` | `/api/expense-receipts/{id}` | Actualizar recibo | +| `PATCH` | `/api/expense-receipts/{id}/pay` | Registrar pago | +| `DELETE` | `/api/expense-receipts/{id}` | Eliminar recibo | + +## Incidencias + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/incidents?propertyId=&statusId=` | Listar/filtrar incidencias | +| `GET` | `/api/incidents/{id}` | Obtener incidencia por ID | +| `POST` | `/api/incidents` | Crear incidencia | +| `PATCH` | `/api/incidents/{id}/status` | Actualizar estado | +| `PATCH` | `/api/incidents/{id}/assign` | Asignar técnico | +| `PATCH` | `/api/incidents/{id}/schedule` | Programar reparación | +| `DELETE` | `/api/incidents/{id}` | Eliminar incidencia | + +**Flujo de estados de incidencia:** +`SIN_REVISAR` → `TECNICO_AVISADO` → `REPARACION_PREVISTA` → `REPARADO` + → `IGNORADO` + → `ANULADO` + +## Mantenimiento Programado + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/maintenance?propertyId=` | Listar mantenimientos | +| `GET` | `/api/maintenance/pending` | Mantenimientos pendientes | +| `GET` | `/api/maintenance/upcoming?from=&to=` | Próximos mantenimientos | +| `GET` | `/api/maintenance/{id}` | Obtener por ID | +| `POST` | `/api/maintenance` | Crear mantenimiento | +| `PUT` | `/api/maintenance/{id}` | Actualizar | +| `PATCH` | `/api/maintenance/{id}/complete` | Marcar completado | +| `DELETE` | `/api/maintenance/{id}` | Eliminar | + +## Recibos + +| Método | Ruta | Descripción | Rol | +|--------|------|-------------|-----| +| `GET` | `/api/receipts` | Listar recibos | ADMIN, GERENTE, CONTABLE | +| `GET` | `/api/receipts/{id}` | Obtener recibo | ADMIN, GERENTE, CONTABLE | +| `POST` | `/api/receipts/generate` | Generar recibo individual | ADMIN, GERENTE, CONTABLE | +| `POST` | `/api/receipts/generate-monthly` | Generar recibos mensuales | ADMIN | +| `GET` | `/api/receipts/{id}/pdf` | Descargar PDF | ADMIN, GERENTE, CONTABLE | +| `POST` | `/api/receipts/{id}/send-email` | Enviar por email | ADMIN, GERENTE, CONTABLE | +| `GET` | `/api/receipts/reports/monthly?year=&month=` | Reporte Excel mensual | ADMIN, GERENTE, CONTABLE | + +### `POST /api/receipts/generate` + +Genera un recibo individual para un contrato específico. + +**Request body:** +```json +{ + "contractId": 1, + "issueDate": "2026-07-01", + "dueDate": "2026-07-15", + "description": "Alquiler julio 2026" +} +``` + +### `POST /api/receipts/generate-monthly` + +Genera recibos para todos los contratos activos cuyo `payment_day` coincida con el mes actual. Solo ADMIN. + +### `GET /api/receipts/{id}/pdf` + +Devuelve el PDF del recibo como `application/pdf` con header `Content-Disposition: attachment; filename="recibo-R-2026-00001.pdf"`. + +### `POST /api/receipts/{id}/send-email` + +Envía el recibo por email al inquilino con el PDF adjunto. + +### `GET /api/receipts/reports/monthly?year=2026&month=7` + +Descarga un informe Excel (`.xlsx`) con el resumen de recibos de ingresos, recibos de gastos y balance del mes. + +## Notificaciones + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/notifications?unreadOnly=true` | Listar notificaciones del usuario | +| `GET` | `/api/notifications/unread-count` | Contar no leídas | +| `PATCH` | `/api/notifications/{id}/read` | Marcar como leída | +| `PATCH` | `/api/notifications/read-all` | Marcar todas como leídas | + +## Dashboard + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `GET` | `/api/dashboard/summary` | Resumen general (contadores, YTD) | +| `GET` | `/api/dashboard/income-expense?year=2026` | Ingresos/gastos mensuales del año | + +**Respuesta de `/summary`:** +```json +{ + "success": true, + "data": { + "totalProperties": 10, + "rentedProperties": 5, + "activeContracts": 5, + "totalTenants": 8, + "openIncidents": 2, + "pendingMaintenance": 1, + "incomeYtd": 42500.00, + "expenseYtd": 12300.00, + "pendingIncome": 2850.00 + } +} +``` + +## Documentos + +| Método | Ruta | Descripción | +|--------|------|-------------| +| `POST` | `/api/documents/upload` | Subir archivo (multipart) | +| `GET` | `/api/documents/entity/{entityType}/{entityId}` | Documentos de una entidad | +| `GET` | `/api/documents/types-for-entity/{entityType}` | Tipos de documento permitidos para una entidad | +| `GET` | `/api/documents/search` | Búsqueda avanzada con filtros | +| `GET` | `/api/documents/{id}/download` | Descargar documento | +| `DELETE` | `/api/documents/{id}` | Eliminar documento | +| `GET` | `/api/documents/{id}/entities` | Ver entidades asociadas a un documento | +| `POST` | `/api/documents/{id}/entities` | Asociar documento a otra entidad | +| `DELETE` | `/api/documents/{id}/entities/{entityType}/{entityId}` | Desasociar documento de una entidad | + +**Tipos de entidad soportados:** `PROPERTY`, `CONTRACT`, `TENANT`, `INCIDENT`, `MAINTENANCE` + +**Upload params:** `file` (multipart), `entityType`, `entityId`, `documentTypeId`, `description` (opcional) + +### `GET /api/documents/types-for-entity/{entityType}` + +Devuelve los tipos de documento permitidos para una entidad, indicando cuáles son obligatorios. + +**Response (200):** +```json +{ + "success": true, + "data": [ + { + "documentTypeId": 1, + "documentTypeName": "CONTRATO", + "canUpload": true, + "mustHave": true, + "description": "Documento principal del contrato de alquiler" + }, + { + "documentTypeId": 10, + "documentTypeName": "OTRO", + "canUpload": true, + "mustHave": false, + "description": "Otros documentos del contrato" + } + ] +} +``` + +## Códigos de Error + +| Código | Significado | +|--------|-------------| +| 200 | OK | +| 201 | Creado | +| 400 | Bad Request (validación, datos incorrectos) | +| 401 | No autenticado | +| 403 | No autorizado (rol insuficiente) | +| 404 | Recurso no encontrado | +| 409 | Conflicto (duplicado) | +| 500 | Error interno del servidor | + +## Documentación Interactiva (Swagger) + +Disponible en `http://localhost:8080/swagger-ui.html` cuando el backend está corriendo. También se puede obtener el spec OpenAPI en `http://localhost:8080/api-docs`. diff --git a/docs/tecnicas/arquitectura.md b/docs/tecnicas/arquitectura.md new file mode 100644 index 0000000..6abac92 --- /dev/null +++ b/docs/tecnicas/arquitectura.md @@ -0,0 +1,155 @@ +# Arquitectura del Sistema + +## 1. Visión General + +Sa Polar es un **monolito modular** con frontend separado. El backend Spring Boot expone una API RESTful que consume un frontend React. La base de datos MySQL se inicializa mediante un script SQL ejecutado en el arranque del contenedor. + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Cliente │────▶│ Frontend │────▶│ Backend │────▶│ MySQL 8 │ +│ (Browser) │ │ (React 19) │ │ (Spring Boot)│ │ │ +│ │◀────│ (Vite 8) │◀────│ (Java 21) │◀────│ │ +└──────────────┘ └──────────────┘ └──────────────┘ + │ │ + │ /api/* │ JPA/Hibernate + ▼ ▼ + Nginx (proxy) JWT Security +``` + +## 2. Componentes + +### 2.1 Backend (Spring Boot 3.4.1) + +El backend se organiza en **paquetes verticales** por dominio de negocio: + +| Paquete | Responsabilidad | +|---------|----------------| +| `auth` | Autenticación JWT, login, registro, refresh | +| `user` | CRUD de usuarios, roles, permisos | +| `property` | Gestión de inmuebles (jerárquica), tipos, estados y conjuntos (PropertyGroup) | +| `tenant` | Gestión de inquilinos (personas físicas/jurídicas) | +| `contract` | Contratos de alquiler, estados, periodos de pago | +| `finance.income` | Recibos de ingresos (IncomeReceipt), cobros, categorías | +| `finance.expense` | Plantillas de gastos (ExpenseTemplate), recibos de gastos (ExpenseReceipt), categorías | +| `finance.receipt` | Recibos, PDF, email, reportes Excel, scheduler | +| `incident` | Incidencias, prioridades, asignación técnica | +| `maintenance` | Mantenimiento programado recurrente | +| `notification` | Notificaciones por usuario | +| `document` | Gestión de documentos adjuntos (polimórfico) | +| `dashboard` | Agregaciones y resúmenes | +| `config` | Seguridad, CORS, OpenAPI, almacenamiento | +| `common` | DTOs genéricos, excepciones, utilidades | + +### 2.2 Frontend (React 19 + TypeScript 6) + +Aplicación SPA con las siguientes capas: + +| Capa | Descripción | +|------|-------------| +| `api/client.ts` | Instancia Axios con interceptor JWT y redirección 401 | +| `api/auth.ts` | Funciones de login y refresh | +| `api/resources.ts` | Funciones CRUD para cada recurso | +| `contexts/AuthContext.tsx` | Estado global de autenticación | +| `components/Layout.tsx` | Sidebar de navegación + contenido principal | +| `pages/*.tsx` | Páginas individuales (Login, Dashboard, Properties, etc.) | +| `types/api.ts` | Interfaces TypeScript para los DTOs | + +### 2.3 Base de datos (MySQL 8) + +Esquema gestionado mediante script SQL de inicialización (`db/init.sql`). Hibernate opera en modo `validate` para verificar que el mapeo JPA coincida con el esquema existente. + +## 3. Seguridad + +### 3.1 Autenticación JWT + +1. El usuario envía credenciales a `POST /api/auth/login` +2. El servidor valida contra la base de datos y devuelve: + - `accessToken`: válido por 24 horas + - `refreshToken`: válido por 30 días +3. El frontend almacena los tokens en `localStorage` +4. Cada petición incluye `Authorization: Bearer ` +5. El `JwtAuthenticationFilter` extrae y valida el token en cada request +6. Si el token expira, el frontend usa `POST /api/auth/refresh` para obtener uno nuevo + +### 3.2 Roles y permisos + +| Rol | Acceso | +|-----|--------| +| `ADMIN` | Todos los endpoints, incluyendo gestión de usuarios | +| `GERENTE` | Propiedades, inquilinos, contratos, incidencias, mantenimiento, recibos de ingresos, plantillas de gastos, recibos de gastos, dashboard | +| `CONTABLE` | Recibos de ingresos, plantillas de gastos, recibos de gastos, dashboard, reportes | +| `VISUALIZADOR` | Autenticado (acceso básico de solo lectura según configuración) | + +### 3.3 Seguridad adicional + +- CSRF deshabilitado (API stateless) +- Sesiones sin estado (`SessionCreationPolicy.STATELESS`) +- CORS configurable mediante `app.cors.allowed-origins` +- Contraseñas almacenadas con BCrypt + +## 4. Flujo de Datos + +### 4.1 Autenticación + +``` +Browser Frontend Backend MySQL + │ │ │ │ + │ login(user, pass) │ │ │ + │──────────────────────▶│ POST /api/auth/login │ │ + │ │───────────────────────▶│ │ + │ │ │ SELECT user by email │ + │ │ │───────────────────────▶│ + │ │ │◀───────────────────────│ + │ │ │ Verificar BCrypt hash │ + │ │ │ Generar JWT tokens │ + │ │◀───────────────────────│ │ + │◀──────────────────────│ TokenResponse │ │ + │ Guardar en localStorage │ │ +``` + +### 4.2 Generación de recibos automáticos + +``` +Scheduler (cron: 0 0 6 1 * ?) + │ + ▼ +ReceiptService.generateMonthlyReceipts() + │ + ├── Buscar contratos ACTIVOS con payment_day = mes actual + ├── Para cada contrato: + │ ├── Obtener siguiente número de serie (ReceiptSeries) + │ ├── Crear registro IncomeReceipt con receipt_number + │ ├── Generar PDF (PdfReceiptService) + │ └── Enviar email si el inquilino tiene email (EmailReceiptService) + └── Log de emails enviados (email_log) +``` + +## 5. Despliegue + +### 5.1 Docker Compose + +Tres servicios orquestados: + +1. **mysql**: Imagen `mysql:8.0`, puerto `3307:3306`, volumen persistente, script init.sql +2. **backend**: Build multi-etapa (Maven + JRE), puerto `8080:8080` +3. **frontend**: Build multi-etapa (Node + Nginx), puerto `3000:3000`, proxy reverso `/api/` al backend + +### 5.2 Entornos + +| Entorno | Backend URL | Frontend URL | Propósito | +|---------|-------------|--------------|-----------| +| Desarrollo | `localhost:8080` | `localhost:5173` (Vite) | Desarrollo local | +| Producción | `localhost:8080` | `localhost:3000` (Nginx) | Docker compose | + +## 6. Dependencias Externas + +| Dependencia | Versión | Uso | +|-------------|---------|-----| +| Spring Boot | 3.4.1 | Framework principal | +| JJWT | 0.12.6 | Tokens JWT | +| SpringDoc OpenAPI | 2.7.0 | Documentación Swagger | +| iText | 8.0.5 | Generación de PDFs | +| Apache POI | 5.3.0 | Generación de Excel | +| MapStruct | 1.6.3 | Mapeo de DTOs (si se usa) | +| Lombok | 1.18.36 | Reducción de boilerplate | +| Flyway | - | Dependencia incluida pero deshabilitada | diff --git a/docs/tecnicas/base-de-datos.md b/docs/tecnicas/base-de-datos.md new file mode 100644 index 0000000..12fb8db --- /dev/null +++ b/docs/tecnicas/base-de-datos.md @@ -0,0 +1,569 @@ +# Esquema de Base de Datos + +## Visión General + +Base de datos MySQL 8 con 24 tablas. El esquema se inicializa mediante `db/init.sql` en el arranque del contenedor MySQL. Hibernate opera en modo `validate` para verificar que el mapeo JPA coincida con el esquema. + +## Diagrama de Tablas + +``` +property_groups ──── properties ──── property_types + │ │ property_statuses + │ └── property_status_history + │ +roles ──── users ──── user_permissions + │ + │ + ├── tenants ──── tenant_types + │ + ├── contracts ──── contract_statuses + │ │ payment_periods + │ │ + │ └── income_receipts ──── income_categories + │ │ income_statuses + │ │ + │ └── email_log + │ + ├── expense_templates ──── expense_categories + │ expense_statuses + │ payment_periods + │ + ├── expense_receipts ──── expense_categories + │ expense_statuses + │ + ├── incidents ──── incident_statuses + │ incident_priorities + │ + ├── scheduled_maintenance ──── maintenance_periods + │ + └── notifications ──── notification_types + +documents ──── document_types ──── document_type_entity_allowed + +receipt_series +``` + +## Tablas Detalladas + +### 1. roles + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | Nombre del rol | +| description | VARCHAR(255) | Descripción | + +**Datos semilla:** ADMIN, GERENTE, CONTABLE, VISUALIZADOR + +### 2. users + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| username | VARCHAR(50) UNIQUE | Nombre de usuario | +| email | VARCHAR(100) UNIQUE | Correo electrónico | +| password_hash | VARCHAR(255) | Hash BCrypt | +| full_name | VARCHAR(150) | Nombre completo | +| phone | VARCHAR(20) | Teléfono | +| role_id | INT FK → roles(id) | Rol del usuario | +| active | BOOLEAN | Usuario activo (soporta soft-delete) | +| created_at | DATETIME | Fecha de creación | +| updated_at | DATETIME | Fecha de modificación | +| last_login | DATETIME | Último inicio de sesión | + +### 3. user_permissions + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| user_id | BIGINT FK → users(id) CASCADE | Usuario | +| permission | VARCHAR(50) | Permiso específico | +| granted | BOOLEAN | Concedido/denegado | + +**Unique:** (user_id, permission) + +### 4. property_types + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(50) UNIQUE | Tipo (EDIFICIO, PISO, etc.) | +| description | VARCHAR(255) | Descripción | + +### 5. property_statuses + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(50) UNIQUE | Estado (DISPONIBLE, ALQUILADO, etc.) | +| description | VARCHAR(255) | Descripción | + +### 6. properties + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| parent_id | BIGINT FK → properties(id) SET NULL | Propiedad padre (jerarquía) | +| group_id | BIGINT FK → property_groups(id) SET NULL | Conjunto al que pertenece | +| type_id | INT FK → property_types(id) | Tipo de propiedad | +| status_id | INT FK → property_statuses(id) | Estado actual | +| reference | VARCHAR(50) UNIQUE | Referencia interna | +| name | VARCHAR(200) | Nombre identificativo | +| description | TEXT | Descripción | +| address_street | VARCHAR(200) | Calle | +| address_number | VARCHAR(20) | Número | +| address_city | VARCHAR(100) | Ciudad | +| address_postal_code | VARCHAR(10) | Código postal | +| address_province | VARCHAR(100) | Provincia | +| cadastral_ref | VARCHAR(30) | Referencia catastral | +| surface_m2 | DECIMAL(10,2) | Superficie en m² | +| floor | VARCHAR(50) | Planta / Piso | +| door | VARCHAR(50) | Puerta | +| rental_amount | DECIMAL(12,2) | Importe de alquiler | +| rented_since | DATE | Alquilado desde | +| vacant_since | DATE | Vacío desde | +| occupied_since | DATE | Ocupado desde | +| notes | TEXT | Notas | +| active | BOOLEAN | Activo (soft-delete) | +| created_by | BIGINT FK → users(id) SET NULL | Creado por | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +**Índices:** parent, type, status, active + +### 7. property_status_history + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| property_id | BIGINT FK → properties(id) CASCADE | Propiedad | +| status_id | INT FK → property_statuses(id) | Nuevo estado | +| changed_by | BIGINT FK → users(id) SET NULL | Quién cambió | +| changed_at | DATETIME | Cuándo | +| notes | VARCHAR(500) | Motivo del cambio | + +### 8. property_groups + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| name | VARCHAR(200) | Nombre del conjunto | +| address_street | VARCHAR(200) | Calle | +| address_number | VARCHAR(20) | Número | +| address_city | VARCHAR(100) | Ciudad | +| address_postal_code | VARCHAR(10) | Código postal | +| is_active | BOOLEAN | Activo | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +**Relaciones:** Una propiedad puede pertenecer opcionalmente a un conjunto. Al eliminar un conjunto, las propiedades asociadas quedan con `group_id = NULL` (`ON DELETE SET NULL`). + +### 9. tenant_types + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | PERSONA_FISICA / PERSONA_JURIDICA | + +### 10. tenants + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| tenant_type_id | INT FK → tenant_types(id) | Tipo de inquilino | +| fiscal_id | VARCHAR(20) UNIQUE | DNI/NIF/CIF | +| full_name | VARCHAR(200) | Nombre o razón social | +| business_name | VARCHAR(200) | Solo personas jurídicas | +| email | VARCHAR(100) | Correo electrónico | +| phone | VARCHAR(20) | Teléfono | +| address | VARCHAR(300) | Dirección | +| iban | VARCHAR(34) | IBAN para domiciliación | +| notes | TEXT | Notas | +| active | BOOLEAN | Activo (soft-delete) | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 11. contract_statuses + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | ACTIVO, VENCIDO, RENOVADO, RESCINDIDO, ANULADO | + +### 12. payment_periods + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | MENSUAL, TRIMESTRAL, SEMESTRAL, ANUAL | + +### 13. contracts + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| property_id | BIGINT FK → properties(id) | Propiedad | +| tenant_id | BIGINT FK → tenants(id) | Inquilino | +| status_id | INT FK → contract_statuses(id) | Estado | +| period_id | INT FK → payment_periods(id) DEFAULT 1 | Período de pago | +| contract_number | VARCHAR(50) UNIQUE | Número de contrato | +| start_date | DATE | Fecha de inicio | +| end_date | DATE | Fecha de fin | +| renewal_date | DATE | Fecha de renovación | +| rental_amount | DECIMAL(12,2) | Renta mensual | +| deposit_amount | DECIMAL(12,2) | Fianza | +| payment_day | INT DEFAULT 1 | Día de pago | +| payment_day_end | INT DEFAULT NULL | Día final del rango de pago (NULL = día único) | +| iban_charge | VARCHAR(34) | IBAN domiciliación | +| notes | TEXT | Notas | +| signed_at | DATE | Fecha de firma | +| terminated_at | DATE | Fecha de terminación | +| termination_cause | VARCHAR(500) | Causa de terminación | +| created_by | BIGINT FK → users(id) SET NULL | Creado por | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 14. document_types + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(50) UNIQUE | Tipo de documento | + +**Tipos:** CONTRATO, ANEXO_CONTRATO, DNI_ARREENDATARIO, CIF_EMPRESA, FOTO_PROPIEDAD, FOTO_INCIDENCIA, FACTURA, JUSTIFICANTE_PAGO, CERTIFICADO, OTRO + +### 16. documents + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| document_type_id | INT FK → document_types(id) | Tipo | +| original_name | VARCHAR(255) | Nombre original del archivo | +| stored_name | VARCHAR(255) | Nombre almacenado en disco (UUID + extensión) | +| mime_type | VARCHAR(100) | Tipo MIME | +| file_size | BIGINT | Tamaño en bytes | +| description | VARCHAR(500) | Descripción | +| uploaded_by | BIGINT FK → users(id) SET NULL | Subido por | +| uploaded_at | DATETIME | Fecha de subida | + +**Relaciones:** Relación One-to-Many con `document_entities`. + +### 17. document_entities (tabla pivote) + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| document_id | BIGINT FK → documents(id) CASCADE | Documento | +| entity_type | VARCHAR(30) | Tipo de entidad asociada | +| entity_id | BIGINT | ID de la entidad | +| created_at | DATETIME | Fecha de creación | + +**Entidades soportadas:** PROPERTY, TENANT, CONTRACT, INCOME, EXPENSE, INCIDENT, MAINTENANCE + +**Caso de uso:** Permite asociar un mismo documento a múltiples entidades. Por ejemplo, una fianza puede estar asociada tanto a un INCOME (cuando se recibe) como a un EXPENSE (cuando se devuelve). + +**Índices:** UNIQUE(document_id, entity_type, entity_id), INDEX(entity_type, entity_id) + +### 17. document_type_entity_allowed (tabla pivote) + +Define qué tipos de documento pueden asociarse a qué tipos de entidades del sistema. + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| document_type_id | INT FK → document_types(id) | Tipo de documento | +| entity_type | VARCHAR(30) | Tipo de entidad (PROPERTY, TENANT, CONTRACT, INCOME, EXPENSE, INCIDENT, MAINTENANCE) | +| can_upload | BOOLEAN | Si el usuario puede subir este tipo en esta entidad | +| must_have | BOOLEAN | Si es obligatorio al crear/editar la entidad | +| description | VARCHAR(255) | Descripción del uso del documento | +| created_at | DATETIME | Fecha de creación | + +**Relaciones permitidas:** + +| Tipo Documento | Entidad | Obligatorio | Descripción | +|----------------|---------|-------------|-------------| +| CONTRATO | CONTRACT | Sí | Documento principal del contrato | +| CONTRATO | TENANT | No | Copia firmada por inquilino | +| CONTRATO | PROPERTY | No | Contrato asociado al inmueble | +| ANEXO_CONTRATO | CONTRACT | No | Anexos y modificaciones | +| DNI_ARRENDATARIO | TENANT | Sí | DNI del inquilino | +| CIF_EMPRESA | TENANT | No | Para personas jurídicas | +| FOTO_PROPIEDAD | PROPERTY | Sí | Fotos del inmueble | +| FOTO_INCIDENCIA | INCIDENT | No | Fotos de la incidencia | +| FACTURA | EXPENSE | Sí | Factura o justificante del gasto | +| JUSTIFICANTE_PAGO | INCOME | Sí | Justificante de pago | +| CERTIFICADO | TENANT | No | Certificados varios | +| CERTIFICADO | CONTRACT | No | Certificados asociados | +| OTRO | Todas | No | Otros documentos | + +**Índices:** UNIQUE(document_type_id, entity_type), INDEX(entity_type) + +### 18. income_categories + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| name | VARCHAR(100) | Nombre | +| description | VARCHAR(255) | Descripción | +| active | BOOLEAN | Activo | + +**Categorías:** ALQUILER, FIANZA, GASTOS_COMUNIDAD, INTERESES_DEMORA, INDEMNIZACION, OTROS_INGRESOS + +### 17. income_statuses + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | PENDIENTE, PAGADO, VENCIDO, PARCIAL, ANULADO | + +### 18. income_receipts + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| contract_id | BIGINT FK → contracts(id) SET NULL | Contrato asociado | +| property_id | BIGINT FK → properties(id) | Propiedad (NOT NULL) | +| tenant_id | BIGINT FK → tenants(id) SET NULL | Inquilino | +| bank_account_id | BIGINT FK → bank_accounts(id) SET NULL | Cuenta bancaria | +| is_domiciled | BOOLEAN DEFAULT FALSE | Domiciliado | +| category_id | BIGINT FK → income_categories(id) SET NULL | Categoría | +| status_id | INT FK → income_statuses(id) | Estado | +| period_label | VARCHAR(20) | Etiqueta de período (ej: "2026-07") | +| amount | DECIMAL(12,2) | Importe bruto | +| tax_withheld | DECIMAL(12,2) DEFAULT 0 | Retención IRPF | +| net_amount | DECIMAL(12,2) | Importe neto | +| issue_date | DATE | Fecha de emisión | +| due_date | DATE | Fecha de vencimiento | +| payment_date | DATE | Fecha de pago | +| payment_method | VARCHAR(30) | TRANSFERENCIA / EFECTIVO / BIZUM / RECIBO / TARJETA | +| description | VARCHAR(500) | Descripción | +| receipt_number | VARCHAR(50) | Número de recibo | +| notes | TEXT | Notas | +| created_by | BIGINT FK → users(id) SET NULL | Creado por | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 19. expense_categories + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| name | VARCHAR(100) | Nombre | +| description | VARCHAR(255) | Descripción | +| active | BOOLEAN | Activo | + +### 20. expense_statuses + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | PENDIENTE, PAGADO, VENCIDO, ANULADO | + +### 21. expense_templates + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| property_id | BIGINT FK → properties(id) NULL | Propiedad asociada | +| property_group_id | BIGINT FK → property_groups(id) NULL | Conjunto asociado | +| bank_account_id | BIGINT FK → bank_accounts(id) NULL | Cuenta bancaria | +| is_domiciled | BOOLEAN DEFAULT FALSE | Domiciliado | +| category_id | BIGINT FK → expense_categories(id) SET NULL | Categoría | +| period_id | INT FK → payment_periods(id) | Periodicidad | +| payment_day | INT DEFAULT 1 | Día de pago | +| supplier_name | VARCHAR(200) | Proveedor | +| supplier_fiscal_id | VARCHAR(20) | NIF/CIF | +| amount | DECIMAL(12,2) NULL | Importe fijo (NULL = variable) | +| tax_amount | DECIMAL(12,2) | Importe impuestos | +| description | VARCHAR(500) | Concepto | +| notes | TEXT | Notas | +| is_variable | BOOLEAN DEFAULT FALSE | Importe variable (usuario rellena cada mes) | +| active | BOOLEAN DEFAULT TRUE | Template activo/inactivo | +| created_by | BIGINT FK → users(id) SET NULL | Creador | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 22. expense_receipts + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| template_id | BIGINT FK → expense_templates(id) SET NULL | Template origen (NULL = gasto manual) | +| property_id | BIGINT FK → properties(id) NULL | Propiedad asociada | +| property_group_id | BIGINT FK → property_groups(id) NULL | Conjunto asociado | +| bank_account_id | BIGINT FK → bank_accounts(id) NULL | Cuenta bancaria | +| is_domiciled | BOOLEAN DEFAULT FALSE | Domiciliado | +| category_id | BIGINT FK → expense_categories(id) SET NULL | Categoría | +| status_id | INT FK → expense_statuses(id) | Estado | +| supplier_name | VARCHAR(200) | Proveedor | +| supplier_fiscal_id | VARCHAR(20) | NIF/CIF | +| invoice_number | VARCHAR(50) | Número de factura | +| amount | DECIMAL(12,2) DEFAULT 0 | Base imponible | +| tax_amount | DECIMAL(12,2) DEFAULT 0 | Importe impuestos | +| total_amount | DECIMAL(12,2) | Total con impuestos | +| is_variable | BOOLEAN DEFAULT FALSE | Es gasto variable | +| previous_amount | DECIMAL(12,2) NULL | Importe del periodo anterior (para variables) | +| issue_date | DATE | Fecha de emisión | +| due_date | DATE | Fecha de vencimiento | +| payment_date | DATE | Fecha de pago | +| payment_method | VARCHAR(30) | Método de pago | +| description | VARCHAR(500) | Concepto | +| notes | TEXT | Notas | +| created_by | BIGINT FK → users(id) SET NULL | Creador | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +**Índices:** template, property, property_group, category, status, issue_date + +### 23. incident_statuses + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(50) UNIQUE | SIN_REVISAR, TECNICO_AVISADO, REPARACION_PREVISTA, REPARADO, IGNORADO, ANULADO | + +### 24. incident_priorities + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(20) UNIQUE | BAJA, MEDIA, ALTA, URGENTE | + +### 25. incidents + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| property_id | BIGINT FK → properties(id) | Propiedad | +| status_id | INT FK → incident_statuses(id) | Estado | +| priority_id | INT FK → incident_priorities(id) | Prioridad | +| title | VARCHAR(200) | Título | +| description | TEXT | Descripción | +| reported_by | BIGINT FK → users(id) SET NULL | Reportado por | +| assigned_to | BIGINT FK → users(id) SET NULL | Asignado a | +| reported_at | DATETIME | Fecha de reporte | +| scheduled_date | DATE | Fecha prevista reparación | +| resolved_at | DATETIME | Fecha de resolución | +| resolution_notes | TEXT | Notas de resolución | +| cost_estimate | DECIMAL(12,2) | Coste estimado | +| final_cost | DECIMAL(12,2) | Coste final | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 26. maintenance_periods + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(30) UNIQUE | UNICA_VEZ, MENSUAL, TRIMESTRAL, etc. | + +### 27. scheduled_maintenance + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| property_id | BIGINT FK → properties(id) | Propiedad | +| period_id | INT FK → maintenance_periods(id) | Periodicidad | +| title | VARCHAR(200) | Título | +| description | TEXT | Descripción | +| estimated_cost | DECIMAL(12,2) | Coste estimado | +| last_execution | DATE | Última ejecución | +| next_execution | DATE | Próxima ejecución | +| reminder_days_before | INT DEFAULT 30 | Días antes para recordatorio | +| responsible | VARCHAR(200) | Responsable | +| notes | TEXT | Notas | +| completed | BOOLEAN | Completado | +| completed_at | DATE | Fecha de finalización | +| completed_by | BIGINT FK → users(id) SET NULL | Completado por | +| created_by | BIGINT FK → users(id) SET NULL | Creado por | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +### 28. notification_types + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | INT PK | Auto-increment | +| name | VARCHAR(50) UNIQUE | INCIDENCIA_ABIERTA, MANTENIMIENTO_PROXIMO, RECIBO_VENCIDO, CONTRATO_PROXIMO_VENCER, CONTRATO_VENCIDO, SISTEMA | + +### 29. notifications + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| user_id | BIGINT FK → users(id) CASCADE | Usuario destinatario | +| type_id | INT FK → notification_types(id) | Tipo | +| title | VARCHAR(200) | Título | +| body | TEXT | Cuerpo | +| entity_type | VARCHAR(30) | Tipo de entidad relacionada | +| entity_id | BIGINT | ID de entidad | +| sent_by_email | BOOLEAN | Enviado por email | +| read | BOOLEAN | Leído | +| read_at | DATETIME | Fecha de lectura | +| created_at | DATETIME | Creación | + +### 30. receipt_series + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| series_name | VARCHAR(50) | Nombre de la serie | +| fiscal_year | INT | Año fiscal | +| last_number | INT DEFAULT 0 | Último número usado | +| prefix | VARCHAR(20) DEFAULT 'R-' | Prefijo del número | +| active | BOOLEAN | Serie activa | +| created_at | DATETIME | Creación | +| updated_at | DATETIME | Modificación | + +**Unique:** (series_name, fiscal_year) + +### 31. email_log + +| Columna | Tipo | Descripción | +|---------|------|-------------| +| id | BIGINT PK | Auto-increment | +| income_receipt_id | BIGINT | ID del recibo de ingreso asociado | +| recipient_email | VARCHAR(200) | Email del destinatario | +| subject | VARCHAR(300) | Asunto | +| body | TEXT | Cuerpo del mensaje | +| success | BOOLEAN | Envío exitoso | +| error_message | TEXT | Mensaje de error si falló | +| sent_at | DATETIME | Fecha de envío | + +## Notas Técnicas + +- **Soft-delete:** Las tablas `users`, `properties`, `tenants` tienen columna `active` para borrado lógico. +- **Índices:** Las columnas más consultadas tienen índices (foreign keys, fechas, estados). +- **Charset:** `utf8mb4` con collation `utf8mb4_unicode_ci` para soporte completo de Unicode. +- **Motor:** Todas las tablas usan InnoDB para integridad referencial y transacciones. +- **Integridad referencial:** 32 constraints FK en total (incluyendo V5 para document_type_entity_allowed). +- **Al borrar el volumen de datos**, el script `init.sql` se ejecuta automáticamente al arrancar el contenedor MySQL. +- **Hibernate:** Opera con `ddl-auto: validate`. Cualquier cambio en las entidades JPA requiere actualizar `init.sql`. + +## Codificación de caracteres (UTF-8) + +Para evitar problemas con acentos y caracteres especiales (tildes, eñes, etc.) en todo el sistema: + +- **Base de datos:** charset `utf8mb4` y collation `utf8mb4_unicode_ci`. +- **Conexión JDBC (`application.yml`):** la URL incluye `characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci` para forzar la lectura/escritura en UTF-8. +- **Respuestas HTTP (`application.yml`):** `server.servlet.encoding.charset=UTF-8` y `force=true` para que el header `Content-Type` siempre incluya `charset=UTF-8`. +- **Frontend (nginx):** directiva `charset utf-8;` en el bloque `server` del `Dockerfile.frontend`. +- **Datos de prueba (`seed.sql`):** el script incluye `SET NAMES utf8mb4;` al inicio para evitar corrupciones al insertar desde clientes con charset por defecto (latin1). + +Si se ejecuta `seed.sql` manualmente desde un cliente MySQL, usar siempre `--default-character-set=utf8mb4` o ejecutar primero `SET NAMES utf8mb4;`. + +## Datos de prueba (seed) + +El archivo `backend/src/main/resources/db/seed.sql` contiene datos de demostración (usuarios adicionales, propiedades, inquilinos, contratos, recibos de ingresos, plantillas de gastos, recibos de gastos, incidencias, etc.). + +**Para poblar la base de datos tras un `docker compose down -v`:** + +```bash +# Copiar el script al contenedor +docker cp backend/src/main/resources/db/seed.sql sa-polar-mysql:/tmp/seed.sql + +# Ejecutarlo forzando UTF-8 +docker exec sa-polar-mysql bash -c "mysql -u root -proot --default-character-set=utf8mb4 sa_polar < /tmp/seed.sql" +``` + +> Las contraseñas de los usuarios de prueba (`admin`, `gerente`, `contable`) son todas `admin123` y solo válidas para desarrollo local. diff --git a/docs/usuario/manual.md b/docs/usuario/manual.md new file mode 100644 index 0000000..2caa2a0 --- /dev/null +++ b/docs/usuario/manual.md @@ -0,0 +1,382 @@ +# Manual de Usuario - Sa Polar + +## 1. Introducción + +**Sa Polar** es un sistema de gestión de alquileres que permite administrar propiedades inmobiliarias, contratos de arrendamiento, inquilinos, ingresos, gastos, incidencias y mantenimiento programado. Está diseñado para propietarios, administradores de fincas y gestores de alquileres. + +## 2. Acceso al Sistema + +### 2.1 Inicio de sesión + +1. Abrir el navegador y acceder a la URL del sistema: + - **Entorno Docker:** `http://localhost:3000` + - **Entorno desarrollo:** `http://localhost:5173` +2. Introducir credenciales: + - **Usuario:** `admin` + - **Contraseña:** `admin123` +3. Hacer clic en **Iniciar Sesión** + +![Pantalla de login](images/login.png) + +### 2.2 Roles de usuario + +| Rol | Descripción | +|-----|-------------| +| **ADMIN** | Acceso completo a todas las funcionalidades del sistema | +| **GERENTE** | Gestión de propiedades, contratos, inquilinos, incidencias, mantenimiento, recibos y finanzas | +| **CONTABLE** | Gestión de ingresos, gastos, recibos y reportes | +| **VISUALIZADOR** | Acceso de solo lectura a la información | + +## 3. Navegación + +Una vez dentro del sistema, aparece un menú lateral con las siguientes secciones: + +| Sección | Icono | Descripción | +|---------|-------|-------------| +| **Dashboard** | 📊 | Resumen general del sistema | +| **Propiedades** | 🏠 | Gestión de inmuebles | +| **Conjuntos** | 🏢 | Agrupación de propiedades (edificios, urbanizaciones) | +| **Inquilinos** | 👤 | Gestión de arrendatarios | +| **Contratos** | 📝 | Contratos de alquiler | +| **Recibos de Ingreso** | 💰 | Cobros y recibos (antes "Ingresos") | +| **Gastos** | 💸 | Pagos y facturas | +| **Incidencias** | 🔧 | Averías y reparaciones | + +## 4. Dashboard + +La pantalla principal muestra un resumen con: + +- **Total de propiedades** registradas +- **Inquilinos** activos +- **Contratos activos** actualmente vigentes +- **Ingresos pendientes** de cobro + +## 5. Gestión de Propiedades + +### 5.1 Listado de propiedades + +Muestra una tabla con todas las propiedades: ID, nombre, conjunto, ciudad, tipo, estado e importe de alquiler. + +### 5.2 Jerarquía de propiedades + +Las propiedades pueden organizarse jerárquicamente (ej: un edificio contiene varios pisos). La propiedad "padre" se selecciona al crear una nueva propiedad. + +### 5.3 Tipos de propiedad + +| Tipo | Descripción | +|------|-------------| +| EDIFICIO | Edificio completo con varias plantas | +| PISO | Vivienda en un edificio de pisos | +| LOCAL_COMERCIAL | Local comercial | +| BAR | Bar o restaurante | +| NAVE | Nave industrial o almacén | +| GARAJE | Plaza de garaje | +| TRASTERO | Trastero | +| OFICINA | Oficina o despacho | +| ADOSADO | Vivienda unifamiliar adosada | +| CHALET | Vivienda unifamiliar independiente | + +### 5.4 Estados de propiedad + +| Estado | Descripción | +|--------|-------------| +| DISPONIBLE | Disponible para alquilar | +| ALQUILADO | Actualmente alquilado | +| VACIO | Vacío, sin inquilino | +| ANUNCIADO | Anunciado para alquiler | +| MANTENIMIENTO | En obras o mantenimiento | + +Los estados se actualizan automáticamente al crear o terminar contratos. + +### 5.5 Historial de estados + +Cada cambio de estado de una propiedad queda registrado con fecha, usuario y motivo. + +### 5.6 Conjuntos (Agrupaciones) + +Los conjuntos permiten agrupar propiedades que comparten una misma ubicación o promoción (edificios, urbanizaciones, residenciales). + +#### 5.6.1 Listado de conjuntos + +Muestra todos los conjuntos con ID, nombre y dirección. + +#### 5.6.2 Crear un conjunto + +1. Ir a **Conjuntos** en el menú lateral +2. Hacer clic en **+ Nuevo Conjunto** +3. Introducir nombre y dirección +4. Hacer clic en **Crear** + +#### 5.6.3 Asignar propiedades a un conjunto + +Al editar una propiedad, el campo **Conjunto** permite seleccionar el grupo al que pertenece. Una propiedad puede pertenecer a un solo conjunto o a ninguno. + +#### 5.6.4 Detalle del conjunto + +Al hacer clic en "Ver" sobre un conjunto, se muestran sus datos junto con una tabla de las propiedades que pertenecen a ese conjunto. + +## 6. Gestión de Inquilinos + +### 6.1 Listado de inquilinos + +Muestra todos los inquilinos con nombre, NIF/CIF, email, teléfono y tipo (persona física o jurídica). + +### 6.2 Tipos de inquilino + +- **PERSONA_FISICA:** Arrendatario individual (DNI) +- **PERSONA_JURIDICA:** Empresa o entidad (CIF) + +### 6.3 Datos del inquilino + +- Nombre completo (o razón social) +- NIF/CIF +- Dirección +- Teléfono y email +- IBAN para domiciliación de pagos + +## 7. Gestión de Contratos + +### 7.1 Listado de contratos + +Muestra: número de contrato, propiedad, inquilino, importe, fechas de inicio/fin y estado. + +### 7.2 Estados de contrato + +| Estado | Descripción | +|--------|-------------| +| ACTIVO | Contrato vigente | +| VENCIDO | Fecha de fin superada | +| RENOVADO | Renovado a un nuevo contrato | +| RESCINDIDO | Cancelado antes del fin | +| ANULADO | Anulado sin efecto | + +### 7.3 Creación de contrato + +Al crear un contrato: +1. Seleccionar propiedad (debe estar en estado DISPONIBLE) +2. Seleccionar inquilino +3. Introducir importe de renta, fecha de inicio, período de pago +4. Opcional: fianza, fecha de fin, día de pago, IBAN domiciliación +5. **Automáticamente:** la propiedad pasa a estado ALQUILADO + +### 7.4 Terminación de contrato + +Al terminar un contrato: +1. Seleccionar causa de terminación +2. **Automáticamente:** la propiedad pasa a estado VACIO +3. El contrato se marca como RESCINDIDO + +## 8. Gestión de Ingresos + +### 8.1 Listado de ingresos + +Muestra: número de recibo, propiedad, inquilino, importe bruto/neto, fechas de emisión/vencimiento/pago, estado. + +### 8.2 Estados de ingreso + +| Estado | Descripción | +|--------|-------------| +| PENDIENTE | Emitido pero no cobrado | +| PAGADO | Cobrado | +| VENCIDO | Fecha de vencimiento superada sin cobro | +| PARCIAL | Cobro parcial | +| ANULADO | Anulado | + +### 8.3 Registrar pago + +1. Localizar el ingreso en el listado +2. Hacer clic en "Registrar pago" +3. El sistema actualiza automáticamente: + - Estado a PAGADO + - Fecha de pago + - Importe neto (después de retención IRPF) + +### 8.4 Categorías de ingreso + +| Categoría | Descripción | +|-----------|-------------| +| ALQUILER | Pago de renta mensual o periódica | +| FIANZA | Depósito de garantía | +| GASTOS_COMUNIDAD | Repercusión de gastos de comunidad | +| INTERESES_DEMORA | Intereses por pago fuera de plazo | +| INDEMNIZACION | Indemnización por daños o rescisión | +| OTROS_INGRESOS | Otros ingresos no clasificados | + +## 9. Gestión de Gastos + +### 9.1 Listado de gastos + +Muestra: proveedor, factura, concepto, importe, fechas de emisión/pago y estado. + +### 9.2 Categorías de gasto + +| Categoría | Descripción | +|-----------|-------------| +| REPARACION | Reparaciones y arreglos | +| MANTENIMIENTO | Mantenimiento preventivo | +| COMUNIDAD | Gastos de comunidad de propietarios | +| IBI | Impuesto de Bienes Inmuebles | +| BASURA | Tasa de basura | +| SUMINISTROS_LUZ | Electricidad y suministro eléctrico | +| SUMINISTROS_GAS | Gas natural, butano, propano | +| SUMINISTROS_OTROS | Agua, internet, telefonía y otros suministros | +| SEGURO | Seguro del inmueble | +| REFORMA | Obras de reforma o mejora | +| GESTION | Gastos de gestión inmobiliaria | +| NOTARIA_REGISTRO | Gastos notariales y de registro | +| PUBLICIDAD | Anuncios y marketing | +| OTROS_GASTOS | Otros gastos no clasificados | + +### 9.3 Gastos planificados + +Se pueden crear gastos con fecha planificada futura para prever pagos periódicos (ej: IBI anual, seguro). + +## 10. Gestión de Incidencias + +### 10.1 Listado de incidencias + +Muestra: título, propiedad, prioridad, técnico asignado, fecha y estado. + +### 10.2 Estados de incidencia + +``` +SIN_REVISAR → TECNICO_AVISADO → REPARACION_PREVISTA → REPARADO + → IGNORADO + → ANULADO +``` + +### 10.3 Prioridades + +| Prioridad | Descripción | +|-----------|-------------| +| BAJA | Puede esperar | +| MEDIA | Atención normal | +| ALTA | Urgencia relativa | +| URGENTE | Atención inmediata | + +### 10.4 Flujo de trabajo + +1. **Crear incidencia:** Seleccionar propiedad, título, descripción y prioridad +2. **Asignar técnico:** El gestor asigna un técnico responsable +3. **Programar reparación:** Establecer fecha prevista de intervención +4. **Resolver:** Marcar como reparado, ignorado o anulado, con notas de resolución + +## 11. Recibos Automáticos + +### 11.1 Generación de recibos + +El sistema puede generar recibos de dos formas: + +- **Individual:** Generar un recibo para un contrato específico indicando fecha de emisión y vencimiento. +- **Mensual (automático):** El día 1 de cada mes a las 06:00, el sistema genera recibos para todos los contratos activos. + +### 11.2 Numeración + +Cada recibo recibe un número único secuencial por año fiscal con formato: `R-2026-00001`, `R-2026-00002`, etc. + +### 11.3 PDF + +Cada recibo puede descargarse en formato PDF con: +- Datos del arrendador +- Datos del inquilino +- Datos de la propiedad +- Importe, retención IRPF e importe neto +- Número de recibo y fechas + +### 11.4 Envío por email + +Los recibos pueden enviarse por email al inquilino con el PDF adjunto. El sistema registra un log de cada envío (destinatario, fecha, éxito/error). + +### 11.5 Vencimiento automático + +El sistema revisa diariamente los ingresos pendientes cuya fecha de vencimiento ha pasado y los marca como VENCIDOS automáticamente. + +## 12. Reportes + +### 12.1 Informe mensual Excel + +Se puede descargar un informe mensual en formato Excel (.xlsx) que incluye: + +- **Ingresos del mes:** listado de cobros +- **Gastos del mes:** listado de pagos +- **Balance:** ingresos - gastos = resultado del mes + +### 12.2 Dashboard + +El dashboard muestra resúmenes visuales con datos agregados del año en curso. + +## 13. Gestión de Documentos + +El sistema permite adjuntar documentos a cualquier entidad: + +- **Propiedades:** fotos, planos, certificados +- **Contratos:** contratos firmados, anexos +- **Inquilinos:** DNI, CIF +- **Incidencias:** fotos de la avería +- **Ingresos:** justificantes de pago +- **Gastos:** facturas escaneadas + +**Formatos soportados:** PDF, imágenes (JPG, PNG), documentos (DOC, DOCX, XLS, XLSX) + +## 14. Notificaciones + +El sistema genera notificaciones automáticas para: + +- Incidencias abiertas y asignadas +- Mantenimiento programado próximo a vencer +- Recibos vencidos +- Contratos próximos a vencer +- Contratos vencidos + +Las notificaciones pueden marcarse como leídas individualmente o todas a la vez. + +## 15. Consejos y Buenas Prácticas + +### 15.1 Organización de propiedades + +- Usar la jerarquía para agrupar: Edificio (padre) → Pisos (hijos) +- Asignar referencias únicas y descriptivas +- Mantener actualizado el estado de cada propiedad + +### 15.2 Gestión de contratos + +- Registrar siempre la fecha de fin, aunque sea estimada +- Usar el IBAN de domiciliación para facilitar cobros recurrentes +- Revisar contratos próximos a vencer con antelación + +### 15.3 Control financiero + +- Registrar los gastos a medida que se generan, no solo cuando se pagan +- Usar la funcionalidad de gastos planificados para prever pagos periódicos +- Generar recibos mensualmente para mantener la tesorería controlada +- Descargar informes mensuales para llevar un control contable externo + +### 15.4 Incidencias + +- Incluir fotos y descripciones detalladas al crear incidencias +- Establecer prioridades realistas +- Registrar el coste final de cada reparación para control presupuestario + +## 16. Preguntas Frecuentes + +**¿Puedo recuperar una propiedad eliminada?** +No, la eliminación es lógica (soft-delete). Un administrador puede reactivarla desde la base de datos. + +**¿Cómo se calcula el importe neto de un ingreso?** +`net_amount = amount - tax_withheld`. La retención de IRPF se aplica sobre el importe bruto. + +**¿Puedo modificar un recibo ya generado?** +Sí, modificando el ingreso correspondiente desde la sección Ingresos. + +**¿Qué ocurre si falla el envío de un email?** +El sistema registra el error en el log de emails. Puede reintentarse manualmente. + +**¿Los recibos se generan automáticamente todos los meses?** +Sí, el día 1 de cada mes a las 06:00. Un administrador también puede generar todos los recibos manualmente desde la API. + +**¿Puedo tener varias series de numeración de recibos?** +Sí, la tabla `receipt_series` permite múltiples series. Por defecto se crea una serie "RECIBOS" para el año actual. + +## 17. Soporte + +Para incidencias técnicas o consultas, contactar con el administrador del sistema. diff --git a/export/README.md b/export/README.md new file mode 100644 index 0000000..2980e56 --- /dev/null +++ b/export/README.md @@ -0,0 +1,80 @@ +# Sa Polar - Despliegue en QNAP Container Station + +## Archivos necesarios + +Copia todos estos archivos a tu QNAP: +- `docker-compose.yml` +- `.env` +- `sa-polar-mysql.tar` +- `sa-polar-backend.tar` +- `sa-polar-frontend.tar` + +## Pasos de instalación + +### 1. Importar las imágenes Docker + +Accede por SSH al QNAP y ejecuta: +```bash +cd /ruta/donde/copiaste/los/archivos + +docker load -i sa-polar-mysql.tar +docker load -i sa-polar-backend.tar +docker load -i sa-polar-frontend.tar +``` + +### 2. Verificar las imágenes +```bash +docker images +``` + +Deberías ver: +- `mysql:8.0` +- `sa-polar-backend:latest` +- `sa-polar-frontend:latest` + +### 3. Iniciar los contenedores + +**Opción A: Usando docker-compose** +```bash +docker compose up -d +``` + +**Opción B: Importar en Container Station** +1. Abre Container Station en tu QNAP +2. Ve a "Crear aplicación" +3. Importa el archivo `docker-compose.yml` +4. Ajusta las variables de entorno si es necesario (.env) +5. Crea e inicia + +## Acceso + +Una vez iniciado: +- **Frontend:** http://IP_DEL_QNAP:3000 +- **Backend API:** http://IP_DEL_QNAP:8080 + +## Credenciales + +- Usuario: `admin` +- Contraseña: `admin123` + +## Notas + +- El puerto MySQL expuesto es 3307 (accesible desde el host como 3307) +- Los datos persisten en volúmenes Docker +- Para actualizar, recarga las imágenes y recrea los contenedores + +## Comandos útiles + +```bash +# Ver estado +docker compose ps + +# Ver logs +docker compose logs -f + +# Parar +docker compose down + +# Iniciar de nuevo +docker compose up -d +``` diff --git a/export/docker-compose.yml b/export/docker-compose.yml new file mode 100644 index 0000000..2a3e3d6 --- /dev/null +++ b/export/docker-compose.yml @@ -0,0 +1,56 @@ +name: sa-polar + +services: + mysql: + image: mysql:8.0 + container_name: sapolar_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root} + MYSQL_DATABASE: ${DB_NAME:-sa_polar} + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 5s + retries: 20 + interval: 5s + + backend: + image: sa-polar-backend:latest + container_name: sapolar_backend + restart: unless-stopped + depends_on: + mysql: + condition: service_healthy + environment: + DB_HOST: mysql + DB_PORT: 3306 + DB_NAME: ${DB_NAME:-sa_polar} + DB_USER: root + DB_PASSWORD: ${DB_PASSWORD:-root} + JWT_SECRET: ${JWT_SECRET:-a2V5X3N1cGVyX3NlY3JldGFfcGFyYV9sb2dpbl9kZV9zYV9wb2xhcl9kZWJlc19zZXJfZGUzMl9jYXJhY3RlcmVz} + CORS_ORIGINS: "*" + UPLOAD_PATH: /app/uploads + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-dev} + ports: + - "8080:8080" + volumes: + - uploads_data:/app/uploads + + frontend: + image: sa-polar-frontend:latest + container_name: sapolar_frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "3000:3000" + +volumes: + mysql_data: + uploads_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72404d5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Gestión Contable + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a8e28fd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2152 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.18.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "recharts": "^3.9.2" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8864996 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.18.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "recharts": "^3.9.2" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..aabeb97 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,58 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ToastProvider } from './components/Toast'; +import Layout from './components/Layout'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import Properties from './pages/Properties'; +import PropertyGroups from './pages/PropertyGroups'; +import Tenants from './pages/Tenants'; +import Contracts from './pages/Contracts'; +import IncomeReceipts from './pages/IncomeReceipts'; +import ExpenseTemplates from './pages/ExpenseTemplates'; +import ExpenseReceipts from './pages/ExpenseReceipts'; +import Incidents from './pages/Incidents'; +import Maintenance from './pages/Maintenance'; +import Documents from './pages/Documents'; +import BankAccounts from './pages/BankAccounts'; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, loading } = useAuth(); + if (loading) return null; + return isAuthenticated ? <>{children} : ; +} + +function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..4d3041d --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,10 @@ +import client from './client'; +import type { ApiResponse, LoginRequest, TokenResponse } from '../types/api'; + +export const authApi = { + login: (data: LoginRequest) => + client.post>('/auth/login', data), + + refresh: (refreshToken: string) => + client.post>('/auth/refresh', { refreshToken }), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..beb1475 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_URL || '/api'; + +const client = axios.create({ + baseURL: API_BASE, + headers: { 'Content-Type': 'application/json' }, +}); + +client.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401 || error.response?.status === 403) { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/login'; + } + return Promise.reject(error); + }, +); + +export default client; diff --git a/frontend/src/api/resources.ts b/frontend/src/api/resources.ts new file mode 100644 index 0000000..c909399 --- /dev/null +++ b/frontend/src/api/resources.ts @@ -0,0 +1,201 @@ +import client from './client'; +import type { ApiResponse, PagedResponse, Property, PropertyGroup, Tenant, TenantBankData, Contract, IncomeReceipt, ExpenseTemplate, ExpenseReceipt, Incident, Document, DocumentType, DocumentEntity, User, ScheduledMaintenance, ExpenseCategory, BankAccount, ExpenseRepercussion } from '../types/api'; + +interface PageParams { + page?: number; + size?: number; + sort?: string; + dir?: string; + [key: string]: unknown; +} + +export const propertyApi = { + getAll: (params?: PageParams) => client.get>>('/properties', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/properties/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/properties', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/properties/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/properties/${id}`).then(r => r.data), + changeStatus: (id: number, statusId: number, notes?: string) => + client.patch>(`/properties/${id}/status`, { statusId, notes }).then(r => r.data), + getTypes: () => client.get>('/properties/types').then(r => r.data), + getStatuses: () => client.get>('/properties/statuses').then(r => r.data), +}; + +export const tenantApi = { + getAll: (params?: PageParams) => client.get>>('/tenants', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/tenants/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/tenants', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/tenants/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/tenants/${id}`).then(r => r.data), +}; + +export const tenantBankDataApi = { + getAll: (tenantId: number) => + client.get>(`/tenants/${tenantId}/bank-data`).then(r => r.data), + getAllBanks: () => + client.get>(`/tenants/0/bank-data/banks`).then(r => r.data), + create: (tenantId: number, data: Partial) => + client.post>(`/tenants/${tenantId}/bank-data`, data).then(r => r.data), + update: (tenantId: number, id: number, data: Partial) => + client.put>(`/tenants/${tenantId}/bank-data/${id}`, data).then(r => r.data), + setPrincipal: (tenantId: number, id: number) => + client.patch>(`/tenants/${tenantId}/bank-data/${id}/principal`).then(r => r.data), + remove: (tenantId: number, id: number) => + client.delete>(`/tenants/${tenantId}/bank-data/${id}`).then(r => r.data), +}; + +export const contractApi = { + getAll: (params?: PageParams) => client.get>>('/contracts', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/contracts/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/contracts', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/contracts/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/contracts/${id}`).then(r => r.data), + terminate: (id: number, cause?: string) => + client.post>(`/contracts/${id}/terminate`, { cause }).then(r => r.data), +}; + +export const incomeReceiptApi = { + getAll: (params?: PageParams) => client.get>>('/income-receipts', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/income-receipts/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/income-receipts', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/income-receipts/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/income-receipts/${id}`).then(r => r.data), + registerPayment: (id: number, data: { paymentDate: string; paymentMethod: string }) => + client.patch>(`/income-receipts/${id}/pay`, data).then(r => r.data), + getPending: () => client.get>('/income-receipts/pending').then(r => r.data), + getPendingCount: () => client.get>('/income-receipts/pending/count').then(r => r.data), +}; + +export const expenseTemplateApi = { + getAll: (params?: PageParams) => client.get>>('/expense-templates', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/expense-templates/${id}`).then(r => r.data), + getActive: () => client.get>('/expense-templates/active').then(r => r.data), + create: (data: Partial) => client.post>('/expense-templates', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/expense-templates/${id}`, data).then(r => r.data), + toggleActive: (id: number) => client.patch>(`/expense-templates/${id}/toggle-active`).then(r => r.data), + remove: (id: number) => client.delete>(`/expense-templates/${id}`).then(r => r.data), +}; + +export const expenseReceiptApi = { + getAll: (params?: PageParams) => client.get>>('/expense-receipts', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/expense-receipts/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/expense-receipts', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/expense-receipts/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/expense-receipts/${id}`).then(r => r.data), + registerPayment: (id: number, data: { paymentDate: string; paymentMethod: string }) => + client.patch>(`/expense-receipts/${id}/pay`, data).then(r => r.data), + updateAmount: (id: number, data: { amount: number }) => + client.patch>(`/expense-receipts/${id}/amount`, data).then(r => r.data), + getPending: () => client.get>('/expense-receipts/pending').then(r => r.data), + getPendingCount: () => client.get>('/expense-receipts/pending/count').then(r => r.data), + getPendingVariable: () => client.get>('/expense-receipts/pending/variable').then(r => r.data), +}; + +export const bankAccountApi = { + getAll: (params?: { all?: boolean }) => + client.get>('/bank-accounts', { params }).then(r => r.data), + getById: (id: number) => + client.get>(`/bank-accounts/${id}`).then(r => r.data), + create: (data: Partial) => + client.post>('/bank-accounts', data).then(r => r.data), + update: (id: number, data: Partial) => + client.put>(`/bank-accounts/${id}`, data).then(r => r.data), + remove: (id: number) => + client.delete>(`/bank-accounts/${id}`).then(r => r.data), +}; + +export const groupApi = { + getAll: (params?: PageParams) => client.get>>('/property-groups', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/property-groups/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/property-groups', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/property-groups/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/property-groups/${id}`).then(r => r.data), +}; + +export const incidentApi = { + getAll: (params?: PageParams) => client.get>>('/incidents', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/incidents/${id}`).then(r => r.data), + create: (data: Partial) => client.post>('/incidents', data).then(r => r.data), + update: (id: number, data: Partial) => client.put>(`/incidents/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/incidents/${id}`).then(r => r.data), + updateStatus: (id: number, statusId: number, resolutionNotes?: string) => + client.patch>(`/incidents/${id}/status`, { statusId, resolutionNotes }).then(r => r.data), + assign: (id: number, assignedTo: number) => + client.patch>(`/incidents/${id}/assign`, { assignedTo }).then(r => r.data), + schedule: (id: number, scheduledDate: string) => + client.patch>(`/incidents/${id}/schedule`, { scheduledDate }).then(r => r.data), +}; + +export const documentApi = { + getForEntity: (entityType: string, entityId: number) => + client.get>(`/documents/entity/${entityType}/${entityId}`).then(r => r.data), + + search: (params?: { entityType?: string; entityId?: number; documentTypeId?: number; originalName?: string }) => + client.get>('/documents/search', { params }).then(r => r.data), + + upload: (formData: FormData) => + client.post>('/documents/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }).then(r => r.data), + + download: (id: number) => + client.get(`/documents/${id}/download`, { responseType: 'blob' }).then(r => r.data), + + remove: (id: number) => + client.delete>(`/documents/${id}`).then(r => r.data), + + getEntities: (id: number) => + client.get>(`/documents/${id}/entities`).then(r => r.data), + + addEntity: (id: number, entityType: string, entityId: number) => + client.post>(`/documents/${id}/entities`, null, { + params: { entityType, entityId }, + }).then(r => r.data), + + removeEntity: (id: number, entityType: string, entityId: number) => + client.delete>(`/documents/${id}/entities/${entityType}/${entityId}`).then(r => r.data), + + getTypes: () => client.get>('/documents/types').then(r => r.data), + + getTypesForEntity: (entityType: string) => + client.get>(`/documents/types/entity/${entityType}`).then(r => r.data), +}; + +export const maintenanceApi = { + getAll: (params?: { propertyId?: number }) => + client.get>('/maintenance', { params }).then(r => r.data), + getById: (id: number) => + client.get>(`/maintenance/${id}`).then(r => r.data), + getPending: () => + client.get>('/maintenance/pending').then(r => r.data), + getUpcoming: (from: string, to: string) => + client.get>('/maintenance/upcoming', { params: { from, to } }).then(r => r.data), + getExpenseCategories: () => + client.get>('/maintenance/expense-categories').then(r => r.data), + create: (data: Partial) => + client.post>('/maintenance', data).then(r => r.data), + update: (id: number, data: Partial) => + client.put>(`/maintenance/${id}`, data).then(r => r.data), + markCompleted: (id: number) => + client.patch>(`/maintenance/${id}/complete`).then(r => r.data), + reopen: (id: number) => + client.patch>(`/maintenance/${id}/reopen`).then(r => r.data), + remove: (id: number) => + client.delete>(`/maintenance/${id}`).then(r => r.data), +}; + +export const userApi = { + getAll: (params?: PageParams) => client.get>>('/users', { params }).then(r => r.data), + getById: (id: number) => client.get>(`/users/${id}`).then(r => r.data), +}; + +export const expenseRepercussionApi = { + getByContract: (contractId: number) => + client.get>('/expense-repercussions', { params: { contractId } }).then(r => r.data), + getById: (id: number) => client.get>(`/expense-repercussions/${id}`).then(r => r.data), + create: (contractId: number, data: Partial) => + client.post>('/expense-repercussions', data, { params: { contractId } }).then(r => r.data), + update: (id: number, data: Partial) => + client.put>(`/expense-repercussions/${id}`, data).then(r => r.data), + remove: (id: number) => client.delete>(`/expense-repercussions/${id}`).then(r => r.data), +}; diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/BankDataManager.tsx b/frontend/src/components/BankDataManager.tsx new file mode 100644 index 0000000..2ced024 --- /dev/null +++ b/frontend/src/components/BankDataManager.tsx @@ -0,0 +1,343 @@ +import { useEffect, useState, useCallback } from 'react'; +import { tenantBankDataApi } from '../api/resources'; +import type { TenantBankData } from '../types/api'; +import { useToast } from './Toast'; +import ConfirmDialog from './ConfirmDialog'; + +interface BankDataManagerProps { + tenantId: number; + tenantFullName?: string; + tenantBusinessName?: string; + tenantTypeId?: number; +} + +interface FormData { + alias: string; + iban: string; + bic: string; + bankName: string; + accountHolder: string; + notes: string; + isPrincipal: boolean; +} + +const emptyForm = (): FormData => ({ + alias: '', + iban: '', + bic: '', + bankName: '', + accountHolder: '', + notes: '', + isPrincipal: false, +}); + +function formatIban(iban: string): string { + return iban.replace(/\s/g, '').replace(/(.{4})/g, '$1 ').trim(); +} + +export default function BankDataManager({ tenantId, tenantFullName, tenantBusinessName, tenantTypeId }: BankDataManagerProps) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(emptyForm()); + const [saving, setSaving] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [bankNames, setBankNames] = useState([]); + const { toast } = useToast(); + + const load = useCallback(async () => { + try { + const res = await tenantBankDataApi.getAll(tenantId); + if (res.success) setItems(res.data); + } catch { + toast('Error al cargar datos bancarios', 'error'); + } finally { + setLoading(false); + } + }, [tenantId, toast]); + + const loadBankNames = useCallback(async () => { + try { + const res = await tenantBankDataApi.getAllBanks(); + if (res.success && res.data) { + setBankNames(res.data); + } + } catch { + // Silently fail - autocomplete is not critical + } + }, []); + + useEffect(() => { + load(); + loadBankNames(); + }, [load, loadBankNames]); + + const startCreate = () => { + // Persona Jurídica (typeId 2) usa businessName, Persona Física usa fullName + const defaultAccountHolder = tenantTypeId === 2 ? (tenantBusinessName || '') : (tenantFullName || ''); + setForm({ ...emptyForm(), accountHolder: defaultAccountHolder }); + setEditingId(null); + setShowForm(true); + }; + + const startEdit = (item: TenantBankData) => { + setForm({ + alias: item.alias ?? '', + iban: item.iban, + bic: item.bic ?? '', + bankName: item.bankName ?? '', + accountHolder: item.accountHolder ?? '', + notes: item.notes ?? '', + isPrincipal: item.isPrincipal, + }); + setEditingId(item.id); + setShowForm(true); + }; + + const cancel = () => { + setShowForm(false); + setEditingId(null); + setForm(emptyForm()); + }; + + const validateIban = (iban: string): string | null => { + const v = iban.replace(/\s/g, '').toUpperCase(); + if (!v) return 'El IBAN es obligatorio'; + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(v)) { + return 'Formato de IBAN no válido (ej: ES91 2100 0418 4502 0005 1332)'; + } + return null; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const ibanError = validateIban(form.iban); + if (ibanError) { + toast(ibanError, 'error'); + return; + } + + setSaving(true); + const payload = { + alias: form.alias || undefined, + iban: form.iban.replace(/\s/g, '').toUpperCase(), + bic: form.bic || undefined, + bankName: form.bankName || undefined, + accountHolder: form.accountHolder || undefined, + notes: form.notes || undefined, + isPrincipal: form.isPrincipal, + }; + + try { + if (editingId) { + await tenantBankDataApi.update(tenantId, editingId, payload); + toast('Dato bancario actualizado', 'success'); + } else { + await tenantBankDataApi.create(tenantId, payload); + toast('Dato bancario creado', 'success'); + } + cancel(); + load(); + } catch (err: unknown) { + const e = err as { response?: { data?: { message?: string } } }; + toast(e.response?.data?.message ?? 'Error al guardar', 'error'); + } finally { + setSaving(false); + } + }; + + const handleSetPrincipal = async (id: number) => { + try { + await tenantBankDataApi.setPrincipal(tenantId, id); + toast('Marcado como principal', 'success'); + load(); + } catch { + toast('Error al marcar como principal', 'error'); + } + }; + + const handleDelete = async () => { + if (!deleteId) return; + try { + await tenantBankDataApi.remove(tenantId, deleteId); + toast('Dato bancario eliminado', 'success'); + setDeleteId(null); + load(); + } catch { + toast('Error al eliminar', 'error'); + } + }; + + if (loading) { + return
Cargando datos bancarios...
; + } + + return ( +
+
+

Datos Bancarios

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+
{editingId ? 'Editar' : 'Nuevos'} datos bancarios
+
+
+ + setForm({ ...form, alias: e.target.value })} + placeholder="Ej: Cuenta principal, Cuenta nómina..." + maxLength={100} + /> +
+
+ + setForm({ ...form, iban: e.target.value })} + placeholder="ES91 2100 0418 4502 0005 1332" + required + maxLength={40} + /> +
+
+ + setForm({ ...form, bic: e.target.value.toUpperCase() })} + placeholder="Ej: CAIXESBBXXX" + maxLength={11} + /> +
+
+ + setForm({ ...form, bankName: e.target.value })} + placeholder="Nombre del banco" + maxLength={200} + /> + + {bankNames.map((name) => ( + +
+
+ + setForm({ ...form, accountHolder: e.target.value })} + placeholder="Titular de la cuenta" + maxLength={200} + /> +
+
+ +
+
+ +