From 8603b987140f353da9469286144f5638c5f911e2 Mon Sep 17 00:00:00 2001 From: Nessar Date: Wed, 15 Jul 2026 20:24:22 +0200 Subject: [PATCH] feat: manage tenants and leases (#4) --- .../lmnporganizer/backend/SecurityConfig.java | 2 + .../backend/controller/LeaseController.java | 121 ++++++++ .../backend/controller/TenantController.java | 121 ++++++++ .../backend/model/Guarantor.java | 70 +++++ .../lmnporganizer/backend/model/Lease.java | 79 ++++++ .../backend/model/LeaseStatus.java | 7 + .../lmnporganizer/backend/model/Tenant.java | 102 +++++++ .../backend/model/TenantAttachment.java | 35 +++ .../backend/repository/LeaseRepository.java | 7 + .../backend/repository/TenantRepository.java | 7 + .../backend/service/LeasePdfService.java | 91 ++++++ .../service/TenantAttachmentStorage.java | 56 ++++ .../src/main/resources/application.properties | 1 + .../src/main/resources/templates/lease.txt | 12 + .../controller/LeaseControllerTest.java | 176 ++++++++++++ .../controller/TenantControllerTest.java | 161 +++++++++++ .../backend/model/ModelCoverageTest.java | 122 +++++++++ .../service/TenantAttachmentStorageTest.java | 43 +++ .../src/test/resources/application.properties | 1 + compose.yml | 4 + frontend/angular.json | 8 +- frontend/e2e/app.spec.ts | 103 +++++++ frontend/playwright.config.ts | 8 +- frontend/src/app/app.html | 19 ++ frontend/src/app/app.spec.ts | 100 +++++++ frontend/src/app/app.ts | 95 +++++++ .../components/leases-page/leases-page.css | 240 ++++++++++++++++ .../components/leases-page/leases-page.html | 259 ++++++++++++++++++ .../leases-page/leases-page.spec.ts | 88 ++++++ .../app/components/leases-page/leases-page.ts | 142 ++++++++++ .../components/tenants-page/tenants-page.css | 207 ++++++++++++++ .../components/tenants-page/tenants-page.html | 164 +++++++++++ .../tenants-page/tenants-page.spec.ts | 75 +++++ .../components/tenants-page/tenants-page.ts | 94 +++++++ .../workspace-navigation.css | 2 +- .../workspace-navigation.html | 42 +++ .../workspace-navigation.ts | 2 +- .../src/app/services/lease.service.spec.ts | 50 ++++ frontend/src/app/services/lease.service.ts | 51 ++++ .../src/app/services/tenant.service.spec.ts | 58 ++++ frontend/src/app/services/tenant.service.ts | 54 ++++ frontend/tsconfig.spec.json | 28 +- 42 files changed, 3089 insertions(+), 18 deletions(-) create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/model/LeaseStatus.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/model/TenantAttachment.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java create mode 100644 backend/src/main/resources/templates/lease.txt create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java create mode 100644 frontend/src/app/components/leases-page/leases-page.css create mode 100644 frontend/src/app/components/leases-page/leases-page.html create mode 100644 frontend/src/app/components/leases-page/leases-page.spec.ts create mode 100644 frontend/src/app/components/leases-page/leases-page.ts create mode 100644 frontend/src/app/components/tenants-page/tenants-page.css create mode 100644 frontend/src/app/components/tenants-page/tenants-page.html create mode 100644 frontend/src/app/components/tenants-page/tenants-page.spec.ts create mode 100644 frontend/src/app/components/tenants-page/tenants-page.ts create mode 100644 frontend/src/app/services/lease.service.spec.ts create mode 100644 frontend/src/app/services/lease.service.ts create mode 100644 frontend/src/app/services/tenant.service.spec.ts create mode 100644 frontend/src/app/services/tenant.service.ts diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java index 280032c..bb051de 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java @@ -20,6 +20,8 @@ public class SecurityConfig { .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/hello").authenticated() .requestMatchers("/api/apartments", "/api/apartments/**").authenticated() + .requestMatchers("/api/tenants", "/api/tenants/**").authenticated() + .requestMatchers("/api/leases", "/api/leases/**").authenticated() .requestMatchers("/api/user").permitAll() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java new file mode 100644 index 0000000..56dc50c --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java @@ -0,0 +1,121 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import fr.nessar.lmnporganizer.backend.model.Lease; +import fr.nessar.lmnporganizer.backend.model.Tenant; +import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; +import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import fr.nessar.lmnporganizer.backend.service.LeasePdfService; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +@RestController +@RequestMapping("/api/leases") +public class LeaseController { + private final LeaseRepository leaseRepository; + private final ApartmentRepository apartmentRepository; + private final TenantRepository tenantRepository; + private final LeasePdfService leasePdfService; + + public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository, + TenantRepository tenantRepository, LeasePdfService leasePdfService) { + this.leaseRepository = leaseRepository; + this.apartmentRepository = apartmentRepository; + this.tenantRepository = tenantRepository; + this.leasePdfService = leasePdfService; + } + + @GetMapping + public List getAllLeases() { + return leaseRepository.findAll(); + } + + @GetMapping("/{id}") + public ResponseEntity getLease(@PathVariable String id) { + return leaseRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createLease(@RequestBody Lease lease) { + if (!referencesExist(lease)) { + return ResponseEntity.badRequest().build(); + } + lease.setId(null); + lease.setLandlordId("default-landlord"); + return ResponseEntity.status(HttpStatus.CREATED).body(leaseRepository.save(lease)); + } + + @PutMapping("/{id}") + public ResponseEntity updateLease(@PathVariable String id, @RequestBody Lease details) { + if (!referencesExist(details)) { + return ResponseEntity.badRequest().build(); + } + return leaseRepository.findById(id).map(lease -> { + copyTerms(details, lease); + return ResponseEntity.ok(leaseRepository.save(lease)); + }).orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteLease(@PathVariable String id) { + if (!leaseRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + leaseRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/pdf") + public ResponseEntity downloadPdf(@PathVariable String id) { + Lease lease = leaseRepository.findById(id).orElse(null); + if (lease == null) { + return ResponseEntity.notFound().build(); + } + Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null); + List tenants = tenantsFor(lease.getTenantIds()); + if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString()) + .body(leasePdfService.generate(lease, apartment, tenants)); + } + + private boolean referencesExist(Lease lease) { + if (lease.getApartmentId() == null || !apartmentRepository.existsById(lease.getApartmentId()) + || lease.getTenantIds().isEmpty()) { + return false; + } + return tenantsFor(lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size(); + } + + private List tenantsFor(List ids) { + List tenants = new ArrayList<>(); + tenantRepository.findAllById(ids).forEach(tenants::add); + return tenants; + } + + private void copyTerms(Lease source, Lease target) { + target.setLandlordId("default-landlord"); + target.setApartmentId(source.getApartmentId()); + target.setTenantIds(source.getTenantIds()); + target.setGuarantors(source.getGuarantors()); + target.setStartDate(source.getStartDate()); + target.setEndDate(source.getEndDate()); + target.setMonthlyRent(source.getMonthlyRent()); + target.setMonthlyCharges(source.getMonthlyCharges()); + target.setSecurityDeposit(source.getSecurityDeposit()); + target.setStatus(source.getStatus()); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java new file mode 100644 index 0000000..015c897 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java @@ -0,0 +1,121 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Tenant; +import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/tenants") +public class TenantController { + private final TenantRepository tenantRepository; + private final TenantAttachmentStorage attachmentStorage; + + public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage) { + this.tenantRepository = tenantRepository; + this.attachmentStorage = attachmentStorage; + } + + @GetMapping + public List getAllTenants() { + return tenantRepository.findAll(); + } + + @GetMapping("/{id}") + public ResponseEntity getTenant(@PathVariable String id) { + return tenantRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createTenant(@RequestBody Tenant tenant) { + tenant.setId(null); + tenant.setLandlordId("default-landlord"); + return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant)); + } + + @PutMapping("/{id}") + public ResponseEntity updateTenant(@PathVariable String id, @RequestBody Tenant details) { + return tenantRepository.findById(id).map(tenant -> { + tenant.setFirstName(details.getFirstName()); + tenant.setLastName(details.getLastName()); + tenant.setEmail(details.getEmail()); + tenant.setPhone(details.getPhone()); + tenant.setAddress(details.getAddress()); + tenant.setLandlordId("default-landlord"); + return ResponseEntity.ok(tenantRepository.save(tenant)); + }).orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTenant(@PathVariable String id) { + if (!tenantRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + tenantRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + @PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadAttachment(@PathVariable String tenantId, + @RequestPart("file") MultipartFile file) throws IOException { + Tenant tenant = tenantRepository.findById(tenantId).orElse(null); + if (tenant == null) { + return ResponseEntity.notFound().build(); + } + TenantAttachment attachment = attachmentStorage.store(tenantId, file); + tenant.getAttachments().add(attachment); + tenantRepository.save(tenant); + return ResponseEntity.status(HttpStatus.CREATED).body(attachment); + } + + @GetMapping("/{tenantId}/attachments/{attachmentId}") + public ResponseEntity downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId) + throws IOException { + TenantAttachment attachment = tenantRepository.findById(tenantId) + .flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst()) + .orElse(null); + if (attachment == null) { + return ResponseEntity.notFound().build(); + } + MediaType mediaType; + try { + mediaType = MediaType.parseMediaType(attachment.getContentType()); + } catch (IllegalArgumentException exception) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(attachment.getFileName()).build().toString()) + .body(attachmentStorage.read(attachment)); + } + + @DeleteMapping("/{tenantId}/attachments/{attachmentId}") + public ResponseEntity deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId) + throws IOException { + Tenant tenant = tenantRepository.findById(tenantId).orElse(null); + if (tenant == null) { + return ResponseEntity.notFound().build(); + } + TenantAttachment attachment = tenant.getAttachments().stream() + .filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null); + if (attachment == null) { + return ResponseEntity.notFound().build(); + } + attachmentStorage.delete(attachment); + tenant.getAttachments().remove(attachment); + tenantRepository.save(tenant); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java new file mode 100644 index 0000000..333ad70 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Guarantor.java @@ -0,0 +1,70 @@ +package fr.nessar.lmnporganizer.backend.model; + +public class Guarantor { + private String id; + private String firstName; + private String lastName; + private String email; + private String phone; + private String address; + + public Guarantor() { + } + + public Guarantor(String id, String firstName, String lastName, String email, String phone, String address) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.phone = phone; + this.address = address; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java new file mode 100644 index 0000000..cce4160 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Lease.java @@ -0,0 +1,79 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Document(collection = "leases") +public class Lease { + @Id + private String id; + + @Indexed + private String landlordId; + + @Indexed + private String apartmentId; + + @Indexed + private List tenantIds = new ArrayList<>(); + + private List guarantors = new ArrayList<>(); + private LocalDate startDate; + private LocalDate endDate; + private BigDecimal monthlyRent; + private BigDecimal monthlyCharges; + private BigDecimal securityDeposit; + private LeaseStatus status; + + public Lease() { + } + + public Lease(String id, String landlordId, String apartmentId, List tenantIds, + List guarantors, LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent, + BigDecimal monthlyCharges, BigDecimal securityDeposit, LeaseStatus status) { + this.id = id; + this.landlordId = landlordId; + this.apartmentId = apartmentId; + setTenantIds(tenantIds); + setGuarantors(guarantors); + this.startDate = startDate; + this.endDate = endDate; + this.monthlyRent = monthlyRent; + this.monthlyCharges = monthlyCharges; + this.securityDeposit = securityDeposit; + this.status = status; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getLandlordId() { return landlordId; } + public void setLandlordId(String landlordId) { this.landlordId = landlordId; } + public String getApartmentId() { return apartmentId; } + public void setApartmentId(String apartmentId) { this.apartmentId = apartmentId; } + public List getTenantIds() { return tenantIds; } + public void setTenantIds(List tenantIds) { + this.tenantIds = tenantIds == null ? new ArrayList<>() : new ArrayList<>(tenantIds); + } + public List getGuarantors() { return guarantors; } + public void setGuarantors(List guarantors) { + this.guarantors = guarantors == null ? new ArrayList<>() : new ArrayList<>(guarantors); + } + public LocalDate getStartDate() { return startDate; } + public void setStartDate(LocalDate startDate) { this.startDate = startDate; } + public LocalDate getEndDate() { return endDate; } + public void setEndDate(LocalDate endDate) { this.endDate = endDate; } + public BigDecimal getMonthlyRent() { return monthlyRent; } + public void setMonthlyRent(BigDecimal monthlyRent) { this.monthlyRent = monthlyRent; } + public BigDecimal getMonthlyCharges() { return monthlyCharges; } + public void setMonthlyCharges(BigDecimal monthlyCharges) { this.monthlyCharges = monthlyCharges; } + public BigDecimal getSecurityDeposit() { return securityDeposit; } + public void setSecurityDeposit(BigDecimal securityDeposit) { this.securityDeposit = securityDeposit; } + public LeaseStatus getStatus() { return status; } + public void setStatus(LeaseStatus status) { this.status = status; } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/LeaseStatus.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/LeaseStatus.java new file mode 100644 index 0000000..89f1659 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/LeaseStatus.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.model; + +public enum LeaseStatus { + DRAFT, + ACTIVE, + ARCHIVED +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java new file mode 100644 index 0000000..442d659 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java @@ -0,0 +1,102 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.ArrayList; +import java.util.List; + +@Document(collection = "tenants") +public class Tenant { + @Id + private String id; + + @Indexed + private String landlordId; + + private String firstName; + private String lastName; + private String email; + private String phone; + private String address; + private List attachments = new ArrayList<>(); + + public Tenant() { + } + + public Tenant(String id, String landlordId, String firstName, String lastName, String email, String phone, + String address) { + this.id = id; + this.landlordId = landlordId; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.phone = phone; + this.address = address; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLandlordId() { + return landlordId; + } + + public void setLandlordId(String landlordId) { + this.landlordId = landlordId; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/TenantAttachment.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/TenantAttachment.java new file mode 100644 index 0000000..e04819f --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/TenantAttachment.java @@ -0,0 +1,35 @@ +package fr.nessar.lmnporganizer.backend.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class TenantAttachment { + private String id; + private String fileName; + private String contentType; + private long size; + + @JsonIgnore + private String storagePath; + + public TenantAttachment() { + } + + public TenantAttachment(String id, String fileName, String contentType, long size, String storagePath) { + this.id = id; + this.fileName = fileName; + this.contentType = contentType; + this.size = size; + this.storagePath = storagePath; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + public String getContentType() { return contentType; } + public void setContentType(String contentType) { this.contentType = contentType; } + public long getSize() { return size; } + public void setSize(long size) { this.size = size; } + public String getStoragePath() { return storagePath; } + public void setStoragePath(String storagePath) { this.storagePath = storagePath; } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java new file mode 100644 index 0000000..db9d1a4 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.repository; + +import fr.nessar.lmnporganizer.backend.model.Lease; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface LeaseRepository extends MongoRepository { +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java new file mode 100644 index 0000000..3995cc3 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.repository; + +import fr.nessar.lmnporganizer.backend.model.Tenant; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface TenantRepository extends MongoRepository { +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java new file mode 100644 index 0000000..b5718a1 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java @@ -0,0 +1,91 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import fr.nessar.lmnporganizer.backend.model.Lease; +import fr.nessar.lmnporganizer.backend.model.Tenant; +import org.springframework.stereotype.Service; +import org.springframework.core.io.ClassPathResource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@Service +public class LeasePdfService { + public byte[] generate(Lease lease, Apartment apartment, List tenants) { + String tenantNames = tenants.stream().map(tenant -> tenant.getFirstName() + " " + tenant.getLastName()) + .reduce((first, second) -> first + ", " + second).orElse(""); + String guarantorNames = lease.getGuarantors().stream() + .map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()) + .reduce((first, second) -> first + ", " + second).orElse("Aucun"); + String renderedTemplate = loadTemplate() + .replace("{{leaseId}}", lease.getId()) + .replace("{{apartmentName}}", apartment.getName()) + .replace("{{apartmentAddress}}", apartment.getAddress()) + .replace("{{tenantNames}}", tenantNames) + .replace("{{startDate}}", String.valueOf(lease.getStartDate())) + .replace("{{endDate}}", String.valueOf(lease.getEndDate())) + .replace("{{monthlyRent}}", String.valueOf(lease.getMonthlyRent())) + .replace("{{monthlyCharges}}", String.valueOf(lease.getMonthlyCharges())) + .replace("{{securityDeposit}}", String.valueOf(lease.getSecurityDeposit())) + .replace("{{guarantorNames}}", guarantorNames); + return buildPdf(List.of(renderedTemplate.split("\\R", -1))); + } + + private String loadTemplate() { + try { + return new ClassPathResource("templates/lease.txt").getContentAsString(StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Lease PDF template is unavailable", exception); + } + } + + private byte[] buildPdf(List lines) { + StringBuilder stream = new StringBuilder("BT\n/F1 18 Tf\n50 790 Td\n"); + for (int index = 0; index < lines.size(); index++) { + if (index == 1) stream.append("/F1 11 Tf\n"); + stream.append('(').append(escape(lines.get(index))).append(") Tj\n0 -28 Td\n"); + } + stream.append("ET\n"); + byte[] streamBytes = stream.toString().getBytes(StandardCharsets.US_ASCII); + List objects = List.of( + ascii("<< /Type /Catalog /Pages 2 0 R >>"), + ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"), + ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), + concat(ascii("<< /Length " + streamBytes.length + " >>\nstream\n"), streamBytes, ascii("endstream"))); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + write(output, ascii("%PDF-1.4\n")); + List offsets = new ArrayList<>(); + for (int index = 0; index < objects.size(); index++) { + offsets.add(output.size()); + write(output, ascii((index + 1) + " 0 obj\n")); + write(output, objects.get(index)); + write(output, ascii("\nendobj\n")); + } + int xrefOffset = output.size(); + write(output, ascii("xref\n0 " + (objects.size() + 1) + "\n0000000000 65535 f \n")); + offsets.forEach(offset -> write(output, ascii(String.format(Locale.ROOT, "%010d 00000 n \n", offset)))); + write(output, ascii("trailer\n<< /Size " + (objects.size() + 1) + " /Root 1 0 R >>\nstartxref\n" + + xrefOffset + "\n%%EOF\n")); + return output.toByteArray(); + } + + private String escape(String value) { + String ascii = Normalizer.normalize(String.valueOf(value), Normalizer.Form.NFD) + .replaceAll("\\p{M}", "").replaceAll("[^\\x20-\\x7E]", "?"); + return ascii.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)"); + } + + private byte[] ascii(String value) { return value.getBytes(StandardCharsets.US_ASCII); } + private byte[] concat(byte[]... parts) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (byte[] part : parts) write(output, part); + return output.toByteArray(); + } + private void write(ByteArrayOutputStream output, byte[] bytes) { output.writeBytes(bytes); } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java new file mode 100644 index 0000000..d17f2f4 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java @@ -0,0 +1,56 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +@Service +public class TenantAttachmentStorage { + private final Path root; + + public TenantAttachmentStorage(@Value("${lmnp.storage.attachments}") String root) { + this.root = Path.of(root).toAbsolutePath().normalize(); + } + + public TenantAttachment store(String tenantId, MultipartFile file) throws IOException { + String attachmentId = UUID.randomUUID().toString(); + String suppliedName = file.getOriginalFilename(); + String originalName = suppliedName == null || suppliedName.isBlank() + ? "attachment" + : Path.of(suppliedName).getFileName().toString(); + Path tenantDirectory = root.resolve(tenantId).normalize(); + if (!tenantDirectory.startsWith(root)) { + throw new IOException("Invalid tenant attachment path"); + } + Files.createDirectories(tenantDirectory); + Path destination = tenantDirectory.resolve(attachmentId).normalize(); + file.transferTo(destination); + String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType(); + return new TenantAttachment(attachmentId, originalName, contentType, file.getSize(), + root.relativize(destination).toString()); + } + + public Resource read(TenantAttachment attachment) throws IOException { + Path path = root.resolve(attachment.getStoragePath()).normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + throw new IOException("Tenant attachment not found"); + } + return new UrlResource(path.toUri()); + } + + public void delete(TenantAttachment attachment) throws IOException { + Path path = root.resolve(attachment.getStoragePath()).normalize(); + if (!path.startsWith(root)) { + throw new IOException("Invalid tenant attachment path"); + } + Files.deleteIfExists(path); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 217284a..0ab3fcf 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,5 +1,6 @@ spring.application.name=backend spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp} +lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments} # OAuth2 Client registration spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client} diff --git a/backend/src/main/resources/templates/lease.txt b/backend/src/main/resources/templates/lease.txt new file mode 100644 index 0000000..c5b9764 --- /dev/null +++ b/backend/src/main/resources/templates/lease.txt @@ -0,0 +1,12 @@ +BAIL D'HABITATION MEUBLÉE +Référence : {{leaseId}} +Logement : {{apartmentName}} +Adresse : {{apartmentAddress}} +Locataire(s) : {{tenantNames}} +Période : {{startDate}} au {{endDate}} +Loyer mensuel : {{monthlyRent}} EUR +Charges mensuelles : {{monthlyCharges}} EUR +Dépôt de garantie : {{securityDeposit}} EUR +Garant(s) : {{guarantorNames}} + +Bail généré par LMNP Organizer. À compléter et signer par les parties. diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java new file mode 100644 index 0000000..c288e61 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java @@ -0,0 +1,176 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Apartment; +import fr.nessar.lmnporganizer.backend.model.Guarantor; +import fr.nessar.lmnporganizer.backend.model.Lease; +import fr.nessar.lmnporganizer.backend.model.LeaseStatus; +import fr.nessar.lmnporganizer.backend.model.Tenant; +import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; +import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = "lmnp.data-initializer.enabled=false") +@AutoConfigureMockMvc +class LeaseControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LeaseRepository leaseRepository; + + @MockitoBean + private ApartmentRepository apartmentRepository; + + @MockitoBean + private TenantRepository tenantRepository; + + @Test + void whenCreateLease_thenLinksApartmentTenantsAndGuarantors() throws Exception { + when(apartmentRepository.existsById("apartment-1")).thenReturn(true); + when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); + when(leaseRepository.save(any(Lease.class))).thenAnswer(invocation -> { + Lease lease = invocation.getArgument(0); + lease.setId("lease-1"); + return lease; + }); + + mockMvc.perform(post("/api/leases") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(leaseJson())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("lease-1")) + .andExpect(jsonPath("$.landlordId").value("default-landlord")) + .andExpect(jsonPath("$.apartmentId").value("apartment-1")) + .andExpect(jsonPath("$.tenantIds[0]").value("tenant-1")) + .andExpect(jsonPath("$.guarantors[0].lastName").value("Durand")); + } + + @Test + void whenLeaseReferencesUnknownApartment_thenBadRequest() throws Exception { + when(apartmentRepository.existsById("apartment-1")).thenReturn(false); + + mockMvc.perform(post("/api/leases") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(leaseJson())) + .andExpect(status().isBadRequest()); + } + + @Test + void whenGetLeasesWithoutAuthentication_thenUnauthorized() throws Exception { + mockMvc.perform(get("/api/leases")) + .andExpect(status().isUnauthorized()); + } + + @Test + void whenGetLeases_thenReturnsPortfolioLeases() throws Exception { + when(leaseRepository.findAll()).thenReturn(List.of(lease())); + + mockMvc.perform(get("/api/leases").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("lease-1")); + } + + @Test + void whenUpdateLease_thenTermsAreUpdated() throws Exception { + when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease())); + when(apartmentRepository.existsById("apartment-1")).thenReturn(true); + when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); + when(leaseRepository.save(any(Lease.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(put("/api/leases/lease-1") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(leaseJson().replace("850.00", "900.00"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.monthlyRent").value(900.00)); + } + + @Test + void whenDeleteLease_thenNoContent() throws Exception { + when(leaseRepository.existsById("lease-1")).thenReturn(true); + + mockMvc.perform(delete("/api/leases/lease-1").with(oidcLogin())) + .andExpect(status().isNoContent()); + + verify(leaseRepository).deleteById("lease-1"); + } + + @Test + void whenDownloadLeasePdf_thenReturnsPdfAttachment() throws Exception { + Lease lease = lease(); + Apartment apartment = new Apartment("apartment-1", "default-landlord", "Studio République", + "12 rue de la République", 150000, ""); + when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease)); + when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment)); + when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); + + mockMvc.perform(get("/api/leases/lease-1/pdf").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/pdf")) + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("bail-lease-1.pdf"))) + .andExpect(result -> { + byte[] content = result.getResponse().getContentAsByteArray(); + if (content.length < 100 || content[0] != '%' || content[1] != 'P') { + throw new AssertionError("Expected a generated lease PDF"); + } + String pdf = new String(content, java.nio.charset.StandardCharsets.US_ASCII); + if (!pdf.contains("Studio Republique") || !pdf.contains("Camille Martin")) { + throw new AssertionError("Expected rendered lease template values"); + } + }); + } + + private Tenant tenant() { + return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com", + "+33612345678", "10 rue des Lilas"); + } + + private Lease lease() { + return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"), + List.of(new Guarantor("guarantor-1", "Alex", "Durand", "alex@example.com", "+33600000000", + "5 rue Victor Hugo")), LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31), + new BigDecimal("850.00"), new BigDecimal("50.00"), new BigDecimal("850.00"), LeaseStatus.ACTIVE); + } + + private String leaseJson() { + return """ + { + "apartmentId": "apartment-1", + "tenantIds": ["tenant-1"], + "guarantors": [{ + "firstName": "Alex", + "lastName": "Durand", + "email": "alex@example.com", + "phone": "+33600000000", + "address": "5 rue Victor Hugo" + }], + "startDate": "2026-09-01", + "endDate": "2027-08-31", + "monthlyRent": 850.00, + "monthlyCharges": 50.00, + "securityDeposit": 850.00, + "status": "ACTIVE" + } + """; + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java new file mode 100644 index 0000000..b799685 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java @@ -0,0 +1,161 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import fr.nessar.lmnporganizer.backend.model.Tenant; +import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import java.util.List; +import java.util.Optional; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "lmnp.data-initializer.enabled=false") +@AutoConfigureMockMvc +class TenantControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private TenantRepository tenantRepository; + + @MockitoBean + private TenantAttachmentStorage attachmentStorage; + + @Test + void whenCreateTenant_thenCreatedForDefaultLandlord() throws Exception { + Tenant savedTenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + when(tenantRepository.save(any(Tenant.class))).thenReturn(savedTenant); + + mockMvc.perform(post("/api/tenants") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "firstName": "Camille", + "lastName": "Martin", + "email": "camille@example.com", + "phone": "+33612345678", + "address": "10 rue des Lilas" + } + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("tenant-1")) + .andExpect(jsonPath("$.landlordId").value("default-landlord")); + } + + @Test + void whenGetTenantsWithoutAuthentication_thenUnauthorized() throws Exception { + mockMvc.perform(get("/api/tenants")) + .andExpect(status().isUnauthorized()); + } + + @Test + void whenGetTenants_thenReturnsPortfolioTenants() throws Exception { + Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + when(tenantRepository.findAll()).thenReturn(List.of(tenant)); + + mockMvc.perform(get("/api/tenants").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Camille")); + } + + @Test + void whenGetUnknownTenant_thenNotFound() throws Exception { + when(tenantRepository.findById("missing")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/tenants/missing").with(oidcLogin())) + .andExpect(status().isNotFound()); + } + + @Test + void whenUpdateTenant_thenContactDetailsAreUpdated() throws Exception { + Tenant existing = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + "old@example.com", "0100000000", "Ancienne adresse"); + when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(existing)); + when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(put("/api/tenants/tenant-1") + .with(oidcLogin()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "firstName": "Camille", + "lastName": "Martin", + "email": "new@example.com", + "phone": "+33612345678", + "address": "10 rue des Lilas" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("new@example.com")) + .andExpect(jsonPath("$.address").value("10 rue des Lilas")); + } + + @Test + void whenDeleteTenant_thenNoContent() throws Exception { + when(tenantRepository.existsById("tenant-1")).thenReturn(true); + + mockMvc.perform(delete("/api/tenants/tenant-1").with(oidcLogin())) + .andExpect(status().isNoContent()); + + verify(tenantRepository).deleteById("tenant-1"); + } + + @Test + void whenUploadAttachment_thenMetadataIsAddedToTenant() throws Exception { + Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/document-1.pdf"); + MockMultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()); + when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant)); + when(attachmentStorage.store(eq("tenant-1"), any())).thenReturn(attachment); + when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(multipart("/api/tenants/tenant-1/attachments") + .file(file) + .with(oidcLogin())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("attachment-1")) + .andExpect(jsonPath("$.fileName").value("identite.pdf")) + .andExpect(jsonPath("$.storagePath").doesNotExist()); + } + + @Test + void whenDownloadAttachment_thenReturnsAttachment() throws Exception { + Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/document-1.pdf"); + tenant.setAttachments(List.of(attachment)); + when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant)); + when(attachmentStorage.read(attachment)).thenReturn(new ByteArrayResource("PDF!".getBytes())); + + mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(oidcLogin())) + .andExpect(status().isOk()) + .andExpect(result -> { + if (!"PDF!".equals(result.getResponse().getContentAsString())) { + throw new AssertionError("Unexpected document content"); + } + }); + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java index f133aeb..02a2473 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/ModelCoverageTest.java @@ -1,6 +1,11 @@ package fr.nessar.lmnporganizer.backend.model; import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; class ModelCoverageTest { @@ -70,4 +75,121 @@ class ModelCoverageTest { assertEquals(200.0, fullApt.getPurchasePrice()); assertEquals("desc2", fullApt.getDescription()); } + + @Test + void testTenantAndDocumentAccessors() { + Tenant tenant = new Tenant(); + assertNull(tenant.getId()); + assertNull(tenant.getLandlordId()); + assertNull(tenant.getFirstName()); + assertNull(tenant.getLastName()); + assertNull(tenant.getEmail()); + assertNull(tenant.getPhone()); + assertNull(tenant.getAddress()); + assertTrue(tenant.getAttachments().isEmpty()); + + TenantAttachment attachment = new TenantAttachment(); + attachment.setId("attachment-1"); + attachment.setFileName("identite.pdf"); + attachment.setContentType("application/pdf"); + attachment.setSize(42); + attachment.setStoragePath("tenant-1/attachment-1"); + assertEquals("attachment-1", attachment.getId()); + assertEquals("identite.pdf", attachment.getFileName()); + assertEquals("application/pdf", attachment.getContentType()); + assertEquals(42, attachment.getSize()); + assertEquals("tenant-1/attachment-1", attachment.getStoragePath()); + + tenant.setId("tenant-1"); + tenant.setLandlordId("default-landlord"); + tenant.setFirstName("Camille"); + tenant.setLastName("Martin"); + tenant.setEmail("camille@example.com"); + tenant.setPhone("0600000000"); + tenant.setAddress("10 rue des Lilas"); + tenant.setAttachments(List.of(attachment)); + assertEquals("tenant-1", tenant.getId()); + assertEquals("default-landlord", tenant.getLandlordId()); + assertEquals("Camille", tenant.getFirstName()); + assertEquals("Martin", tenant.getLastName()); + assertEquals("camille@example.com", tenant.getEmail()); + assertEquals("0600000000", tenant.getPhone()); + assertEquals("10 rue des Lilas", tenant.getAddress()); + assertEquals(List.of(attachment), tenant.getAttachments()); + tenant.setAttachments(null); + assertTrue(tenant.getAttachments().isEmpty()); + + Tenant fullTenant = new Tenant("tenant-2", "landlord", "Alex", "Durand", "alex@example.com", + "0700000000", "5 rue Victor Hugo"); + assertEquals("tenant-2", fullTenant.getId()); + TenantAttachment fullAttachment = new TenantAttachment("attachment-2", "revenus.pdf", "application/pdf", 10, + "tenant-2/attachment-2"); + assertEquals("attachment-2", fullAttachment.getId()); + } + + @Test + void testLeaseAndGuarantorAccessors() { + Guarantor guarantor = new Guarantor(); + guarantor.setId("guarantor-1"); + guarantor.setFirstName("Alex"); + guarantor.setLastName("Durand"); + guarantor.setEmail("alex@example.com"); + guarantor.setPhone("0700000000"); + guarantor.setAddress("5 rue Victor Hugo"); + assertEquals("guarantor-1", guarantor.getId()); + assertEquals("Alex", guarantor.getFirstName()); + assertEquals("Durand", guarantor.getLastName()); + assertEquals("alex@example.com", guarantor.getEmail()); + assertEquals("0700000000", guarantor.getPhone()); + assertEquals("5 rue Victor Hugo", guarantor.getAddress()); + + Guarantor fullGuarantor = new Guarantor("guarantor-2", "Jo", "Petit", "jo@example.com", + "0600000000", "Paris"); + assertEquals("guarantor-2", fullGuarantor.getId()); + + Lease lease = new Lease(); + assertNull(lease.getId()); + assertNull(lease.getLandlordId()); + assertNull(lease.getApartmentId()); + assertTrue(lease.getTenantIds().isEmpty()); + assertTrue(lease.getGuarantors().isEmpty()); + assertNull(lease.getStartDate()); + assertNull(lease.getEndDate()); + assertNull(lease.getMonthlyRent()); + assertNull(lease.getMonthlyCharges()); + assertNull(lease.getSecurityDeposit()); + assertNull(lease.getStatus()); + + lease.setId("lease-1"); + lease.setLandlordId("default-landlord"); + lease.setApartmentId("apartment-1"); + lease.setTenantIds(List.of("tenant-1")); + lease.setGuarantors(List.of(guarantor)); + lease.setStartDate(LocalDate.of(2026, 9, 1)); + lease.setEndDate(LocalDate.of(2027, 8, 31)); + lease.setMonthlyRent(new BigDecimal("850")); + lease.setMonthlyCharges(new BigDecimal("50")); + lease.setSecurityDeposit(new BigDecimal("850")); + lease.setStatus(LeaseStatus.ACTIVE); + assertEquals("lease-1", lease.getId()); + assertEquals("default-landlord", lease.getLandlordId()); + assertEquals("apartment-1", lease.getApartmentId()); + assertEquals(List.of("tenant-1"), lease.getTenantIds()); + assertEquals(List.of(guarantor), lease.getGuarantors()); + assertEquals(LocalDate.of(2026, 9, 1), lease.getStartDate()); + assertEquals(LocalDate.of(2027, 8, 31), lease.getEndDate()); + assertEquals(new BigDecimal("850"), lease.getMonthlyRent()); + assertEquals(new BigDecimal("50"), lease.getMonthlyCharges()); + assertEquals(new BigDecimal("850"), lease.getSecurityDeposit()); + assertEquals(LeaseStatus.ACTIVE, lease.getStatus()); + lease.setTenantIds(null); + lease.setGuarantors(null); + assertTrue(lease.getTenantIds().isEmpty()); + assertTrue(lease.getGuarantors().isEmpty()); + + Lease fullLease = new Lease("lease-2", "landlord", "apartment-2", List.of("tenant-2"), + List.of(fullGuarantor), LocalDate.now(), LocalDate.now().plusYears(1), BigDecimal.ONE, + BigDecimal.ZERO, BigDecimal.TEN, LeaseStatus.DRAFT); + assertEquals("lease-2", fullLease.getId()); + } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java new file mode 100644 index 0000000..e0a9a20 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java @@ -0,0 +1,43 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class TenantAttachmentStorageTest { + @TempDir + Path directory; + + @Test + void storesReadsAndDeletesTenantAttachment() throws IOException { + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + MockMultipartFile file = new MockMultipartFile("file", "../identite.pdf", "application/pdf", + "PDF!".getBytes()); + TenantAttachment attachment = storage.store("tenant-1", file); + assertEquals("identite.pdf", attachment.getFileName()); + assertEquals("application/pdf", attachment.getContentType()); + assertEquals(4, attachment.getSize()); + assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())); + storage.delete(attachment); + assertThrows(IOException.class, () -> storage.read(attachment)); + } + + @Test + void appliesSafeDefaultsAndRejectsPathsOutsideRoot() throws IOException { + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + MockMultipartFile file = new MockMultipartFile("file", null, null, new byte[]{1}); + TenantAttachment attachment = storage.store("tenant-1", file); + assertEquals("attachment", attachment.getFileName()); + assertEquals("application/octet-stream", attachment.getContentType()); + TenantAttachment unsafe = new TenantAttachment("id", "unsafe", "text/plain", 1, "../unsafe"); + assertThrows(IOException.class, () -> storage.read(unsafe)); + assertThrows(IOException.class, () -> storage.delete(unsafe)); + assertThrows(IOException.class, () -> storage.store("../outside", file)); + } +} diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index 590bfd6..c38bf99 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -10,3 +10,4 @@ spring.security.oauth2.client.provider.oidc.token-uri=http://localhost:8090/real spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/realms/test/protocol/openid-connect/userinfo spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username +lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments diff --git a/compose.yml b/compose.yml index 15b32f7..f87b067 100644 --- a/compose.yml +++ b/compose.yml @@ -28,6 +28,9 @@ services: OIDC_ISSUER_URI: ${OIDC_ISSUER_URI:-http://localhost:8090/realms/voidAuth} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-lmnp-client} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-lmnp-secret} + LMNP_ATTACHMENT_STORAGE_PATH: /data/attachments + volumes: + - attachment_data:/data/attachments depends_on: mongodb: condition: service_healthy @@ -44,6 +47,7 @@ services: volumes: mongodb_data: + attachment_data: networks: default: diff --git a/frontend/angular.json b/frontend/angular.json index 711e6d7..ef9add8 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -68,7 +68,13 @@ "builder": "@angular/build:unit-test", "options": { "tsConfig": "tsconfig.spec.json", - "coverageExclude": ["**/*.html", "src/main.ts"] + "coverageExclude": ["**/*.html", "src/main.ts"], + "coverageThresholds": { + "statements": 80, + "branches": 80, + "functions": 80, + "lines": 80 + } } }, "lint": { diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index 4cd65bc..0bf3e66 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -195,3 +195,106 @@ test('follows operating-system changes for the System theme', async ({ page }) = await page.emulateMedia({ colorScheme: 'light' }); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); }); + +test('creates a tenant with an attachment and a lease with a guarantor', async ({ page }) => { + const apartments = [ + { + id: 'apartment-1', + name: 'Studio République', + address: '12 rue de la République, Lyon', + purchasePrice: 150000, + description: '', + }, + ]; + const tenants: Record[] = []; + const leases: Record[] = []; + + await mockUser(page, true); + await page.route('**/api/apartments', (route) => + route.fulfill({ contentType: 'application/json', json: apartments }), + ); + await page.route('**/api/tenants**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (request.method() === 'POST' && path.endsWith('/attachments')) { + const attachment = { + id: 'attachment-1', + fileName: 'identite.pdf', + contentType: 'application/pdf', + size: 8, + }; + (tenants[0]['attachments'] as Record[]).push(attachment); + await route.fulfill({ contentType: 'application/json', json: attachment }); + return; + } + if (request.method() === 'POST') { + const tenant = { id: 'tenant-1', attachments: [], ...request.postDataJSON() }; + tenants.push(tenant); + await route.fulfill({ contentType: 'application/json', json: tenant }); + return; + } + await route.fulfill({ contentType: 'application/json', json: tenants }); + }); + await page.route('**/api/leases**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (request.method() === 'GET' && path.endsWith('/pdf')) { + await route.fulfill({ + contentType: 'application/pdf', + headers: { 'Content-Disposition': 'attachment; filename="bail-lease-1.pdf"' }, + body: '%PDF-1.4\n%%EOF', + }); + return; + } + if (request.method() === 'POST') { + const lease = { id: 'lease-1', ...request.postDataJSON() }; + leases.push(lease); + await route.fulfill({ contentType: 'application/json', json: lease }); + return; + } + await route.fulfill({ contentType: 'application/json', json: leases }); + }); + + await page.goto('/'); + await page.getByTestId('primary-navigation').getByRole('button', { name: 'Locataires' }).click(); + await page.getByRole('button', { name: 'Ajouter un locataire' }).first().click(); + await page.getByLabel('Prénom', { exact: true }).fill('Camille'); + await page.getByLabel('Nom', { exact: true }).fill('Martin'); + await page.getByLabel('E-mail', { exact: true }).fill('camille@example.com'); + await page.getByLabel('Téléphone', { exact: true }).fill('+33612345678'); + await page.getByLabel('Adresse complète').fill('10 rue des Lilas, Lyon'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await expect(page.getByRole('heading', { name: 'Camille Martin' })).toBeVisible(); + + await page.getByLabel('Ajouter une pièce jointe pour Camille Martin').setInputFiles({ + name: 'identite.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('identity'), + }); + await expect( + page.getByRole('button', { name: 'Télécharger la pièce jointe identite.pdf' }), + ).toBeVisible(); + + await page.getByTestId('primary-navigation').getByRole('button', { name: 'Baux' }).click(); + await page.getByRole('button', { name: 'Créer un bail' }).first().click(); + await page.getByLabel('Appartement').selectOption('apartment-1'); + await page.getByLabel('Locataire').selectOption('tenant-1'); + await page.getByLabel('Date de début').fill('2026-09-01'); + await page.getByLabel('Date de fin').fill('2027-08-31'); + await page.getByLabel('Loyer mensuel').fill('850'); + await page.getByLabel('Charges mensuelles').fill('50'); + await page.getByLabel('Dépôt de garantie').fill('850'); + await page.getByLabel('Prénom du garant').fill('Alex'); + await page.getByLabel('Nom du garant', { exact: true }).fill('Durand'); + await page.getByLabel('E-mail du garant').fill('alex@example.com'); + await page.getByRole('button', { name: 'Ajouter le garant' }).click(); + await page.getByRole('button', { name: 'Enregistrer le bail' }).click(); + + await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible(); + await expect(page.getByText('Alex Durand')).toBeVisible(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Télécharger le bail' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('bail-lease-1.pdf'); + await expectNoHorizontalOverflow(page); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index eacdbb8..a19fc29 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; +const e2ePort = process.env['E2E_PORT'] ?? '4200'; + export default defineConfig({ testDir: './e2e', fullyParallel: true, @@ -8,7 +10,7 @@ export default defineConfig({ workers: process.env['CI'] ? 1 : undefined, reporter: process.env['CI'] ? 'line' : 'html', use: { - baseURL: 'http://127.0.0.1:4200', + baseURL: `http://127.0.0.1:${e2ePort}`, screenshot: 'only-on-failure', trace: 'on-first-retry', }, @@ -36,8 +38,8 @@ export default defineConfig({ }, ], webServer: { - command: 'bun run start -- --host 127.0.0.1 --port 4200', - url: 'http://127.0.0.1:4200', + command: `bun run start -- --host 127.0.0.1 --port ${e2ePort}`, + url: `http://127.0.0.1:${e2ePort}`, reuseExistingServer: !process.env['CI'], timeout: 120_000, }, diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 5928e7e..0eb02e7 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -39,6 +39,25 @@ (apartmentSaved)="saveApartment($event)" /> } + @if (activeTab() === 'tenants') { + + } + @if (activeTab() === 'leases') { + + } @if (activeTab() === 'settings') { { let mockHttpClient: any; let mockApartmentService: any; + let mockTenantService: any; + let mockLeaseService: any; let mockUserResponse: { authenticated: boolean; username: string; email: string }; let originalLocation: any; let locationMock: { href: string; origin: string }; @@ -42,6 +46,23 @@ describe('App', () => { deleteApartment: () => of(undefined), }; + mockTenantService = { + getTenants: vi.fn().mockReturnValue(of([])), + createTenant: vi.fn().mockImplementation((tenant) => of({ id: 'tenant-2', ...tenant })), + updateTenant: vi.fn().mockImplementation((id, tenant) => of({ id, ...tenant })), + deleteTenant: vi.fn().mockReturnValue(of(undefined)), + uploadAttachment: vi.fn().mockReturnValue(of({ id: 'attachment-1' })), + downloadAttachment: vi.fn().mockReturnValue(of(new Blob(['attachment']))), + }; + + mockLeaseService = { + getLeases: vi.fn().mockReturnValue(of([])), + createLease: vi.fn().mockImplementation((lease) => of({ id: 'lease-1', ...lease })), + updateLease: vi.fn().mockImplementation((id, lease) => of({ id, ...lease })), + deleteLease: vi.fn().mockReturnValue(of(undefined)), + downloadPdf: vi.fn().mockReturnValue(of(new Blob(['pdf']))), + }; + originalLocation = window.location; delete (window as any).location; locationMock = { @@ -55,6 +76,8 @@ describe('App', () => { providers: [ { provide: HttpClient, useValue: mockHttpClient }, { provide: ApartmentService, useValue: mockApartmentService }, + { provide: TenantService, useValue: mockTenantService }, + { provide: LeaseService, useValue: mockLeaseService }, ], }).compileComponents(); }); @@ -286,4 +309,81 @@ describe('App', () => { await fixture.whenStable(); expect(deleteSpy).not.toHaveBeenCalled(); }); + + it('should orchestrate tenant mutations and attachment upload', () => { + const app = TestBed.createComponent(App).componentInstance; + const tenant = { + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + }; + const file = new File(['identity'], 'identite.pdf'); + + app.saveTenant({ tenant }); + app.saveTenant({ tenant, tenantId: 'tenant-1' }); + app.uploadTenantAttachment({ tenantId: 'tenant-1', file }); + + expect(mockTenantService.createTenant).toHaveBeenCalledWith(tenant); + expect(mockTenantService.updateTenant).toHaveBeenCalledWith('tenant-1', tenant); + expect(mockTenantService.uploadAttachment).toHaveBeenCalledWith('tenant-1', file); + expect(mockTenantService.getTenants).toHaveBeenCalledTimes(3); + }); + + it('should orchestrate lease creation and confirmed deletions', () => { + const app = TestBed.createComponent(App).componentInstance; + const lease = { + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + startDate: '2026-09-01', + endDate: '2027-08-31', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + status: 'ACTIVE' as const, + }; + vi.spyOn(window, 'confirm').mockReturnValue(true); + + app.saveLease(lease); + app.deleteLease('lease-1'); + app.deleteTenant('tenant-1'); + + expect(mockLeaseService.createLease).toHaveBeenCalledWith(lease); + expect(mockLeaseService.deleteLease).toHaveBeenCalledWith('lease-1'); + expect(mockTenantService.deleteTenant).toHaveBeenCalledWith('tenant-1'); + }); + + it('should download tenant attachments and lease PDFs', () => { + const app = TestBed.createComponent(App).componentInstance; + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + app.downloadTenantAttachment({ + tenantId: 'tenant-1', + attachment: { id: 'attachment-1', fileName: 'identite.pdf' }, + }); + app.downloadLeasePdf({ + id: 'lease-1', + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + startDate: '2026-09-01', + endDate: '2027-08-31', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + status: 'ACTIVE', + }); + + expect(mockTenantService.downloadAttachment).toHaveBeenCalled(); + expect(mockLeaseService.downloadPdf).toHaveBeenCalledWith('lease-1'); + expect(createObjectUrl).toHaveBeenCalledTimes(2); + expect(click).toHaveBeenCalledTimes(2); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:test'); + }); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index e9c5540..60f247d 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -12,6 +12,10 @@ import { DashboardPage } from './components/dashboard-page/dashboard-page'; import { ApartmentsPage } from './components/apartments-page/apartments-page'; import { ApartmentSaveRequest } from './components/apartments-page/apartments-page'; import { SettingsPage } from './components/settings-page/settings-page'; +import { Tenant, TenantService } from './services/tenant.service'; +import { Lease, LeaseService } from './services/lease.service'; +import { TenantsPage, TenantSaveRequest } from './components/tenants-page/tenants-page'; +import { LeasesPage } from './components/leases-page/leases-page'; @Component({ selector: 'app-root', @@ -21,6 +25,8 @@ import { SettingsPage } from './components/settings-page/settings-page'; WorkspaceNavigation, DashboardPage, ApartmentsPage, + TenantsPage, + LeasesPage, SettingsPage, ], templateUrl: './app.html', @@ -33,6 +39,8 @@ export class App implements OnInit { // Tab & CRUD States readonly activeTab = signal('dashboard'); readonly apartments = signal([]); + readonly tenants = signal([]); + readonly leases = signal([]); private readonly _isLoggedIn = signal(false); private readonly _username = signal(''); private readonly _email = signal(''); @@ -40,6 +48,8 @@ export class App implements OnInit { private readonly http = inject(HttpClient); private readonly apartmentService = inject(ApartmentService); + private readonly tenantService = inject(TenantService); + private readonly leaseService = inject(LeaseService); protected readonly themeService = inject(ThemeService); readonly themePreference = this.themeService.preference; @@ -47,6 +57,8 @@ export class App implements OnInit { effect(() => { if (this.isLoggedIn) { this.loadApartments(); + this.loadTenants(); + this.loadLeases(); } }); } @@ -139,4 +151,87 @@ export class App implements OnInit { }); } } + + loadTenants(): void { + this.tenantService.getTenants().subscribe({ + next: (data) => this.tenants.set(data), + error: (err) => console.error('Error loading tenants', err), + }); + } + + saveTenant({ tenant, tenantId }: TenantSaveRequest): void { + const request = tenantId + ? this.tenantService.updateTenant(tenantId, tenant) + : this.tenantService.createTenant(tenant); + request.subscribe({ + next: () => this.loadTenants(), + error: (err) => console.error('Error saving tenant', err), + }); + } + + deleteTenant(id: string): void { + if (confirm('Êtes-vous sûr de vouloir supprimer ce locataire ?')) { + this.tenantService.deleteTenant(id).subscribe({ + next: () => this.loadTenants(), + error: (err) => console.error('Error deleting tenant', err), + }); + } + } + + uploadTenantAttachment(request: { tenantId: string; file: File }): void { + this.tenantService.uploadAttachment(request.tenantId, request.file).subscribe({ + next: () => this.loadTenants(), + error: (err) => console.error('Error uploading tenant attachment', err), + }); + } + + downloadTenantAttachment(request: { + tenantId: string; + attachment: { id: string; fileName: string }; + }): void { + this.tenantService.downloadAttachment(request.tenantId, request.attachment.id).subscribe({ + next: (blob) => this.saveBlob(blob, request.attachment.fileName), + error: (err) => console.error('Error downloading tenant attachment', err), + }); + } + + loadLeases(): void { + this.leaseService.getLeases().subscribe({ + next: (data) => this.leases.set(data), + error: (err) => console.error('Error loading leases', err), + }); + } + + saveLease(lease: Lease): void { + this.leaseService.createLease(lease).subscribe({ + next: () => this.loadLeases(), + error: (err) => console.error('Error saving lease', err), + }); + } + + deleteLease(id: string): void { + if (confirm('Êtes-vous sûr de vouloir supprimer ce bail ?')) { + this.leaseService.deleteLease(id).subscribe({ + next: () => this.loadLeases(), + error: (err) => console.error('Error deleting lease', err), + }); + } + } + + downloadLeasePdf(lease: Lease): void { + if (!lease.id) return; + this.leaseService.downloadPdf(lease.id).subscribe({ + next: (blob) => this.saveBlob(blob, `bail-${lease.id}.pdf`), + error: (err) => console.error('Error downloading lease PDF', err), + }); + } + + private saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); + } } diff --git a/frontend/src/app/components/leases-page/leases-page.css b/frontend/src/app/components/leases-page/leases-page.css new file mode 100644 index 0000000..9528d6c --- /dev/null +++ b/frontend/src/app/components/leases-page/leases-page.css @@ -0,0 +1,240 @@ +:host { + display: block; +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.page-heading .primary-action { + width: 100%; + margin-top: 20px; +} +.empty-state, +.lease-card, +.lease-form { + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); +} +.empty-state { + padding: 46px 22px; + text-align: center; +} +.empty-state > p:not(.eyebrow) { + color: var(--text-muted); +} +.lease-grid { + display: grid; + gap: 16px; +} +.lease-card { + min-width: 0; + padding: 20px; +} +.lease-topline { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 700; +} +.lease-topline strong { + margin-left: auto; + color: var(--text); + font-size: 1rem; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 4px var(--surface-accent); +} +.lease-card h2 { + margin: 25px 0 6px; + font-size: 1.35rem; + letter-spacing: -0.035em; +} +.tenant-name { + color: var(--accent); + font-size: 0.86rem; + font-weight: 700; +} +.terms { + display: grid; + gap: 12px; + margin: 22px 0; +} +.terms dt { + color: var(--text-subtle); + font-size: 0.68rem; + font-weight: 750; + text-transform: uppercase; +} +.terms dd { + margin: 3px 0 0; + font-size: 0.84rem; +} +.guarantor-summary { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + padding: 13px; + border-radius: 13px; + background: var(--surface-soft); + font-size: 0.8rem; +} +.guarantor-summary span { + width: 100%; + color: var(--text-subtle); + font-size: 0.66rem; + font-weight: 750; + text-transform: uppercase; +} +.card-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 14px; +} +.card-actions .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent); +} +.card-actions .danger-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--danger); +} +.back-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + margin: 0 0 20px -16px; +} +.lease-form { + padding: 20px; +} +.form-section + .form-section { + margin-top: 30px; + padding-top: 28px; + border-top: 1px solid var(--border); +} +.section-heading { + display: flex; + gap: 12px; + margin-bottom: 20px; +} +.section-heading > span { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 30px; + place-items: center; + border-radius: 10px; + background: var(--surface-accent); + color: var(--accent); + font-size: 0.7rem; + font-weight: 800; +} +.section-heading h2 { + margin: 0 0 4px; + font-size: 1rem; +} +.section-heading p { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; +} +.form-grid { + display: grid; + gap: 2px; +} +.form-grid mat-form-field { + width: 100%; +} +.guarantor-chip { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + padding: 5px 7px 5px 14px; + border-radius: 12px; + background: var(--surface-soft); + font-size: 0.82rem; +} +.guarantor-chip .mat-mdc-button { + --mdc-text-button-label-text-color: var(--danger); +} +.add-guarantor.mat-mdc-outlined-button { + --mdc-outlined-button-label-text-color: var(--accent); + --mdc-outlined-button-outline-color: var(--border-strong); + width: 100%; +} +.alert-error { + margin-bottom: 18px; + padding: 13px 15px; + border-radius: 12px; + background: var(--danger-soft); + color: var(--danger); +} +.form-actions { + display: flex; + flex-direction: column-reverse; + gap: 8px; + margin-top: 28px; +} +.form-actions button { + width: 100%; +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } + .page-heading .primary-action { + width: auto; + margin-top: 0; + } + .lease-grid, + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + } + .three-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .full-field { + grid-column: span 2; + } + .lease-form { + padding: 30px; + } + .add-guarantor.mat-mdc-outlined-button { + width: auto; + } + .form-actions { + flex-direction: row; + justify-content: flex-end; + } + .form-actions button { + width: auto; + } +} +@media (min-width: 1180px) { + .lease-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .form-page { + max-width: 980px; + } +} diff --git a/frontend/src/app/components/leases-page/leases-page.html b/frontend/src/app/components/leases-page/leases-page.html new file mode 100644 index 0000000..0b4d73f --- /dev/null +++ b/frontend/src/app/components/leases-page/leases-page.html @@ -0,0 +1,259 @@ +@if (!showForm()) { +
+
+
+

Location

+

Mes baux

+

Les baux actifs, leurs parties et leurs conditions financières.

+
+ +
+ @if (leases().length === 0) { +
+

Mise en location

+

Aucun bail enregistré

+

Créez un bail dès qu’un appartement et un locataire sont prêts.

+ +
+ } @else { +
+ @for (lease of leases(); track lease.id) { +
+
+ {{ statusLabel(lease.status) }}{{ lease.monthlyRent.toLocaleString('fr-FR') }} € +
+

{{ apartmentName(lease) }}

+

{{ tenantNames(lease) }}

+
+
+
Période
+
{{ lease.startDate }} → {{ lease.endDate }}
+
+
+
Charges
+
{{ lease.monthlyCharges.toLocaleString('fr-FR') }} € / mois
+
+
+
Dépôt
+
{{ lease.securityDeposit.toLocaleString('fr-FR') }} €
+
+
+ @if (lease.guarantors.length) { +
+ Garant + @for (guarantor of lease.guarantors; track $index) { + {{ guarantor.firstName }} {{ guarantor.lastName }} + } +
+ } +
+ + @if (lease.id) { + + } +
+
+ } +
+ } +
+} @else { +
+
+
+ +

Nouveau bail

+

Créer un bail

+

Associez le bien, le locataire et les éventuels garants.

+
+
+
+ @if (error()) { + + } +
+
+ 01 +
+

Parties du bail

+

Choisissez le logement et le locataire.

+
+
+
+ Appartement + Locataire +
+
+
+
+ 02 +
+

Conditions

+

Définissez la période et les montants mensuels.

+
+
+
+ Date de début + Date de fin + Loyer mensuel€ + Charges mensuelles€ + Dépôt de garantie€ +
+
+
+
+ 03 +
+

Garants

+

Ajoutez un ou plusieurs contacts de garantie.

+
+
+ @for (guarantor of guarantors(); track $index) { +
+ {{ guarantor.firstName }} {{ guarantor.lastName }} +
+ } +
+ Prénom du garant + Nom du garant + E-mail du garant + Téléphone du garant + Adresse du garant +
+ +
+
+ +
+
+
+} diff --git a/frontend/src/app/components/leases-page/leases-page.spec.ts b/frontend/src/app/components/leases-page/leases-page.spec.ts new file mode 100644 index 0000000..9411c9e --- /dev/null +++ b/frontend/src/app/components/leases-page/leases-page.spec.ts @@ -0,0 +1,88 @@ +import { TestBed } from '@angular/core/testing'; +import { Lease } from '../../services/lease.service'; +import { LeasesPage } from './leases-page'; + +describe('LeasesPage', () => { + function createComponent(): LeasesPage { + const fixture = TestBed.createComponent(LeasesPage); + fixture.componentRef.setInput('leases', []); + fixture.componentRef.setInput('apartments', [ + { id: 'apartment-1', name: 'Studio', address: 'Lyon', purchasePrice: 1, description: '' }, + ]); + fixture.componentRef.setInput('tenants', [ + { + id: 'tenant-1', + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + }, + ]); + return fixture.componentInstance; + } + + it('adds a guarantor and emits a valid lease', () => { + const component = createComponent(); + let savedLease: Lease | undefined; + component.leaseSaved.subscribe((lease) => (savedLease = lease)); + + component.guarantorFirstName.set('Alex'); + component.guarantorLastName.set('Durand'); + component.guarantorEmail.set('alex@example.com'); + component.addGuarantor(); + expect(component.guarantors()).toHaveLength(1); + + component.apartmentId.set('apartment-1'); + component.tenantId.set('tenant-1'); + component.startDate.set('2026-09-01'); + component.endDate.set('2027-08-31'); + component.monthlyRent.set(850); + component.save(); + + expect(savedLease?.tenantIds).toEqual(['tenant-1']); + expect(savedLease?.guarantors[0].lastName).toBe('Durand'); + }); + + it('rejects incomplete lease and guarantor drafts', () => { + const component = createComponent(); + + component.addGuarantor(); + expect(component.error()).toContain('garant'); + component.save(); + expect(component.error()).toContain('Sélectionnez'); + }); + + it('resets the form and resolves display labels', () => { + const component = createComponent(); + component.monthlyRent.set(900); + component.guarantors.set([ + { firstName: 'Alex', lastName: 'Durand', email: 'alex@example.com', phone: '', address: '' }, + ]); + + component.openForm(); + + expect(component.showForm()).toBe(true); + expect(component.monthlyRent()).toBe(0); + expect(component.guarantors()).toEqual([]); + expect(component.apartmentName({ apartmentId: 'apartment-1' } as Lease)).toBe('Studio'); + expect(component.tenantNames({ tenantIds: ['tenant-1'] } as Lease)).toBe('Camille Martin'); + expect(component.statusLabel('DRAFT')).toBe('Bail en préparation'); + expect(component.statusLabel('ACTIVE')).toBe('Bail actif'); + expect(component.statusLabel('ARCHIVED')).toBe('Bail archivé'); + component.closeForm(); + expect(component.showForm()).toBe(false); + }); + + it('removes a guarantor', () => { + const component = createComponent(); + component.guarantors.set([ + { firstName: 'Alex', lastName: 'Durand', email: 'alex@example.com', phone: '', address: '' }, + { firstName: 'Jo', lastName: 'Petit', email: 'jo@example.com', phone: '', address: '' }, + ]); + + component.removeGuarantor(0); + + expect(component.guarantors().map((guarantor) => guarantor.firstName)).toEqual(['Jo']); + }); +}); diff --git a/frontend/src/app/components/leases-page/leases-page.ts b/frontend/src/app/components/leases-page/leases-page.ts new file mode 100644 index 0000000..330ee2b --- /dev/null +++ b/frontend/src/app/components/leases-page/leases-page.ts @@ -0,0 +1,142 @@ +import { Component, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { Apartment } from '../../services/apartment.service'; +import { Guarantor, Lease } from '../../services/lease.service'; +import { Tenant } from '../../services/tenant.service'; + +@Component({ + selector: 'app-leases-page', + imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + templateUrl: './leases-page.html', + styleUrl: './leases-page.css', +}) +export class LeasesPage { + readonly leases = input.required(); + readonly apartments = input.required(); + readonly tenants = input.required(); + readonly leaseSaved = output(); + readonly deleteRequested = output(); + readonly pdfRequested = output(); + + readonly showForm = signal(false); + readonly apartmentId = signal(''); + readonly tenantId = signal(''); + readonly startDate = signal(''); + readonly endDate = signal(''); + readonly monthlyRent = signal(0); + readonly monthlyCharges = signal(0); + readonly securityDeposit = signal(0); + readonly guarantors = signal([]); + readonly guarantorFirstName = signal(''); + readonly guarantorLastName = signal(''); + readonly guarantorEmail = signal(''); + readonly guarantorPhone = signal(''); + readonly guarantorAddress = signal(''); + readonly error = signal(''); + + openForm(): void { + this.reset(); + this.showForm.set(true); + } + + closeForm(): void { + this.showForm.set(false); + } + + addGuarantor(): void { + if ( + !this.guarantorFirstName().trim() || + !this.guarantorLastName().trim() || + !this.guarantorEmail().trim() + ) { + this.error.set('Le prénom, le nom et l’e-mail du garant sont obligatoires.'); + return; + } + this.guarantors.update((guarantors) => [ + ...guarantors, + { + firstName: this.guarantorFirstName().trim(), + lastName: this.guarantorLastName().trim(), + email: this.guarantorEmail().trim(), + phone: this.guarantorPhone().trim(), + address: this.guarantorAddress().trim(), + }, + ]); + this.guarantorFirstName.set(''); + this.guarantorLastName.set(''); + this.guarantorEmail.set(''); + this.guarantorPhone.set(''); + this.guarantorAddress.set(''); + this.error.set(''); + } + + removeGuarantor(index: number): void { + this.guarantors.update((guarantors) => + guarantors.filter((_, itemIndex) => itemIndex !== index), + ); + } + + save(): void { + if ( + !this.apartmentId() || + !this.tenantId() || + !this.startDate() || + !this.endDate() || + this.monthlyRent() <= 0 + ) { + this.error.set('Sélectionnez un bien et un locataire, puis complétez les dates et le loyer.'); + return; + } + this.leaseSaved.emit({ + apartmentId: this.apartmentId(), + tenantIds: [this.tenantId()], + guarantors: this.guarantors(), + startDate: this.startDate(), + endDate: this.endDate(), + monthlyRent: this.monthlyRent(), + monthlyCharges: this.monthlyCharges(), + securityDeposit: this.securityDeposit(), + status: 'ACTIVE', + }); + this.closeForm(); + } + + apartmentName(lease: Lease): string { + return ( + this.apartments().find((apartment) => apartment.id === lease.apartmentId)?.name ?? + 'Bien inconnu' + ); + } + + tenantNames(lease: Lease): string { + return lease.tenantIds + .map((id) => this.tenants().find((tenant) => tenant.id === id)) + .filter((tenant): tenant is Tenant => Boolean(tenant)) + .map((tenant) => `${tenant.firstName} ${tenant.lastName}`) + .join(', '); + } + + statusLabel(status: Lease['status']): string { + return { DRAFT: 'Bail en préparation', ACTIVE: 'Bail actif', ARCHIVED: 'Bail archivé' }[status]; + } + + private reset(): void { + this.apartmentId.set(''); + this.tenantId.set(''); + this.startDate.set(''); + this.endDate.set(''); + this.monthlyRent.set(0); + this.monthlyCharges.set(0); + this.securityDeposit.set(0); + this.guarantors.set([]); + this.guarantorFirstName.set(''); + this.guarantorLastName.set(''); + this.guarantorEmail.set(''); + this.guarantorPhone.set(''); + this.guarantorAddress.set(''); + this.error.set(''); + } +} diff --git a/frontend/src/app/components/tenants-page/tenants-page.css b/frontend/src/app/components/tenants-page/tenants-page.css new file mode 100644 index 0000000..3703fb4 --- /dev/null +++ b/frontend/src/app/components/tenants-page/tenants-page.css @@ -0,0 +1,207 @@ +:host { + display: block; +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + margin-bottom: 8px; + font-size: clamp(2rem, 7vw, 3.2rem); + font-weight: 750; + letter-spacing: -0.055em; + line-height: 1.02; +} +.page-heading p:last-child { + margin-bottom: 0; + color: var(--text-muted); + line-height: 1.6; +} +.page-heading .primary-action { + width: 100%; + margin-top: 20px; +} +.empty-state, +.tenant-card, +.entity-form { + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); +} +.empty-state { + padding: 46px 22px; + text-align: center; +} +.empty-state > p:not(.eyebrow) { + color: var(--text-muted); +} +.tenant-grid { + display: grid; + gap: 16px; +} +.tenant-card { + min-width: 0; + padding: 20px; +} +.tenant-identity { + display: flex; + align-items: center; + gap: 14px; +} +.tenant-identity h2 { + margin: 0; + font-size: 1.25rem; + letter-spacing: -0.03em; +} +.tenant-avatar { + display: grid; + width: 46px; + height: 46px; + flex: 0 0 46px; + place-items: center; + border-radius: 15px; + background: var(--surface-accent); + color: var(--accent); + font-weight: 800; +} +.contact-list { + display: grid; + gap: 11px; + margin: 22px 0; +} +.contact-list div { + min-width: 0; +} +.contact-list dt { + color: var(--text-subtle); + font-size: 0.7rem; + font-weight: 750; + text-transform: uppercase; +} +.contact-list dd { + overflow-wrap: anywhere; + margin: 3px 0 0; + font-size: 0.88rem; +} +.attachments { + padding: 15px; + border-radius: 15px; + background: var(--surface-soft); +} +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 0.82rem; +} +.section-title span { + display: grid; + min-width: 24px; + height: 24px; + place-items: center; + border-radius: 50%; + background: var(--surface); + color: var(--text-muted); +} +.attachment-row.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text); + width: 100%; + min-width: 0; + justify-content: flex-start; + padding: 0 5px; + font-size: 0.78rem; +} +.upload-action { + display: block; + min-height: 40px; + padding: 11px 5px 4px; + color: var(--accent); + cursor: pointer; + font-size: 0.78rem; + font-weight: 750; +} +.upload-action input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} +.card-actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} +.card-actions .mat-mdc-button { + --mdc-text-button-label-text-color: var(--accent); +} +.card-actions .danger-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--danger); +} +.back-action.mat-mdc-button { + --mdc-text-button-label-text-color: var(--text-muted); + margin: 0 0 20px -16px; +} +.entity-form { + padding: 20px; +} +.form-grid { + display: grid; + gap: 2px; +} +.form-grid mat-form-field { + width: 100%; +} +.alert-error { + margin-bottom: 18px; + padding: 13px 15px; + border-radius: 12px; + background: var(--danger-soft); + color: var(--danger); +} +.form-actions { + display: flex; + flex-direction: column-reverse; + gap: 8px; +} +.form-actions button { + width: 100%; +} +@media (min-width: 700px) { + .page-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + } + .page-heading .primary-action { + width: auto; + margin-top: 0; + } + .tenant-grid, + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + } + .full-field { + grid-column: span 2; + } + .entity-form { + padding: 30px; + } + .form-actions { + flex-direction: row; + justify-content: flex-end; + } + .form-actions button { + width: auto; + } +} +@media (min-width: 1180px) { + .tenant-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .form-page { + max-width: 900px; + } +} diff --git a/frontend/src/app/components/tenants-page/tenants-page.html b/frontend/src/app/components/tenants-page/tenants-page.html new file mode 100644 index 0000000..20842b6 --- /dev/null +++ b/frontend/src/app/components/tenants-page/tenants-page.html @@ -0,0 +1,164 @@ +@if (!showForm()) { +
+
+
+

Dossiers

+

Mes locataires

+

Contacts, pièces jointes et accès rapide aux baux associés.

+
+ +
+ + @if (tenants().length === 0) { +
+

Premier dossier

+

Aucun locataire enregistré

+

Centralisez ici les coordonnées et pièces jointes utiles de vos locataires.

+ +
+ } @else { +
+ @for (tenant of tenants(); track tenant.id) { +
+
+ {{ tenant.firstName.charAt(0) }}{{ tenant.lastName.charAt(0) }} +
+

Locataire

+

{{ tenant.firstName }} {{ tenant.lastName }}

+
+
+
+
+
E-mail
+
{{ tenant.email }}
+
+
+
Téléphone
+
{{ tenant.phone || 'Non renseigné' }}
+
+
+
Adresse
+
{{ tenant.address || 'Non renseignée' }}
+
+
+
+
+ Pièces jointes{{ tenant.attachments?.length ?? 0 }} +
+ @for (attachment of tenant.attachments ?? []; track attachment.id) { + + } + @if (tenant.id) { + + } +
+
+ + @if (tenant.id) { + + } +
+
+ } +
+ } +
+} @else { +
+
+
+ +

Dossier locataire

+

{{ editingTenant() ? 'Modifier le locataire' : 'Ajouter un locataire' }}

+

Renseignez les coordonnées nécessaires à la gestion du bail.

+
+
+
+ @if (error()) { + + } +
+ Prénom + Nom + E-mail + Téléphone + Adresse complète +
+
+ +
+
+
+} diff --git a/frontend/src/app/components/tenants-page/tenants-page.spec.ts b/frontend/src/app/components/tenants-page/tenants-page.spec.ts new file mode 100644 index 0000000..921105a --- /dev/null +++ b/frontend/src/app/components/tenants-page/tenants-page.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { TenantsPage } from './tenants-page'; + +describe('TenantsPage', () => { + it('validates and emits a tenant draft', () => { + const fixture = TestBed.createComponent(TenantsPage); + fixture.componentRef.setInput('tenants', []); + const component = fixture.componentInstance; + let savedName = ''; + component.tenantSaved.subscribe((request) => (savedName = request.tenant.firstName)); + + component.save(); + expect(component.error()).toContain('obligatoires'); + + component.firstName.set('Camille'); + component.lastName.set('Martin'); + component.email.set('camille@example.com'); + component.save(); + + expect(savedName).toBe('Camille'); + expect(component.showForm()).toBe(false); + }); + + it('emits a selected attachment for an existing tenant', () => { + const fixture = TestBed.createComponent(TenantsPage); + fixture.componentRef.setInput('tenants', []); + const component = fixture.componentInstance; + const file = new File(['identity'], 'identite.pdf', { type: 'application/pdf' }); + let uploadedFile: File | undefined; + component.attachmentUploadRequested.subscribe((request) => (uploadedFile = request.file)); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: { item: () => file } }); + + component.selectFile( + { + id: 'tenant-1', + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '', + address: '', + }, + { target: input } as unknown as Event, + ); + + expect(uploadedFile).toBe(file); + }); + + it('opens, fills, and closes the edit form', () => { + const fixture = TestBed.createComponent(TenantsPage); + fixture.componentRef.setInput('tenants', []); + const component = fixture.componentInstance; + const tenant = { + id: 'tenant-1', + firstName: 'Camille', + lastName: 'Martin', + email: 'camille@example.com', + phone: '0600000000', + address: 'Lyon', + }; + + component.openEditForm(tenant); + fixture.detectChanges(); + expect(component.showForm()).toBe(true); + expect(component.firstName()).toBe('Camille'); + + component.closeForm(); + fixture.detectChanges(); + expect(component.showForm()).toBe(false); + expect(component.firstName()).toBe(''); + + component.openAddForm(); + expect(component.showForm()).toBe(true); + }); +}); diff --git a/frontend/src/app/components/tenants-page/tenants-page.ts b/frontend/src/app/components/tenants-page/tenants-page.ts new file mode 100644 index 0000000..8f26175 --- /dev/null +++ b/frontend/src/app/components/tenants-page/tenants-page.ts @@ -0,0 +1,94 @@ +import { Component, effect, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { Tenant, TenantAttachment } from '../../services/tenant.service'; + +export interface TenantSaveRequest { + tenant: Tenant; + tenantId?: string; +} + +export interface TenantAttachmentRequest { + tenantId: string; + attachment: TenantAttachment; +} + +@Component({ + selector: 'app-tenants-page', + imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule], + templateUrl: './tenants-page.html', + styleUrl: './tenants-page.css', +}) +export class TenantsPage { + readonly tenants = input.required(); + readonly tenantSaved = output(); + readonly deleteRequested = output(); + readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>(); + readonly attachmentDownloadRequested = output(); + + readonly showForm = signal(false); + readonly editingTenant = signal(null); + readonly firstName = signal(''); + readonly lastName = signal(''); + readonly email = signal(''); + readonly phone = signal(''); + readonly address = signal(''); + readonly error = signal(''); + + constructor() { + effect(() => this.fillForm(this.editingTenant())); + } + + openAddForm(): void { + this.editingTenant.set(null); + this.fillForm(null); + this.showForm.set(true); + } + + openEditForm(tenant: Tenant): void { + this.editingTenant.set(tenant); + this.showForm.set(true); + } + + closeForm(): void { + this.showForm.set(false); + this.editingTenant.set(null); + } + + save(): void { + if (!this.firstName().trim() || !this.lastName().trim() || !this.email().trim()) { + this.error.set('Le prénom, le nom et l’e-mail sont obligatoires.'); + return; + } + this.tenantSaved.emit({ + tenant: { + firstName: this.firstName().trim(), + lastName: this.lastName().trim(), + email: this.email().trim(), + phone: this.phone().trim(), + address: this.address().trim(), + }, + tenantId: this.editingTenant()?.id, + }); + this.closeForm(); + } + + selectFile(tenant: Tenant, event: Event): void { + const file = (event.target as HTMLInputElement).files?.item(0); + if (tenant.id && file) { + this.attachmentUploadRequested.emit({ tenantId: tenant.id, file }); + } + (event.target as HTMLInputElement).value = ''; + } + + private fillForm(tenant: Tenant | null): void { + this.firstName.set(tenant?.firstName ?? ''); + this.lastName.set(tenant?.lastName ?? ''); + this.email.set(tenant?.email ?? ''); + this.phone.set(tenant?.phone ?? ''); + this.address.set(tenant?.address ?? ''); + this.error.set(''); + } +} diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.css b/frontend/src/app/components/workspace-navigation/workspace-navigation.css index 37a956e..4b42970 100644 --- a/frontend/src/app/components/workspace-navigation/workspace-navigation.css +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.css @@ -21,7 +21,7 @@ .navigation-items { display: grid; height: 100%; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 4px; } .navigation-item.mat-mdc-button { diff --git a/frontend/src/app/components/workspace-navigation/workspace-navigation.html b/frontend/src/app/components/workspace-navigation/workspace-navigation.html index 97ba524..3306b28 100644 --- a/frontend/src/app/components/workspace-navigation/workspace-navigation.html +++ b/frontend/src/app/components/workspace-navigation/workspace-navigation.html @@ -47,6 +47,48 @@ Appartements + +