chore: add husky pre-commit hook and lint-staged configuration

- Created a pre-commit hook in .husky to run lint-staged.
- Added .lintstagedrc.mjs to format frontend files with Prettier and apply Spotless to backend Java files.
- Introduced .prettierignore to exclude specific directories from formatting.
This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 5d2d85aa8c
commit 878da155bd
564 files changed
+61467 -47179

No files matched your search

@@ -1,124 +1,124 @@
:host {
display: block;
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);
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;
margin: 0;
}
.revision-preview header p,
.source-label {
color: var(--text-muted, #687670);
font-size: 0.8rem;
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;
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);
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;
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;
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;
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;
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);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: var(--space-3, 0.75rem);
}
.preview-result dl {
margin: 0;
margin: 0;
}
.preview-result dd {
margin: var(--space-1, 0.25rem) 0 0;
overflow-wrap: anywhere;
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;
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;
padding: var(--space-2, 0.5rem);
overflow-wrap: anywhere;
font-weight: 700;
text-align: center;
}
.preview-warnings,
.operation-error {
color: var(--danger, #b42318);
color: var(--danger, #b42318);
}
.preview-warnings ul {
display: grid;
gap: var(--space-2, 0.5rem);
margin-block-end: 0;
padding-inline-start: 1.1rem;
display: grid;
gap: var(--space-2, 0.5rem);
margin-block-end: 0;
padding-inline-start: 1.1rem;
}
.preview-warnings span {
display: block;
display: block;
}
.preview-submit {
justify-self: start;
justify-self: start;
}
@@ -1,157 +1,173 @@
<section
class="revision-preview"
aria-label="Prévision de révision annuelle du loyer"
[attr.aria-busy]="pending()"
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>
<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>
<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 (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 (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 (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>
}
@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()"
/>
<app-rent-revision-proposals
[leaseId]="leaseId()"
[preview]="preview()"
[previewRequest]="lastSuccessfulRequest()"
/>
</section>
@@ -6,212 +6,218 @@ 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,
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;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
],
});
http = TestBed.inject(HttpTestingController);
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: WorkspaceService, useValue: { acceptRentRevisionApplication: vi.fn() } },
],
});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
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;
}
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();
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();
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());
});
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, ' ');
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');
});
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();
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');
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');
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();
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');
});
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();
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('.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.');
});
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();
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({});
});
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();
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();
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');
});
expect(fixture.nativeElement.querySelector('.preview-result')).toBeNull();
expect(fixture.nativeElement.querySelector('.proposal-form')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recalcul impossible');
});
});
@@ -5,151 +5,158 @@ import { FormField, FormRoot, form, min } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { finalize } from 'rxjs';
import {
LeaseService,
RentRevisionPreview,
RentRevisionPreviewRequest,
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',
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
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',
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);
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);
});
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.'));
},
});
}
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)));
}
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);
}
currency(value: number): string {
return currencyFormatter.format(value);
}
index(value: number): string {
return indexFormatter.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];
}
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);
}
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 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'
);
}
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'
);
}
}