feat: add lease end page and resolution forms with necessary services and components

- Implemented LeaseEndPage component for managing lease end processes.
- Created LeaseEndResolutionForms component for handling agreement and closure submissions.
- Added civil date formatting utilities for French date representation.
- Developed styles for lease end resolution forms and rent due correction components.
- Integrated HTTP service for lease end operations including notice recording, withdrawal acceptance, and closure.
- Added unit tests for new components and services to ensure functionality.
This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 3d42c9a256
commit c1e6b905d5
114 files changed
+6582 -345

No files matched your search

@@ -0,0 +1,67 @@
:host {
display: block;
min-width: 0;
}
.notice-form {
padding: 1.25rem;
border: 1px solid var(--border, #dfe3e6);
border-radius: 1rem;
background: var(--surface, #fff);
}
h2 {
margin-block-start: 0;
}
.notice-form > p,
small {
color: var(--text-muted, #5f6368);
}
fieldset {
display: grid;
gap: 1rem;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 1rem;
}
label:not(.confirmation-field) {
display: grid;
gap: 0.4rem;
min-width: 0;
}
input,
select {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
}
.confirmation-field {
display: flex;
align-items: flex-start;
gap: 0.65rem;
min-width: 0;
}
.confirmation-field input {
flex: 0 0 auto;
}
.missing-evidence-warning {
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--surface-soft, #f6f7f8);
}
.missing-evidence-warning p {
margin-block-start: 0;
color: var(--text-muted, #5f6368);
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -0,0 +1,67 @@
<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>
<form
[formRoot]="noticeForm"
[attr.aria-busy]="submitting()"
(submit)="submit(); $event.preventDefault()"
>
<fieldset [disabled]="submitting()">
<div class="field-grid">
<label>
<span>Date de réception</span>
<input type="date" [formField]="noticeForm.receivedOn" />
</label>
<label>
<span>Départ demandé</span>
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
</label>
</div>
<label>
<span>Mode de remise</span>
<select [formField]="noticeForm.deliveryMode">
<option value="">Choisissez un mode</option>
@for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option>
}
</select>
</label>
@if (model().deliveryMode === 'OTHER') {
<label>
<span>Mode de remise utilisé</span>
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
</label>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
</label>
}
@if (lateRegistration()) {
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
<span>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span>
</label>
}
<label>
<span>Preuve (facultative, tout format)</span>
<input class="notice-evidence" type="file" (change)="selectEvidence($event)" />
<small>{{ evidence()?.name ?? 'Aucun fichier sélectionné' }}</small>
</label>
@if (!evidence()) {
<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" />
<span>Je confirme enregistrer ce congé sans justificatif.</span>
</label>
</div>
}
@if (validationError() || operationError()) {
<p class="operation-error" role="alert">{{ validationError() || operationError() }}</p>
}
<button mat-flat-button type="submit" [disabled]="submitting()">
{{ submitting() ? 'Enregistrement…' : 'Enregistrer le congé' }}
</button>
</fieldset>
</form>
</section>
@@ -0,0 +1,119 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { LeaseEndNoticeForm } from './lease-end-notice-form';
describe('LeaseEndNoticeForm', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('requires explicit confirmations for a late registration and OTHER delivery', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('submitting', false);
fixture.detectChanges();
fixture.componentInstance.model.set({
receivedOn: '2026-07-18',
requestedDepartureOn: '2026-08-18',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
fixture.componentInstance.model.update((model) => ({
...model,
lateRegistrationConfirmed: true,
}));
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('mode de remise non standard');
});
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('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
fixture.detectChanges();
fixture.componentInstance.model.update((model) => ({
...model,
missingEvidenceConfirmed: true,
}));
const evidence = new File(['photo'], 'preuve.jpg', { type: 'image/jpeg' });
const input = fixture.nativeElement.querySelector('.notice-evidence');
Object.defineProperty(input, 'files', { value: { item: () => evidence } });
input.dispatchEvent(new Event('change'));
fixture.detectChanges();
expect(fixture.componentInstance.model().missingEvidenceConfirmed).toBe(false);
expect(fixture.nativeElement.querySelector('.missing-evidence-warning')).toBeNull();
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: true,
missingEvidenceConfirmed: false,
});
fixture.componentInstance.submit();
expect(requested).toHaveBeenCalledWith({
timeZone: 'America/Los_Angeles',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'OTHER',
otherDeliveryMode: 'Remise par le gardien',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: true,
missingEvidenceConfirmed: false,
evidence,
});
expect(input.getAttribute('accept')).toBeNull();
});
it('requires and sends an explicit attestation when no evidence is selected', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
fixture.detectChanges();
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
fixture.componentInstance.model.update((model) => ({
...model,
missingEvidenceConfirmed: true,
}));
fixture.componentInstance.submit();
expect(requested).toHaveBeenCalledWith({
timeZone: 'Europe/Paris',
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-08-19',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
});
});
@@ -0,0 +1,94 @@
import { Component, computed, 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 { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
@Component({
selector: 'app-lease-end-notice-form',
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
templateUrl: './lease-end-notice-form.html',
styleUrl: './lease-end-notice-form.css',
})
export class LeaseEndNoticeForm {
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly submitting = input.required<boolean>();
readonly operationError = input('');
readonly noticeRequested = output<RecordNoticeRequest>();
readonly model = signal({
receivedOn: '',
requestedDepartureOn: '',
deliveryMode: '' as NoticeDeliveryMode | '',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: false,
});
readonly noticeForm = form(this.model, (fields) => {
maxLength(fields.otherDeliveryMode, 200);
});
readonly evidence = signal<File | null>(null);
readonly validationError = signal('');
readonly lateRegistration = computed(
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
);
readonly deliveryModes = [
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée avec avis de réception' },
{ value: 'BAILIFF', label: 'Acte d’un commissaire de justice' },
{ value: 'HAND_DELIVERY', label: 'Remise en main propre contre récépissé' },
{ value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
selectEvidence(event: Event): void {
const input = event.target;
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
this.evidence.set(evidence);
if (evidence) {
this.model.update((model) => ({ ...model, missingEvidenceConfirmed: false }));
}
if (input instanceof HTMLInputElement) input.value = '';
}
submit(): void {
if (this.submitting()) return;
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é.');
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 evidence = this.evidence();
this.noticeRequested.emit({
timeZone: this.timeZone(),
receivedOn: model.receivedOn,
requestedDepartureOn: model.requestedDepartureOn,
deliveryMode: model.deliveryMode,
...(model.deliveryMode === 'OTHER'
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
: {}),
lateRegistrationConfirmed: this.lateRegistration(),
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
missingEvidenceConfirmed: evidence === null,
...(evidence ? { evidence } : {}),
});
}
}