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:
1 parent
3d42c9a256
commit
c1e6b905d5
114 files changed
+6582
-345
No files matched your search
@@ -8,6 +8,7 @@ describe('routes', () => {
|
||||
'apartments',
|
||||
'tenants',
|
||||
'leases/:leaseId/finalize',
|
||||
'leases/:leaseId/end',
|
||||
'leases',
|
||||
'rent-payments',
|
||||
'settings',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApartmentsPage } from './components/apartments-page/apartments-page';
|
||||
import { DashboardPage } from './components/dashboard-page/dashboard-page';
|
||||
import { LeasesPage } from './components/leases-page/leases-page';
|
||||
import { LeaseFinalizationPage } from './components/lease-finalization-page/lease-finalization-page';
|
||||
import { LeaseEndPage } from './components/lease-end-page/lease-end-page';
|
||||
import { RentPaymentsPage } from './components/rent-payments-page/rent-payments-page';
|
||||
import { SettingsPage } from './components/settings-page/settings-page';
|
||||
import { TenantsPage } from './components/tenants-page/tenants-page';
|
||||
@@ -12,6 +13,7 @@ export const routes: Routes = [
|
||||
{ path: 'apartments', component: ApartmentsPage },
|
||||
{ path: 'tenants', component: TenantsPage },
|
||||
{ path: 'leases/:leaseId/finalize', component: LeaseFinalizationPage },
|
||||
{ path: 'leases/:leaseId/end', component: LeaseEndPage },
|
||||
{ path: 'leases', component: LeasesPage },
|
||||
{ path: 'rent-payments', component: RentPaymentsPage },
|
||||
{ path: 'settings', component: SettingsPage },
|
||||
|
||||
@@ -161,3 +161,11 @@
|
||||
.card-error {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.lease-end-unsupported {
|
||||
margin: 0.875rem 0 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -50,10 +50,13 @@
|
||||
}
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>Période</dt>
|
||||
<dt>{{ lease().leaseType === 'STANDARD' ? 'Période initiale' : 'Période' }}</dt>
|
||||
<dd>
|
||||
{{ civilDate(lease().startDate) }} →
|
||||
{{ civilDate(lease().endDate) }}
|
||||
@if (lease().leaseType === 'STANDARD') {
|
||||
· bail reconductible
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -150,7 +153,26 @@
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="lease-end-action"
|
||||
(click)="leaseEndRequested.emit(leaseId)"
|
||||
>
|
||||
{{
|
||||
lease().status === 'ACTIVE' && lease().tenantIds.length === 1
|
||||
? 'Gérer la fin du bail'
|
||||
: 'Voir la fin du bail'
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (lease().status === 'ACTIVE' && lease().tenantIds.length > 1) {
|
||||
<p class="lease-end-unsupported" role="note">
|
||||
La fin de bail en colocation n’est pas encore prise en charge. Aucun congé ni aucune clôture
|
||||
ne peut être enregistré ici.
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
@@ -194,6 +194,7 @@ describe('LeaseCard', () => {
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Période initiale 1 janvier 2026 → 1 janvier 2027 · bail reconductible');
|
||||
expect(text).toContain('Générer / télécharger le bail');
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
@@ -215,6 +216,61 @@ describe('LeaseCard', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
|
||||
});
|
||||
|
||||
it('manages an active single lease and keeps read access for colocation and archives', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2028-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.leaseEndRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
|
||||
const action: HTMLButtonElement = fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(action.textContent).toContain('Gérer la fin du bail');
|
||||
action.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Archiver');
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
tenantIds: ['tenant-1', 'tenant-2'],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const colocationAction: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(colocationAction.textContent).toContain('Voir la fin du bail');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La fin de bail en colocation n’est pas encore prise en charge',
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-action').textContent).toContain(
|
||||
'Voir la fin du bail',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
||||
@@ -5,16 +5,10 @@ import { Apartment } from '../../services/apartment.service';
|
||||
import { Lease, LeaseRevisionMetadata, RentDue } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { apartmentDocumentTypeLabel } from '../../services/apartment-document-label';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import { leaseWarningDescription } from '../../services/lease-warning-label';
|
||||
import { RentRevisionPreviewComponent } from '../rent-revision-preview/rent-revision-preview';
|
||||
|
||||
const frenchCivilDate = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-card',
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
|
||||
@@ -38,7 +32,9 @@ export class LeaseCard {
|
||||
readonly dossierRequested = output<Lease>();
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly finalizationRequested = output<string>();
|
||||
readonly leaseEndRequested = output<string>();
|
||||
readonly rentRevisionOpen = signal(false);
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
toggleRentRevision(): void {
|
||||
this.rentRevisionOpen.update((open) => !open);
|
||||
@@ -76,9 +72,4 @@ export class LeaseCard {
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningDescription(code);
|
||||
}
|
||||
|
||||
civilDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return frenchCivilDate.format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
:host,
|
||||
.notice-card,
|
||||
header > div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h3,
|
||||
h4 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.notice-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block: 1.25rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.evidence-list ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.evidence-attestation {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
.evidence-list li {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
padding-block-start: 1.25rem;
|
||||
border-block-start: 1px solid var(--border, #dfe3e6);
|
||||
}
|
||||
fieldset,
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
legend {
|
||||
margin-block-end: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<article class="notice-card">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{{ statusLabel() }}</p>
|
||||
<h3>Congé reçu le {{ civilDate(notice().receivedOn) }}</h3>
|
||||
</div>
|
||||
<span>Départ demandé le {{ civilDate(notice().requestedDepartureOn) }}</span>
|
||||
</header>
|
||||
<dl class="notice-facts">
|
||||
<div>
|
||||
<dt>Remise</dt>
|
||||
<dd>
|
||||
{{ deliveryModeLabel(notice().deliveryMode) }}
|
||||
@if (notice().otherDeliveryMode) {
|
||||
— {{ notice().otherDeliveryMode }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Règle figée</dt>
|
||||
<dd>
|
||||
Préavis retenu : {{ notice().noticeMonths }} mois · fin minimale le
|
||||
{{ civilDate(notice().minimumEndOn) }} · Version de règle : {{ notice().ruleVersion }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Audit</dt>
|
||||
<dd>{{ notice().authorId }} · {{ notice().recordedAt }}</dd>
|
||||
</div>
|
||||
@if (notice().withdrawal; as withdrawal) {
|
||||
<div>
|
||||
<dt>Retrait accepté</dt>
|
||||
<dd>
|
||||
{{ civilDate(withdrawal.acceptedOn) }} · {{ withdrawal.reason }} ·
|
||||
{{ withdrawal.authorId }} · {{ withdrawal.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (notice().invalidation; as invalidation) {
|
||||
<div>
|
||||
<dt>Invalidation administrative</dt>
|
||||
<dd>
|
||||
{{ civilDate(invalidation.invalidatedOn) }} · {{ invalidation.reason }} ·
|
||||
{{ invalidation.authorId }} · {{ invalidation.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@if (notice().recordedWithoutEvidenceConfirmed) {
|
||||
<p class="evidence-attestation">
|
||||
Le bailleur a confirmé que ce congé a été enregistré sans justificatif initial.
|
||||
</p>
|
||||
}
|
||||
|
||||
<section class="evidence-list" aria-label="Versions des preuves">
|
||||
<h4>Preuves conservées</h4>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<ul>
|
||||
@for (evidence of notice().evidenceVersions; track evidence.version) {
|
||||
<li>
|
||||
<span>
|
||||
Version {{ evidence.version }} · {{ evidence.fileName }}
|
||||
@if (evidence.reason) {
|
||||
— {{ evidence.reason }}
|
||||
}
|
||||
</span>
|
||||
<span>Empreinte SHA-256 : {{ evidence.sha256 }}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="evidence-download"
|
||||
[disabled]="endService.evidenceDownloading(leaseId(), notice().id, evidence.version)"
|
||||
(click)="
|
||||
endService.downloadEvidence(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version,
|
||||
evidence.fileName
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
endService.evidenceDownloading(leaseId(), notice().id, evidence.version)
|
||||
? 'Téléchargement…'
|
||||
: 'Télécharger'
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
endService.evidenceDownloadError(leaseId(), notice().id, evidence.version);
|
||||
as downloadError
|
||||
) {
|
||||
<span class="operation-error" role="alert">{{ downloadError }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune preuve téléversée.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (lifecycleMutationsEnabled() || evidenceUploadEnabled()) {
|
||||
<div class="notice-actions">
|
||||
@if (evidenceUploadEnabled()) {
|
||||
<form
|
||||
class="evidence-form"
|
||||
[attr.aria-busy]="evidencePending()"
|
||||
(submit)="submitEvidence(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="evidencePending()">
|
||||
<legend>Ajouter une version de preuve</legend>
|
||||
<label>
|
||||
<span>Fichier (tout format)</span>
|
||||
<input type="file" (change)="selectEvidence($event)" />
|
||||
</label>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<label>
|
||||
<span>Raison de la nouvelle version</span>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="500"
|
||||
[value]="evidenceReason()"
|
||||
(input)="updateEvidenceReason($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (evidenceError() || evidenceOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ evidenceError() || evidenceOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="evidencePending()">
|
||||
{{ evidencePending() ? 'Ajout…' : 'Ajouter la preuve' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (lifecycleMutationsEnabled() && notice().status === 'ACTIVE') {
|
||||
<form
|
||||
[formRoot]="withdrawalForm"
|
||||
[attr.aria-busy]="withdrawalPending()"
|
||||
(submit)="submitWithdrawal(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="withdrawalPending()">
|
||||
<legend>Accepter un retrait</legend>
|
||||
<label
|
||||
><span>Date d’acceptation</span
|
||||
><input type="date" [formField]="withdrawalForm.acceptedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
|
||||
</label>
|
||||
@if (withdrawalError() || withdrawalOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalError() || withdrawalOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
|
||||
{{ withdrawalPending() ? 'Acceptation…' : 'Accepter le retrait' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form
|
||||
[formRoot]="invalidationForm"
|
||||
[attr.aria-busy]="invalidationPending()"
|
||||
(submit)="submitInvalidation(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="invalidationPending()">
|
||||
<legend>Invalider administrativement</legend>
|
||||
<label
|
||||
><span>Date d’invalidation</span
|
||||
><input type="date" [formField]="invalidationForm.invalidatedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
|
||||
</label>
|
||||
@if (invalidationError() || invalidationOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationError() || invalidationOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
|
||||
{{ invalidationPending() ? 'Invalidation…' : 'Invalider ce congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndNotice } from '../../services/lease-end.service';
|
||||
import { LeaseEndService } from '../../services/lease-end.service';
|
||||
import { LeaseEndNoticeCard } from './lease-end-notice-card';
|
||||
|
||||
describe('LeaseEndNoticeCard', () => {
|
||||
const downloadEvidence = vi.fn();
|
||||
const evidenceDownloading = vi.fn().mockReturnValue(false);
|
||||
const evidenceDownloadError = vi.fn().mockReturnValue('');
|
||||
const notice: LeaseEndNotice = {
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [
|
||||
{
|
||||
version: 1,
|
||||
fileName: 'preuve.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
size: 1200,
|
||||
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
reason: null,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: { downloadEvidence, evidenceDownloading, evidenceDownloadError },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeCard);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('notice', notice);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
|
||||
fixture.componentRef.setInput('evidenceUploadEnabled', true);
|
||||
fixture.componentRef.setInput('withdrawalPending', false);
|
||||
fixture.componentRef.setInput('invalidationPending', false);
|
||||
fixture.componentRef.setInput('evidencePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows the rule snapshot and asks a reason from evidence version two', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.evidenceRequested.subscribe(requested);
|
||||
const evidence = new File(['new'], 'preuve-2.txt', { type: 'text/plain' });
|
||||
fixture.componentInstance.evidence.set(evidence);
|
||||
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(fixture.componentInstance.evidenceError()).toContain('raison');
|
||||
|
||||
fixture.componentInstance.evidenceReason.set('Version plus lisible');
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: { timeZone: 'Europe/Paris', evidence, reason: 'Version plus lisible' },
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Préavis retenu : 1 mois');
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07');
|
||||
expect(fixture.nativeElement.textContent).toContain('enregistré sans justificatif');
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(500);
|
||||
expect(
|
||||
[...fixture.nativeElement.querySelectorAll('textarea')].map(
|
||||
(textarea: HTMLTextAreaElement) => textarea.maxLength,
|
||||
),
|
||||
).toEqual([500, 500]);
|
||||
});
|
||||
|
||||
it('emits dated withdrawal and evidence download intents', () => {
|
||||
const fixture = createFixture();
|
||||
const withdrawal = vi.fn();
|
||||
fixture.componentInstance.withdrawalRequested.subscribe(withdrawal);
|
||||
fixture.componentInstance.withdrawalModel.set({
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitWithdrawal();
|
||||
fixture.nativeElement.querySelector('.evidence-download').click();
|
||||
|
||||
expect(withdrawal).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
},
|
||||
});
|
||||
expect(downloadEvidence).toHaveBeenCalledWith('lease-1', 'notice-1', 1, 'preuve.jpg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Component, 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 { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import {
|
||||
AcceptWithdrawalRequest,
|
||||
AppendEvidenceRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndNotice,
|
||||
LeaseEndService,
|
||||
NoticeDeliveryMode,
|
||||
} from '../../services/lease-end.service';
|
||||
|
||||
export interface NoticeAction<T> {
|
||||
noticeId: string;
|
||||
request: T;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-card',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-card.html',
|
||||
styleUrl: './lease-end-notice-card.css',
|
||||
})
|
||||
export class LeaseEndNoticeCard {
|
||||
readonly endService = inject(LeaseEndService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly notice = input.required<LeaseEndNotice>();
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly lifecycleMutationsEnabled = input.required<boolean>();
|
||||
readonly evidenceUploadEnabled = input.required<boolean>();
|
||||
readonly withdrawalPending = input(false);
|
||||
readonly invalidationPending = input(false);
|
||||
readonly evidencePending = input(false);
|
||||
readonly withdrawalOperationError = input('');
|
||||
readonly invalidationOperationError = input('');
|
||||
readonly evidenceOperationError = input('');
|
||||
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
|
||||
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
|
||||
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
|
||||
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
|
||||
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
|
||||
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly invalidationForm = form(this.invalidationModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly evidenceReason = signal('');
|
||||
readonly withdrawalError = signal('');
|
||||
readonly invalidationError = signal('');
|
||||
readonly evidenceError = signal('');
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
statusLabel(): string {
|
||||
return {
|
||||
ACTIVE: 'Congé en cours',
|
||||
WITHDRAWN: 'Retrait accepté',
|
||||
INVALIDATED: 'Congé invalidé',
|
||||
}[this.notice().status];
|
||||
}
|
||||
|
||||
deliveryModeLabel(mode: NoticeDeliveryMode): string {
|
||||
return {
|
||||
REGISTERED_LETTER: 'Lettre recommandée avec avis de réception',
|
||||
BAILIFF: 'Acte d’un commissaire de justice',
|
||||
HAND_DELIVERY: 'Remise en main propre contre récépissé',
|
||||
OTHER: 'Autre mode',
|
||||
}[mode];
|
||||
}
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidence.set(input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null);
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
updateEvidenceReason(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidenceReason.set(input instanceof HTMLInputElement ? input.value : '');
|
||||
}
|
||||
|
||||
submitWithdrawal(): void {
|
||||
if (this.withdrawalPending()) return;
|
||||
const model = this.withdrawalModel();
|
||||
if (!model.acceptedOn || !model.reason.trim()) {
|
||||
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
|
||||
return;
|
||||
}
|
||||
this.withdrawalError.set('');
|
||||
this.withdrawalRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
acceptedOn: model.acceptedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitInvalidation(): void {
|
||||
if (this.invalidationPending()) return;
|
||||
const model = this.invalidationModel();
|
||||
if (!model.invalidatedOn || !model.reason.trim()) {
|
||||
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
|
||||
return;
|
||||
}
|
||||
this.invalidationError.set('');
|
||||
this.invalidationRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
invalidatedOn: model.invalidatedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitEvidence(): void {
|
||||
if (this.evidencePending()) return;
|
||||
const evidence = this.evidence();
|
||||
const reason = this.evidenceReason().trim();
|
||||
if (!evidence) {
|
||||
this.evidenceError.set('Sélectionnez une preuve à conserver.');
|
||||
return;
|
||||
}
|
||||
if (this.notice().evidenceVersions.length > 0 && !reason) {
|
||||
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
|
||||
return;
|
||||
}
|
||||
this.evidenceError.set('');
|
||||
this.evidenceRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.lease-end-page {
|
||||
max-width: 62rem;
|
||||
}
|
||||
.lease-end-skeleton {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.lease-end-skeleton span {
|
||||
min-height: 5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.phase-summary,
|
||||
.unsupported-state,
|
||||
.timeline,
|
||||
.correction-warning {
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.phase-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.phase-summary h2,
|
||||
.timeline h2,
|
||||
.notice-history > h2,
|
||||
.unsupported-state h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.student-summary {
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
.student-final-month {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-accent, #eaf5f1);
|
||||
}
|
||||
.student-final-month span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.student-final-month strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.unsupported-state,
|
||||
.correction-warning,
|
||||
.timeline,
|
||||
.notice-history,
|
||||
app-lease-end-notice-form,
|
||||
app-lease-end-resolution-forms {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
.unsupported-state {
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
}
|
||||
.correction-warning {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
.timeline ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.timeline li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
border-inline-start: 0.2rem solid var(--accent, #236b5d);
|
||||
}
|
||||
.timeline span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.timeline small {
|
||||
color: var(--text-muted, #5f6368);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-history {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<section class="form-page lease-end-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Fin du bail</h1>
|
||||
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as endState) {
|
||||
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
|
||||
<div>
|
||||
<p class="eyebrow">Situation actuelle</p>
|
||||
<h2>{{ phaseLabel(endState.phase) }}</h2>
|
||||
</div>
|
||||
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
|
||||
<p class="student-final-month">
|
||||
<span>Dernier mois du bail étudiant</span>
|
||||
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (!endState.supported) {
|
||||
<article class="unsupported-state" role="status">
|
||||
<h2>Gestion non prise en charge</h2>
|
||||
<p>{{ endState.unsupportedReason ?? 'Ce type de bail ne peut pas être modifié ici.' }}</p>
|
||||
<p>
|
||||
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs des congés
|
||||
historiques peuvent toujours être complétés.
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
@if (endState.correctionRequired) {
|
||||
<article class="correction-warning" role="alert">
|
||||
<p>
|
||||
Une correction financière est requise : contrepasser le paiement concerné, puis
|
||||
enregistrer le bon paiement.
|
||||
</p>
|
||||
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
|
||||
</article>
|
||||
}
|
||||
|
||||
<section class="timeline" aria-labelledby="timeline-title">
|
||||
<h2 id="timeline-title">Chronologie</h2>
|
||||
<ol>
|
||||
@if (endState.fixedEndOn) {
|
||||
<li>
|
||||
<span>Terme fixe du bail étudiant</span
|
||||
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<li>
|
||||
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Départ demandé</span><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
|
||||
<li>
|
||||
<span>Fin anticipée convenue</span>
|
||||
<strong>{{ civilDate(agreement.endOn) }}</strong>
|
||||
<small>
|
||||
{{ agreement.reason }} · {{ agreement.authorId }} · {{ agreement.recordedAt }}
|
||||
</small>
|
||||
</li>
|
||||
}
|
||||
@if (endState.effectiveEndOn) {
|
||||
<li>
|
||||
<span>Fin effective retenue</span
|
||||
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@if (endState.closure) {
|
||||
<li>
|
||||
<span>Clés restituées</span
|
||||
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (endState.closure) {
|
||||
<p>
|
||||
Clôture confirmée par {{ endState.closure.authorId }} · {{ endState.closure.recordedAt }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (endState.notices.length) {
|
||||
<section class="notice-history" aria-labelledby="notice-history-title">
|
||||
<h2 id="notice-history-title">Historique des congés</h2>
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<app-lease-end-notice-card
|
||||
[leaseId]="leaseId"
|
||||
[notice]="notice"
|
||||
[timeZone]="timeZone"
|
||||
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
|
||||
[evidenceUploadEnabled]="true"
|
||||
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
|
||||
[invalidationPending]="operationPending('invalidation:' + notice.id)"
|
||||
[evidencePending]="operationPending('evidence:' + notice.id)"
|
||||
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
|
||||
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
|
||||
[evidenceOperationError]="operationError('evidence:' + notice.id)"
|
||||
(withdrawalRequested)="requestWithdrawal($event)"
|
||||
(invalidationRequested)="requestInvalidation($event)"
|
||||
(evidenceRequested)="appendEvidence($event)"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (canRecordNotice()) {
|
||||
<app-lease-end-notice-form
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[submitting]="operationPending('notice')"
|
||||
[operationError]="operationError('notice')"
|
||||
(noticeRequested)="recordNotice($event)"
|
||||
/>
|
||||
}
|
||||
@if (canRecordAgreement() || canClose()) {
|
||||
<app-lease-end-resolution-forms
|
||||
[timeZone]="timeZone"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[agreementPending]="operationPending('agreement')"
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
[closureOperationError]="operationError('closure')"
|
||||
(agreedEndRequested)="recordAgreement($event)"
|
||||
(closureRequested)="closeLease($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,258 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import { LeaseEndPage } from './lease-end-page';
|
||||
|
||||
describe('LeaseEndPage', () => {
|
||||
const state: LeaseEndState = {
|
||||
leaseId: 'lease-1',
|
||||
supported: true,
|
||||
unsupportedReason: null,
|
||||
phase: 'NOTICE_PERIOD',
|
||||
leaseType: 'STUDENT',
|
||||
fixedEndOn: '2026-07-31',
|
||||
effectiveEndOn: '2026-07-31',
|
||||
correctionRequired: false,
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
closure: null,
|
||||
notices: [
|
||||
{
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const recordNotice = vi.fn();
|
||||
const acceptWithdrawal = vi.fn();
|
||||
const invalidateNotice = vi.fn();
|
||||
const appendEvidence = vi.fn();
|
||||
const recordAgreedEarlyEnd = vi.fn();
|
||||
const close = vi.fn();
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: {
|
||||
getState,
|
||||
recordNotice,
|
||||
acceptWithdrawal,
|
||||
invalidateNotice,
|
||||
appendEvidence,
|
||||
recordAgreedEarlyEnd,
|
||||
close,
|
||||
evidenceDownloading: vi.fn().mockReturnValue(false),
|
||||
evidenceDownloadError: vi.fn().mockReturnValue(''),
|
||||
downloadEvidence: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseEndState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
|
||||
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the authoritative phase, civil timeline and student final month', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Préavis en cours');
|
||||
expect(text).toContain('19 juillet 2026');
|
||||
expect(text).toContain('Juillet 2026');
|
||||
expect(text).toContain('enregistré sans justificatif initial');
|
||||
expect(text).not.toContain('18 juillet 2026');
|
||||
});
|
||||
|
||||
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
supported: false,
|
||||
unsupportedReason: 'La fin de bail en colocation doit être gérée locataire par locataire.',
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('doit être gérée locataire par locataire');
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
});
|
||||
|
||||
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
phase: 'ARCHIVED',
|
||||
closure: {
|
||||
keysReturnedOn: '2026-07-31',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-31T18:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
|
||||
});
|
||||
|
||||
it('links a financial correction to the existing payment reversal journey', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const correction: HTMLAnchorElement =
|
||||
fixture.nativeElement.querySelector('.correction-warning a');
|
||||
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
|
||||
expect(correction.getAttribute('href')).toBe('/rent-payments');
|
||||
});
|
||||
|
||||
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
notices: [],
|
||||
agreedEarlyEnds: [
|
||||
{
|
||||
endOn: '2026-08-10',
|
||||
reason: 'Premier accord',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
endOn: '2026-07-31',
|
||||
reason: 'Accord avancé',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-12T10:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Premier accord');
|
||||
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
|
||||
expect(fixture.nativeElement.textContent).toContain('owner-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
|
||||
});
|
||||
|
||||
it('keeps notice loading and failures local without publishing false success', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
|
||||
const response = new Subject<LeaseEndState>();
|
||||
recordNotice.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request: RecordNoticeRequest = {
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
};
|
||||
|
||||
fixture.componentInstance.recordNotice(request);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent).toContain(
|
||||
'Enregistrement…',
|
||||
);
|
||||
|
||||
response.error({ error: { message: 'Congé refusé' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
|
||||
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request = {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
|
||||
});
|
||||
|
||||
it('keeps a closure failure actionable', () => {
|
||||
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.closeLease({
|
||||
timeZone: 'Europe/Paris',
|
||||
keysReturnedOn: '2026-08-19',
|
||||
confirmed: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Observable, finalize } from 'rxjs';
|
||||
import {
|
||||
browserTimeZone,
|
||||
formatFrenchCivilDate,
|
||||
formatFrenchCivilMonth,
|
||||
todayInTimeZone,
|
||||
} from '../../services/civil-date';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { withoutRecordKey } from '../../services/immutable-state';
|
||||
import {
|
||||
AgreedEarlyEndRequest,
|
||||
AppendEvidenceRequest,
|
||||
CloseLeaseRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndPhase,
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
AcceptWithdrawalRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../confirmation-dialog/confirmation-dialog';
|
||||
import { LeaseEndNoticeForm } from '../lease-end-notice-form/lease-end-notice-form';
|
||||
import { LeaseEndNoticeCard, NoticeAction } from '../lease-end-notice-card/lease-end-notice-card';
|
||||
import { LeaseEndResolutionForms } from '../lease-end-resolution-forms/lease-end-resolution-forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-page',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
LeaseEndNoticeForm,
|
||||
LeaseEndNoticeCard,
|
||||
LeaseEndResolutionForms,
|
||||
],
|
||||
templateUrl: './lease-end-page.html',
|
||||
styleUrl: './lease-end-page.css',
|
||||
})
|
||||
export class LeaseEndPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly endService = inject(LeaseEndService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly timeZone = browserTimeZone;
|
||||
readonly today = todayInTimeZone(this.timeZone);
|
||||
readonly state = signal<LeaseEndState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly operationErrors = signal<Record<string, string>>({});
|
||||
readonly lifecycleMutationsEnabled = computed(() => {
|
||||
const state = this.state();
|
||||
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
|
||||
});
|
||||
readonly canRecordNotice = computed(
|
||||
() =>
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
this.state()?.phase === 'ACTIVE' &&
|
||||
!this.state()?.notices.some((notice) => notice.status === 'ACTIVE'),
|
||||
);
|
||||
readonly canRecordAgreement = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD')
|
||||
);
|
||||
});
|
||||
readonly canClose = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
|
||||
);
|
||||
});
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilMonth = formatFrenchCivilMonth;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.endService
|
||||
.getState(this.leaseId, this.timeZone)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.loading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.loadError.set(httpErrorMessage(error, 'Impossible de charger la fin de ce bail.')),
|
||||
});
|
||||
}
|
||||
|
||||
phaseLabel(phase: LeaseEndPhase): string {
|
||||
return {
|
||||
ACTIVE: 'Bail actif',
|
||||
NOTICE_PERIOD: 'Préavis en cours',
|
||||
AWAITING_CLOSURE: 'En attente de clôture',
|
||||
UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre',
|
||||
ARCHIVED: 'Bail clôturé et archivé',
|
||||
}[phase];
|
||||
}
|
||||
|
||||
recordNotice(request: RecordNoticeRequest): void {
|
||||
this.mutate(
|
||||
'notice',
|
||||
this.endService.recordNotice(this.leaseId, request),
|
||||
'Impossible d’enregistrer ce congé.',
|
||||
);
|
||||
}
|
||||
|
||||
requestWithdrawal(action: NoticeAction<AcceptWithdrawalRequest>): void {
|
||||
const key = `withdrawal:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Accepter le retrait de ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Accepter le retrait',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’accepter ce retrait.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
requestInvalidation(action: NoticeAction<InvalidateNoticeRequest>): void {
|
||||
const key = `invalidation:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Invalider administrativement ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Invalider le congé',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’invalider ce congé.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
appendEvidence(action: NoticeAction<AppendEvidenceRequest>): void {
|
||||
this.mutate(
|
||||
`evidence:${action.noticeId}`,
|
||||
this.endService.appendEvidence(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’ajouter cette preuve.',
|
||||
);
|
||||
}
|
||||
|
||||
recordAgreement(request: AgreedEarlyEndRequest): void {
|
||||
this.mutate(
|
||||
'agreement',
|
||||
this.endService.recordAgreedEarlyEnd(this.leaseId, request),
|
||||
'Impossible d’enregistrer cette fin anticipée.',
|
||||
);
|
||||
}
|
||||
|
||||
closeLease(request: CloseLeaseRequest): void {
|
||||
this.mutate(
|
||||
'closure',
|
||||
this.endService.close(this.leaseId, request),
|
||||
'Impossible de clôturer ce bail.',
|
||||
);
|
||||
}
|
||||
|
||||
operationPending(key: string): boolean {
|
||||
return this.pendingOperations().has(key) || this.confirmingOperations().has(key);
|
||||
}
|
||||
|
||||
operationError(key: string): string {
|
||||
return this.operationErrors()[key] ?? '';
|
||||
}
|
||||
|
||||
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
|
||||
if (this.operationPending(key)) return;
|
||||
this.confirmingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingOperations.update((keys) => withoutKey(keys, key));
|
||||
if (confirmed) action();
|
||||
});
|
||||
}
|
||||
|
||||
private mutate(key: string, request: Observable<LeaseEndState>, fallback: string): void {
|
||||
if (this.pendingOperations().has(key)) return;
|
||||
this.pendingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.operationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, fallback),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function withoutKey(keys: ReadonlySet<string>, key: string): ReadonlySet<string> {
|
||||
const next = new Set(keys);
|
||||
next.delete(key);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
:host,
|
||||
.resolution-card {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.resolution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.resolution-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.closure-card {
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
fieldset,
|
||||
label:not(.confirmation-field) {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
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;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<div class="resolution-grid">
|
||||
@if (showAgreement()) {
|
||||
<section class="resolution-card" aria-labelledby="agreement-title">
|
||||
<h2 id="agreement-title">Fin anticipée convenue</h2>
|
||||
<form
|
||||
[formRoot]="agreementForm"
|
||||
[attr.aria-busy]="agreementPending()"
|
||||
(submit)="submitAgreement(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="agreementPending()">
|
||||
<label>
|
||||
<span>Date de fin convenue</span>
|
||||
<input type="date" [formField]="agreementForm.endOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Raison de l’accord</span>
|
||||
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
|
||||
</label>
|
||||
@if (agreementError() || agreementOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementError() || agreementOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
|
||||
{{ agreementPending() ? 'Enregistrement…' : 'Enregistrer l’accord' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (showClosure()) {
|
||||
<section class="resolution-card closure-card" aria-labelledby="closure-title">
|
||||
<h2 id="closure-title">Clôturer le bail</h2>
|
||||
<form
|
||||
[formRoot]="closureForm"
|
||||
[attr.aria-busy]="closurePending()"
|
||||
(submit)="submitClosure(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="closurePending()">
|
||||
<label>
|
||||
<span>Date réelle de restitution des clés</span>
|
||||
<input type="date" [formField]="closureForm.keysReturnedOn" />
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<span>Je confirme que les clés ont réellement été restituées à cette date.</span>
|
||||
</label>
|
||||
@if (closureError() || closureOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ closureError() || closureOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="closurePending()">
|
||||
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndResolutionForms } from './lease-end-resolution-forms';
|
||||
|
||||
describe('LeaseEndResolutionForms', () => {
|
||||
function createFixture() {
|
||||
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('agreementPending', false);
|
||||
fixture.componentRef.setInput('closurePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('records a reasoned agreed end, including a retroactive civil date', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.agreedEndRequested.subscribe(requested);
|
||||
fixture.componentInstance.agreementModel.set({
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitAgreement();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
|
||||
});
|
||||
|
||||
it('only emits closure after explicit confirmation and keeps the civil day', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.closureRequested.subscribe(requested);
|
||||
fixture.componentInstance.closureModel.set({
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
|
||||
|
||||
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-resolution-forms',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-resolution-forms.html',
|
||||
styleUrl: './lease-end-resolution-forms.css',
|
||||
})
|
||||
export class LeaseEndResolutionForms {
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly agreementPending = input(false);
|
||||
readonly closurePending = input(false);
|
||||
readonly agreementOperationError = input('');
|
||||
readonly closureOperationError = input('');
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ keysReturnedOn: '', confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly closureForm = form(this.closureModel);
|
||||
readonly agreementError = signal('');
|
||||
readonly closureError = signal('');
|
||||
|
||||
submitAgreement(): void {
|
||||
if (this.agreementPending()) return;
|
||||
const model = this.agreementModel();
|
||||
if (!model.endOn || !model.reason.trim()) {
|
||||
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
|
||||
return;
|
||||
}
|
||||
this.agreementError.set('');
|
||||
this.agreedEndRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
endOn: model.endOn,
|
||||
reason: model.reason.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
if (!model.keysReturnedOn) {
|
||||
this.closureError.set('Renseignez la date réelle de restitution des clés.');
|
||||
return;
|
||||
}
|
||||
if (!model.confirmed) {
|
||||
this.closureError.set('Confirmez la restitution réelle des clés avant de clôturer.');
|
||||
return;
|
||||
}
|
||||
this.closureError.set('');
|
||||
this.closureRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
keysReturnedOn: model.keysReturnedOn,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,8 @@ describe('LeaseForm', () => {
|
||||
...lease.legalTerms,
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDate: '2026-09-01',
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 9,
|
||||
referenceQuarter: 2,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
@@ -284,6 +285,94 @@ describe('LeaseForm', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('à confirmer lors de la finalisation');
|
||||
});
|
||||
|
||||
it('submits a recurring IRL revision day and month without a year', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentInstance.leaseModel.set({
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-09-01',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
legalTerms: {
|
||||
...emptyLeaseLegalTerms(),
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 1,
|
||||
referenceQuarter: 1,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
},
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('#annualRevisionDay')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('#annualRevisionMonth')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Année de révision');
|
||||
expect(submitted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
legalTerms: expect.objectContaining({
|
||||
rentRevision: expect.objectContaining({
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an impossible recurring IRL calendar date', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentInstance.leaseModel.set({
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-09-01',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
legalTerms: {
|
||||
...emptyLeaseLegalTerms(),
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDay: 31,
|
||||
annualRevisionMonth: 2,
|
||||
referenceQuarter: 1,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
},
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(submitted).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('ne forment pas une date valide');
|
||||
});
|
||||
|
||||
it('clears an unfinished rent supplement when the reference-rent scheme is disabled', async () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
|
||||
@@ -102,13 +102,36 @@
|
||||
}
|
||||
<div class="form-grid three-columns">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date annuelle de révision</mat-label>
|
||||
<mat-label>Jour de révision</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionDate"
|
||||
id="annualRevisionDay"
|
||||
type="number"
|
||||
step="1"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionDay"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mois de révision</mat-label>
|
||||
<mat-select
|
||||
id="annualRevisionMonth"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionMonth"
|
||||
>
|
||||
<mat-option [value]="null">Non renseigné</mat-option>
|
||||
<mat-option [value]="1">Janvier</mat-option>
|
||||
<mat-option [value]="2">Février</mat-option>
|
||||
<mat-option [value]="3">Mars</mat-option>
|
||||
<mat-option [value]="4">Avril</mat-option>
|
||||
<mat-option [value]="5">Mai</mat-option>
|
||||
<mat-option [value]="6">Juin</mat-option>
|
||||
<mat-option [value]="7">Juillet</mat-option>
|
||||
<mat-option [value]="8">Août</mat-option>
|
||||
<mat-option [value]="9">Septembre</mat-option>
|
||||
<mat-option [value]="10">Octobre</mat-option>
|
||||
<mat-option [value]="11">Novembre</mat-option>
|
||||
<mat-option [value]="12">Décembre</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Trimestre IRL de référence</mat-label>
|
||||
<mat-select [formField]="legalTermsForm().rentRevision.referenceQuarter">
|
||||
|
||||
@@ -85,8 +85,21 @@ export class LeaseLegalTermsEditor {
|
||||
}
|
||||
}
|
||||
if (terms.rentRevision.enabled) {
|
||||
if (!terms.rentRevision.annualRevisionDate) {
|
||||
return 'Renseignez la date annuelle de révision du loyer.';
|
||||
const day = terms.rentRevision.annualRevisionDay;
|
||||
const month = terms.rentRevision.annualRevisionMonth;
|
||||
if (day === null || month === null) {
|
||||
return 'Renseignez le jour et le mois de révision annuelle du loyer.';
|
||||
}
|
||||
const lastDayOfMonth = new Date(Date.UTC(2000, month, 0)).getUTCDate();
|
||||
if (
|
||||
!Number.isInteger(day) ||
|
||||
!Number.isInteger(month) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > lastDayOfMonth
|
||||
) {
|
||||
return 'Le jour et le mois de révision annuelle ne forment pas une date valide.';
|
||||
}
|
||||
if (
|
||||
terms.rentRevision.referenceQuarter === null ||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
(dossierRequested)="workspace.downloadLeaseDossier($event)"
|
||||
(editRequested)="openForm($event)"
|
||||
(finalizationRequested)="openFinalization($event)"
|
||||
(leaseEndRequested)="openLeaseEnd($event)"
|
||||
(deleteRequested)="workspace.deleteLease($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ describe('LeasesPage', () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'finalize']);
|
||||
});
|
||||
|
||||
it('navigates an active lease to its dedicated ending page', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
|
||||
fixture.componentInstance.openLeaseEnd('lease-1');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'end']);
|
||||
});
|
||||
|
||||
it('opens an existing draft for editing', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
const draft = {
|
||||
|
||||
@@ -77,6 +77,10 @@ export class LeasesPage {
|
||||
void this.router.navigate(['/leases', leaseId, 'finalize']);
|
||||
}
|
||||
|
||||
openLeaseEnd(leaseId: string): void {
|
||||
void this.router.navigate(['/leases', leaseId, 'end']);
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
|
||||
this.workspace.loadApartments();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rent-due-correction {
|
||||
min-width: 0;
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid var(--warning, #9a6700);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #f5f1e8);
|
||||
}
|
||||
|
||||
.rent-due-correction > strong {
|
||||
color: var(--warning, #9a6700);
|
||||
}
|
||||
|
||||
.rent-due-correction p {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.correction-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.correction-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<section class="rent-due-correction" role="alert">
|
||||
<strong>Correction de règlement nécessaire</strong>
|
||||
<p>
|
||||
La fin du bail a rendu un ou plusieurs règlements suraffectés. Contrepassez chaque règlement
|
||||
concerné, puis enregistrez le règlement avec le montant correct.
|
||||
</p>
|
||||
@if (!loadState().loaded) {
|
||||
@if (loadState().error) {
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retryRequested.emit()">Réessayer</button>
|
||||
} @else {
|
||||
<p>Chargement du règlement concerné…</p>
|
||||
}
|
||||
} @else {
|
||||
<div class="correction-actions">
|
||||
@for (payment of matchingPayments(); track payment.id) {
|
||||
<button mat-stroked-button type="button" (click)="reversalRequested.emit(payment)">
|
||||
Contrepasser le règlement du {{ civilDate(payment.receivedAt) }}
|
||||
</button>
|
||||
} @empty {
|
||||
<p>Le règlement confirmé concerné est introuvable ou a déjà été contrepassé.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { RentPayment } from '../../services/rent-payment.service';
|
||||
|
||||
interface PaymentLoadState {
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-due-correction',
|
||||
imports: [MatButtonModule],
|
||||
templateUrl: './rent-due-correction.html',
|
||||
styleUrl: './rent-due-correction.css',
|
||||
})
|
||||
export class RentDueCorrection {
|
||||
readonly rentDue = input.required<RentDue>();
|
||||
readonly payments = input.required<RentPayment[]>();
|
||||
readonly loadState = input.required<PaymentLoadState>();
|
||||
readonly reversalRequested = output<RentPayment>();
|
||||
readonly retryRequested = output<void>();
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly matchingPayments = computed(() =>
|
||||
this.payments().filter(
|
||||
(payment) =>
|
||||
payment.status === 'CONFIRMED' &&
|
||||
payment.allocations.some((allocation) => allocation.rentDueId === this.rentDue().id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,12 @@ describe('rent payment dialogs', () => {
|
||||
|
||||
component.form.controls.reason.setValue('Erreur de saisie');
|
||||
component.submit();
|
||||
expect(payments.reverse).toHaveBeenCalledWith('payment-1', 3, 'Erreur de saisie');
|
||||
expect(payments.reverse).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
3,
|
||||
'Erreur de saisie',
|
||||
browserTimeZone,
|
||||
);
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
@@ -149,6 +154,7 @@ function due(overrides: Partial<RentDue> = {}): RentDue {
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
correctionRequired: false,
|
||||
version: 2,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { finalize } from 'rxjs';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro } from '../../services/rent-payment-format';
|
||||
import { browserTimeZone, formatEuro } from '../../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
@@ -45,7 +45,12 @@ export class RentPaymentReversalDialog {
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.reverse(this.payment.id, this.payment.version, this.form.controls.reason.value)
|
||||
.reverse(
|
||||
this.payment.id,
|
||||
this.payment.version,
|
||||
this.form.controls.reason.value,
|
||||
browserTimeZone,
|
||||
)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (payment) => this.dialogRef.close(payment),
|
||||
|
||||
@@ -100,29 +100,39 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Échéance</dt>
|
||||
<dd>{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}</dd>
|
||||
<dd>{{ civilDate(rentDue.dueDate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="card-actions">
|
||||
@if (rentDue.settlement !== 'PAID') {
|
||||
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
|
||||
Enregistrer un règlement
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
|
||||
(click)="workspace.downloadRentReceipt(rentDue)"
|
||||
>
|
||||
@if (workspace.rentReceiptDownloading(rentDue.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger la quittance
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (workspace.rentReceiptDownloadError(rentDue.id)) {
|
||||
@if (rentDue.correctionRequired) {
|
||||
<app-rent-due-correction
|
||||
[rentDue]="rentDue"
|
||||
[payments]="workspace.rentPayments()"
|
||||
[loadState]="workspace.rentPaymentLoadState()"
|
||||
(reversalRequested)="reversePayment($event)"
|
||||
(retryRequested)="workspace.loadRentPayments()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="card-actions">
|
||||
@if (rentDue.settlement !== 'PAID') {
|
||||
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
|
||||
Enregistrer un règlement
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
|
||||
(click)="workspace.downloadRentReceipt(rentDue)"
|
||||
>
|
||||
@if (workspace.rentReceiptDownloading(rentDue.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger la quittance
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!rentDue.correctionRequired && workspace.rentReceiptDownloadError(rentDue.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
|
||||
</p>
|
||||
@@ -175,7 +185,7 @@
|
||||
</span>
|
||||
<strong>{{ formatEuro(payment.amount) }}</strong>
|
||||
</div>
|
||||
<p>Reçu le {{ payment.receivedAt | date: 'dd MMMM yyyy' }}</p>
|
||||
<p>Reçu le {{ civilDate(payment.receivedAt) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Affecté</dt>
|
||||
@@ -210,7 +220,7 @@
|
||||
<ul>
|
||||
@for (refund of payment.refunds; track refund.id) {
|
||||
<li>
|
||||
<span>{{ refund.refundedAt | date: 'dd MMMM yyyy' }}</span
|
||||
<span>{{ civilDate(refund.refundedAt) }}</span
|
||||
><strong>{{ formatEuro(refund.amount) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@@ -219,7 +229,7 @@
|
||||
}
|
||||
@if (payment.reversal; as reversal) {
|
||||
<div class="reversal-details">
|
||||
<strong>Contrepassé le {{ reversal.reversedAt | date: 'dd MMMM yyyy' }}</strong>
|
||||
<strong>Contrepassé le {{ civilDate(reversal.reversedAt) }}</strong>
|
||||
<span>{{ reversal.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -44,6 +44,70 @@ describe('RentPaymentsPage', () => {
|
||||
expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard');
|
||||
});
|
||||
|
||||
it('offers the existing reversal journey when a lease end overallocates a due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const allocatedPayment = payment();
|
||||
const { fixture, dialog } = createFixture([overallocated], [allocatedPayment]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(undefined) });
|
||||
|
||||
const warning = fixture.nativeElement.querySelector('.rent-due-correction') as HTMLElement;
|
||||
expect(warning.getAttribute('role')).toBe('alert');
|
||||
expect(warning.textContent).toContain(
|
||||
'La fin du bail a rendu un ou plusieurs règlements suraffectés',
|
||||
);
|
||||
expect(button(fixture, 'Contrepasser le règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Télécharger la quittance');
|
||||
|
||||
button(fixture, 'Contrepasser le règlement')?.click();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: allocatedPayment, role: 'alertdialog' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers reversal for every confirmed partial payment allocated to the corrected due', () => {
|
||||
const overallocated = rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 900,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
});
|
||||
const first = payment({
|
||||
id: 'payment-1',
|
||||
amount: 500,
|
||||
allocatedAmount: 500,
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 500, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
});
|
||||
const second = payment({
|
||||
id: 'payment-2',
|
||||
receivedAt: '2026-07-07',
|
||||
amount: 400,
|
||||
allocatedAmount: 400,
|
||||
allocations: [
|
||||
{ id: 'allocation-2', rentDueId: 'due-1', amount: 400, allocatedAt: '2026-07-07' },
|
||||
],
|
||||
});
|
||||
const { fixture, dialog } = createFixture([overallocated], [first, second]);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
|
||||
const actions = [
|
||||
...fixture.nativeElement.querySelectorAll('.rent-due-correction button'),
|
||||
] as HTMLButtonElement[];
|
||||
expect(actions).toHaveLength(2);
|
||||
|
||||
actions[1].click();
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentReversalDialog,
|
||||
expect.objectContaining({ data: second }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows overdue dues through the next upcoming due', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||
@@ -74,6 +138,47 @@ describe('RentPaymentsPage', () => {
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']);
|
||||
});
|
||||
|
||||
it('never hides an older due that still requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('corrected-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('paid-february', '2026-02', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-march', '2026-03', 'OVERDUE'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'janvier 2026',
|
||||
'février 2026',
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extends the current schedule through every prepaid due that requires correction', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('upcoming-february', '2026-02', 'UPCOMING'),
|
||||
rentDue('corrected-march', '2026-03', 'UPCOMING', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
correctionRequired: true,
|
||||
}),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026']);
|
||||
});
|
||||
|
||||
it('reveals and hides the full chronological schedule', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
@@ -241,6 +346,7 @@ function rentDue(
|
||||
remainingAmount: 850,
|
||||
settlement,
|
||||
timing,
|
||||
correctionRequired: false,
|
||||
version: 0,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
@@ -19,15 +18,12 @@ import { formatEuro } from '../../services/rent-payment-format';
|
||||
import { PaymentAllocation, RentPayment } from '../../services/rent-payment.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
interface PaymentFeedback {
|
||||
kind: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
import { RentDueCorrection } from '../rent-due-correction/rent-due-correction';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payments-page',
|
||||
imports: [DatePipe, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, RentDueCorrection],
|
||||
templateUrl: './rent-payments-page.html',
|
||||
styleUrl: './rent-payments-page.css',
|
||||
})
|
||||
@@ -35,8 +31,9 @@ export class RentPaymentsPage {
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly showFullSchedule = signal(false);
|
||||
readonly feedback = signal<PaymentFeedback | null>(null);
|
||||
readonly feedback = signal<{ kind: 'success' | 'error'; message: string } | null>(null);
|
||||
readonly formatEuro = formatEuro;
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly displayedRentDues = computed(() =>
|
||||
this.showFullSchedule()
|
||||
? sortRentDues(this.workspace.rentDues())
|
||||
@@ -227,14 +224,21 @@ function currentRentDues(rentDues: readonly RentDue[]): RentDue[] {
|
||||
|
||||
function currentSchedule(schedule: RentDue[]): RentDue[] {
|
||||
const nextDueIndex = schedule.findIndex((rentDue) => rentDue.timing === 'UPCOMING');
|
||||
const throughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1);
|
||||
const lastCorrectionIndex = schedule.reduce(
|
||||
(lastCorrection, rentDue, index) => (rentDue.correctionRequired ? index : lastCorrection),
|
||||
-1,
|
||||
);
|
||||
const visibleEndIndex =
|
||||
nextDueIndex === -1 ? schedule.length - 1 : Math.max(nextDueIndex, lastCorrectionIndex);
|
||||
const throughNextDue = schedule.slice(0, visibleEndIndex + 1);
|
||||
const lastPaidIndex = throughNextDue.reduce(
|
||||
(lastPaid, rentDue, index) => (rentDue.settlement === 'PAID' ? index : lastPaid),
|
||||
-1,
|
||||
);
|
||||
const firstUnpaidIndex = throughNextDue.findIndex((rentDue) => rentDue.settlement !== 'PAID');
|
||||
const firstCorrectionIndex = throughNextDue.findIndex((rentDue) => rentDue.correctionRequired);
|
||||
const firstVisibleIndex = Math.min(
|
||||
...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1),
|
||||
...[lastPaidIndex, firstUnpaidIndex, firstCorrectionIndex].filter((index) => index !== -1),
|
||||
);
|
||||
|
||||
return firstVisibleIndex === Infinity ? [] : throughNextDue.slice(firstVisibleIndex);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatFrenchCivilDate, formatFrenchCivilMonth, todayInTimeZone } from './civil-date';
|
||||
|
||||
describe('civil dates', () => {
|
||||
it('formats full French dates without shifting the stored day', () => {
|
||||
expect(formatFrenchCivilDate('2026-07-19')).toBe('19 juillet 2026');
|
||||
expect(formatFrenchCivilMonth('2026-07-31')).toBe('Juillet 2026');
|
||||
});
|
||||
|
||||
it('derives today from the user timezone rather than the server day', () => {
|
||||
const sameInstant = new Date('2026-07-19T00:30:00Z');
|
||||
|
||||
expect(todayInTimeZone('America/Los_Angeles', sameInstant)).toBe('2026-07-18');
|
||||
expect(todayInTimeZone('Europe/Paris', sameInstant)).toBe('2026-07-19');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const frenchMonths = [
|
||||
'janvier',
|
||||
'février',
|
||||
'mars',
|
||||
'avril',
|
||||
'mai',
|
||||
'juin',
|
||||
'juillet',
|
||||
'août',
|
||||
'septembre',
|
||||
'octobre',
|
||||
'novembre',
|
||||
'décembre',
|
||||
] as const;
|
||||
|
||||
export function formatFrenchCivilDate(value: string): string {
|
||||
const [year, month, day] = value.split('-');
|
||||
const monthName = frenchMonths[Number(month) - 1];
|
||||
return monthName && year && day ? `${Number(day)} ${monthName} ${year}` : value;
|
||||
}
|
||||
|
||||
export function formatFrenchCivilMonth(value: string): string {
|
||||
const [year, month] = value.split('-');
|
||||
const monthName = frenchMonths[Number(month) - 1];
|
||||
if (!monthName || !year) return value;
|
||||
return `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)} ${year}`;
|
||||
}
|
||||
|
||||
export { browserTimeZone } from './rent-payment-format';
|
||||
|
||||
export function todayInTimeZone(timeZone: string, now = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone,
|
||||
}).formatToParts(now);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((candidate) => candidate.type === type)?.value ?? '';
|
||||
return `${part('year')}-${part('month')}-${part('day')}`;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndService } from './lease-end.service';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
|
||||
describe('LeaseEndService', () => {
|
||||
const http = {
|
||||
get: vi.fn().mockReturnValue(of({})),
|
||||
post: vi.fn().mockReturnValue(of({})),
|
||||
};
|
||||
const downloads = {
|
||||
download: vi.fn(),
|
||||
isPending: vi.fn().mockReturnValue(false),
|
||||
error: vi.fn().mockReturnValue(''),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
LeaseEndService,
|
||||
{ provide: HttpClient, useValue: http },
|
||||
{ provide: FileDownloadService, useValue: downloads },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the canonical lease-end state', () => {
|
||||
TestBed.inject(LeaseEndService).getState('lease-1', 'America/Los_Angeles').subscribe();
|
||||
|
||||
const options = http.get.mock.calls[0][1] as { params: HttpParams };
|
||||
expect(http.get.mock.calls[0][0]).toBe('/api/leases/lease-1/end');
|
||||
expect(options.params.toString()).toBe('timeZone=America/Los_Angeles');
|
||||
});
|
||||
|
||||
it('records a notice as civil facts and keeps arbitrary evidence', () => {
|
||||
const evidence = new File(['photo'], 'remise-des-cles.jpg', { type: 'image/jpeg' });
|
||||
TestBed.inject(LeaseEndService)
|
||||
.recordNotice('lease-1', {
|
||||
timeZone: 'America/Los_Angeles',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: true,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
evidence,
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
const formData = http.post.mock.calls[0][1] as FormData;
|
||||
expect(http.post.mock.calls[0][0]).toBe('/api/leases/lease-1/end/notices');
|
||||
expect(Object.fromEntries(formData.entries())).toEqual({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: 'true',
|
||||
nonStandardDeliveryConfirmed: 'true',
|
||||
missingEvidenceConfirmed: 'false',
|
||||
evidence,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the explicit confirmation when a notice has no evidence', () => {
|
||||
TestBed.inject(LeaseEndService)
|
||||
.recordNotice('lease-1', {
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
const formData = http.post.mock.calls[0][1] as FormData;
|
||||
expect(formData.get('missingEvidenceConfirmed')).toBe('true');
|
||||
expect(formData.has('evidence')).toBe(false);
|
||||
});
|
||||
|
||||
it('records withdrawal and administrative invalidation with their civil dates', () => {
|
||||
const service = TestBed.inject(LeaseEndService);
|
||||
|
||||
service
|
||||
.acceptWithdrawal('lease-1', 'notice-1', {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-19',
|
||||
reason: 'Retrait écrit accepté',
|
||||
})
|
||||
.subscribe();
|
||||
service
|
||||
.invalidateNotice('lease-1', 'notice-1', {
|
||||
timeZone: 'Europe/Paris',
|
||||
invalidatedOn: '2026-07-20',
|
||||
reason: 'Saisie administrative en double',
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/leases/lease-1/end/notices/notice-1/withdrawals',
|
||||
{
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-19',
|
||||
reason: 'Retrait écrit accepté',
|
||||
},
|
||||
);
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/leases/lease-1/end/notices/notice-1/invalidations',
|
||||
{
|
||||
timeZone: 'Europe/Paris',
|
||||
invalidatedOn: '2026-07-20',
|
||||
reason: 'Saisie administrative en double',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('appends unrestricted evidence and an optional version reason', () => {
|
||||
const evidence = new File(['message'], 'message.txt', { type: 'text/plain' });
|
||||
TestBed.inject(LeaseEndService)
|
||||
.appendEvidence('lease-1', 'notice-1', {
|
||||
timeZone: 'Europe/Paris',
|
||||
evidence,
|
||||
reason: 'Version plus lisible',
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
const formData = http.post.mock.calls[0][1] as FormData;
|
||||
expect(http.post.mock.calls[0][0]).toBe('/api/leases/lease-1/end/notices/notice-1/evidence');
|
||||
expect(Object.fromEntries(formData.entries())).toEqual({
|
||||
timeZone: 'Europe/Paris',
|
||||
evidence,
|
||||
reason: 'Version plus lisible',
|
||||
});
|
||||
});
|
||||
|
||||
it('downloads one opaque evidence version without previewing it', () => {
|
||||
const response = of(new Blob(['proof']));
|
||||
http.get.mockReturnValueOnce(response);
|
||||
|
||||
TestBed.inject(LeaseEndService).downloadEvidence('lease-1', 'notice-1', 2, 'preuve.jpg');
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/end/notices/notice-1/evidence/2', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
expect(downloads.download).toHaveBeenCalledWith(
|
||||
'lease-end-evidence:lease-1:notice-1:2',
|
||||
response,
|
||||
'preuve.jpg',
|
||||
'Impossible de télécharger cette preuve.',
|
||||
);
|
||||
});
|
||||
|
||||
it('records an agreed early end and a confirmed closure', () => {
|
||||
const service = TestBed.inject(LeaseEndService);
|
||||
|
||||
service
|
||||
.recordAgreedEarlyEnd('lease-1', {
|
||||
timeZone: 'Europe/London',
|
||||
endOn: '2026-08-01',
|
||||
reason: 'Accord écrit des parties',
|
||||
})
|
||||
.subscribe();
|
||||
service
|
||||
.close('lease-1', {
|
||||
timeZone: 'Europe/London',
|
||||
keysReturnedOn: '2026-08-01',
|
||||
confirmed: true,
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/api/leases/lease-1/end/agreed-early-end', {
|
||||
timeZone: 'Europe/London',
|
||||
endOn: '2026-08-01',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/api/leases/lease-1/end/closure', {
|
||||
timeZone: 'Europe/London',
|
||||
keysReturnedOn: '2026-08-01',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
|
||||
export type LeaseEndPhase =
|
||||
'ACTIVE' | 'NOTICE_PERIOD' | 'AWAITING_CLOSURE' | 'UNAUTHORIZED_OCCUPANCY' | 'ARCHIVED';
|
||||
|
||||
export type NoticeDeliveryMode = 'REGISTERED_LETTER' | 'BAILIFF' | 'HAND_DELIVERY' | 'OTHER';
|
||||
|
||||
export interface LeaseEndEvidence {
|
||||
version: number;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
reason: string | null;
|
||||
authorId: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface LeaseEndAudit {
|
||||
authorId: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface LeaseEndNotice {
|
||||
id: string;
|
||||
status: 'ACTIVE' | 'WITHDRAWN' | 'INVALIDATED';
|
||||
receivedOn: string;
|
||||
requestedDepartureOn: string;
|
||||
deliveryMode: NoticeDeliveryMode;
|
||||
otherDeliveryMode: string | null;
|
||||
noticeMonths: number;
|
||||
minimumEndOn: string;
|
||||
ruleVersion: string;
|
||||
recordedWithoutEvidenceConfirmed: boolean;
|
||||
authorId: string;
|
||||
recordedAt: string;
|
||||
evidenceVersions: LeaseEndEvidence[];
|
||||
withdrawal: (LeaseEndAudit & { acceptedOn: string; reason: string }) | null;
|
||||
invalidation: (LeaseEndAudit & { invalidatedOn: string; reason: string }) | null;
|
||||
}
|
||||
|
||||
export interface LeaseEndState {
|
||||
leaseId: string;
|
||||
supported: boolean;
|
||||
unsupportedReason: string | null;
|
||||
phase: LeaseEndPhase;
|
||||
leaseType: 'STANDARD' | 'STUDENT';
|
||||
fixedEndOn: string | null;
|
||||
effectiveEndOn: string | null;
|
||||
correctionRequired: boolean;
|
||||
notices: LeaseEndNotice[];
|
||||
agreedEarlyEnds: LeaseEndAgreement[];
|
||||
agreedEarlyEnd: LeaseEndAgreement | null;
|
||||
closure: {
|
||||
keysReturnedOn: string;
|
||||
authorId: string;
|
||||
recordedAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface LeaseEndAgreement {
|
||||
endOn: string;
|
||||
reason: string;
|
||||
authorId: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface RecordNoticeRequest {
|
||||
timeZone: string;
|
||||
receivedOn: string;
|
||||
requestedDepartureOn: string;
|
||||
deliveryMode: NoticeDeliveryMode;
|
||||
otherDeliveryMode?: string;
|
||||
lateRegistrationConfirmed: boolean;
|
||||
nonStandardDeliveryConfirmed: boolean;
|
||||
missingEvidenceConfirmed: boolean;
|
||||
evidence?: File;
|
||||
}
|
||||
|
||||
export interface AcceptWithdrawalRequest {
|
||||
timeZone: string;
|
||||
acceptedOn: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface InvalidateNoticeRequest {
|
||||
timeZone: string;
|
||||
invalidatedOn: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface AppendEvidenceRequest {
|
||||
timeZone: string;
|
||||
evidence: File;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AgreedEarlyEndRequest {
|
||||
timeZone: string;
|
||||
endOn: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CloseLeaseRequest {
|
||||
timeZone: string;
|
||||
keysReturnedOn: string;
|
||||
confirmed: true;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LeaseEndService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly downloads = inject(FileDownloadService);
|
||||
|
||||
getState(leaseId: string, timeZone: string): Observable<LeaseEndState> {
|
||||
const params = new HttpParams().set('timeZone', timeZone);
|
||||
return this.http.get<LeaseEndState>(`/api/leases/${leaseId}/end`, { params });
|
||||
}
|
||||
|
||||
recordNotice(leaseId: string, request: RecordNoticeRequest): Observable<LeaseEndState> {
|
||||
return this.http.post<LeaseEndState>(
|
||||
`/api/leases/${leaseId}/end/notices`,
|
||||
noticeFormData(request),
|
||||
);
|
||||
}
|
||||
|
||||
acceptWithdrawal(
|
||||
leaseId: string,
|
||||
noticeId: string,
|
||||
request: AcceptWithdrawalRequest,
|
||||
): Observable<LeaseEndState> {
|
||||
return this.http.post<LeaseEndState>(
|
||||
`/api/leases/${leaseId}/end/notices/${noticeId}/withdrawals`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
invalidateNotice(
|
||||
leaseId: string,
|
||||
noticeId: string,
|
||||
request: InvalidateNoticeRequest,
|
||||
): Observable<LeaseEndState> {
|
||||
return this.http.post<LeaseEndState>(
|
||||
`/api/leases/${leaseId}/end/notices/${noticeId}/invalidations`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
appendEvidence(
|
||||
leaseId: string,
|
||||
noticeId: string,
|
||||
request: AppendEvidenceRequest,
|
||||
): Observable<LeaseEndState> {
|
||||
const formData = new FormData();
|
||||
formData.append('timeZone', request.timeZone);
|
||||
formData.append('evidence', request.evidence);
|
||||
if (request.reason) formData.append('reason', request.reason);
|
||||
return this.http.post<LeaseEndState>(
|
||||
`/api/leases/${leaseId}/end/notices/${noticeId}/evidence`,
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
downloadEvidence(leaseId: string, noticeId: string, version: number, fileName: string): void {
|
||||
const key = this.evidenceDownloadKey(leaseId, noticeId, version);
|
||||
this.downloads.download(
|
||||
key,
|
||||
this.http.get(`/api/leases/${leaseId}/end/notices/${noticeId}/evidence/${version}`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
fileName,
|
||||
'Impossible de télécharger cette preuve.',
|
||||
);
|
||||
}
|
||||
|
||||
evidenceDownloading(leaseId: string, noticeId: string, version: number): boolean {
|
||||
return this.downloads.isPending(this.evidenceDownloadKey(leaseId, noticeId, version));
|
||||
}
|
||||
|
||||
evidenceDownloadError(leaseId: string, noticeId: string, version: number): string {
|
||||
return this.downloads.error(this.evidenceDownloadKey(leaseId, noticeId, version));
|
||||
}
|
||||
|
||||
recordAgreedEarlyEnd(leaseId: string, request: AgreedEarlyEndRequest): Observable<LeaseEndState> {
|
||||
return this.http.post<LeaseEndState>(`/api/leases/${leaseId}/end/agreed-early-end`, request);
|
||||
}
|
||||
|
||||
close(leaseId: string, request: CloseLeaseRequest): Observable<LeaseEndState> {
|
||||
return this.http.post<LeaseEndState>(`/api/leases/${leaseId}/end/closure`, request);
|
||||
}
|
||||
|
||||
private evidenceDownloadKey(leaseId: string, noticeId: string, version: number): string {
|
||||
return `lease-end-evidence:${leaseId}:${noticeId}:${version}`;
|
||||
}
|
||||
}
|
||||
|
||||
function noticeFormData(request: RecordNoticeRequest): FormData {
|
||||
const formData = new FormData();
|
||||
formData.append('timeZone', request.timeZone);
|
||||
formData.append('receivedOn', request.receivedOn);
|
||||
formData.append('requestedDepartureOn', request.requestedDepartureOn);
|
||||
formData.append('deliveryMode', request.deliveryMode);
|
||||
if (request.otherDeliveryMode) formData.append('otherDeliveryMode', request.otherDeliveryMode);
|
||||
formData.append('lateRegistrationConfirmed', String(request.lateRegistrationConfirmed));
|
||||
formData.append('nonStandardDeliveryConfirmed', String(request.nonStandardDeliveryConfirmed));
|
||||
formData.append('missingEvidenceConfirmed', String(request.missingEvidenceConfirmed));
|
||||
if (request.evidence) formData.append('evidence', request.evidence);
|
||||
return formData;
|
||||
}
|
||||
@@ -36,12 +36,12 @@ export function normalizeLeaseLegalTerms(
|
||||
rentRevision: rentRevisionEnabled
|
||||
? {
|
||||
...terms.rentRevision,
|
||||
annualRevisionDate: terms.rentRevision.annualRevisionDate || null,
|
||||
seriesId: terms.rentRevision.seriesId.trim(),
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
annualRevisionDate: null,
|
||||
annualRevisionDay: null,
|
||||
annualRevisionMonth: null,
|
||||
referenceQuarter: null,
|
||||
referenceYear: null,
|
||||
seriesId: '',
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface LeaseLegalTerms {
|
||||
};
|
||||
rentRevision: {
|
||||
enabled: boolean;
|
||||
annualRevisionDate: string | null;
|
||||
annualRevisionDay: number | null;
|
||||
annualRevisionMonth: number | null;
|
||||
referenceQuarter: number | null;
|
||||
referenceYear: number | null;
|
||||
seriesId: string;
|
||||
@@ -87,7 +88,8 @@ export function emptyLeaseLegalTerms(): LeaseLegalTerms {
|
||||
},
|
||||
rentRevision: {
|
||||
enabled: false,
|
||||
annualRevisionDate: null,
|
||||
annualRevisionDay: null,
|
||||
annualRevisionMonth: null,
|
||||
referenceQuarter: null,
|
||||
referenceYear: null,
|
||||
seriesId: '',
|
||||
@@ -144,6 +146,7 @@ export interface RentDue {
|
||||
remainingAmount: number;
|
||||
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
|
||||
timing: 'UPCOMING' | 'OVERDUE';
|
||||
correctionRequired: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('RentPaymentService', () => {
|
||||
service.record(record).subscribe();
|
||||
service.allocate('payment-1', allocation).subscribe();
|
||||
service.refund('payment-1', refund).subscribe();
|
||||
service.reverse('payment-1', 3, 'Erreur de saisie').subscribe();
|
||||
service.reverse('payment-1', 3, 'Erreur de saisie', 'America/Los_Angeles').subscribe();
|
||||
service.downloadReceipt('payment-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments');
|
||||
@@ -50,6 +50,7 @@ describe('RentPaymentService', () => {
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/reversal', {
|
||||
paymentVersion: 3,
|
||||
reason: 'Erreur de saisie',
|
||||
timeZone: 'America/Los_Angeles',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments/payment-1/receipt', {
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -86,10 +86,16 @@ export class RentPaymentService {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/refunds`, request);
|
||||
}
|
||||
|
||||
reverse(paymentId: string, paymentVersion: number, reason: string): Observable<RentPayment> {
|
||||
reverse(
|
||||
paymentId: string,
|
||||
paymentVersion: number,
|
||||
reason: string,
|
||||
timeZone: string,
|
||||
): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/reversal`, {
|
||||
paymentVersion,
|
||||
reason,
|
||||
timeZone,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('WorkspaceService', () => {
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID' as const,
|
||||
timing: 'UPCOMING' as const,
|
||||
correctionRequired: false,
|
||||
version: 0,
|
||||
};
|
||||
const leaseRevision = {
|
||||
|
||||
Reference in new issue
Block a user