feat: finalize lease documents and IRL revisions
This commit is contained in:
1 parent
f127c69a28
commit
1bf6a3449c
196 files changed
+20564
-666
No files matched your search
@@ -0,0 +1,124 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.revision-preview {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding-block-start: var(--space-4, 1rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.revision-preview h3,
|
||||
.revision-preview h4,
|
||||
.revision-preview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.revision-preview header p,
|
||||
.source-label {
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
fieldset,
|
||||
.manual-indices,
|
||||
.preview-result {
|
||||
display: grid;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.advanced-options,
|
||||
.manual-indices,
|
||||
.preview-result {
|
||||
padding: var(--space-3, 0.75rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.advanced-options summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advanced-options label,
|
||||
.manual-indices label {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin-block-start: var(--space-3, 0.75rem);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type='number'] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding-inline: var(--space-3, 0.75rem);
|
||||
border: 1px solid var(--border-strong, #aebbb4);
|
||||
border-radius: var(--radius-sm, 0.5rem);
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text, #17211e);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.attestation {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.preview-result dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
.preview-result dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-result dd {
|
||||
margin: var(--space-1, 0.25rem) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-result dt,
|
||||
.preview-warnings span {
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.formula {
|
||||
padding: var(--space-2, 0.5rem);
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-warnings,
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.preview-warnings ul {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin-block-end: 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
}
|
||||
|
||||
.preview-warnings span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-submit {
|
||||
justify-self: start;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<section
|
||||
class="revision-preview"
|
||||
aria-label="Prévision de révision annuelle du loyer"
|
||||
[attr.aria-busy]="pending()"
|
||||
>
|
||||
<header>
|
||||
<h3>Révision annuelle du loyer</h3>
|
||||
<p>
|
||||
Calculez le plafond applicable avant toute décision. Aucun nouveau loyer ne sera appliqué.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form [formRoot]="requestForm" (submit)="prepare(); $event.preventDefault()">
|
||||
<fieldset [disabled]="pending()">
|
||||
<details class="advanced-options">
|
||||
<summary>Changer la série INSEE</summary>
|
||||
<label>
|
||||
<span>Nouvelle série officielle</span>
|
||||
<select class="series-override" [formField]="requestForm.seriesIdOverride">
|
||||
<option value="">Série prévue dans le bail</option>
|
||||
<option value="001515333">Métropole — 001515333</option>
|
||||
<option value="010760507">Corse — 010760507</option>
|
||||
<option value="010760509">Départements d’outre-mer (article 73) — 010760509</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (requestModel().seriesIdOverride.trim()) {
|
||||
<label class="attestation">
|
||||
<input
|
||||
class="series-confirm"
|
||||
type="checkbox"
|
||||
[formField]="requestForm.seriesOverrideConfirmed"
|
||||
/>
|
||||
<span>Je confirme remplacer la série INSEE prévue par le bail.</span>
|
||||
</label>
|
||||
}
|
||||
</details>
|
||||
|
||||
@if (manualRequired()) {
|
||||
<div class="manual-indices">
|
||||
<h4>Indices IRL saisis manuellement</h4>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>Indice précédent</span>
|
||||
<input
|
||||
class="previous-index"
|
||||
type="number"
|
||||
step="0.01"
|
||||
[formField]="requestForm.previousIndex"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Nouvel indice</span>
|
||||
<input
|
||||
class="new-index"
|
||||
type="number"
|
||||
step="0.01"
|
||||
[formField]="requestForm.newIndex"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="attestation">
|
||||
<input
|
||||
class="manual-confirm"
|
||||
type="checkbox"
|
||||
[formField]="requestForm.manualIndicesConfirmed"
|
||||
/>
|
||||
<span>J’atteste avoir vérifié ces deux indices IRL officiels.</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (validationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" class="preview-submit" [disabled]="pending()">
|
||||
{{ pending() ? 'Calcul en cours…' : preview() ? 'Recalculer' : 'Préparer la révision' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@if (error()) {
|
||||
<div class="operation-error" role="alert">
|
||||
<p>{{ error() }}</p>
|
||||
@if (!manualRequired()) {
|
||||
<button mat-button type="button" class="retry-preview" (click)="prepare()">
|
||||
Réessayer
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (preview(); as result) {
|
||||
<article class="preview-result">
|
||||
<h4>Simulation de révision</h4>
|
||||
<p class="source-label">Source : {{ sourceLabel(result.indexSource) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Date annuelle</dt>
|
||||
<dd>{{ civilDate(result.annualRevisionDate) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Date limite de demande</dt>
|
||||
<dd>{{ civilDate(result.requestDeadline) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Série et référence</dt>
|
||||
<dd>
|
||||
{{ result.seriesId }} · T{{ result.referenceQuarter }} ·
|
||||
{{ result.previousIndexYear }} → {{ result.newIndexYear }}
|
||||
@if (result.seriesOverridden) {
|
||||
<span>Série remplacée après attestation.</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Plafond du nouveau loyer</dt>
|
||||
<dd>{{ currency(result.rentCeiling) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Plafond des charges</dt>
|
||||
<dd>
|
||||
{{ result.chargeCeiling === null ? 'Non applicable' : currency(result.chargeCeiling) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="formula">
|
||||
{{ currency(result.previousRent) }} × {{ index(result.newIndex) }} ÷
|
||||
{{ index(result.previousIndex) }} = {{ currency(result.rentCeiling) }}
|
||||
</p>
|
||||
@if (result.chargeCeiling !== null) {
|
||||
<p class="formula charge-formula">
|
||||
{{ currency(result.previousCharges) }} × {{ index(result.newIndex) }} ÷
|
||||
{{ index(result.previousIndex) }} = {{ currency(result.chargeCeiling) }}
|
||||
</p>
|
||||
}
|
||||
@if (result.warnings.length) {
|
||||
<div class="preview-warnings" role="status">
|
||||
<strong>Avertissements</strong>
|
||||
<ul>
|
||||
@for (warning of result.warnings; track warning.code + ':' + warning.ruleVersion) {
|
||||
<li>
|
||||
{{ warningLabel(warning.code) }}
|
||||
<span>Version de règle : {{ warning.ruleVersion }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
|
||||
<app-rent-revision-proposals
|
||||
[leaseId]="leaseId()"
|
||||
[preview]="preview()"
|
||||
[previewRequest]="lastSuccessfulRequest()"
|
||||
/>
|
||||
</section>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RentRevisionPreview } from '../../services/lease.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { RentRevisionPreviewComponent } from './rent-revision-preview';
|
||||
|
||||
const previewResponse = (overrides: Partial<RentRevisionPreview> = {}): RentRevisionPreview => ({
|
||||
leaseId: 'lease-1',
|
||||
annualRevisionDate: '2026-07-19',
|
||||
requestDeadline: '2027-07-18',
|
||||
seriesId: '001515333',
|
||||
seriesOverridden: false,
|
||||
indexSource: 'INSEE',
|
||||
referenceQuarter: 2,
|
||||
previousIndexYear: 2025,
|
||||
newIndexYear: 2026,
|
||||
previousIndex: 145.17,
|
||||
newIndex: 146.68,
|
||||
previousRent: 800,
|
||||
rentCeiling: 808.32,
|
||||
previousCharges: 50,
|
||||
chargeCeiling: null,
|
||||
warnings: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('RentRevisionPreviewComponent', () => {
|
||||
let http: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
|
||||
],
|
||||
});
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
|
||||
function createComponent() {
|
||||
const fixture = TestBed.createComponent(RentRevisionPreviewComponent);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions').flush([]);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('prepares once in the user IANA timezone with truthful local progress', () => {
|
||||
const fixture = createComponent();
|
||||
const submit: HTMLButtonElement = fixture.nativeElement.querySelector('.preview-submit');
|
||||
expect(fixture.nativeElement.querySelector('[id]')).toBeNull();
|
||||
|
||||
submit.click();
|
||||
submit.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const request = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body).toEqual({
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
expect(submit.disabled).toBe(true);
|
||||
expect(submit.textContent).toContain('Calcul en cours…');
|
||||
request.flush(previewResponse());
|
||||
});
|
||||
|
||||
it('shows the source, civil dates, formula, ceilings, and versioned French warnings', () => {
|
||||
const fixture = createComponent();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(
|
||||
previewResponse({
|
||||
indexSource: 'CACHE',
|
||||
chargeCeiling: 51,
|
||||
warnings: [{ code: 'IRL_REVISION_WITH_F_OR_G_DPE', ruleVersion: '2026-07-01' }],
|
||||
}),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
|
||||
expect(text).toContain('Cache local des indices officiels');
|
||||
expect(text).toContain('19 juillet 2026');
|
||||
expect(text).toContain('18 juillet 2027');
|
||||
expect(text).toContain('800,00 € × 146,68 ÷ 145,17');
|
||||
expect(text).toContain('50,00 € × 146,68 ÷ 145,17 = 51,00 €');
|
||||
expect(text).toContain('808,32 €');
|
||||
expect(text).toContain('51,00 €');
|
||||
expect(text).toContain('Le logement est classé F ou G');
|
||||
expect(text).toContain('Version de règle : 2026-07-01');
|
||||
expect(text).not.toContain('IRL_REVISION_WITH_F_OR_G_DPE');
|
||||
});
|
||||
|
||||
it('reveals and confirms manual indices only for the stable unavailable code', () => {
|
||||
const fixture = createComponent();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(
|
||||
{
|
||||
code: 'IRL_INDEX_UNAVAILABLE',
|
||||
message:
|
||||
'Les indices IRL officiels sont indisponibles ; saisissez-les manuellement après confirmation.',
|
||||
},
|
||||
{ status: 503, statusText: 'Service Unavailable' },
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.previous-index')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('indices IRL officiels sont indisponibles');
|
||||
|
||||
const previous: HTMLInputElement = fixture.nativeElement.querySelector('.previous-index');
|
||||
const next: HTMLInputElement = fixture.nativeElement.querySelector('.new-index');
|
||||
expect(previous.min).toBe('0.01');
|
||||
expect(next.min).toBe('0.01');
|
||||
previous.value = '145.17';
|
||||
previous.dispatchEvent(new Event('input'));
|
||||
next.value = '146.68';
|
||||
next.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Confirmez la saisie manuelle des indices IRL.',
|
||||
);
|
||||
http.expectNone('/api/leases/lease-1/rent-revisions/preview');
|
||||
|
||||
fixture.nativeElement.querySelector('.manual-confirm').click();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
|
||||
const manual = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
|
||||
expect(manual.request.body).toEqual({
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
previousIndex: 145.17,
|
||||
newIndex: 146.68,
|
||||
manualIndicesConfirmed: true,
|
||||
});
|
||||
manual.flush(previewResponse({ indexSource: 'MANUAL' }));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Indices saisis manuellement et attestés');
|
||||
});
|
||||
|
||||
it('requires an attestation before overriding the INSEE series', async () => {
|
||||
const fixture = createComponent();
|
||||
const series: HTMLSelectElement = fixture.nativeElement.querySelector('.series-override');
|
||||
expect([...series.options].map((option) => option.value)).toEqual([
|
||||
'',
|
||||
'001515333',
|
||||
'010760507',
|
||||
'010760509',
|
||||
]);
|
||||
expect(series.textContent).toContain('Métropole — 001515333');
|
||||
expect(series.textContent).toContain('Corse — 010760507');
|
||||
expect(series.textContent).toContain('Départements d’outre-mer (article 73) — 010760509');
|
||||
series.value = '001515333';
|
||||
series.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Confirmez le remplacement de la série INSEE.',
|
||||
);
|
||||
http.expectNone('/api/leases/lease-1/rent-revisions/preview');
|
||||
|
||||
fixture.nativeElement.querySelector('.series-confirm').click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
const request = http.expectOne('/api/leases/lease-1/rent-revisions/preview');
|
||||
expect(request.request.body).toEqual({
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
seriesIdOverride: '001515333',
|
||||
seriesOverrideConfirmed: true,
|
||||
});
|
||||
request.flush(previewResponse({ seriesOverridden: true }));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Série remplacée après attestation.');
|
||||
});
|
||||
|
||||
it('shows a retryable generic failure without revealing manual inputs', () => {
|
||||
const fixture = createComponent();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http
|
||||
.expectOne('/api/leases/lease-1/rent-revisions/preview')
|
||||
.flush({ code: 'OTHER_UNAVAILABLE' }, { status: 503, statusText: 'Service Unavailable' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.previous-index')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Impossible de préparer la révision du loyer.',
|
||||
);
|
||||
fixture.nativeElement.querySelector('.retry-preview').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush({});
|
||||
});
|
||||
|
||||
it('does not leave a previous simulation actionable after a recalculation failure', () => {
|
||||
const fixture = createComponent();
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http.expectOne('/api/leases/lease-1/rent-revisions/preview').flush(previewResponse());
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.proposal-form')).not.toBeNull();
|
||||
|
||||
fixture.nativeElement.querySelector('.preview-submit').click();
|
||||
http
|
||||
.expectOne('/api/leases/lease-1/rent-revisions/preview')
|
||||
.flush({ message: 'Recalcul impossible' }, { status: 409, statusText: 'Conflict' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.preview-result')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.proposal-form')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Recalcul impossible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Component, DestroyRef, inject, input, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, min } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { finalize } from 'rxjs';
|
||||
import {
|
||||
LeaseService,
|
||||
RentRevisionPreview,
|
||||
RentRevisionPreviewRequest,
|
||||
} from '../../services/lease.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { leaseWarningDescription } from '../../services/lease-warning-label';
|
||||
import { RentRevisionProposalsComponent } from '../rent-revision-proposals/rent-revision-proposals';
|
||||
|
||||
const civilDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
const indexFormatter = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 4 });
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-revision-preview',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule, RentRevisionProposalsComponent],
|
||||
templateUrl: './rent-revision-preview.html',
|
||||
styleUrl: './rent-revision-preview.css',
|
||||
})
|
||||
export class RentRevisionPreviewComponent {
|
||||
private readonly leases = inject(LeaseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly preview = signal<RentRevisionPreview | null>(null);
|
||||
readonly pending = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly validationError = signal('');
|
||||
readonly manualRequired = signal(false);
|
||||
readonly lastSuccessfulRequest = signal<RentRevisionPreviewRequest | null>(null);
|
||||
readonly requestModel = signal({
|
||||
seriesIdOverride: '',
|
||||
seriesOverrideConfirmed: false,
|
||||
previousIndex: null as number | null,
|
||||
newIndex: null as number | null,
|
||||
manualIndicesConfirmed: false,
|
||||
});
|
||||
readonly requestForm = form(this.requestModel, (fields) => {
|
||||
min(fields.previousIndex, 0.01);
|
||||
min(fields.newIndex, 0.01);
|
||||
});
|
||||
|
||||
prepare(): void {
|
||||
if (this.pending()) return;
|
||||
const request = this.buildRequest();
|
||||
if (!request) return;
|
||||
this.preview.set(null);
|
||||
this.lastSuccessfulRequest.set(null);
|
||||
this.pending.set(true);
|
||||
this.error.set('');
|
||||
this.validationError.set('');
|
||||
this.leases
|
||||
.previewRentRevision(this.leaseId(), request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.pending.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (preview) => {
|
||||
this.preview.set(preview);
|
||||
this.lastSuccessfulRequest.set(request);
|
||||
this.manualRequired.set(preview.indexSource === 'MANUAL');
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
if (this.isIndexUnavailable(error)) this.manualRequired.set(true);
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de préparer la révision du loyer.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
civilDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return civilDateFormatter.format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
|
||||
currency(value: number): string {
|
||||
return currencyFormatter.format(value);
|
||||
}
|
||||
|
||||
index(value: number): string {
|
||||
return indexFormatter.format(value);
|
||||
}
|
||||
|
||||
sourceLabel(source: RentRevisionPreview['indexSource']): string {
|
||||
return {
|
||||
INSEE: 'Indices officiels INSEE',
|
||||
CACHE: 'Cache local des indices officiels',
|
||||
MANUAL: 'Indices saisis manuellement et attestés',
|
||||
}[source];
|
||||
}
|
||||
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningDescription(code);
|
||||
}
|
||||
|
||||
private buildRequest(): RentRevisionPreviewRequest | null {
|
||||
const model = this.requestModel();
|
||||
const seriesIdOverride = model.seriesIdOverride.trim();
|
||||
const previousIndex = model.previousIndex;
|
||||
const newIndex = model.newIndex;
|
||||
if (seriesIdOverride && !model.seriesOverrideConfirmed) {
|
||||
this.validationError.set('Confirmez le remplacement de la série INSEE.');
|
||||
return null;
|
||||
}
|
||||
const baseRequest: RentRevisionPreviewRequest = {
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
...(seriesIdOverride ? { seriesIdOverride, seriesOverrideConfirmed: true } : {}),
|
||||
};
|
||||
if (this.manualRequired()) {
|
||||
if (previousIndex === null || previousIndex <= 0 || newIndex === null || newIndex <= 0) {
|
||||
this.validationError.set('Renseignez deux indices IRL strictement positifs.');
|
||||
return null;
|
||||
}
|
||||
if (!model.manualIndicesConfirmed) {
|
||||
this.validationError.set('Confirmez la saisie manuelle des indices IRL.');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...baseRequest,
|
||||
previousIndex,
|
||||
newIndex,
|
||||
manualIndicesConfirmed: true,
|
||||
};
|
||||
}
|
||||
return baseRequest;
|
||||
}
|
||||
|
||||
private isIndexUnavailable(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'error' in error &&
|
||||
typeof error.error === 'object' &&
|
||||
error.error !== null &&
|
||||
'code' in error.error &&
|
||||
error.error.code === 'IRL_INDEX_UNAVAILABLE'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user