feat: manage tenants and leases (#4)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
1 parent 26ab037084
commit 8603b98714
42 files changed
+3089 -18

No files matched your search

@@ -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()
)
@@ -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<Lease> getAllLeases() {
return leaseRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Lease> getLease(@PathVariable String id) {
return leaseRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Lease> 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<Lease> 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<Void> deleteLease(@PathVariable String id) {
if (!leaseRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
leaseRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> 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<Tenant> 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<Tenant> tenantsFor(List<String> ids) {
List<Tenant> 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());
}
}
@@ -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<Tenant> getAllTenants() {
return tenantRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Tenant> getTenant(@PathVariable String id) {
return tenantRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
tenant.setId(null);
tenant.setLandlordId("default-landlord");
return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant));
}
@PutMapping("/{id}")
public ResponseEntity<Tenant> 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<Void> 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<TenantAttachment> 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<Resource> 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<Void> 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();
}
}
@@ -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;
}
}
@@ -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<String> tenantIds = new ArrayList<>();
private List<Guarantor> 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<String> tenantIds,
List<Guarantor> 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<String> getTenantIds() { return tenantIds; }
public void setTenantIds(List<String> tenantIds) {
this.tenantIds = tenantIds == null ? new ArrayList<>() : new ArrayList<>(tenantIds);
}
public List<Guarantor> getGuarantors() { return guarantors; }
public void setGuarantors(List<Guarantor> 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; }
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.model;
public enum LeaseStatus {
DRAFT,
ACTIVE,
ARCHIVED
}
@@ -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<TenantAttachment> 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<TenantAttachment> getAttachments() {
return attachments;
}
public void setAttachments(List<TenantAttachment> attachments) {
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
}
}
@@ -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; }
}
@@ -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<Lease, String> {
}
@@ -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<Tenant, String> {
}
@@ -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<Tenant> 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<String> 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<byte[]> 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<Integer> 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); }
}
@@ -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);
}
}
@@ -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}
@@ -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.
@@ -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"
}
""";
}
}
@@ -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");
}
});
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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
+4
View File
@@ -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:
+7 -1
View File
@@ -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": {
+103
View File
@@ -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<string, unknown>[] = [];
const leases: Record<string, unknown>[] = [];
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<string, unknown>[]).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);
});
+5 -3
View File
@@ -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,
},
+19
View File
@@ -39,6 +39,25 @@
(apartmentSaved)="saveApartment($event)"
/>
}
@if (activeTab() === 'tenants') {
<app-tenants-page
[tenants]="tenants()"
(tenantSaved)="saveTenant($event)"
(deleteRequested)="deleteTenant($event)"
(attachmentUploadRequested)="uploadTenantAttachment($event)"
(attachmentDownloadRequested)="downloadTenantAttachment($event)"
/>
}
@if (activeTab() === 'leases') {
<app-leases-page
[leases]="leases()"
[apartments]="apartments()"
[tenants]="tenants()"
(leaseSaved)="saveLease($event)"
(deleteRequested)="deleteLease($event)"
(pdfRequested)="downloadLeasePdf($event)"
/>
}
@if (activeTab() === 'settings') {
<app-settings-page
[username]="username"
+100
View File
@@ -2,12 +2,16 @@ import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { HttpClient } from '@angular/common/http';
import { ApartmentService } from './services/apartment.service';
import { TenantService } from './services/tenant.service';
import { LeaseService } from './services/lease.service';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
describe('App', () => {
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');
});
});
+95
View File
@@ -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<WorkspaceTab>('dashboard');
readonly apartments = signal<Apartment[]>([]);
readonly tenants = signal<Tenant[]>([]);
readonly leases = signal<Lease[]>([]);
private readonly _isLoggedIn = signal<boolean>(false);
private readonly _username = signal<string>('');
private readonly _email = signal<string>('');
@@ -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);
}
}
@@ -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;
}
}
@@ -0,0 +1,259 @@
@if (!showForm()) {
<section class="page-section">
<header class="page-heading">
<div>
<p class="eyebrow">Location</p>
<h1>Mes baux</h1>
<p>Les baux actifs, leurs parties et leurs conditions financières.</p>
</div>
<button mat-flat-button type="button" class="primary-action" (click)="openForm()">
<span aria-hidden="true">+</span> Créer un bail
</button>
</header>
@if (leases().length === 0) {
<article class="empty-state">
<p class="eyebrow">Mise en location</p>
<h2>Aucun bail enregistré</h2>
<p>Créez un bail dès qu’un appartement et un locataire sont prêts.</p>
<button mat-stroked-button type="button" (click)="openForm()">Créer un bail</button>
</article>
} @else {
<div class="lease-grid">
@for (lease of leases(); track lease.id) {
<article class="lease-card">
<div class="lease-topline">
<span class="status-dot"></span><span>{{ statusLabel(lease.status) }}</span
><strong>{{ lease.monthlyRent.toLocaleString('fr-FR') }} €</strong>
</div>
<h2>{{ apartmentName(lease) }}</h2>
<p class="tenant-name">{{ tenantNames(lease) }}</p>
<dl class="terms">
<div>
<dt>Période</dt>
<dd>{{ lease.startDate }} → {{ lease.endDate }}</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ lease.monthlyCharges.toLocaleString('fr-FR') }} € / mois</dd>
</div>
<div>
<dt>Dépôt</dt>
<dd>{{ lease.securityDeposit.toLocaleString('fr-FR') }} €</dd>
</div>
</dl>
@if (lease.guarantors.length) {
<div class="guarantor-summary">
<span>Garant</span>
@for (guarantor of lease.guarantors; track $index) {
<strong>{{ guarantor.firstName }} {{ guarantor.lastName }}</strong>
}
</div>
}
<div class="card-actions">
<button mat-button type="button" (click)="pdfRequested.emit(lease)">
Télécharger le bail
</button>
@if (lease.id) {
<button
mat-button
type="button"
class="danger-action"
(click)="deleteRequested.emit(lease.id)"
>
Supprimer
</button>
}
</div>
</article>
}
</div>
}
</section>
} @else {
<section class="form-page">
<header class="page-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closeForm()">← Retour</button>
<p class="eyebrow">Nouveau bail</p>
<h1>Créer un bail</h1>
<p>Associez le bien, le locataire et les éventuels garants.</p>
</div>
</header>
<form class="lease-form" (submit)="save(); $event.preventDefault()">
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<section class="form-section">
<div class="section-heading">
<span>01</span>
<div>
<h2>Parties du bail</h2>
<p>Choisissez le logement et le locataire.</p>
</div>
</div>
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><select
matNativeControl
name="apartmentId"
[ngModel]="apartmentId()"
(ngModelChange)="apartmentId.set($event)"
required
>
<option value=""></option>
@for (apartment of apartments(); track apartment.id) {
<option [value]="apartment.id">{{ apartment.name }}</option>
}
</select></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Locataire</mat-label
><select
matNativeControl
name="tenantId"
[ngModel]="tenantId()"
(ngModelChange)="tenantId.set($event)"
required
>
<option value=""></option>
@for (tenant of tenants(); track tenant.id) {
<option [value]="tenant.id">{{ tenant.firstName }} {{ tenant.lastName }}</option>
}
</select></mat-form-field
>
</div>
</section>
<section class="form-section">
<div class="section-heading">
<span>02</span>
<div>
<h2>Conditions</h2>
<p>Définissez la période et les montants mensuels.</p>
</div>
</div>
<div class="form-grid three-columns">
<mat-form-field appearance="outline"
><mat-label>Date de début</mat-label
><input
matInput
type="date"
name="startDate"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Date de fin</mat-label
><input
matInput
type="date"
name="endDate"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Loyer mensuel</mat-label
><input
matInput
type="number"
name="monthlyRent"
[ngModel]="monthlyRent()"
(ngModelChange)="monthlyRent.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Charges mensuelles</mat-label
><input
matInput
type="number"
name="monthlyCharges"
[ngModel]="monthlyCharges()"
(ngModelChange)="monthlyCharges.set($event)"
min="0"
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline"
><mat-label>Dépôt de garantie</mat-label
><input
matInput
type="number"
name="securityDeposit"
[ngModel]="securityDeposit()"
(ngModelChange)="securityDeposit.set($event)"
min="0"
/><span matTextSuffix>€</span></mat-form-field
>
</div>
</section>
<section class="form-section">
<div class="section-heading">
<span>03</span>
<div>
<h2>Garants</h2>
<p>Ajoutez un ou plusieurs contacts de garantie.</p>
</div>
</div>
@for (guarantor of guarantors(); track $index) {
<div class="guarantor-chip">
<span>{{ guarantor.firstName }} {{ guarantor.lastName }}</span
><button mat-button type="button" (click)="removeGuarantor($index)">Retirer</button>
</div>
}
<div class="form-grid guarantor-fields">
<mat-form-field appearance="outline"
><mat-label>Prénom du garant</mat-label
><input
matInput
name="guarantorFirstName"
[ngModel]="guarantorFirstName()"
(ngModelChange)="guarantorFirstName.set($event)"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom du garant</mat-label
><input
matInput
name="guarantorLastName"
[ngModel]="guarantorLastName()"
(ngModelChange)="guarantorLastName.set($event)"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail du garant</mat-label
><input
matInput
type="email"
name="guarantorEmail"
[ngModel]="guarantorEmail()"
(ngModelChange)="guarantorEmail.set($event)"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone du garant</mat-label
><input
matInput
name="guarantorPhone"
[ngModel]="guarantorPhone()"
(ngModelChange)="guarantorPhone.set($event)"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse du garant</mat-label
><input
matInput
name="guarantorAddress"
[ngModel]="guarantorAddress()"
(ngModelChange)="guarantorAddress.set($event)"
/></mat-form-field>
</div>
<button mat-stroked-button type="button" class="add-guarantor" (click)="addGuarantor()">
Ajouter le garant
</button>
</section>
<div class="form-actions">
<button mat-button type="button" (click)="closeForm()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">Enregistrer le bail</button>
</div>
</form>
</section>
}
@@ -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']);
});
});
@@ -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<Lease[]>();
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly leaseSaved = output<Lease>();
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
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<Guarantor[]>([]);
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('');
}
}
@@ -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;
}
}
@@ -0,0 +1,164 @@
@if (!showForm()) {
<section class="page-section">
<header class="page-heading">
<div>
<p class="eyebrow">Dossiers</p>
<h1>Mes locataires</h1>
<p>Contacts, pièces jointes et accès rapide aux baux associés.</p>
</div>
<button mat-flat-button type="button" class="primary-action" (click)="openAddForm()">
<span aria-hidden="true">+</span> Ajouter un locataire
</button>
</header>
@if (tenants().length === 0) {
<article class="empty-state">
<p class="eyebrow">Premier dossier</p>
<h2>Aucun locataire enregistré</h2>
<p>Centralisez ici les coordonnées et pièces jointes utiles de vos locataires.</p>
<button mat-stroked-button type="button" (click)="openAddForm()">
Ajouter un locataire
</button>
</article>
} @else {
<div class="tenant-grid">
@for (tenant of tenants(); track tenant.id) {
<article class="tenant-card">
<div class="tenant-identity">
<span class="tenant-avatar"
>{{ tenant.firstName.charAt(0) }}{{ tenant.lastName.charAt(0) }}</span
>
<div>
<p class="property-label">Locataire</p>
<h2>{{ tenant.firstName }} {{ tenant.lastName }}</h2>
</div>
</div>
<dl class="contact-list">
<div>
<dt>E-mail</dt>
<dd>{{ tenant.email }}</dd>
</div>
<div>
<dt>Téléphone</dt>
<dd>{{ tenant.phone || 'Non renseigné' }}</dd>
</div>
<div>
<dt>Adresse</dt>
<dd>{{ tenant.address || 'Non renseignée' }}</dd>
</div>
</dl>
<div class="attachments">
<div class="section-title">
<strong>Pièces jointes</strong><span>{{ tenant.attachments?.length ?? 0 }}</span>
</div>
@for (attachment of tenant.attachments ?? []; track attachment.id) {
<button
mat-button
type="button"
class="attachment-row"
[attr.aria-label]="'Télécharger la pièce jointe ' + attachment.fileName"
(click)="attachmentDownloadRequested.emit({ tenantId: tenant.id!, attachment })"
>
<span aria-hidden="true">↧</span>{{ attachment.fileName }}
</button>
}
@if (tenant.id) {
<label class="upload-action">
<input
type="file"
accept="application/pdf,image/*"
[attr.aria-label]="
'Ajouter une pièce jointe pour ' + tenant.firstName + ' ' + tenant.lastName
"
(change)="selectFile(tenant, $event)"
/>
+ Ajouter une pièce jointe
</label>
}
</div>
<div class="card-actions">
<button mat-button type="button" (click)="openEditForm(tenant)">Modifier</button>
@if (tenant.id) {
<button
mat-button
type="button"
class="danger-action"
(click)="deleteRequested.emit(tenant.id)"
>
Supprimer
</button>
}
</div>
</article>
}
</div>
}
</section>
} @else {
<section class="form-page">
<header class="page-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closeForm()">← Retour</button>
<p class="eyebrow">Dossier locataire</p>
<h1>{{ editingTenant() ? 'Modifier le locataire' : 'Ajouter un locataire' }}</h1>
<p>Renseignez les coordonnées nécessaires à la gestion du bail.</p>
</div>
</header>
<form class="entity-form" (submit)="save(); $event.preventDefault()">
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Prénom</mat-label
><input
matInput
name="firstName"
[ngModel]="firstName()"
(ngModelChange)="firstName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nom</mat-label
><input
matInput
name="lastName"
[ngModel]="lastName()"
(ngModelChange)="lastName.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>E-mail</mat-label
><input
matInput
type="email"
name="email"
[ngModel]="email()"
(ngModelChange)="email.set($event)"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Téléphone</mat-label
><input
matInput
type="tel"
name="phone"
[ngModel]="phone()"
(ngModelChange)="phone.set($event)"
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
/></mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closeForm()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
</div>
</form>
</section>
}
@@ -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);
});
});
@@ -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<Tenant[]>();
readonly tenantSaved = output<TenantSaveRequest>();
readonly deleteRequested = output<string>();
readonly attachmentUploadRequested = output<{ tenantId: string; file: File }>();
readonly attachmentDownloadRequested = output<TenantAttachmentRequest>();
readonly showForm = signal(false);
readonly editingTenant = signal<Tenant | null>(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('');
}
}
@@ -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 {
@@ -47,6 +47,48 @@
</svg>
<span>Appartements</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'tenants'"
[attr.aria-current]="activeTab() === 'tenants' ? 'page' : null"
(click)="tabSelected.emit('tenants')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<circle cx="9" cy="8" r="3" />
<path
d="M3.5 20v-2.5A4.5 4.5 0 0 1 8 13h2a4.5 4.5 0 0 1 4.5 4.5V20M16 5.5a3 3 0 0 1 0 5.8M17 14a4.5 4.5 0 0 1 3.5 4.4V20"
/>
</svg>
<span>Locataires</span>
</button>
<button
mat-button
type="button"
class="navigation-item"
[class.active]="activeTab() === 'leases'"
[attr.aria-current]="activeTab() === 'leases' ? 'page' : null"
(click)="tabSelected.emit('leases')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path d="M6 3h9l4 4v14H6z" />
<path d="M14 3v5h5M9 12h7M9 16h7" />
</svg>
<span>Baux</span>
</button>
<button
mat-button
type="button"
@@ -2,7 +2,7 @@ import { Component, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BrandMark } from '../brand-mark';
export type WorkspaceTab = 'dashboard' | 'apartments' | 'settings';
export type WorkspaceTab = 'dashboard' | 'apartments' | 'tenants' | 'leases' | 'settings';
@Component({
selector: 'app-workspace-navigation',
@@ -0,0 +1,50 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { Lease, LeaseService } from './lease.service';
describe('LeaseService', () => {
const http = {
get: vi.fn().mockReturnValue(of([])),
post: vi.fn().mockReturnValue(of({})),
put: vi.fn().mockReturnValue(of({})),
delete: vi.fn().mockReturnValue(of(undefined)),
};
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [LeaseService, { provide: HttpClient, useValue: http }],
});
});
it('uses lease CRUD and PDF endpoints', () => {
const service = TestBed.inject(LeaseService);
const lease: Lease = {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE',
};
service.getLeases().subscribe();
service.createLease(lease).subscribe();
service.updateLease('lease-1', lease).subscribe();
service.deleteLease('lease-1').subscribe();
service.downloadPdf('lease-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/leases');
expect(http.post).toHaveBeenCalledWith('/api/leases', lease);
expect(http.put).toHaveBeenCalledWith('/api/leases/lease-1', lease);
expect(http.delete).toHaveBeenCalledWith('/api/leases/lease-1');
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
responseType: 'blob',
});
});
});
@@ -0,0 +1,51 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Guarantor {
id?: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
}
export interface Lease {
id?: string;
landlordId?: string;
apartmentId: string;
tenantIds: string[];
guarantors: Guarantor[];
startDate: string;
endDate: string;
monthlyRent: number;
monthlyCharges: number;
securityDeposit: number;
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
}
@Injectable({ providedIn: 'root' })
export class LeaseService {
private readonly http = inject(HttpClient);
getLeases(): Observable<Lease[]> {
return this.http.get<Lease[]>('/api/leases');
}
createLease(lease: Lease): Observable<Lease> {
return this.http.post<Lease>('/api/leases', lease);
}
updateLease(id: string, lease: Lease): Observable<Lease> {
return this.http.put<Lease>(`/api/leases/${id}`, lease);
}
deleteLease(id: string): Observable<void> {
return this.http.delete<void>(`/api/leases/${id}`);
}
downloadPdf(id: string): Observable<Blob> {
return this.http.get(`/api/leases/${id}/pdf`, { responseType: 'blob' });
}
}
@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { TenantService } from './tenant.service';
describe('TenantService', () => {
const http = {
get: vi.fn().mockReturnValue(of([])),
post: vi.fn().mockReturnValue(of({})),
put: vi.fn().mockReturnValue(of({})),
delete: vi.fn().mockReturnValue(of(undefined)),
};
beforeEach(() => {
vi.clearAllMocks();
TestBed.configureTestingModule({
providers: [TenantService, { provide: HttpClient, useValue: http }],
});
});
it('uses the tenant CRUD endpoints', () => {
const service = TestBed.inject(TenantService);
const tenant = {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33612345678',
address: '10 rue des Lilas',
};
service.getTenants().subscribe();
service.createTenant(tenant).subscribe();
service.updateTenant('tenant-1', tenant).subscribe();
service.deleteTenant('tenant-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/tenants');
expect(http.post).toHaveBeenCalledWith('/api/tenants', tenant);
expect(http.put).toHaveBeenCalledWith('/api/tenants/tenant-1', tenant);
expect(http.delete).toHaveBeenCalledWith('/api/tenants/tenant-1');
});
it('uploads and downloads tenant attachments', () => {
const service = TestBed.inject(TenantService);
const file = new File(['identity'], 'identite.pdf', { type: 'application/pdf' });
service.uploadAttachment('tenant-1', file).subscribe();
service.downloadAttachment('tenant-1', 'attachment-1').subscribe();
expect(http.post).toHaveBeenCalledWith(
'/api/tenants/tenant-1/attachments',
expect.any(FormData),
);
expect(http.get).toHaveBeenCalledWith('/api/tenants/tenant-1/attachments/attachment-1', {
responseType: 'blob',
});
});
});
@@ -0,0 +1,54 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface TenantAttachment {
id: string;
fileName: string;
contentType: string;
size: number;
}
export interface Tenant {
id?: string;
landlordId?: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
attachments?: TenantAttachment[];
}
@Injectable({ providedIn: 'root' })
export class TenantService {
private readonly http = inject(HttpClient);
getTenants(): Observable<Tenant[]> {
return this.http.get<Tenant[]>('/api/tenants');
}
createTenant(tenant: Tenant): Observable<Tenant> {
return this.http.post<Tenant>('/api/tenants', tenant);
}
updateTenant(id: string, tenant: Tenant): Observable<Tenant> {
return this.http.put<Tenant>(`/api/tenants/${id}`, tenant);
}
deleteTenant(id: string): Observable<void> {
return this.http.delete<void>(`/api/tenants/${id}`);
}
uploadAttachment(tenantId: string, file: File): Observable<TenantAttachment> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<TenantAttachment>(`/api/tenants/${tenantId}/attachments`, formData);
}
downloadAttachment(tenantId: string, attachmentId: string): Observable<Blob> {
return this.http.get(`/api/tenants/${tenantId}/attachments/${attachmentId}`, {
responseType: 'blob',
});
}
}
+16 -12
View File
@@ -1,15 +1,19 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"rootDir": "./src/app",
"types": [
"vitest/globals"
]
},
"angularCompilerOptions": {
"strictTemplates": true
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}