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,86 @@
|
||||
:host {
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.section-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-heading > span {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.section-heading h2,
|
||||
.section-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
.section-heading h2 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.section-heading p,
|
||||
.catalog-clause p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.local-loading,
|
||||
.local-error,
|
||||
.unavailable-clause,
|
||||
.custom-clause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.local-loading {
|
||||
min-height: 72px;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.local-error,
|
||||
.unavailable-clause {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.catalog-clause {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.catalog-clause p {
|
||||
margin: 4px 0 0 32px;
|
||||
}
|
||||
.parameter-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 14px 0 0 32px;
|
||||
}
|
||||
.parameter-grid mat-form-field,
|
||||
.custom-clause mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
.custom-clauses,
|
||||
.validation-error,
|
||||
.unavailable-clause {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.custom-clause {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.custom-clause button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<section class="clause-editor" aria-labelledby="clause-editor-heading">
|
||||
<div class="section-heading">
|
||||
<span>04</span>
|
||||
<div>
|
||||
<h2 id="clause-editor-heading">Clauses particulières</h2>
|
||||
<p>Sélectionnez une clause proposée ou rédigez une clause personnalisée.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="local-loading" role="status">
|
||||
<mat-spinner diameter="24" aria-hidden="true" /> Chargement des clauses…
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<div class="alert-error local-error" role="alert">
|
||||
<span>{{ loadError() }}</span>
|
||||
<button mat-button type="button" (click)="retry()">Réessayer</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="catalog-clauses">
|
||||
@for (definition of definitions(); track definition.id) {
|
||||
<article class="catalog-clause">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(definition.id)"
|
||||
(change)="toggleCatalog(definition.id, $event.checked)"
|
||||
>
|
||||
{{ definition.label }}
|
||||
</mat-checkbox>
|
||||
<p>{{ definition.description }}</p>
|
||||
|
||||
@if (isSelected(definition.id) && definition.parameters.length) {
|
||||
<div class="parameter-grid">
|
||||
@for (parameter of definition.parameters; track parameter.name) {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ parameter.label }}</mat-label>
|
||||
@switch (parameter.type) {
|
||||
@case ('CHOICE') {
|
||||
<mat-select
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(selectionChange)="
|
||||
setParameter(definition.id, parameter.name, $event.value)
|
||||
"
|
||||
>
|
||||
@for (choice of parameter.choices; track choice.value) {
|
||||
<mat-option [value]="choice.value">{{ choice.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
}
|
||||
@case ('NUMBER') {
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('DATE') {
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('TIME') {
|
||||
<input
|
||||
matInput
|
||||
type="time"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
[required]="parameter.required"
|
||||
[value]="parameterValue(definition.id, parameter.name)"
|
||||
(input)="setParameterFromEvent(definition.id, parameter.name, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-catalog">Aucune clause proposée pour ce type de bail.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (item of unavailableCatalogClauses(); track item.index) {
|
||||
@if (!loading()) {
|
||||
<div class="unavailable-clause alert-error">
|
||||
<span>La clause « {{ item.clause.catalogId }} » n'est plus disponible.</span>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="custom-clauses">
|
||||
@for (item of customClauses(); track item.index) {
|
||||
<div class="custom-clause">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
required
|
||||
[value]="item.clause.customText"
|
||||
(input)="updateCustomClauseFromEvent(item.index, $event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
|
||||
</div>
|
||||
}
|
||||
<button mat-stroked-button type="button" (click)="addCustomClause()">
|
||||
Ajouter une clause personnalisée
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showValidationError() && validationMessage()) {
|
||||
<div class="alert-error validation-error" role="alert">{{ validationMessage() }}</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,127 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseClauseDefinition, LeaseService } from '../../services/lease.service';
|
||||
import { LeaseClauseEditor } from './lease-clause-editor';
|
||||
|
||||
describe('LeaseClauseEditor', () => {
|
||||
const definitions: LeaseClauseDefinition[] = [
|
||||
{
|
||||
id: 'VISITS',
|
||||
label: 'Visites du logement',
|
||||
description: 'Autorise des visites dans les limites légales.',
|
||||
parameters: [
|
||||
{ name: 'note', label: 'Précision', type: 'TEXT', required: false, choices: [] },
|
||||
{ name: 'hours', label: 'Durée', type: 'NUMBER', required: false, choices: [] },
|
||||
{ name: 'date', label: 'Date', type: 'DATE', required: false, choices: [] },
|
||||
{ name: 'startTime', label: 'Heure de début', type: 'TIME', required: true, choices: [] },
|
||||
{
|
||||
name: 'day',
|
||||
label: 'Jour',
|
||||
type: 'CHOICE',
|
||||
required: false,
|
||||
choices: [{ value: 'MONDAY', label: 'Lundi' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const getLeaseClauses = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getLeaseClauses.mockReturnValue(of(definitions));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LeaseService, useValue: { getLeaseClauses } }],
|
||||
});
|
||||
});
|
||||
|
||||
function createEditor() {
|
||||
const fixture = TestBed.createComponent(LeaseClauseEditor);
|
||||
fixture.componentRef.setInput('context', 'STANDARD');
|
||||
fixture.componentInstance.specialClausesChange.subscribe((clauses) => {
|
||||
fixture.componentRef.setInput('specialClauses', clauses);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows local loading content until the catalog arrives', () => {
|
||||
const response = new Subject<LeaseClauseDefinition[]>();
|
||||
getLeaseClauses.mockReturnValue(response);
|
||||
const fixture = createEditor();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Chargement des clauses');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).not.toBeNull();
|
||||
|
||||
response.next(definitions);
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
expect(fixture.nativeElement.querySelector('[role=status]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a retryable catalog error', () => {
|
||||
getLeaseClauses
|
||||
.mockReturnValueOnce(throwError(() => new Error('network')))
|
||||
.mockReturnValueOnce(of(definitions));
|
||||
const fixture = createEditor();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Impossible de charger');
|
||||
fixture.nativeElement.querySelector('.local-error button').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLeaseClauses).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.nativeElement.textContent).toContain('Visites du logement');
|
||||
});
|
||||
|
||||
it('renders typed parameters and requires mandatory values', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
editor.toggleCatalog('VISITS', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input[type=text]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=number]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=date]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('input[type=time]').required).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('mat-select')).not.toBeNull();
|
||||
expect(editor.validate()).toBe(false);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Heure de début');
|
||||
|
||||
editor.setParameter('VISITS', 'startTime', '18:00');
|
||||
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: 'VISITS', parameters: { startTime: '18:00' }, customText: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds, validates, edits, and removes a custom clause', () => {
|
||||
const fixture = createEditor();
|
||||
const editor = fixture.componentInstance;
|
||||
|
||||
editor.addCustomClause();
|
||||
expect(editor.validate()).toBe(false);
|
||||
editor.updateCustomClause(0, ' Interdiction de fumer. ');
|
||||
|
||||
expect(editor.validate()).toBe(true);
|
||||
expect(editor.specialClauses()).toEqual([
|
||||
{ catalogId: null, parameters: {}, customText: ' Interdiction de fumer. ' },
|
||||
]);
|
||||
|
||||
editor.removeClause(0);
|
||||
expect(editor.specialClauses()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reloads the catalog when the lease context changes', () => {
|
||||
const fixture = createEditor();
|
||||
|
||||
fixture.componentRef.setInput('context', 'COLOCATION');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD');
|
||||
expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import {
|
||||
LeaseClauseContext,
|
||||
LeaseClauseDefinition,
|
||||
LeaseService,
|
||||
LeaseSpecialClause,
|
||||
} from '../../services/lease.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-clause-editor',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './lease-clause-editor.html',
|
||||
styleUrl: './lease-clause-editor.css',
|
||||
})
|
||||
export class LeaseClauseEditor {
|
||||
private readonly leaseService = inject(LeaseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private requestId = 0;
|
||||
|
||||
readonly context = input.required<LeaseClauseContext>();
|
||||
readonly specialClauses = input<LeaseSpecialClause[]>([]);
|
||||
readonly specialClausesChange = output<LeaseSpecialClause[]>();
|
||||
|
||||
readonly definitions = signal<LeaseClauseDefinition[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly showValidationError = signal(false);
|
||||
|
||||
readonly customClauses = computed(() =>
|
||||
this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(({ clause }) => clause.catalogId === null),
|
||||
);
|
||||
|
||||
readonly unavailableCatalogClauses = computed(() => {
|
||||
const availableIds = new Set(this.definitions().map(({ id }) => id));
|
||||
return this.specialClauses()
|
||||
.map((clause, index) => ({ clause, index }))
|
||||
.filter(({ clause }) => clause.catalogId !== null && !availableIds.has(clause.catalogId));
|
||||
});
|
||||
|
||||
readonly validationMessage = computed(() => {
|
||||
const custom = this.customClauses().find(({ clause }) => !clause.customText.trim());
|
||||
if (custom) return 'Renseignez le texte de chaque clause personnalisée ou retirez-la.';
|
||||
|
||||
const selectedCatalog = this.specialClauses().filter(({ catalogId }) => catalogId !== null);
|
||||
if (selectedCatalog.length && this.loading())
|
||||
return 'Attendez la fin du chargement des clauses.';
|
||||
if (selectedCatalog.length && this.loadError()) {
|
||||
return 'Rechargez le catalogue pour vérifier les clauses sélectionnées.';
|
||||
}
|
||||
|
||||
for (const clause of selectedCatalog) {
|
||||
const definition = this.definitions().find(({ id }) => id === clause.catalogId);
|
||||
if (!definition)
|
||||
return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`;
|
||||
const missing = definition.parameters.find(
|
||||
(parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(),
|
||||
);
|
||||
if (missing) return `Renseignez « ${missing.label} » pour la clause « ${definition.label} ».`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.context();
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
validate(): boolean {
|
||||
this.showValidationError.set(true);
|
||||
return !this.validationMessage();
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
return this.specialClauses().some(({ catalogId }) => catalogId === id);
|
||||
}
|
||||
|
||||
toggleCatalog(id: string, selected: boolean): void {
|
||||
const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id);
|
||||
this.emit(selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses);
|
||||
}
|
||||
|
||||
parameterValue(catalogId: string, parameterName: string): string {
|
||||
return (
|
||||
this.specialClauses().find((clause) => clause.catalogId === catalogId)?.parameters[
|
||||
parameterName
|
||||
] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
setParameter(catalogId: string, parameterName: string, value: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause) =>
|
||||
clause.catalogId === catalogId
|
||||
? { ...clause, parameters: { ...clause.parameters, [parameterName]: value } }
|
||||
: clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void {
|
||||
this.setParameter(catalogId, parameterName, this.eventValue(event));
|
||||
}
|
||||
|
||||
addCustomClause(): void {
|
||||
this.emit([...this.specialClauses(), { catalogId: null, parameters: {}, customText: '' }]);
|
||||
}
|
||||
|
||||
updateCustomClause(index: number, customText: string): void {
|
||||
this.emit(
|
||||
this.specialClauses().map((clause, clauseIndex) =>
|
||||
clauseIndex === index ? { ...clause, customText } : clause,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
updateCustomClauseFromEvent(index: number, event: Event): void {
|
||||
this.updateCustomClause(index, this.eventValue(event));
|
||||
}
|
||||
|
||||
removeClause(index: number): void {
|
||||
this.emit(this.specialClauses().filter((_, clauseIndex) => clauseIndex !== index));
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
const requestId = ++this.requestId;
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.definitions.set([]);
|
||||
this.leaseService
|
||||
.getLeaseClauses(this.context())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (definitions) => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.definitions.set(definitions);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (requestId !== this.requestId) return;
|
||||
this.loading.set(false);
|
||||
this.loadError.set('Impossible de charger les clauses proposées.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emit(clauses: LeaseSpecialClause[]): void {
|
||||
this.showValidationError.set(false);
|
||||
this.specialClausesChange.emit(clauses);
|
||||
}
|
||||
|
||||
private eventValue(event: Event): string {
|
||||
const target = event.target;
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|
||||
? target.value
|
||||
: '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user