Align lease ending validation flows (#54)
This commit is contained in:
1 parent
ca3e28d3a2
commit
a0f86bb511
25 files changed
+847
-130
No files matched your search
+10
@@ -19,14 +19,22 @@ import java.util.List;
|
||||
final class LeaseEndingApi {
|
||||
private LeaseEndingApi() {}
|
||||
|
||||
record OperationError(String message) {}
|
||||
|
||||
record ValidationError(String code, List<FieldError> errors) {}
|
||||
|
||||
record FieldError(String field, String code) {}
|
||||
|
||||
record StateResponse(
|
||||
String leaseId,
|
||||
boolean supported,
|
||||
String unsupportedReason,
|
||||
LeaseEndPhase phase,
|
||||
LeaseType leaseType,
|
||||
LocalDate leaseStartOn,
|
||||
LocalDate fixedEndOn,
|
||||
LocalDate effectiveEndOn,
|
||||
long maxEvidenceBytes,
|
||||
boolean correctionRequired,
|
||||
List<NoticeResponse> notices,
|
||||
AgreedEarlyEndResponse agreedEarlyEnd,
|
||||
@@ -41,8 +49,10 @@ record StateResponse(
|
||||
state.unsupportedReason().orElse(null),
|
||||
state.phase(),
|
||||
state.leaseType(),
|
||||
state.leaseStartOn(),
|
||||
state.fixedEndOn().orElse(null),
|
||||
state.effectiveEndOn().orElse(null),
|
||||
state.maxEvidenceBytes(),
|
||||
state.correctionRequired(),
|
||||
history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(),
|
||||
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
|
||||
|
||||
+48
-5
@@ -3,12 +3,16 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.io.IOException;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
@@ -18,8 +22,11 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -28,7 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
@RestController("rentalLeaseEndingController")
|
||||
@RequestMapping("/api/leases/{leaseId}/end")
|
||||
@@ -42,6 +51,34 @@ public LeaseEndingController(
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
}
|
||||
|
||||
@ExceptionHandler(LeaseEndingConflict.class)
|
||||
ResponseEntity<LeaseEndingApi.OperationError> conflict(LeaseEndingConflict failure) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new LeaseEndingApi.OperationError(failure.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(LeaseEndingValidationFailure.class)
|
||||
ResponseEntity<LeaseEndingApi.ValidationError> invalid(LeaseEndingValidationFailure failure) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
new LeaseEndingApi.ValidationError(
|
||||
"validation_failed",
|
||||
failure.errors().stream()
|
||||
.map(error -> new LeaseEndingApi.FieldError(error.field(), error.code()))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MissingServletRequestParameterException.class,
|
||||
MissingServletRequestPartException.class,
|
||||
MethodArgumentTypeMismatchException.class,
|
||||
HttpMessageNotReadableException.class
|
||||
})
|
||||
ResponseEntity<LeaseEndingApi.OperationError> invalidRequest() {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new LeaseEndingApi.OperationError("La requête de fin de bail est invalide."));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
LeaseEndingApi.StateResponse state(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@@ -205,7 +242,7 @@ private static ZoneId zone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
|
||||
throw new LeaseEndingValidationFailure("timeZone", "iana_time_zone");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +259,19 @@ private static String safeFileName(String value) {
|
||||
}
|
||||
|
||||
record WithdrawalRequest(
|
||||
@NotBlank String timeZone, @NotNull LocalDate acceptedOn, @NotBlank String reason) {}
|
||||
@NotBlank String timeZone,
|
||||
@NotNull LocalDate acceptedOn,
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
|
||||
record InvalidationRequest(
|
||||
@NotBlank String timeZone, @NotNull LocalDate invalidatedOn, @NotBlank String reason) {}
|
||||
@NotBlank String timeZone,
|
||||
@NotNull LocalDate invalidatedOn,
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
|
||||
record AgreedEndRequest(
|
||||
@NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {}
|
||||
@NotBlank String timeZone,
|
||||
@NotNull LocalDate endOn,
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
|
||||
record ClosureRequest(@NotBlank String timeZone, boolean confirmed) {}
|
||||
record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed) {}
|
||||
}
|
||||
+7
-3
@@ -14,6 +14,8 @@ import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
@@ -92,7 +94,7 @@ public LeaseEndingState recordNotice(
|
||||
EvidenceUpload evidence) {
|
||||
Objects.requireNonNull(request);
|
||||
if (evidence == null && !request.missingEvidenceConfirmed()) {
|
||||
throw new LeaseRuleViolation("L'absence de justificatif doit être confirmée.");
|
||||
throw new LeaseEndingValidationFailure("missingEvidenceConfirmed", "required");
|
||||
}
|
||||
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
|
||||
StoredEvidence stored = evidence == null ? null : store(evidence);
|
||||
@@ -191,7 +193,7 @@ public LeaseEndingState close(
|
||||
.filter(PropertyConditionReport::finalized)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new LeaseRuleViolation(
|
||||
new LeaseEndingConflict(
|
||||
"Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
|
||||
lease.close(
|
||||
exitReport.occurredOn(),
|
||||
@@ -295,8 +297,10 @@ private LeaseEndingState state(
|
||||
"La fin de bail locataire n'est pas prise en charge pour une colocation."),
|
||||
lease.endPhaseOn(today),
|
||||
lease.leaseType(),
|
||||
lease.period().startDate(),
|
||||
lease.fixedEndOn(),
|
||||
lease.contractEndOn(),
|
||||
maxEvidenceBytes,
|
||||
dues.stream().anyMatch(RentDue::correctionRequired),
|
||||
reports
|
||||
.find(landlordId, lease.id(), PropertyConditionReportType.EXIT)
|
||||
@@ -339,7 +343,7 @@ private StoredEvidence store(EvidenceUpload upload) {
|
||||
byte[] content =
|
||||
Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
|
||||
if (content.length == 0) {
|
||||
throw new LeaseRuleViolation("Le justificatif est vide.");
|
||||
throw new LeaseEndingValidationFailure("evidence", "empty");
|
||||
}
|
||||
if (content.length > maxEvidenceBytes) {
|
||||
throw new AttachmentTooLarge(maxEvidenceBytes);
|
||||
|
||||
+6
@@ -14,8 +14,10 @@ public record LeaseEndingState(
|
||||
Optional<String> unsupportedReason,
|
||||
LeaseEndPhase phase,
|
||||
LeaseType leaseType,
|
||||
LocalDate leaseStartOn,
|
||||
Optional<LocalDate> fixedEndOn,
|
||||
Optional<LocalDate> effectiveEndOn,
|
||||
long maxEvidenceBytes,
|
||||
boolean correctionRequired,
|
||||
Optional<ExitConditionReport> exitConditionReport,
|
||||
LeaseEndingHistory history) {
|
||||
@@ -24,8 +26,12 @@ public LeaseEndingState {
|
||||
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
|
||||
Objects.requireNonNull(phase);
|
||||
Objects.requireNonNull(leaseType);
|
||||
Objects.requireNonNull(leaseStartOn);
|
||||
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
|
||||
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
|
||||
if (maxEvidenceBytes <= 0) {
|
||||
throw new IllegalArgumentException("Maximum evidence size must be positive.");
|
||||
}
|
||||
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
|
||||
Objects.requireNonNull(history);
|
||||
}
|
||||
|
||||
@@ -507,18 +507,17 @@ public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionA
|
||||
Objects.requireNonNull(command);
|
||||
Objects.requireNonNull(audit);
|
||||
if (command.receivedOn().isAfter(command.today())) {
|
||||
throw new LeaseRuleViolation("La date de réception du congé ne peut pas être future.");
|
||||
throw new LeaseEndingValidationFailure("receivedOn", "not_future");
|
||||
}
|
||||
if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) {
|
||||
throw new LeaseRuleViolation("La saisie tardive du congé doit être confirmée.");
|
||||
throw new LeaseEndingValidationFailure("lateRegistrationConfirmed", "required");
|
||||
}
|
||||
if (command.requestedDepartureOn().isBefore(command.receivedOn())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"La date de départ demandée ne peut pas précéder la réception du congé.");
|
||||
throw new LeaseEndingValidationFailure("requestedDepartureOn", "after_or_equal_received_on");
|
||||
}
|
||||
if (command.deliveryMode() == NoticeDeliveryMode.OTHER
|
||||
&& !command.nonStandardDeliveryConfirmed()) {
|
||||
throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé.");
|
||||
throw new LeaseEndingValidationFailure("nonStandardDeliveryConfirmed", "required");
|
||||
}
|
||||
TenantNotice notice =
|
||||
new TenantNotice(
|
||||
@@ -547,7 +546,7 @@ public NoticeWithdrawal acceptNoticeWithdrawal(
|
||||
endingHistory
|
||||
.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn());
|
||||
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn(), "acceptedOn");
|
||||
return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
|
||||
}
|
||||
|
||||
@@ -562,7 +561,7 @@ public NoticeInvalidation invalidateNotice(
|
||||
endingHistory
|
||||
.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn());
|
||||
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn(), "invalidatedOn");
|
||||
return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
|
||||
}
|
||||
|
||||
@@ -571,11 +570,11 @@ public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(
|
||||
requireActiveEndingSupported();
|
||||
Objects.requireNonNull(endOn, "Agreed end date is required.");
|
||||
if (endOn.isBefore(period.startDate())) {
|
||||
throw new LeaseRuleViolation("La fin convenue ne peut pas précéder le début du bail.");
|
||||
throw new LeaseEndingValidationFailure("endOn", "after_or_equal_lease_start");
|
||||
}
|
||||
Optional<LocalDate> currentEnd = contractEndOn();
|
||||
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) {
|
||||
throw new LeaseRuleViolation("La fin convenue doit avancer la fin de bail actuelle.");
|
||||
throw new LeaseEndingValidationFailure("endOn", "before_current_end");
|
||||
}
|
||||
return endingHistory.agree(endOn, reason, audit);
|
||||
}
|
||||
@@ -597,11 +596,11 @@ public LeaseClosure close(
|
||||
LocalDate endOn =
|
||||
contractEndOn()
|
||||
.orElseThrow(
|
||||
() -> new LeaseRuleViolation("Le bail n'a pas encore de fin contractuelle."));
|
||||
() -> new LeaseEndingConflict("Le bail n'a pas encore de fin contractuelle."));
|
||||
Objects.requireNonNull(keysReturnedOn, "Keys return date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (today.isBefore(endOn)) {
|
||||
throw new LeaseRuleViolation("Le bail ne peut être clôturé avant sa fin contractuelle.");
|
||||
throw new LeaseEndingConflict("Le bail ne peut être clôturé avant sa fin contractuelle.");
|
||||
}
|
||||
if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation(
|
||||
@@ -682,25 +681,24 @@ public LeaseEndPhase endPhaseOn(LocalDate date) {
|
||||
|
||||
private void requireActiveEndingSupported() {
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut recevoir un fait de fin de bail.");
|
||||
throw new LeaseEndingConflict("Seul un bail actif peut recevoir un fait de fin de bail.");
|
||||
}
|
||||
requireEndingSupported();
|
||||
}
|
||||
|
||||
private void requireEndingSupported() {
|
||||
if (!endingSupported()) {
|
||||
throw new LeaseRuleViolation(
|
||||
throw new LeaseEndingConflict(
|
||||
"La fin de bail locataire n'est pas prise en charge pour une colocation.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAdministrativeFactDate(
|
||||
LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn) {
|
||||
LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn, String field) {
|
||||
Objects.requireNonNull(factOn, "Administrative fact date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation(
|
||||
"La date administrative doit suivre le congé et ne peut pas être future.");
|
||||
throw new LeaseEndingValidationFailure(field, "between_notice_received_on_and_today");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public final class LeaseEndingConflict extends RuntimeException {
|
||||
public LeaseEndingConflict(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -51,7 +51,7 @@ TenantNotice recordNotice(TenantNotice notice) {
|
||||
throw new LeaseRuleViolation("Ce congé existe déjà.");
|
||||
}
|
||||
if (activeNotice().isPresent()) {
|
||||
throw new LeaseRuleViolation("Un congé locataire est déjà actif pour ce bail.");
|
||||
throw new LeaseEndingConflict("Un congé locataire est déjà actif pour ce bail.");
|
||||
}
|
||||
notices = appended(notices, notice);
|
||||
return notice;
|
||||
@@ -94,7 +94,7 @@ NoticeEvidenceVersion appendEvidence(
|
||||
|
||||
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
|
||||
if (closure != null) {
|
||||
throw new LeaseRuleViolation("Le bail est déjà clôturé.");
|
||||
throw new LeaseEndingConflict("Le bail est déjà clôturé.");
|
||||
}
|
||||
closure = new LeaseClosure(keysReturnedOn, audit);
|
||||
return closure;
|
||||
@@ -157,7 +157,7 @@ private TenantNotice requireActive(TenantNoticeId noticeId) {
|
||||
find(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
if (statusOf(noticeId) != TenantNoticeStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un congé actif peut être modifié.");
|
||||
throw new LeaseEndingConflict("Seul un congé actif peut être modifié.");
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
@@ -215,11 +215,11 @@ private void requireKnownNotice(TenantNoticeId noticeId) {
|
||||
|
||||
static String requiredReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new LeaseRuleViolation("La raison est obligatoire.");
|
||||
throw new LeaseEndingValidationFailure("reason", "required");
|
||||
}
|
||||
String value = reason.trim();
|
||||
if (value.length() > 500) {
|
||||
throw new LeaseRuleViolation("La raison ne peut pas dépasser 500 caractères.");
|
||||
throw new LeaseEndingValidationFailure("reason", "max_length");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class LeaseEndingValidationFailure extends RuntimeException {
|
||||
private final List<FieldError> errors;
|
||||
|
||||
public LeaseEndingValidationFailure(String field, String code) {
|
||||
super("La demande de fin de bail est invalide.");
|
||||
this.errors = List.of(new FieldError(field, code));
|
||||
}
|
||||
|
||||
public List<FieldError> errors() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
public record FieldError(String field, String code) {}
|
||||
}
|
||||
@@ -33,14 +33,13 @@ public TenantNotice {
|
||||
ruleVersion = ruleVersion.trim();
|
||||
otherDeliveryMode = optional(otherDeliveryMode);
|
||||
if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Le libellé du mode de remise ne peut pas dépasser 200 caractères.");
|
||||
throw new LeaseEndingValidationFailure("otherDeliveryMode", "max_length");
|
||||
}
|
||||
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) {
|
||||
throw new LeaseRuleViolation("Le mode de remise alternatif est obligatoire.");
|
||||
throw new LeaseEndingValidationFailure("otherDeliveryMode", "required");
|
||||
}
|
||||
if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) {
|
||||
throw new LeaseRuleViolation("Le détail du mode de remise est réservé au mode OTHER.");
|
||||
throw new LeaseEndingValidationFailure("otherDeliveryMode", "forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -29,6 +29,8 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
@@ -102,6 +104,8 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
|
||||
.with(landlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD"))
|
||||
.andExpect(jsonPath("$.leaseStartOn").value("2026-01-01"))
|
||||
.andExpect(jsonPath("$.maxEvidenceBytes").value(262144000))
|
||||
.andExpect(jsonPath("$.notices[0].ruleVersion").value("2026-07"))
|
||||
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
|
||||
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1"))
|
||||
@@ -115,6 +119,50 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
|
||||
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lifecycleConflictsRemainDistinctFromRequestValidation() throws Exception {
|
||||
when(endings.recordAgreedEarlyEnd(any(), any(), any(), any()))
|
||||
.thenThrow(
|
||||
new LeaseEndingConflict("La fin convenue doit avancer la fin de bail actuelle."));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/leases/lease-1/end/agreed-early-end")
|
||||
.with(landlord())
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{"timeZone":"Europe/Paris","endOn":"2026-08-18","reason":"Accord écrit"}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(
|
||||
jsonPath("$.message").value("La fin convenue doit avancer la fin de bail actuelle."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainValidationUsesStableFieldPathsAndCodes() throws Exception {
|
||||
when(endings.recordNotice(any(), any(), any(), any(), any()))
|
||||
.thenThrow(new LeaseEndingValidationFailure("receivedOn", "not_future"));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/end/notices")
|
||||
.param("timeZone", "Europe/Paris")
|
||||
.param("receivedOn", "2026-07-20")
|
||||
.param("requestedDepartureOn", "2026-08-20")
|
||||
.param("deliveryMode", "REGISTERED_LETTER")
|
||||
.param("lateRegistrationConfirmed", "false")
|
||||
.param("nonStandardDeliveryConfirmed", "false")
|
||||
.param("missingEvidenceConfirmed", "true")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("receivedOn"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("not_future"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
|
||||
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
@@ -310,8 +358,10 @@ private static LeaseEndingState state() {
|
||||
Optional.empty(),
|
||||
LeaseEndPhase.NOTICE_PERIOD,
|
||||
LeaseType.STANDARD,
|
||||
LocalDate.of(2026, 1, 1),
|
||||
Optional.empty(),
|
||||
Optional.of(LocalDate.of(2026, 8, 18)),
|
||||
250L * 1024 * 1024,
|
||||
false,
|
||||
Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)),
|
||||
lease.endingHistory());
|
||||
|
||||
+5
-3
@@ -22,6 +22,8 @@ import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
@@ -118,7 +120,7 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
|
||||
storage);
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
service.recordNotice(
|
||||
LANDLORD_ID,
|
||||
@@ -189,7 +191,7 @@ void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEntere
|
||||
boundaryClock);
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lasVegasService.recordNotice(
|
||||
LANDLORD_ID,
|
||||
@@ -459,7 +461,7 @@ void closingRejectsAnUnfinalizedExitReport() {
|
||||
reports);
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingConflict.class,
|
||||
() ->
|
||||
service.close(
|
||||
LANDLORD_ID,
|
||||
|
||||
+17
-14
@@ -56,7 +56,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lease.recordTenantNotice(
|
||||
notice(
|
||||
@@ -69,7 +69,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
|
||||
false),
|
||||
AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lease.recordTenantNotice(
|
||||
notice(
|
||||
@@ -82,7 +82,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
|
||||
false),
|
||||
AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lease.recordTenantNotice(
|
||||
notice(
|
||||
@@ -95,7 +95,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
|
||||
false),
|
||||
AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lease.recordTenantNotice(
|
||||
notice(
|
||||
@@ -108,7 +108,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
|
||||
true),
|
||||
AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() ->
|
||||
lease.recordTenantNotice(
|
||||
notice(
|
||||
@@ -161,7 +161,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
|
||||
assertFalse(shared.endingSupported());
|
||||
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingConflict.class,
|
||||
() ->
|
||||
shared.recordTenantNotice(
|
||||
notice(
|
||||
@@ -174,7 +174,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
|
||||
false),
|
||||
AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingConflict.class,
|
||||
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
|
||||
assertEquals(1, lease.endingHistory().withdrawals().size());
|
||||
assertEquals(1, lease.endingHistory().invalidations().size());
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingConflict.class,
|
||||
() ->
|
||||
lease.acceptNoticeWithdrawal(
|
||||
new TenantNoticeId("invalidated"),
|
||||
@@ -317,11 +317,14 @@ void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
|
||||
false,
|
||||
false),
|
||||
AUDIT);
|
||||
LeaseEndingValidationFailure noChange =
|
||||
assertThrows(
|
||||
LeaseEndingValidationFailure.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
|
||||
assertEquals("endOn", noChange.errors().getFirst().field());
|
||||
assertEquals("before_current_end", noChange.errors().getFirst().code());
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Accord postérieur au congé", AUDIT);
|
||||
|
||||
@@ -347,10 +350,10 @@ void evidenceIsVersionedAndEveryReplacementRequiresAReason() {
|
||||
NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
|
||||
assertEquals(1, first.version());
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseEndingValidationFailure.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
|
||||
NoticeEvidenceVersion second =
|
||||
lease.appendNoticeEvidence(noticeId, replacement, "Document corrigé", AUDIT);
|
||||
|
||||
@@ -159,17 +159,45 @@
|
||||
>
|
||||
<fieldset [disabled]="withdrawalPending()">
|
||||
<legend>Accepter un retrait</legend>
|
||||
<label
|
||||
><span>Date d’acceptation</span
|
||||
><input type="date" [formField]="withdrawalForm.acceptedOn"
|
||||
/></label>
|
||||
<label>
|
||||
<span>Date d’acceptation</span>
|
||||
<input
|
||||
id="withdrawal-date"
|
||||
type="date"
|
||||
[formField]="withdrawalForm.acceptedOn"
|
||||
(input)="withdrawalFieldChanged.emit('acceptedOn')"
|
||||
/>
|
||||
</label>
|
||||
@if (withdrawalDateError()) {
|
||||
<p class="operation-error" role="alert">{{ withdrawalDateError() }}</p>
|
||||
}
|
||||
@if (serverError(withdrawalFieldErrors(), 'acceptedOn')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(withdrawalFieldErrors(), 'acceptedOn') }}
|
||||
</p>
|
||||
}
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
|
||||
><textarea
|
||||
id="withdrawal-reason"
|
||||
rows="2"
|
||||
[formField]="withdrawalForm.reason"
|
||||
(input)="withdrawalFieldChanged.emit('reason')"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (withdrawalError() || withdrawalOperationError()) {
|
||||
@if (withdrawalReasonError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalError() || withdrawalOperationError() }}
|
||||
{{ withdrawalReasonError() }}
|
||||
</p>
|
||||
}
|
||||
@if (serverError(withdrawalFieldErrors(), 'reason')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(withdrawalFieldErrors(), 'reason') }}
|
||||
</p>
|
||||
}
|
||||
@if (withdrawalOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
|
||||
@@ -184,17 +212,47 @@
|
||||
>
|
||||
<fieldset [disabled]="invalidationPending()">
|
||||
<legend>Invalider administrativement</legend>
|
||||
<label
|
||||
><span>Date d’invalidation</span
|
||||
><input type="date" [formField]="invalidationForm.invalidatedOn"
|
||||
/></label>
|
||||
<label>
|
||||
<span>Date d’invalidation</span>
|
||||
<input
|
||||
id="invalidation-date"
|
||||
type="date"
|
||||
[formField]="invalidationForm.invalidatedOn"
|
||||
(input)="invalidationFieldChanged.emit('invalidatedOn')"
|
||||
/>
|
||||
</label>
|
||||
@if (invalidationDateError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationDateError() }}
|
||||
</p>
|
||||
}
|
||||
@if (serverError(invalidationFieldErrors(), 'invalidatedOn')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(invalidationFieldErrors(), 'invalidatedOn') }}
|
||||
</p>
|
||||
}
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
|
||||
><textarea
|
||||
id="invalidation-reason"
|
||||
rows="2"
|
||||
[formField]="invalidationForm.reason"
|
||||
(input)="invalidationFieldChanged.emit('reason')"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (invalidationError() || invalidationOperationError()) {
|
||||
@if (invalidationReasonError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationError() || invalidationOperationError() }}
|
||||
{{ invalidationReasonError() }}
|
||||
</p>
|
||||
}
|
||||
@if (serverError(invalidationFieldErrors(), 'reason')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(invalidationFieldErrors(), 'reason') }}
|
||||
</p>
|
||||
}
|
||||
@if (invalidationOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('LeaseEndNoticeCard', () => {
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('notice', notice);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-20');
|
||||
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
|
||||
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
|
||||
fixture.componentRef.setInput('evidenceUploadEnabled', true);
|
||||
fixture.componentRef.setInput('withdrawalPending', false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LeaseEndService,
|
||||
NoticeDeliveryMode,
|
||||
} from '../../services/lease-end.service';
|
||||
import { FieldValidationError } from '../../services/http-error-message';
|
||||
|
||||
export interface NoticeAction<T> {
|
||||
noticeId: string;
|
||||
@@ -24,10 +25,13 @@ export interface NoticeAction<T> {
|
||||
styleUrl: './lease-end-notice-card.css',
|
||||
})
|
||||
export class LeaseEndNoticeCard {
|
||||
private readonly element = inject(ElementRef<HTMLElement>);
|
||||
readonly endService = inject(LeaseEndService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly notice = input.required<LeaseEndNotice>();
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly today = input.required<string>();
|
||||
readonly maxEvidenceBytes = input.required<number>();
|
||||
readonly lifecycleMutationsEnabled = input.required<boolean>();
|
||||
readonly evidenceUploadEnabled = input.required<boolean>();
|
||||
readonly withdrawalPending = input(false);
|
||||
@@ -36,9 +40,13 @@ export class LeaseEndNoticeCard {
|
||||
readonly withdrawalOperationError = input('');
|
||||
readonly invalidationOperationError = input('');
|
||||
readonly evidenceOperationError = input('');
|
||||
readonly withdrawalFieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly invalidationFieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
|
||||
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
|
||||
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
|
||||
readonly withdrawalFieldChanged = output<string>();
|
||||
readonly invalidationFieldChanged = output<string>();
|
||||
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
|
||||
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
|
||||
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
|
||||
@@ -49,12 +57,41 @@ export class LeaseEndNoticeCard {
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly evidenceReason = signal('');
|
||||
readonly withdrawalError = signal('');
|
||||
readonly invalidationError = signal('');
|
||||
readonly evidenceError = signal('');
|
||||
readonly withdrawalSubmitted = signal(false);
|
||||
readonly invalidationSubmitted = signal(false);
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const withdrawalField = this.withdrawalFieldErrors()[0]?.field;
|
||||
if (withdrawalField) {
|
||||
queueMicrotask(() =>
|
||||
(this.element.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>(
|
||||
withdrawalField === 'reason'
|
||||
? '#withdrawal-reason'
|
||||
: '#withdrawal-date',
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}
|
||||
const invalidationField = this.invalidationFieldErrors()[0]?.field;
|
||||
if (invalidationField) {
|
||||
queueMicrotask(() =>
|
||||
(this.element.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>(
|
||||
invalidationField === 'reason'
|
||||
? '#invalidation-reason'
|
||||
: '#invalidation-date',
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
statusLabel(): string {
|
||||
return {
|
||||
ACTIVE: 'Congé en cours',
|
||||
@@ -87,12 +124,12 @@ export class LeaseEndNoticeCard {
|
||||
|
||||
submitWithdrawal(): void {
|
||||
if (this.withdrawalPending()) return;
|
||||
this.withdrawalSubmitted.set(true);
|
||||
this.withdrawalForm().markAsTouched();
|
||||
const model = this.withdrawalModel();
|
||||
if (!model.acceptedOn || !model.reason.trim()) {
|
||||
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
|
||||
if (this.withdrawalDateError() || this.withdrawalReasonError()) {
|
||||
return;
|
||||
}
|
||||
this.withdrawalError.set('');
|
||||
this.withdrawalRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
@@ -105,12 +142,12 @@ export class LeaseEndNoticeCard {
|
||||
|
||||
submitInvalidation(): void {
|
||||
if (this.invalidationPending()) return;
|
||||
this.invalidationSubmitted.set(true);
|
||||
this.invalidationForm().markAsTouched();
|
||||
const model = this.invalidationModel();
|
||||
if (!model.invalidatedOn || !model.reason.trim()) {
|
||||
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
|
||||
if (this.invalidationDateError() || this.invalidationReasonError()) {
|
||||
return;
|
||||
}
|
||||
this.invalidationError.set('');
|
||||
this.invalidationRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
@@ -129,6 +166,18 @@ export class LeaseEndNoticeCard {
|
||||
this.evidenceError.set('Sélectionnez une preuve à conserver.');
|
||||
return;
|
||||
}
|
||||
if (evidence.size === 0) {
|
||||
this.evidenceError.set('La preuve sélectionnée est vide.');
|
||||
return;
|
||||
}
|
||||
if (evidence.size > this.maxEvidenceBytes()) {
|
||||
this.evidenceError.set('La preuve dépasse la taille maximale autorisée.');
|
||||
return;
|
||||
}
|
||||
if (reason.length > 500) {
|
||||
this.evidenceError.set('La raison ne peut pas dépasser 500 caractères.');
|
||||
return;
|
||||
}
|
||||
if (this.notice().evidenceVersions.length > 0 && !reason) {
|
||||
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
|
||||
return;
|
||||
@@ -139,4 +188,60 @@ export class LeaseEndNoticeCard {
|
||||
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
withdrawalDateError(): string {
|
||||
const date = this.withdrawalModel().acceptedOn;
|
||||
if (!this.show(this.withdrawalSubmitted(), this.withdrawalForm.acceptedOn())) return '';
|
||||
if (!date) return 'Renseignez la date d’acceptation.';
|
||||
return date < this.notice().receivedOn || date > this.today()
|
||||
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
|
||||
: '';
|
||||
}
|
||||
|
||||
withdrawalReasonError(): string {
|
||||
return this.reasonError(
|
||||
this.withdrawalSubmitted(),
|
||||
this.withdrawalForm.reason(),
|
||||
this.withdrawalModel().reason,
|
||||
);
|
||||
}
|
||||
|
||||
invalidationDateError(): string {
|
||||
const date = this.invalidationModel().invalidatedOn;
|
||||
if (!this.show(this.invalidationSubmitted(), this.invalidationForm.invalidatedOn()))
|
||||
return '';
|
||||
if (!date) return 'Renseignez la date d’invalidation.';
|
||||
return date < this.notice().receivedOn || date > this.today()
|
||||
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
|
||||
: '';
|
||||
}
|
||||
|
||||
invalidationReasonError(): string {
|
||||
return this.reasonError(
|
||||
this.invalidationSubmitted(),
|
||||
this.invalidationForm.reason(),
|
||||
this.invalidationModel().reason,
|
||||
);
|
||||
}
|
||||
|
||||
serverError(errors: FieldValidationError[], field: string): string {
|
||||
const code = errors.find((error) => error.field === field)?.code;
|
||||
if (field === 'reason' && code === 'max_length')
|
||||
return 'La raison ne peut pas dépasser 500 caractères.';
|
||||
if (field === 'reason' && code === 'required') return 'Renseignez la raison.';
|
||||
if ((field === 'acceptedOn' || field === 'invalidatedOn') && code) {
|
||||
return 'La date doit être comprise entre la réception du congé et aujourd’hui.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private reasonError(submitted: boolean, field: { touched(): boolean }, value: string): string {
|
||||
if (!this.show(submitted, field)) return '';
|
||||
if (!value.trim()) return 'Renseignez la raison.';
|
||||
return value.trim().length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
|
||||
}
|
||||
|
||||
private show(submitted: boolean, field: { touched(): boolean }): boolean {
|
||||
return submitted || field.touched();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="notice-form" aria-labelledby="notice-form-title">
|
||||
<h2 id="notice-form-title">Enregistrer un congé</h2>
|
||||
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
|
||||
<p>Les dates restent des dates civiles dans le fuseau {{ timeZone() }}.</p>
|
||||
<form
|
||||
[formRoot]="noticeForm"
|
||||
[attr.aria-busy]="submitting()"
|
||||
@@ -10,39 +10,109 @@
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>Date de réception</span>
|
||||
<input type="date" [formField]="noticeForm.receivedOn" />
|
||||
<input
|
||||
id="notice-received-on"
|
||||
type="date"
|
||||
[formField]="noticeForm.receivedOn"
|
||||
(input)="fieldWasChanged('receivedOn')"
|
||||
/>
|
||||
@if (receivedOnError()) {
|
||||
<p class="operation-error" role="alert">{{ receivedOnError() }}</p>
|
||||
}
|
||||
@if (serverError('receivedOn')) {
|
||||
<p class="operation-error" role="alert">{{ serverError('receivedOn') }}</p>
|
||||
}
|
||||
</label>
|
||||
<label>
|
||||
<span>Départ demandé</span>
|
||||
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
|
||||
<input
|
||||
id="notice-requested-departure-on"
|
||||
type="date"
|
||||
[formField]="noticeForm.requestedDepartureOn"
|
||||
(input)="fieldWasChanged('requestedDepartureOn')"
|
||||
/>
|
||||
@if (requestedDepartureOnError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ requestedDepartureOnError() }}
|
||||
</p>
|
||||
}
|
||||
@if (serverError('requestedDepartureOn')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError('requestedDepartureOn') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Mode de remise</span>
|
||||
<select [formField]="noticeForm.deliveryMode">
|
||||
<select
|
||||
id="notice-delivery-mode"
|
||||
[formField]="noticeForm.deliveryMode"
|
||||
(change)="fieldWasChanged('deliveryMode')"
|
||||
>
|
||||
<option value="">Choisissez un mode</option>
|
||||
@for (mode of deliveryModes; track mode.value) {
|
||||
<option [value]="mode.value">{{ mode.label }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (deliveryModeError()) {
|
||||
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
|
||||
}
|
||||
</label>
|
||||
@if (model().deliveryMode === 'OTHER') {
|
||||
<label>
|
||||
<span>Mode de remise utilisé</span>
|
||||
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
|
||||
<input
|
||||
id="notice-other-delivery-mode"
|
||||
type="text"
|
||||
[formField]="noticeForm.otherDeliveryMode"
|
||||
(input)="fieldWasChanged('otherDeliveryMode')"
|
||||
/>
|
||||
@if (otherDeliveryModeError()) {
|
||||
<p class="operation-error" role="alert">{{ otherDeliveryModeError() }}</p>
|
||||
}
|
||||
@if (serverError('otherDeliveryMode')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError('otherDeliveryMode') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
|
||||
<input
|
||||
type="checkbox"
|
||||
[formField]="noticeForm.nonStandardDeliveryConfirmed"
|
||||
(change)="fieldWasChanged('nonStandardDeliveryConfirmed')"
|
||||
/>
|
||||
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
|
||||
</label>
|
||||
@if (nonStandardDeliveryError()) {
|
||||
<p class="operation-error" role="alert">{{ nonStandardDeliveryError() }}</p>
|
||||
}
|
||||
@if (serverError('nonStandardDeliveryConfirmed')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError('nonStandardDeliveryConfirmed') }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@if (lateRegistration()) {
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
|
||||
<input
|
||||
type="checkbox"
|
||||
[formField]="noticeForm.lateRegistrationConfirmed"
|
||||
(change)="fieldWasChanged('lateRegistrationConfirmed')"
|
||||
/>
|
||||
<span
|
||||
>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span
|
||||
>
|
||||
</label>
|
||||
@if (lateRegistrationError()) {
|
||||
<p class="operation-error" role="alert">{{ lateRegistrationError() }}</p>
|
||||
}
|
||||
@if (serverError('lateRegistrationConfirmed')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError('lateRegistrationConfirmed') }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
<label>
|
||||
<span>Preuve (facultative, tout format)</span>
|
||||
@@ -53,14 +123,23 @@
|
||||
<div class="missing-evidence-warning">
|
||||
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
|
||||
<input
|
||||
type="checkbox"
|
||||
[formField]="noticeForm.missingEvidenceConfirmed"
|
||||
(change)="fieldWasChanged('missingEvidenceConfirmed')"
|
||||
/>
|
||||
<span>Je confirme enregistrer ce congé sans justificatif.</span>
|
||||
</label>
|
||||
@if (serverError('missingEvidenceConfirmed')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError('missingEvidenceConfirmed') }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (validationError() || operationError()) {
|
||||
@if (evidenceError() || operationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ validationError() || operationError() }}
|
||||
{{ evidenceError() || operationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="submitting()">
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('LeaseEndNoticeForm', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
@@ -25,22 +26,48 @@ describe('LeaseEndNoticeForm', () => {
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
|
||||
expect(fixture.componentInstance.lateRegistrationError()).toContain(
|
||||
'enregistrement tardif',
|
||||
);
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
lateRegistrationConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain(
|
||||
expect(fixture.componentInstance.nonStandardDeliveryError()).toContain(
|
||||
'mode de remise non standard',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the requested departure guidance after submit', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-07-18',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
|
||||
expect(fixture.componentInstance.requestedDepartureOnError()).toContain(
|
||||
'ne peut pas précéder',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
@@ -86,6 +113,7 @@ describe('LeaseEndNoticeForm', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
@@ -101,7 +129,7 @@ describe('LeaseEndNoticeForm', () => {
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
|
||||
expect(fixture.componentInstance.evidenceError()).toContain('sans justificatif');
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { FormField, FormRoot, form, maxLength, required } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
|
||||
import { FieldValidationError } from '../../services/http-error-message';
|
||||
|
||||
const serverMessages: Record<string, Record<string, string>> = {
|
||||
receivedOn: { not_future: 'La date de réception ne peut pas être future.' },
|
||||
requestedDepartureOn: {
|
||||
after_or_equal_received_on:
|
||||
'La date de départ demandée ne peut pas précéder la réception du congé.',
|
||||
},
|
||||
otherDeliveryMode: {
|
||||
required: 'Décrivez le mode de remise utilisé.',
|
||||
max_length: 'Le mode de remise ne peut pas dépasser 200 caractères.',
|
||||
},
|
||||
lateRegistrationConfirmed: {
|
||||
required: 'Confirmez cet enregistrement tardif avant de continuer.',
|
||||
},
|
||||
nonStandardDeliveryConfirmed: {
|
||||
required: 'Confirmez le mode de remise non standard avant de continuer.',
|
||||
},
|
||||
missingEvidenceConfirmed: { required: 'Confirmez l’enregistrement sans justificatif.' },
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-form',
|
||||
@@ -11,11 +40,15 @@ import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-en
|
||||
styleUrl: './lease-end-notice-form.css',
|
||||
})
|
||||
export class LeaseEndNoticeForm {
|
||||
private readonly element = inject(ElementRef<HTMLElement>);
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly today = input.required<string>();
|
||||
readonly maxEvidenceBytes = input.required<number>();
|
||||
readonly submitting = input.required<boolean>();
|
||||
readonly operationError = input('');
|
||||
readonly fieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly noticeRequested = output<RecordNoticeRequest>();
|
||||
readonly fieldChanged = output<string>();
|
||||
readonly model = signal({
|
||||
receivedOn: '',
|
||||
requestedDepartureOn: '',
|
||||
@@ -26,10 +59,13 @@ export class LeaseEndNoticeForm {
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
readonly noticeForm = form(this.model, (fields) => {
|
||||
required(fields.receivedOn);
|
||||
required(fields.requestedDepartureOn);
|
||||
required(fields.deliveryMode);
|
||||
maxLength(fields.otherDeliveryMode, 200);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly validationError = signal('');
|
||||
readonly submitted = signal(false);
|
||||
readonly lateRegistration = computed(
|
||||
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
|
||||
);
|
||||
@@ -40,6 +76,12 @@ export class LeaseEndNoticeForm {
|
||||
{ value: 'OTHER', label: 'Autre mode' },
|
||||
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.fieldErrors().length) queueMicrotask(() => this.focusFirstInvalid());
|
||||
});
|
||||
}
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
@@ -50,47 +92,127 @@ export class LeaseEndNoticeForm {
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
receivedOnError(): string {
|
||||
if (!this.show(this.noticeForm.receivedOn())) return '';
|
||||
if (!this.model().receivedOn) return 'Renseignez la date de réception du congé.';
|
||||
return this.model().receivedOn > this.today()
|
||||
? 'La date de réception ne peut pas être future.'
|
||||
: '';
|
||||
}
|
||||
|
||||
requestedDepartureOnError(): string {
|
||||
if (!this.show(this.noticeForm.requestedDepartureOn())) return '';
|
||||
if (!this.model().requestedDepartureOn) return 'Renseignez la date de départ demandée.';
|
||||
return this.model().receivedOn &&
|
||||
this.model().requestedDepartureOn < this.model().receivedOn
|
||||
? 'La date de départ demandée ne peut pas précéder la réception du congé.'
|
||||
: '';
|
||||
}
|
||||
|
||||
deliveryModeError(): string {
|
||||
return this.show(this.noticeForm.deliveryMode()) && !this.model().deliveryMode
|
||||
? 'Choisissez le mode de remise du congé.'
|
||||
: '';
|
||||
}
|
||||
|
||||
otherDeliveryModeError(): string {
|
||||
if (
|
||||
this.model().deliveryMode !== 'OTHER' ||
|
||||
!this.show(this.noticeForm.otherDeliveryMode())
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return !this.model().otherDeliveryMode.trim()
|
||||
? 'Décrivez le mode de remise utilisé.'
|
||||
: this.model().otherDeliveryMode.trim().length > 200
|
||||
? 'Le mode de remise ne peut pas dépasser 200 caractères.'
|
||||
: '';
|
||||
}
|
||||
|
||||
lateRegistrationError(): string {
|
||||
return this.lateRegistration() &&
|
||||
this.show(this.noticeForm.lateRegistrationConfirmed()) &&
|
||||
!this.model().lateRegistrationConfirmed
|
||||
? 'Confirmez cet enregistrement tardif avant de continuer.'
|
||||
: '';
|
||||
}
|
||||
|
||||
nonStandardDeliveryError(): string {
|
||||
return this.model().deliveryMode === 'OTHER' &&
|
||||
this.show(this.noticeForm.nonStandardDeliveryConfirmed()) &&
|
||||
!this.model().nonStandardDeliveryConfirmed
|
||||
? 'Confirmez le mode de remise non standard avant de continuer.'
|
||||
: '';
|
||||
}
|
||||
|
||||
evidenceError(): string {
|
||||
const evidence = this.evidence();
|
||||
if (evidence?.size === 0) return 'La preuve sélectionnée est vide.';
|
||||
if (evidence && evidence.size > this.maxEvidenceBytes()) {
|
||||
return 'La preuve dépasse la taille maximale autorisée.';
|
||||
}
|
||||
return !evidence && this.submitted() && !this.model().missingEvidenceConfirmed
|
||||
? 'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.'
|
||||
: '';
|
||||
}
|
||||
|
||||
serverError(field: string): string {
|
||||
const code = this.fieldErrors().find((error) => error.field === field)?.code;
|
||||
return serverMessages[field]?.[code ?? ''] ?? '';
|
||||
}
|
||||
|
||||
fieldWasChanged(field: string): void {
|
||||
this.fieldChanged.emit(field);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
this.submitted.set(true);
|
||||
this.noticeForm().markAsTouched();
|
||||
const model = this.model();
|
||||
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
|
||||
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
|
||||
if (
|
||||
this.receivedOnError() ||
|
||||
this.requestedDepartureOnError() ||
|
||||
this.deliveryModeError() ||
|
||||
this.otherDeliveryModeError() ||
|
||||
this.lateRegistrationError() ||
|
||||
this.nonStandardDeliveryError() ||
|
||||
this.evidenceError()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
|
||||
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
|
||||
this.validationError.set('Décrivez le mode de remise utilisé.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez le mode de remise non standard avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.evidence() && !model.missingEvidenceConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.validationError.set('');
|
||||
const deliveryMode = model.deliveryMode;
|
||||
if (!deliveryMode) return;
|
||||
const evidence = this.evidence();
|
||||
this.noticeRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
receivedOn: model.receivedOn,
|
||||
requestedDepartureOn: model.requestedDepartureOn,
|
||||
deliveryMode: model.deliveryMode,
|
||||
...(model.deliveryMode === 'OTHER'
|
||||
deliveryMode,
|
||||
...(deliveryMode === 'OTHER'
|
||||
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
|
||||
: {}),
|
||||
lateRegistrationConfirmed: this.lateRegistration(),
|
||||
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
|
||||
nonStandardDeliveryConfirmed: deliveryMode === 'OTHER',
|
||||
missingEvidenceConfirmed: evidence === null,
|
||||
...(evidence ? { evidence } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private show(field: { touched(): boolean }): boolean {
|
||||
return this.submitted() || field.touched();
|
||||
}
|
||||
|
||||
private focusFirstInvalid(): void {
|
||||
const field = this.fieldErrors()[0]?.field;
|
||||
const selector =
|
||||
field === 'receivedOn'
|
||||
? '#notice-received-on'
|
||||
: field === 'requestedDepartureOn'
|
||||
? '#notice-requested-departure-on'
|
||||
: field === 'otherDeliveryMode'
|
||||
? '#notice-other-delivery-mode'
|
||||
: '#notice-delivery-mode';
|
||||
(this.element.nativeElement as HTMLElement).querySelector<HTMLElement>(selector)?.focus();
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,8 @@
|
||||
[leaseId]="leaseId"
|
||||
[notice]="notice"
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[maxEvidenceBytes]="endState.maxEvidenceBytes"
|
||||
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
|
||||
[evidenceUploadEnabled]="true"
|
||||
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
|
||||
@@ -124,9 +126,17 @@
|
||||
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
|
||||
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
|
||||
[evidenceOperationError]="operationError('evidence:' + notice.id)"
|
||||
[withdrawalFieldErrors]="operationFieldError('withdrawal:' + notice.id)"
|
||||
[invalidationFieldErrors]="operationFieldError('invalidation:' + notice.id)"
|
||||
(withdrawalRequested)="requestWithdrawal($event)"
|
||||
(invalidationRequested)="requestInvalidation($event)"
|
||||
(evidenceRequested)="appendEvidence($event)"
|
||||
(withdrawalFieldChanged)="
|
||||
clearOperationFieldError('withdrawal:' + notice.id, $event)
|
||||
"
|
||||
(invalidationFieldChanged)="
|
||||
clearOperationFieldError('invalidation:' + notice.id, $event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
@@ -136,14 +146,19 @@
|
||||
<app-lease-end-notice-form
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[maxEvidenceBytes]="endState.maxEvidenceBytes"
|
||||
[submitting]="operationPending('notice')"
|
||||
[operationError]="operationError('notice')"
|
||||
[fieldErrors]="operationFieldError('notice')"
|
||||
(noticeRequested)="recordNotice($event)"
|
||||
(fieldChanged)="clearOperationFieldError('notice', $event)"
|
||||
/>
|
||||
}
|
||||
@if (canRecordAgreement() || canClose()) {
|
||||
<app-lease-end-resolution-forms
|
||||
[timeZone]="timeZone"
|
||||
[leaseStartOn]="endState.leaseStartOn"
|
||||
[currentEndOn]="endState.effectiveEndOn"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[keysReturnedOn]="exitReportDate()"
|
||||
@@ -152,8 +167,12 @@
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
[closureOperationError]="operationError('closure')"
|
||||
[agreementFieldErrors]="operationFieldError('agreement')"
|
||||
[closureFieldErrors]="operationFieldError('closure')"
|
||||
(agreedEndRequested)="recordAgreement($event)"
|
||||
(closureRequested)="closeLease($event)"
|
||||
(agreementFieldChanged)="clearOperationFieldError('agreement', $event)"
|
||||
(closureFieldChanged)="clearOperationFieldError('closure', $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ describe('LeaseEndPage', () => {
|
||||
unsupportedReason: null,
|
||||
phase: 'NOTICE_PERIOD',
|
||||
leaseType: 'STUDENT',
|
||||
leaseStartOn: '2026-01-01',
|
||||
fixedEndOn: '2026-07-31',
|
||||
effectiveEndOn: '2026-07-31',
|
||||
maxEvidenceBytes: 1024,
|
||||
correctionRequired: false,
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
@@ -230,6 +232,36 @@ describe('LeaseEndPage', () => {
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('clears only the edited structured field error and its generic message', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
|
||||
recordNotice.mockReturnValue(
|
||||
throwError(() => ({
|
||||
status: 400,
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
errors: [{ field: 'receivedOn', code: 'not_future' }],
|
||||
},
|
||||
})),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.recordNotice({
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-20',
|
||||
requestedDepartureOn: '2026-08-20',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.operationFieldError('notice')).toHaveLength(1);
|
||||
fixture.componentInstance.clearOperationFieldError('notice', 'receivedOn');
|
||||
expect(fixture.componentInstance.operationFieldError('notice')).toEqual([]);
|
||||
expect(fixture.componentInstance.operationError('notice')).toBe('');
|
||||
});
|
||||
|
||||
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
|
||||
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
formatFrenchCivilMonth,
|
||||
todayInTimeZone,
|
||||
} from '../../services/civil-date';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
|
||||
import { withoutRecordKey } from '../../services/immutable-state';
|
||||
import {
|
||||
AgreedEarlyEndRequest,
|
||||
@@ -58,6 +58,7 @@ export class LeaseEndPage {
|
||||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly operationErrors = signal<Record<string, string>>({});
|
||||
readonly operationFieldErrors = signal<Record<string, FieldValidationError[]>>({});
|
||||
readonly lifecycleMutationsEnabled = computed(() => {
|
||||
const state = this.state();
|
||||
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
|
||||
@@ -110,7 +111,7 @@ export class LeaseEndPage {
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.loadError.set(
|
||||
httpErrorMessage(error, 'Impossible de charger la fin de ce bail.'),
|
||||
saveFailure(error, 'Impossible de charger la fin de ce bail.').message,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -201,6 +202,22 @@ export class LeaseEndPage {
|
||||
return this.operationErrors()[key] ?? '';
|
||||
}
|
||||
|
||||
operationFieldError(key: string): FieldValidationError[] {
|
||||
return this.operationFieldErrors()[key] ?? [];
|
||||
}
|
||||
|
||||
clearOperationFieldError(key: string, field: string): void {
|
||||
const errors = this.operationFieldError(key);
|
||||
if (!errors.some((error) => error.field === field)) return;
|
||||
const remaining = errors.filter((error) => error.field !== field);
|
||||
this.operationFieldErrors.update((all) =>
|
||||
remaining.length ? { ...all, [key]: remaining } : withoutRecordKey(all, key),
|
||||
);
|
||||
if (!remaining.length) {
|
||||
this.operationErrors.update((all) => withoutRecordKey(all, key));
|
||||
}
|
||||
}
|
||||
|
||||
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
|
||||
if (this.operationPending(key)) return;
|
||||
this.confirmingOperations.update((keys) => new Set(keys).add(key));
|
||||
@@ -223,6 +240,7 @@ export class LeaseEndPage {
|
||||
if (this.pendingOperations().has(key)) return;
|
||||
this.pendingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.operationFieldErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
@@ -230,11 +248,17 @@ export class LeaseEndPage {
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
error: (error: unknown) => {
|
||||
const failure = saveFailure(error, fallback);
|
||||
this.operationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, fallback),
|
||||
})),
|
||||
[key]: failure.message,
|
||||
}));
|
||||
this.operationFieldErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: failure.fieldErrors,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+41
-5
@@ -10,15 +10,41 @@
|
||||
<fieldset [disabled]="agreementPending()">
|
||||
<label>
|
||||
<span>Date de fin convenue</span>
|
||||
<input type="date" [formField]="agreementForm.endOn" />
|
||||
<input
|
||||
id="agreement-end-on"
|
||||
type="date"
|
||||
[formField]="agreementForm.endOn"
|
||||
(input)="agreementFieldChanged.emit('endOn')"
|
||||
/>
|
||||
</label>
|
||||
@if (agreementDateError()) {
|
||||
<p class="operation-error" role="alert">{{ agreementDateError() }}</p>
|
||||
}
|
||||
@if (serverError(agreementFieldErrors(), 'endOn')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(agreementFieldErrors(), 'endOn') }}
|
||||
</p>
|
||||
}
|
||||
<label>
|
||||
<span>Raison de l’accord</span>
|
||||
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
|
||||
<textarea
|
||||
id="agreement-reason"
|
||||
rows="3"
|
||||
[formField]="agreementForm.reason"
|
||||
(input)="agreementFieldChanged.emit('reason')"
|
||||
></textarea>
|
||||
</label>
|
||||
@if (agreementError() || agreementOperationError()) {
|
||||
@if (agreementReasonError()) {
|
||||
<p class="operation-error" role="alert">{{ agreementReasonError() }}</p>
|
||||
}
|
||||
@if (serverError(agreementFieldErrors(), 'reason')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementError() || agreementOperationError() }}
|
||||
{{ serverError(agreementFieldErrors(), 'reason') }}
|
||||
</p>
|
||||
}
|
||||
@if (agreementOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
|
||||
@@ -47,12 +73,22 @@
|
||||
}}</strong>
|
||||
</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<input
|
||||
id="closure-confirmed"
|
||||
type="checkbox"
|
||||
[formField]="closureForm.confirmed"
|
||||
(change)="closureFieldChanged.emit('confirmed')"
|
||||
/>
|
||||
<span
|
||||
>Je confirme que les clés ont réellement été restituées à cette
|
||||
date.</span
|
||||
>
|
||||
</label>
|
||||
@if (serverError(closureFieldErrors(), 'confirmed')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ serverError(closureFieldErrors(), 'confirmed') }}
|
||||
</p>
|
||||
}
|
||||
@if (closureError() || closureOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ closureError() || closureOperationError() }}
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ describe('LeaseEndResolutionForms', () => {
|
||||
function createFixture() {
|
||||
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('leaseStartOn', '2026-01-01');
|
||||
fixture.componentRef.setInput('agreementPending', false);
|
||||
fixture.componentRef.setInput('closurePending', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
+74
-5
@@ -1,8 +1,9 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
|
||||
import { FieldValidationError } from '../../services/http-error-message';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-resolution-forms',
|
||||
@@ -11,34 +12,64 @@ import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-e
|
||||
styleUrl: './lease-end-resolution-forms.css',
|
||||
})
|
||||
export class LeaseEndResolutionForms {
|
||||
private readonly element = inject(ElementRef<HTMLElement>);
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly leaseStartOn = input.required<string>();
|
||||
readonly currentEndOn = input<string | null>(null);
|
||||
readonly agreementPending = input(false);
|
||||
readonly closurePending = input(false);
|
||||
readonly agreementOperationError = input('');
|
||||
readonly closureOperationError = input('');
|
||||
readonly agreementFieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly closureFieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly keysReturnedOn = input<string | null>(null);
|
||||
readonly closureEnabled = input(false);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementFieldChanged = output<string>();
|
||||
readonly closureFieldChanged = output<string>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly closureForm = form(this.closureModel);
|
||||
readonly agreementError = signal('');
|
||||
readonly closureError = signal('');
|
||||
readonly agreementSubmitted = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const agreementField = this.agreementFieldErrors()[0]?.field;
|
||||
if (agreementField) {
|
||||
queueMicrotask(() =>
|
||||
(this.element.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>(
|
||||
agreementField === 'reason' ? '#agreement-reason' : '#agreement-end-on',
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}
|
||||
const closureField = this.closureFieldErrors()[0]?.field;
|
||||
if (closureField) {
|
||||
queueMicrotask(() =>
|
||||
(this.element.nativeElement as HTMLElement)
|
||||
.querySelector<HTMLElement>('#closure-confirmed')
|
||||
?.focus(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitAgreement(): void {
|
||||
if (this.agreementPending()) return;
|
||||
this.agreementSubmitted.set(true);
|
||||
this.agreementForm().markAsTouched();
|
||||
const model = this.agreementModel();
|
||||
if (!model.endOn || !model.reason.trim()) {
|
||||
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
|
||||
if (this.agreementDateError() || this.agreementReasonError()) {
|
||||
return;
|
||||
}
|
||||
this.agreementError.set('');
|
||||
this.agreedEndRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
endOn: model.endOn,
|
||||
@@ -46,6 +77,40 @@ export class LeaseEndResolutionForms {
|
||||
});
|
||||
}
|
||||
|
||||
agreementDateError(): string {
|
||||
if (!this.show(this.agreementForm.endOn())) return '';
|
||||
const endOn = this.agreementModel().endOn;
|
||||
if (!endOn) return 'Renseignez la date de fin convenue.';
|
||||
if (endOn < this.leaseStartOn()) {
|
||||
return 'La fin convenue ne peut pas précéder le début du bail.';
|
||||
}
|
||||
const currentEndOn = this.currentEndOn();
|
||||
return currentEndOn && endOn >= currentEndOn
|
||||
? 'La fin convenue doit être antérieure à la fin actuelle du bail.'
|
||||
: '';
|
||||
}
|
||||
|
||||
agreementReasonError(): string {
|
||||
if (!this.show(this.agreementForm.reason())) return '';
|
||||
const reason = this.agreementModel().reason.trim();
|
||||
if (!reason) return 'Renseignez la raison de la fin anticipée.';
|
||||
return reason.length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
|
||||
}
|
||||
|
||||
serverError(errors: FieldValidationError[], field: string): string {
|
||||
const code = errors.find((error) => error.field === field)?.code;
|
||||
if (field === 'endOn' && code === 'after_or_equal_lease_start') {
|
||||
return 'La fin convenue ne peut pas précéder le début du bail.';
|
||||
}
|
||||
if (field === 'endOn' && code === 'before_current_end') {
|
||||
return 'La fin convenue doit être antérieure à la fin actuelle du bail.';
|
||||
}
|
||||
if (field === 'reason' && code === 'max_length')
|
||||
return 'La raison ne peut pas dépasser 500 caractères.';
|
||||
if (field === 'confirmed' && code) return 'Confirmez la restitution réelle des clés.';
|
||||
return '';
|
||||
}
|
||||
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
@@ -65,4 +130,8 @@ export class LeaseEndResolutionForms {
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
private show(field: { touched(): boolean }): boolean {
|
||||
return this.agreementSubmitted() || field.touched();
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,10 @@ export interface LeaseEndState {
|
||||
unsupportedReason: string | null;
|
||||
phase: LeaseEndPhase;
|
||||
leaseType: 'STANDARD' | 'STUDENT';
|
||||
leaseStartOn: string;
|
||||
fixedEndOn: string | null;
|
||||
effectiveEndOn: string | null;
|
||||
maxEvidenceBytes: number;
|
||||
correctionRequired: boolean;
|
||||
exitConditionReport: {
|
||||
occurredOn: string;
|
||||
|
||||
Reference in new issue
Block a user