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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user