From 22265dffa87cce8fdbd7e4a4be1927abd185af49 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 19:53:00 +0100 Subject: [PATCH 01/25] feat(frontend): add ConfirmationModalNext to replace the old confirmation modal --- .../ConfirmationModalNext.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx diff --git a/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx new file mode 100644 index 000000000..ed8b35a13 --- /dev/null +++ b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx @@ -0,0 +1,44 @@ +import { createModal, ModalProps } from '@codegouvfr/react-dsfr/Modal'; +import { JSX } from 'react'; + +export type ConfirmationModalOptions = { + id: string; + /** + * @default false + */ + isOpenedByDefault?: boolean; +}; + +export interface ConfirmationModalProps extends ModalProps { + onSubmit?(): void; +} + +export function createConfirmationModal(options: ConfirmationModalOptions) { + const modal = createModal({ + id: options.id, + isOpenedByDefault: options.isOpenedByDefault ?? false + }); + + return { + ...modal, + Component(props: ConfirmationModalProps): JSX.Element { + return ( + + ); + } + }; +} From c6c3a92d7d476a341d22555a846e9e552f7166d7 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 19:53:33 +0100 Subject: [PATCH 02/25] feat(frontend): split the precision modal into two components --- .../Precision/PrecisionModalNext.tsx | 51 ++++++++++ .../components/Precision/PrecisionTabs.tsx | 96 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 frontend/src/components/Precision/PrecisionModalNext.tsx create mode 100644 frontend/src/components/Precision/PrecisionTabs.tsx diff --git a/frontend/src/components/Precision/PrecisionModalNext.tsx b/frontend/src/components/Precision/PrecisionModalNext.tsx new file mode 100644 index 000000000..5afb0c64c --- /dev/null +++ b/frontend/src/components/Precision/PrecisionModalNext.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +import { Precision } from '@zerologementvacant/models'; +import { + ConfirmationModalProps, + createConfirmationModal +} from '../modals/ConfirmationModal/ConfirmationModalNext'; +import PrecisionTabs from './PrecisionTabs'; + +export type PrecisionModalProps = Omit< + ConfirmationModalProps, + 'children' | 'size' | 'title' | 'onSubmit' +> & { + options: Precision[]; + value: Precision[]; + onSubmit(value: Precision[]): void; +}; + +function createPrecisionModalNext() { + const confirmationModal = createConfirmationModal({ + id: 'precision-modal', + isOpenedByDefault: false + }); + + return { + ...confirmationModal, + Component(props: PrecisionModalProps) { + const { options, value, onSubmit, ...rest } = props; + const [internalValue, setInternalValue] = useState(value); + + return ( + { + onSubmit(internalValue); + }} + > + + + ); + } + }; +} + +export default createPrecisionModalNext; diff --git a/frontend/src/components/Precision/PrecisionTabs.tsx b/frontend/src/components/Precision/PrecisionTabs.tsx new file mode 100644 index 000000000..ff8f2cbce --- /dev/null +++ b/frontend/src/components/Precision/PrecisionTabs.tsx @@ -0,0 +1,96 @@ +import { fr } from '@codegouvfr/react-dsfr'; +import Checkbox, { CheckboxProps } from '@codegouvfr/react-dsfr/Checkbox'; +import Tabs, { TabsProps } from '@codegouvfr/react-dsfr/Tabs'; +import Grid from '@mui/material/Unstable_Grid2'; +import Typography from '@mui/material/Typography'; +import { List } from 'immutable'; +import { ChangeEvent, useMemo } from 'react'; +import { ElementOf } from 'ts-essentials'; + +import { Precision } from '@zerologementvacant/models'; + +interface PrecisionTabs { + defaultTab?: TabId; + options: Precision[]; + value: Precision[]; + onChange(value: Precision[]): void; +} + +type TabId = 'dispositifs' | 'points-de-blocage' | 'evolutions'; +type Tab = ElementOf; + +function PrecisionTabs(props: PrecisionTabs) { + const optionsByCategory = useMemo( + () => List(props.options).groupBy((option) => option.category), + [props.options] + ); + + const defaultTab = props.defaultTab ?? 'dispositifs'; + const isDefault = (tab: TabId) => tab === defaultTab; + + function onChange(event: ChangeEvent) { + if (event.target.checked) { + const precision = props.options.find( + (option) => option.id === event.target.value + ); + props.onChange([...props.value, precision as Precision]); + } else { + props.onChange( + props.value.filter((precision) => precision.id !== event.target.value) + ); + } + } + + function MechanismsTab(): Tab { + return { + label: 'Dispositifs', + isDefault: isDefault('dispositifs'), + content: ( + + + + + Dispositifs incitatifs + + => ({ + label: option.label, + nativeInputProps: { + checked: props.value.some( + (value) => value.id === option.id + ), + value: option.id, + onChange: onChange + } + }) + )} + /> + + + + + + Dispositifs coercitifs + + + + + + + Hors dispositif public + + + + ) + }; + } + + const tabs: TabsProps.Uncontrolled['tabs'] = [MechanismsTab()]; + + return ; +} + +export default PrecisionTabs; From 53806a19fabee7e5102ce48c4307430bf8807331 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 19:54:01 +0100 Subject: [PATCH 03/25] feat(frontend): map the old precisions to the new format --- frontend/src/models/Precision.ts | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 frontend/src/models/Precision.ts diff --git a/frontend/src/models/Precision.ts b/frontend/src/models/Precision.ts new file mode 100644 index 000000000..e63cd18e4 --- /dev/null +++ b/frontend/src/models/Precision.ts @@ -0,0 +1,47 @@ +import { Map } from 'immutable'; + +import { Precision, PrecisionCategory } from '@zerologementvacant/models'; + +export const PRECISION_TRANSITION_MAPPING: Map = Map( + { + 'Dispositifs > Dispositifs incitatifs': 'dispositifs-incitatifs', + 'Dispositifs > Dispositifs coercitifs': 'dispositifs-coercitifs', + 'Dispositifs > Hors dispositif public': 'hors-dispositif-public', + 'Mode opératoire > Travaux': 'travaux', + 'Mode opératoire > Occupation': 'occupation', + 'Mode opératoire > Mutation': 'mutation' + } +); + +export const VACANCY_REASON_TRANSITION_MAPPING: Map = + Map({ + 'Blocage > Blocage involontaire': 'blocage-involontaire', + 'Blocage > Blocage volontaire': 'blocage-volontaire', + 'Blocage > Immeuble / Environnement': 'immeuble-environnement', + 'Blocage > Tiers en cause': 'tiers-en-cause' + }); + +/** + * @param referential + * @param value Something like 'Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne' + */ +export function toNewPrecision( + referential: Precision[], + value: string +): Precision { + const label = value.split(' > ').slice(-1)[0]; + const valueWithoutLabel = value.split(' > ').slice(0, 2).join(' > '); + const category = + PRECISION_TRANSITION_MAPPING.get(valueWithoutLabel) ?? + VACANCY_REASON_TRANSITION_MAPPING.get(valueWithoutLabel); + + const precision = referential.find( + (precision) => precision.category === category && precision.label === label + ); + if (!precision) { + throw new Error( + `Precision of category ${category} and label ${label} not found` + ); + } + return precision; +} From 471bda40eaf20d72beb89c9ccaf47d22119df7b1 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 19:58:30 +0100 Subject: [PATCH 04/25] feat(frontend): immediately save precisions on modal close --- .../HousingEdition/HousingEditionSideMenu.tsx | 320 +++++++++++++++++- 1 file changed, 310 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index 0a6529b98..e2a5b3032 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -16,8 +16,8 @@ import { HousingStatus, Occupancy, OCCUPANCY_VALUES, - PRECISION_MECHANISM_CATEGORY_VALUES, - PrecisionCategory + Precision, + PRECISION_MECHANISM_CATEGORY_VALUES } from '@zerologementvacant/models'; import { Housing, HousingUpdate } from '../../models/Housing'; import AppLink from '../_app/AppLink/AppLink'; @@ -34,6 +34,8 @@ import AppTextInputNext from '../_app/AppTextInput/AppTextInputNext'; import { useCreateNoteByHousingMutation } from '../../services/note.service'; import { useUpdateHousingNextMutation } from '../../services/housing.service'; import { useNotification } from '../../hooks/useNotification'; +import { toNewPrecision } from '../../models/Precision'; +import createPrecisionModalNext from '../Precision/PrecisionModalNext'; interface HousingEditionSideMenuProps { housing: Housing | null; @@ -65,6 +67,8 @@ const schema = yup.object({ type FormSchema = yup.InferType; +const precisionModal = createPrecisionModalNext(); + function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { const { housing, expand, onClose } = props; const form = useForm({ @@ -160,11 +164,282 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function MobilisationTab(): ElementOf { - // TODO: fetch `GET /precisions` - const precisions: ReadonlyArray = []; - const mechanisms = precisions.filter((precision) => - PRECISION_MECHANISM_CATEGORY_VALUES.includes(precision) - ); + // TODO: implement `GET /precisions` to remove this hardcoded list + const precisionOptions: Precision[] = [ + { + id: 'df989b58-c8a6-4f4d-8240-d69c2b9150ce', + category: 'dispositifs-incitatifs', + label: 'Conventionnement avec travaux' + }, + { + id: '9457efb2-0bde-4964-ab67-3085187855ae', + category: 'dispositifs-incitatifs', + label: 'Conventionnement sans travaux' + }, + { + id: '4fc3cbf2-e564-49ab-bead-85e3be8c2a84', + category: 'dispositifs-incitatifs', + label: 'Aides locales travaux' + }, + { + id: 'f329d2ba-ecde-4e70-904e-ac0137ac8a1b', + category: 'dispositifs-incitatifs', + label: 'Aides à la gestion locative' + }, + { + id: '8905fd8c-364a-4701-ac75-5f8817c454f0', + category: 'dispositifs-incitatifs', + label: 'Intermédiation Locative (IML)' + }, + { + id: 'bca88947-2a67-4bc9-ab72-37eb7b062113', + category: 'dispositifs-incitatifs', + label: 'Dispositif fiscal' + }, + { + id: '5d0e2fde-9653-4f2c-8587-277fe1ad33a0', + category: 'dispositifs-incitatifs', + label: 'Prime locale vacance' + }, + { + id: '53f7b197-6f9b-405f-9808-fa342e335ad5', + category: 'dispositifs-incitatifs', + label: 'Prime vacance France Ruralités' + }, + { + id: 'ad8d2d73-2222-42f8-9bc4-3ba648aa2bbc', + category: 'dispositifs-incitatifs', + label: 'Ma Prime Renov' + }, + { + id: 'f9f7f336-e003-4a2d-b4a5-b2d3d9745215', + category: 'dispositifs-incitatifs', + label: 'Prime Rénovation Globale' + }, + { + id: 'df5118e4-de5b-4cdd-9d0b-1196c58cbb88', + category: 'dispositifs-incitatifs', + label: 'Prime locale rénovation énergétique' + }, + { + id: '252909d6-f61e-42a8-a608-87971ee60602', + category: 'dispositifs-incitatifs', + label: 'Accompagnement à la vente' + }, + { + id: '5165ae82-65d2-4f04-b8c5-ccd063f09178', + category: 'dispositifs-incitatifs', + label: 'Autre' + }, + { + id: '3fc430c3-4748-4523-b188-746134f39355', + category: 'dispositifs-coercitifs', + label: 'ORI - TIROIR' + }, + { + id: '4601da9d-0d20-4870-aaae-b72315bcab79', + category: 'dispositifs-coercitifs', + label: 'Bien sans maître' + }, + { + id: '60cf309b-f186-4abd-a643-11500555edbb', + category: 'dispositifs-coercitifs', + label: 'Abandon manifeste' + }, + { + id: 'da792337-a808-4eb4-85a9-1aa2a712e914', + category: 'dispositifs-coercitifs', + label: 'DIA - préemption' + }, + { + id: '418eee4f-45fb-4ec3-8c85-e48d5c83c64d', + category: 'dispositifs-coercitifs', + label: 'Procédure d’habitat indigne' + }, + { + id: 'a5469c8f-64ef-44db-8315-6bf647039849', + category: 'dispositifs-coercitifs', + label: 'Permis de louer' + }, + { + id: '360eee86-6fe6-4b80-bf1d-789a8c4d7919', + category: 'dispositifs-coercitifs', + label: 'Permis de diviser' + }, + { + id: '99474b59-0f26-4341-8226-29abf540393e', + category: 'dispositifs-coercitifs', + label: 'Autre' + }, + { + id: 'dc3be923-0d7b-4a03-adeb-93a5018e03c9', + category: 'hors-dispositif-public', + label: + 'Accompagné par un professionnel (architecte, agent immobilier, etc.)' + }, + { + id: 'c3645101-df88-49bb-94ef-7b93a99f678d', + category: 'hors-dispositif-public', + label: 'Propriétaire autonome' + }, + { + id: '174dd3df-fad9-4e85-b2f3-ec287c3e32d9', + category: 'blocage-involontaire', + label: 'Mise en location ou vente infructueuse' + }, + { + id: '7d442747-d27c-4621-918c-65f416a68d1d', + category: 'blocage-involontaire', + label: 'Succession difficile, indivision en désaccord' + }, + { + id: '6bec7b77-2f02-47f1-98c6-36aaaeb81faf', + category: 'blocage-involontaire', + label: 'Défaut d’entretien / Nécessité de travaux' + }, + { + id: '94caba4c-4f93-4181-ad64-107acb5149c5', + category: 'blocage-involontaire', + label: 'Problème de financement / Dossier non-éligible' + }, + { + id: '87da78a3-ac64-49a2-a03d-1eeeb568ec84', + category: 'blocage-involontaire', + label: 'Manque de conseils en amont de l’achat' + }, + { + id: '083638a8-6638-44d6-ab63-58ae31d5543e', + category: 'blocage-involontaire', + label: 'En incapacité (âge, handicap, précarité ...)' + }, + { + id: 'dcc49583-3145-4022-a4b5-58f70e846ff8', + category: 'blocage-volontaire', + label: 'Réserve personnelle ou pour une autre personne' + }, + { + id: 'a3291e85-4641-46e3-bcbd-e6776ec5ad9d', + category: 'blocage-volontaire', + label: 'Stratégie de gestion' + }, + { + id: '14144fa6-87f0-4214-be1f-e59ebf62ed6f', + category: 'blocage-volontaire', + label: 'Mauvaise expérience locative' + }, + { + id: '95f74ebc-8aa1-4762-b0bb-a8871769814c', + category: 'blocage-volontaire', + label: 'Montants des travaux perçus comme trop importants' + }, + { + id: '86dff4f5-5eb7-4610-963b-8cc9b29c100b', + category: 'blocage-volontaire', + label: 'Refus catégorique, sans raison' + }, + { + id: '69619020-9614-4c08-8f2f-2d75f22650f0', + category: 'immeuble-environnement', + label: 'Pas d’accès indépendant' + }, + { + id: '2e68a3de-8f1f-49a4-b723-15b6eb6665ca', + category: 'immeuble-environnement', + label: 'Immeuble dégradé' + }, + { + id: '5508da10-c523-432a-9033-d55d830aae48', + category: 'immeuble-environnement', + label: 'Ruine / Immeuble à démolir' + }, + { + id: '131c99a8-d6e6-41cd-a539-80fcd53cadc2', + category: 'immeuble-environnement', + label: 'Nuisances à proximité' + }, + { + id: '06403671-87e9-416f-817b-e6352277ca1d', + category: 'immeuble-environnement', + label: 'Risques Naturels / Technologiques' + }, + { + id: 'ef404dac-5f34-45ce-bc25-46b1c3a9b47c', + category: 'tiers-en-cause', + label: 'Entreprise(s) en défaut' + }, + { + id: '6ddae597-ebcd-4553-9cf3-533fd175f225', + category: 'tiers-en-cause', + label: 'Copropriété en désaccord' + }, + { + id: 'c96e46e4-1062-486c-8e20-17cfbe5d87ae', + category: 'tiers-en-cause', + label: 'Expertise judiciaire' + }, + { + id: '6e090e41-29e9-4156-9eb6-da3842f0bb02', + category: 'tiers-en-cause', + label: 'Autorisation d’urbanisme refusée / Blocage ABF' + }, + { + id: '96790167-2a81-4dbe-8f5a-39dbbdbc6cd5', + category: 'tiers-en-cause', + label: 'Interdiction de location' + }, + { + id: '47ec5b6b-5114-4303-98ee-ce029e17d73c', + category: 'travaux', + label: 'À venir' + }, + { + id: 'e7a3f116-0dc0-4afa-9ecb-c0fed50cdc94', + category: 'travaux', + label: 'En cours' + }, + { + id: 'eb5b46b3-efb0-4ffe-827c-cf94f7b9fc5c', + category: 'travaux', + label: 'Terminés' + }, + { + id: 'e95ae7cb-c916-44a7-97de-49a4c4853fe8', + category: 'occupation', + label: 'À venir' + }, + { + id: '0b799bf9-2803-432d-bc07-8071864fc5ac', + category: 'occupation', + label: 'En cours' + }, + { + id: '9fe7eec0-0eea-4ffb-9058-c284ce99168e', + category: 'occupation', + label: 'Nouvelle occupation' + }, + { + id: 'fee7a6c5-4f71-4844-b92b-7cb4c5441549', + category: 'mutation', + label: 'À venir' + }, + { + id: 'e8781301-966d-40dc-9358-6e1f4af1ea76', + category: 'mutation', + label: 'En cours' + }, + { + id: 'c7b29831-9dec-4e99-9ec9-4998ca99b763', + category: 'mutation', + label: 'Effectuée' + } + ]; + const precisions = + housing?.precisions?.map((precision) => + toNewPrecision(precisionOptions, precision) + ) ?? []; + const mechanisms = precisions.filter((precision) => { + return PRECISION_MECHANISM_CATEGORY_VALUES.includes(precision.category); + }); const { field: statusField, fieldState: statusFieldState } = useController({ @@ -172,6 +447,18 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { control: form.control }); + // Immediately save the selected precisions + function savePrecisions(precisions: Precision[]) { + if (housing) { + updateHousing({ + ...housing, + occupancy: housing.occupancy as Occupancy, + occupancyIntended: housing.occupancyIntended as Occupancy, + precisions: precisions.map((p) => p.id) + }); + } + } + return { content: ( @@ -229,13 +516,20 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { > Dispositifs ({mechanisms.length}) - - Ma Prime Renov’ - Aides aux travaux + {mechanisms.map((precision) => ( + {precision.label} + ))} @@ -296,6 +590,12 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { Travaux : en cours + + ), label: 'Mobilisation' From f0f4f94002eb0187a27a599d4225dbde9ca44149 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 20:00:03 +0100 Subject: [PATCH 05/25] feat(frontend): only send the relevant payload when updating housing --- frontend/src/services/housing.service.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/housing.service.ts b/frontend/src/services/housing.service.ts index f074e2511..f0251b509 100644 --- a/frontend/src/services/housing.service.ts +++ b/frontend/src/services/housing.service.ts @@ -14,6 +14,7 @@ import { import { parseOwner } from './owner.service'; import { HousingCount } from '../models/HousingCount'; import { zlvApi } from './api.service'; +import fp from 'lodash/fp'; export interface FindOptions extends PaginationOptions, @@ -139,7 +140,17 @@ export const housingApi = zlvApi.injectEndpoints({ query: ({ id, ...payload }) => ({ url: `housing/${id}`, method: 'PUT', - body: payload + body: fp.pick( + [ + 'occupancy', + 'occupancyIntended', + 'status', + 'subStatus', + 'precisions', + 'vacancyReasons' + ], + payload + ) }), invalidatesTags: (result, error, payload) => [ { type: 'Housing', id: payload.id }, From 47512fa0b1131183ce5eab6248b8c3738b415e42 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 20:00:34 +0100 Subject: [PATCH 06/25] feat(server): link precisions to a housing --- .../src/repositories/precisionRepository.ts | 41 +++++++++ .../test/precisionRepository.test.ts | 90 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 server/src/repositories/test/precisionRepository.test.ts diff --git a/server/src/repositories/precisionRepository.ts b/server/src/repositories/precisionRepository.ts index 8ead0e8ea..b00668493 100644 --- a/server/src/repositories/precisionRepository.ts +++ b/server/src/repositories/precisionRepository.ts @@ -2,9 +2,50 @@ import { Knex } from 'knex'; import db from '~/infra/database'; import { PrecisionApi } from '~/models/PrecisionApi'; +import { HousingApi } from '~/models/HousingApi'; +import { getTransaction } from '~/infra/database/transaction'; export const PRECISION_TABLE = 'precisions'; export const Precisions = (transaction: Knex = db) => transaction(PRECISION_TABLE); +export const HOUSING_PRECISION_TABLE = 'housing_precisions'; +export const HousingPrecisions = ( + transaction: Knex = db +) => transaction(HOUSING_PRECISION_TABLE); + export type PrecisionDBO = PrecisionApi; +export type HousingPrecisionDBO = { + housing_geo_code: string; + housing_id: string; + precision_id: string; +}; + +async function link( + housing: HousingApi, + precisions: ReadonlyArray +): Promise { + const transaction = getTransaction(); + await HousingPrecisions(transaction) + .where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }) + .delete(); + + if (precisions.length) { + const housingPrecisions: ReadonlyArray = + precisions.map((precision) => ({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + precision_id: precision.id + })); + await HousingPrecisions(transaction).insert(housingPrecisions); + } +} + +const precisionRepository = { + link +}; + +export default precisionRepository; diff --git a/server/src/repositories/test/precisionRepository.test.ts b/server/src/repositories/test/precisionRepository.test.ts new file mode 100644 index 000000000..7cc43894e --- /dev/null +++ b/server/src/repositories/test/precisionRepository.test.ts @@ -0,0 +1,90 @@ +import { faker } from '@faker-js/faker/locale/fr'; + +import precisionRepository, { + HousingPrecisionDBO, + HousingPrecisions, + Precisions +} from '~/repositories/precisionRepository'; +import { genHousingApi } from '~/test/testFixtures'; +import { + formatHousingRecordApi, + Housing +} from '~/repositories/housingRepository'; + +describe('Precision repository', () => { + describe('link', () => { + const housing = genHousingApi(); + + beforeAll(async () => { + await Housing().insert(formatHousingRecordApi(housing)); + }); + + it('should link a housing to precisions', async () => { + const referential = await Precisions(); + const precisions = faker.helpers.arrayElements(referential, 3); + + await precisionRepository.link(housing, precisions); + + const actual = await HousingPrecisions().where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }); + expect(actual).toIncludeSameMembers( + precisions.map((precision) => ({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + precision_id: precision.id + })) + ); + }); + + it('should override precisions', async () => { + const referential = await Precisions(); + const precisionsBefore = faker.helpers.arrayElements(referential, 3); + const precisionsAfter = faker.helpers.arrayElements(referential, 2); + const housingPrecisions = precisionsBefore.map( + (precision) => ({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + precision_id: precision.id + }) + ); + await HousingPrecisions().insert(housingPrecisions); + + await precisionRepository.link(housing, precisionsAfter); + + const actual = await HousingPrecisions().where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }); + expect(actual).toIncludeSameMembers( + precisionsAfter.map((precision) => ({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + precision_id: precision.id + })) + ); + }); + + it('should remove the housing precisions if given an empty array', async () => { + const referential = await Precisions(); + const precisionsBefore = faker.helpers.arrayElements(referential, 3); + const housingPrecisions = precisionsBefore.map( + (precision) => ({ + housing_geo_code: housing.geoCode, + housing_id: housing.id, + precision_id: precision.id + }) + ); + await HousingPrecisions().insert(housingPrecisions); + + await precisionRepository.link(housing, []); + + const actual = await HousingPrecisions().where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }); + expect(actual).toHaveLength(0); + }); + }); +}); From 0b8a5f69195894b743caaf8dcc114026d931ee1d Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 20:01:38 +0100 Subject: [PATCH 07/25] feat(server): add a script to migrate precisions --- server/src/models/PrecisionApi.ts | 37 +- .../scripts/migrate-precisions/index.test.ts | 392 ++++++++++++++++++ .../src/scripts/migrate-precisions/index.ts | 128 ++++++ 3 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 server/src/scripts/migrate-precisions/index.test.ts create mode 100644 server/src/scripts/migrate-precisions/index.ts diff --git a/server/src/models/PrecisionApi.ts b/server/src/models/PrecisionApi.ts index 70824daa7..f869f0cbe 100644 --- a/server/src/models/PrecisionApi.ts +++ b/server/src/models/PrecisionApi.ts @@ -1,6 +1,6 @@ -import { OrderedMap } from 'immutable'; +import { Map, OrderedMap } from 'immutable'; -import { Precision } from '@zerologementvacant/models'; +import { Precision, PrecisionCategory } from '@zerologementvacant/models'; export interface PrecisionApi extends Precision { order: number; @@ -77,3 +77,36 @@ export const PRECISION_TREE_VALUES: OrderedMap< occupation: ['À venir', 'En cours', 'Nouvelle occupation'], mutation: ['À venir', 'En cours', 'Effectuée'] }); + +export const PRECISION_TRANSITION_MAPPING: Map< + PrecisionCategory, + ReadonlyArray +> = Map({ + 'dispositifs-incitatifs': ['Dispositifs', 'Dispositifs incitatifs'], + 'dispositifs-coercitifs': ['Dispositifs', 'Dispositifs coercitifs'], + 'hors-dispositif-public': ['Dispositifs', 'Hors dispositif public'], + travaux: ['Mode opératoire', 'Travaux'], + occupation: ['Mode opératoire', 'Occupation'], + mutation: ['Mode opératoire', 'Mutation'] +}); + +export const VACANCY_REASON_TRANSITION_MAPPING: Map< + PrecisionCategory, + ReadonlyArray +> = Map({ + 'blocage-involontaire': ['Blocage', 'Blocage involontaire'], + 'blocage-volontaire': ['Blocage', 'Blocage volontaire'], + 'immeuble-environnement': ['Blocage', 'Immeuble / Environnement'], + 'tiers-en-cause': ['Blocage', 'Tiers en cause'] +}); + +export function toOldPrecision(precision: PrecisionApi): string { + const mapping = + PRECISION_TRANSITION_MAPPING.get(precision.category) ?? + VACANCY_REASON_TRANSITION_MAPPING.get(precision.category); + if (!mapping) { + throw new Error(`No mapping found for category ${precision.category}`); + } + + return mapping.concat(precision.label).join(' > '); +} diff --git a/server/src/scripts/migrate-precisions/index.test.ts b/server/src/scripts/migrate-precisions/index.test.ts new file mode 100644 index 000000000..df29ab7db --- /dev/null +++ b/server/src/scripts/migrate-precisions/index.test.ts @@ -0,0 +1,392 @@ +import { parse } from 'jsonlines'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { run } from '~/scripts/migrate-precisions/index'; +import { WritableStream } from 'node:stream/web'; +import { PrecisionDBO, Precisions } from '~/repositories/precisionRepository'; +import { List } from 'immutable'; + +describe('Migrate precisions', () => { + const input = path.join(__dirname, 'input.jsonl'); + const output = path.join(__dirname, 'output.jsonl'); + const mapping = [ + { + from: 'Dispositifs > Dispositifs incitatifs > Conventionnement avec travaux', + to: { + category: 'dispositifs-incitatifs', + label: 'Conventionnement avec travaux' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Conventionnement sans travaux', + to: { + category: 'dispositifs-incitatifs', + label: 'Conventionnement sans travaux' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Aides locales travaux', + to: { category: 'dispositifs-incitatifs', label: 'Aides locales travaux' } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Aides à la gestion locative', + to: { + category: 'dispositifs-incitatifs', + label: 'Aides à la gestion locative' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Intermédiation Locative (IML)', + to: { + category: 'dispositifs-incitatifs', + label: 'Intermédiation Locative (IML)' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Dispositif fiscal', + to: { category: 'dispositifs-incitatifs', label: 'Dispositif fiscal' } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Prime locale vacance', + to: { category: 'dispositifs-incitatifs', label: 'Prime locale vacance' } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Prime vacance France Ruralités', + to: { + category: 'dispositifs-incitatifs', + label: 'Prime vacance France Ruralités' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Ma Prime Renov', + to: { category: 'dispositifs-incitatifs', label: 'Ma Prime Renov' } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Prime Rénovation Globale', + to: { + category: 'dispositifs-incitatifs', + label: 'Prime Rénovation Globale' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Prime locale rénovation énergétique', + to: { + category: 'dispositifs-incitatifs', + label: 'Prime locale rénovation énergétique' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Accompagnement à la vente', + to: { + category: 'dispositifs-incitatifs', + label: 'Accompagnement à la vente' + } + }, + { + from: 'Dispositifs > Dispositifs incitatifs > Autre', + to: { category: 'dispositifs-incitatifs', label: 'Autre' } + }, + + { + from: 'Dispositifs > Dispositifs coercitifs > ORI - TIRORI', + to: { category: 'dispositifs-coercitifs', label: 'ORI - TIRORI' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Bien sans maître', + to: { category: 'dispositifs-coercitifs', label: 'Bien sans maître' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Abandon manifeste', + to: { category: 'dispositifs-coercitifs', label: 'Abandon manifeste' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > DIA - préemption', + to: { category: 'dispositifs-coercitifs', label: 'DIA - préemption' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Procédure d’habitat indigne', + to: { + category: 'dispositifs-coercitifs', + label: 'Procédure d’habitat indigne' + } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Permis de louer', + to: { category: 'dispositifs-coercitifs', label: 'Permis de louer' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Permis de diviser', + to: { category: 'dispositifs-coercitifs', label: 'Permis de diviser' } + }, + { + from: 'Dispositifs > Dispositifs coercitifs > Autre', + to: { category: 'dispositifs-coercitifs', label: 'Autre' } + }, + + { + from: 'Dispositifs > Hors dispositif public > Accompagné par un professionnel (architecte, agent immobilier, etc.)', + to: { + category: 'hors-dispositif-public', + label: + 'Accompagné par un professionnel (architecte, agent immobilier, etc.)' + } + }, + { + from: 'Dispositifs > Hors dispositif public > Propriétaire autonome', + to: { category: 'hors-dispositif-public', label: 'Propriétaire autonome' } + }, + + { + from: 'Points de blocage > Blocage involontaire > Mise en location ou vente infructueuse', + to: { + category: 'blocage-involontaire', + label: 'Mise en location ou vente infructueuse' + } + }, + { + from: 'Points de blocage > Blocage involontaire > Succession difficile, indivision en désaccord', + to: { + category: 'blocage-involontaire', + label: 'Succession difficile, indivision en désaccord' + } + }, + { + from: 'Points de blocage > Blocage involontaire > Défaut d’entretien / Nécessité de travaux', + to: { + category: 'blocage-involontaire', + label: 'Défaut d’entretien / Nécessité de travaux' + } + }, + { + from: 'Points de blocage > Blocage involontaire > Problème de financement / Dossier non-éligible', + to: { + category: 'blocage-involontaire', + label: 'Problème de financement / Dossier non-éligible' + } + }, + { + from: 'Points de blocage > Blocage involontaire > Manque de conseils en amont de l’achat', + to: { + category: 'blocage-involontaire', + label: 'Manque de conseils en amont de l’achat' + } + }, + { + from: 'Points de blocage > Blocage involontaire > En incapacité (âge, handicap, précarité ...)', + to: { + category: 'blocage-involontaire', + label: 'En incapacité (âge, handicap, précarité ...)' + } + }, + { + from: 'Points de blocage > Blocage volontaire > Réserve personnelle ou pour une autre personne', + to: { + category: 'blocage-volontaire', + label: 'Réserve personnelle ou pour une autre personne' + } + }, + { + from: 'Points de blocage > Blocage volontaire > Stratégie de gestion', + to: { category: 'blocage-volontaire', label: 'Stratégie de gestion' } + }, + { + from: 'Points de blocage > Blocage volontaire > Mauvaise expérience locative', + to: { + category: 'blocage-volontaire', + label: 'Mauvaise expérience locative' + } + }, + { + from: 'Points de blocage > Blocage volontaire > Montants des travaux perçus comme trop importants', + to: { + category: 'blocage-volontaire', + label: 'Montants des travaux perçus comme trop importants' + } + }, + { + from: 'Points de blocage > Blocage volontaire > Refus catégorique, sans raison', + to: { + category: 'blocage-volontaire', + label: 'Refus catégorique, sans raison' + } + }, + { + from: 'Points de blocage > Immeuble / Environnement > Pas d’accès indépendant', + to: { + category: 'immeuble-environnement', + label: 'Pas d’accès indépendant' + } + }, + { + from: 'Points de blocage > Immeuble / Environnement > Immeuble dégradé', + to: { category: 'immeuble-environnement', label: 'Immeuble dégradé' } + }, + { + from: 'Points de blocage > Immeuble / Environnement > Ruine / Immeuble à démolir', + to: { + category: 'immeuble-environnement', + label: 'Ruine / Immeuble à démolir' + } + }, + { + from: 'Points de blocage > Immeuble / Environnement > Nuisances à proximité', + to: { category: 'immeuble-environnement', label: 'Nuisances à proximité' } + }, + { + from: 'Points de blocage > Immeuble / Environnement > Risques Naturels / Technologiques', + to: { + category: 'immeuble-environnement', + label: 'Risques Naturels / Technologiques' + } + }, + { + from: 'Points de blocage > Tiers en cause > Entreprise(s) en défaut', + to: { category: 'tiers-en-cause', label: 'Entreprise(s) en défaut' } + }, + { + from: 'Points de blocage > Tiers en cause > Copropriété en désaccord', + to: { category: 'tiers-en-cause', label: 'Copropriété en désaccord' } + }, + { + from: 'Points de blocage > Tiers en cause > Expertise judiciaire', + to: { category: 'tiers-en-cause', label: 'Expertise judiciaire' } + }, + { + from: 'Points de blocage > Tiers en cause > Autorisation d’urbanisme refusée / Blocage ABF', + to: { + category: 'tiers-en-cause', + label: 'Autorisation d’urbanisme refusée / Blocage ABF' + } + }, + { + from: 'Points de blocage > Tiers en cause > Interdiction de location', + to: { category: 'tiers-en-cause', label: 'Interdiction de location' } + }, + + { + from: 'Évolutions du logement > Travaux > À venir', + to: { category: 'travaux', label: 'À venir' } + }, + { + from: 'Évolutions du logement > Travaux > En cours', + to: { category: 'travaux', label: 'En cours' } + }, + { + from: 'Évolutions du logement > Travaux > Terminés', + to: { category: 'travaux', label: 'Terminés' } + }, + { + from: 'Évolutions du logement > Occupation > À venir', + to: { category: 'occupation', label: 'À venir' } + }, + { + from: 'Évolutions du logement > Occupation > En cours', + to: { category: 'occupation', label: 'En cours' } + }, + { + from: 'Évolutions du logement > Occupation > Nouvelle occupation', + to: { category: 'occupation', label: 'Nouvelle occupation' } + }, + { + from: 'Évolutions du logement > Mutation > À venir', + to: { category: 'mutation', label: 'À venir' } + }, + { + from: 'Évolutions du logement > Mutation > En cours', + to: { category: 'mutation', label: 'En cours' } + }, + { + from: 'Évolutions du logement > Mutation > Effectuée', + to: { category: 'mutation', label: 'Effectuée' } + } + ]; + + let housings: ReadonlyArray<{ + geo_code: string; + id: string; + precisions: string[]; + }>; + // const housings = Array.from( + // { + // length: faker.number.int({ min: 1_000, max: 10_000 }) + // }, + // () => { + // const precisions = faker.helpers + // .arrayElements(faker.helpers.arrayElements(mapping, { min: 1, max: 4 })) + // .map((precision) => precision.from); + // return { + // geo_code: faker.location.zipCode(), + // id: faker.string.uuid(), + // precisions: precisions + // }; + // } + // ).concat({ + // geo_code: faker.location.zipCode(), + // id: faker.string.uuid(), + // precisions: [ + // 'Étude faisabilité', + // 'Demande de pièces', + // 'Informations transmises - Encore à convaincre', + // 'Informations transmises - rendez-vous à fixer', + // "N'est plus un logement" + // ] + // }); + + beforeAll(async () => { + housings = (await fs + .createReadStream(input, 'utf8') + .pipe(parse()) + .toArray()) as ReadonlyArray<{ + geo_code: string; + id: string; + precisions: string[]; + }>; + // await ReadableStream.from(housings) + // .pipeThrough(Transform.toWeb(stringify())) + // .pipeTo(Writable.toWeb(fs.createWriteStream(input, 'utf8'))); + }); + + afterAll(async () => { + try { + // await Promise.all([unlink(input), unlink(output)]); + } catch { + console.log(`No file ${input} found. Skipping clean up...`); + } + }); + + it('should map $from to $to', async () => { + const PRECISIONS = await Precisions() + .select() + .then((precisions) => { + return List(precisions) + .groupBy((precision) => precision.id) + .map((precisions) => precisions.first() as PrecisionDBO); + }); + + await run(); + + await Readable.toWeb(fs.createReadStream(output, 'utf8')) + .pipeThrough(Transform.toWeb(parse())) + .pipeTo( + new WritableStream({ + write(chunk) { + const expected = housings + .find( + (housing) => + housing.geo_code === chunk.geo_code && housing.id === chunk.id + ) + ?.precisions?.map((precision) => { + return mapping.find((record) => record.from === precision); + }) + ?.map((precision) => precision?.to); + + const actual: ReadonlyArray = chunk.precisions.map( + (precision: PrecisionDBO['id']) => PRECISIONS.get(precision) + ); + expect(actual).toIncludeAllPartialMembers(expected ?? []); + } + }) + ); + }, 30_000); +}); diff --git a/server/src/scripts/migrate-precisions/index.ts b/server/src/scripts/migrate-precisions/index.ts new file mode 100644 index 000000000..9b2b1ae2f --- /dev/null +++ b/server/src/scripts/migrate-precisions/index.ts @@ -0,0 +1,128 @@ +import { parse as parseJSONL, stringify as writeJSONL } from 'jsonlines'; +import { List } from 'immutable'; +import fp from 'lodash/fp'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable, Transform, Writable } from 'node:stream'; +import { ReadableStream, TransformStream } from 'node:stream/web'; +import { match, P } from 'ts-pattern'; + +import { PrecisionCategory } from '@zerologementvacant/models'; +import { isNotNull, slugify } from '@zerologementvacant/utils'; +import { HousingRecordDBO } from '~/repositories/housingRepository'; +import { PrecisionDBO, Precisions } from '~/repositories/precisionRepository'; + +export async function run(): Promise { + const precisions = await Precisions().select().orderBy('category'); + + const input = path.join(__dirname, 'input.jsonl'); + const output = path.join(__dirname, 'output.jsonl'); + + await jsonlReader(input) + .pipeThrough(mapFilter(precisions)) + .pipeThrough(jsonl()) + .pipeTo(toFile(output)); +} + +function jsonlReader(file: string): ReadableStream { + const stream = fs.createReadStream(file, 'utf8'); + return Readable.toWeb(stream.pipe(parseJSONL())); +} + +type MapInput = Required< + Pick +>; +type MapOutput = Omit & { + precisions: ReadonlyArray; +}; + +function mapFilter(precisions: ReadonlyArray) { + const precisionsByCategory = List(precisions).groupBy((p) => p.category); + + return new TransformStream({ + async transform(chunk, controller) { + const matched = chunk.precisions + .filter(isNotNull) + .map((precision) => { + const array = precision.split(' > ').slice(-2); + const category = array.at(0); + const label = array.at(1); + + return match({ + category: category ? slugify(category) : null, + label: label ?? null + }) + .returnType() + .when( + ({ category, label }) => { + return ( + category && + label && + precisionsByCategory + .get(category as PrecisionCategory) + ?.some((p) => p.label === label) + ); + }, + ({ category, label }) => { + const precision = precisionsByCategory + .get(category as PrecisionCategory) + ?.find((p) => p.label === label); + if (!precision) { + throw new PrecisionNotFoundError( + category as PrecisionCategory, + label as string + ); + } + + return precision.id; + } + ) + .with( + { category: 'location-occupation', label: P.string }, + ({ label }) => { + const precision = precisionsByCategory + .get('occupation') + ?.find((p) => p.label === label); + if (!precision) { + throw new PrecisionNotFoundError('occupation', label); + } + + return precision.id; + } + ) + .otherwise(() => null); + }) + .filter(isNotNull); + + if (matched.length > 0) { + controller.enqueue({ + ...chunk, + precisions: fp.uniq(matched) + }); + } + } + }); +} + +function jsonl() { + return Transform.toWeb(writeJSONL()); +} + +function toFile(file: string) { + return Writable.toWeb(fs.createWriteStream(file, 'utf8')); +} + +class PrecisionNotFoundError extends Error { + constructor(category: string, label: string) { + super( + `Precision with category "${category}" and label "${label}" not found` + ); + } +} + +run() + // .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); From 68ac8ebe52c7e23a76964c6f7ca66685d7291a52 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Thu, 23 Jan 2025 20:01:50 +0100 Subject: [PATCH 08/25] feat(server): add PrecisionMissingError --- server/src/errors/precisionMissingError.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 server/src/errors/precisionMissingError.ts diff --git a/server/src/errors/precisionMissingError.ts b/server/src/errors/precisionMissingError.ts new file mode 100644 index 000000000..c9a481842 --- /dev/null +++ b/server/src/errors/precisionMissingError.ts @@ -0,0 +1,16 @@ +import { constants } from 'http2'; + +import { HttpError } from './httpError'; + +export default class PrecisionMissingError + extends HttpError + implements HttpError +{ + constructor(...ids: string[]) { + super({ + name: 'PrecisionMissingError', + message: `Precision(s) ${ids.join(', ')} missing`, + status: constants.HTTP_STATUS_NOT_FOUND + }); + } +} From 8ba92da3ba7b522501ad3220c4d062d922b19d1b Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 00:50:25 +0100 Subject: [PATCH 09/25] feat: set tab when opening precisions; serialize and deserialize old precisions --- .../HousingEdition/HousingEditionSideMenu.tsx | 52 +++-- .../Precision/PrecisionModalNext.tsx | 15 +- .../components/Precision/PrecisionTabs.tsx | 189 ++++++++++++++---- packages/models/src/Precision.ts | 18 ++ server/src/models/HousingApi.ts | 4 + 5 files changed, 218 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index e2a5b3032..f3cb23e5d 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -14,10 +14,12 @@ import * as yup from 'yup'; import { HOUSING_STATUS_VALUES, HousingStatus, + isPrecisionBlockingPointCategory, + isPrecisionEvolutionCategory, + isPrecisionMechanismCategory, Occupancy, OCCUPANCY_VALUES, - Precision, - PRECISION_MECHANISM_CATEGORY_VALUES + Precision } from '@zerologementvacant/models'; import { Housing, HousingUpdate } from '../../models/Housing'; import AppLink from '../_app/AppLink/AppLink'; @@ -36,6 +38,8 @@ import { useUpdateHousingNextMutation } from '../../services/housing.service'; import { useNotification } from '../../hooks/useNotification'; import { toNewPrecision } from '../../models/Precision'; import createPrecisionModalNext from '../Precision/PrecisionModalNext'; +import { useState } from 'react'; +import { PrecisionTabId } from '../Precision/PrecisionTabs'; interface HousingEditionSideMenuProps { housing: Housing | null; @@ -434,12 +438,19 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } ]; const precisions = - housing?.precisions?.map((precision) => - toNewPrecision(precisionOptions, precision) - ) ?? []; - const mechanisms = precisions.filter((precision) => { - return PRECISION_MECHANISM_CATEGORY_VALUES.includes(precision.category); - }); + housing?.precisions + ?.concat(housing?.vacancyReasons ?? []) + ?.map((precision) => toNewPrecision(precisionOptions, precision)) ?? []; + const mechanisms = precisions.filter((precision) => + isPrecisionMechanismCategory(precision.category) + ); + const blockingPoints = precisions.filter((precision) => + isPrecisionBlockingPointCategory(precision.category) + ); + const evolutions = precisions.filter((precision) => + isPrecisionEvolutionCategory(precision.category) + ); + const [tab, setTab] = useState('dispositifs'); const { field: statusField, fieldState: statusFieldState } = useController({ @@ -520,6 +531,7 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { priority="secondary" title="Modifier les dispositifs" onClick={() => { + setTab('dispositifs'); precisionModal.open(); }} > @@ -551,16 +563,24 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { fontWeight: 700 }} > - Points de blocages (0) + Points de blocages ({blockingPoints.length}) - Badges + + {blockingPoints.map((precision) => ( + {precision.label} + ))} + - Évolutions du logement (1) + Évolutions du logement ({evolutions.length}) - Travaux : en cours + {evolutions.map((precision) => ( + {precision.label} + ))} ), diff --git a/frontend/src/components/Precision/PrecisionModalNext.tsx b/frontend/src/components/Precision/PrecisionModalNext.tsx index 5afb0c64c..2cbefee28 100644 --- a/frontend/src/components/Precision/PrecisionModalNext.tsx +++ b/frontend/src/components/Precision/PrecisionModalNext.tsx @@ -10,11 +10,12 @@ import PrecisionTabs from './PrecisionTabs'; export type PrecisionModalProps = Omit< ConfirmationModalProps, 'children' | 'size' | 'title' | 'onSubmit' -> & { - options: Precision[]; - value: Precision[]; - onSubmit(value: Precision[]): void; -}; +> & + Pick & { + options: Precision[]; + value: Precision[]; + onSubmit(value: Precision[]): void; + }; function createPrecisionModalNext() { const confirmationModal = createConfirmationModal({ @@ -25,7 +26,7 @@ function createPrecisionModalNext() { return { ...confirmationModal, Component(props: PrecisionModalProps) { - const { options, value, onSubmit, ...rest } = props; + const { tab, options, value, onSubmit, onTabChange, ...rest } = props; const [internalValue, setInternalValue] = useState(value); return ( @@ -38,9 +39,11 @@ function createPrecisionModalNext() { }} > ); diff --git a/frontend/src/components/Precision/PrecisionTabs.tsx b/frontend/src/components/Precision/PrecisionTabs.tsx index ff8f2cbce..981f1d9dd 100644 --- a/frontend/src/components/Precision/PrecisionTabs.tsx +++ b/frontend/src/components/Precision/PrecisionTabs.tsx @@ -1,23 +1,28 @@ -import { fr } from '@codegouvfr/react-dsfr'; +import { fr, FrIconClassName, RiIconClassName } from '@codegouvfr/react-dsfr'; import Checkbox, { CheckboxProps } from '@codegouvfr/react-dsfr/Checkbox'; -import Tabs, { TabsProps } from '@codegouvfr/react-dsfr/Tabs'; +import Tabs from '@codegouvfr/react-dsfr/Tabs'; import Grid from '@mui/material/Unstable_Grid2'; import Typography from '@mui/material/Typography'; import { List } from 'immutable'; -import { ChangeEvent, useMemo } from 'react'; +import { ChangeEvent, ReactElement, useMemo } from 'react'; import { ElementOf } from 'ts-essentials'; -import { Precision } from '@zerologementvacant/models'; +import { Precision, PrecisionCategory } from '@zerologementvacant/models'; interface PrecisionTabs { - defaultTab?: TabId; + tab: PrecisionTabId; options: Precision[]; value: Precision[]; onChange(value: Precision[]): void; + onTabChange(tab: PrecisionTabId): void; } -type TabId = 'dispositifs' | 'points-de-blocage' | 'evolutions'; -type Tab = ElementOf; +export type PrecisionTabId = 'dispositifs' | 'points-de-blocage' | 'evolutions'; +export type PrecisionTab = { + tabId: PrecisionTabId; + label: string; + children: ReactElement; +}; function PrecisionTabs(props: PrecisionTabs) { const optionsByCategory = useMemo( @@ -25,9 +30,6 @@ function PrecisionTabs(props: PrecisionTabs) { [props.options] ); - const defaultTab = props.defaultTab ?? 'dispositifs'; - const isDefault = (tab: TabId) => tab === defaultTab; - function onChange(event: ChangeEvent) { if (event.target.checked) { const precision = props.options.find( @@ -41,56 +43,159 @@ function PrecisionTabs(props: PrecisionTabs) { } } - function MechanismsTab(): Tab { + interface PrecisionColumnProps { + category: PrecisionCategory; + icon: FrIconClassName | RiIconClassName; + title: string; + } + + function PrecisionColumn(columnProps: PrecisionColumnProps) { + return ( + <> + + + {columnProps.title} + + => ({ + label: option.label, + nativeInputProps: { + checked: props.value.some((value) => value.id === option.id), + value: option.id, + onChange: onChange + } + }) + )} + /> + + ); + } + + function MechanismsTab(): PrecisionTab { return { label: 'Dispositifs', - isDefault: isDefault('dispositifs'), - content: ( + tabId: 'dispositifs', + children: ( - - - Dispositifs incitatifs - - => ({ - label: option.label, - nativeInputProps: { - checked: props.value.some( - (value) => value.id === option.id - ), - value: option.id, - onChange: onChange - } - }) - )} + - - - Dispositifs coercitifs - + - - - Hors dispositif public - + ) }; } - const tabs: TabsProps.Uncontrolled['tabs'] = [MechanismsTab()]; + function BlockingPointsTab(): PrecisionTab { + return { + label: 'Points de blocage', + tabId: 'points-de-blocage', + children: ( + + + + - return ; + + + + + + + + + + + + + ) + }; + } + + function EvolutionsTab(): PrecisionTab { + return { + label: 'Évolutions', + tabId: 'evolutions', + children: ( + + + + + + + + + + + + + + ) + }; + } + + const tabs: PrecisionTab[] = [ + MechanismsTab(), + BlockingPointsTab(), + EvolutionsTab() + ]; + + const tab = props.tab ?? 'dispositifs'; + const activeTab = tabs.find((t) => t.tabId === tab) ?? tabs[0]; + + return ( + + {activeTab.children} + + ); } export default PrecisionTabs; diff --git a/packages/models/src/Precision.ts b/packages/models/src/Precision.ts index b445b3192..eaadfd057 100644 --- a/packages/models/src/Precision.ts +++ b/packages/models/src/Precision.ts @@ -29,6 +29,24 @@ export const PRECISION_BLOCKING_POINT_CATEGORY_VALUES: ReadonlyArray = ['travaux', 'occupation', 'mutation']; +export function isPrecisionMechanismCategory( + category: PrecisionCategory +): category is (typeof PRECISION_MECHANISM_CATEGORY_VALUES)[number] { + return PRECISION_MECHANISM_CATEGORY_VALUES.includes(category); +} + +export function isPrecisionBlockingPointCategory( + category: PrecisionCategory +): category is (typeof PRECISION_BLOCKING_POINT_CATEGORY_VALUES)[number] { + return PRECISION_BLOCKING_POINT_CATEGORY_VALUES.includes(category); +} + +export function isPrecisionEvolutionCategory( + category: PrecisionCategory +): category is (typeof PRECISION_EVOLUTION_CATEGORY_VALUES)[number] { + return PRECISION_EVOLUTION_CATEGORY_VALUES.includes(category); +} + export interface Precision { id: string; category: PrecisionCategory; diff --git a/server/src/models/HousingApi.ts b/server/src/models/HousingApi.ts index 07214e204..e70dceb6c 100644 --- a/server/src/models/HousingApi.ts +++ b/server/src/models/HousingApi.ts @@ -55,6 +55,10 @@ export interface HousingRecordApi { ownershipKind?: string; status: HousingStatusApi; subStatus?: string | null; + /** + * @deprecated To be replaced by the `precisions` + * and the `housing_precisions` tables + */ precisions?: string[] | null; energyConsumption?: EnergyConsumptionGradesApi; energyConsumptionAt?: Date; From 036d0224911c1654386974c5194445852fd2258e Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 09:43:53 +0100 Subject: [PATCH 10/25] refactor(server): simplify precisions migration script --- .../src/scripts/migrate-precisions/index.ts | 90 ++++++++----------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/server/src/scripts/migrate-precisions/index.ts b/server/src/scripts/migrate-precisions/index.ts index 9b2b1ae2f..a61242709 100644 --- a/server/src/scripts/migrate-precisions/index.ts +++ b/server/src/scripts/migrate-precisions/index.ts @@ -1,14 +1,13 @@ import { parse as parseJSONL, stringify as writeJSONL } from 'jsonlines'; -import { List } from 'immutable'; +import { List, Map } from 'immutable'; import fp from 'lodash/fp'; import fs from 'node:fs'; import path from 'node:path'; import { Readable, Transform, Writable } from 'node:stream'; import { ReadableStream, TransformStream } from 'node:stream/web'; -import { match, P } from 'ts-pattern'; import { PrecisionCategory } from '@zerologementvacant/models'; -import { isNotNull, slugify } from '@zerologementvacant/utils'; +import { isNotNull } from '@zerologementvacant/utils'; import { HousingRecordDBO } from '~/repositories/housingRepository'; import { PrecisionDBO, Precisions } from '~/repositories/precisionRepository'; @@ -41,58 +40,43 @@ function mapFilter(precisions: ReadonlyArray) { return new TransformStream({ async transform(chunk, controller) { - const matched = chunk.precisions + const matched: ReadonlyArray = ( + chunk.precisions ?? [] + ) .filter(isNotNull) - .map((precision) => { - const array = precision.split(' > ').slice(-2); - const category = array.at(0); - const label = array.at(1); - - return match({ - category: category ? slugify(category) : null, - label: label ?? null - }) - .returnType() - .when( - ({ category, label }) => { - return ( - category && - label && - precisionsByCategory - .get(category as PrecisionCategory) - ?.some((p) => p.label === label) - ); - }, - ({ category, label }) => { - const precision = precisionsByCategory - .get(category as PrecisionCategory) - ?.find((p) => p.label === label); - if (!precision) { - throw new PrecisionNotFoundError( - category as PrecisionCategory, - label as string - ); - } - - return precision.id; - } - ) - .with( - { category: 'location-occupation', label: P.string }, - ({ label }) => { - const precision = precisionsByCategory - .get('occupation') - ?.find((p) => p.label === label); - if (!precision) { - throw new PrecisionNotFoundError('occupation', label); - } - - return precision.id; - } - ) - .otherwise(() => null); + .filter((precision) => precision.split(' > ').length === 3) + .map((precisionBefore) => { + const array = precisionBefore.split(' > '); + const categoryBefore = array.slice(0, 2).join(' > '); + const label = array.at(-1) as string; + + const mapping: Map = Map({ + 'Dispositifs > Dispositifs incitatifs': 'dispositifs-incitatifs', + 'Dispositifs > Dispositifs coercitifs': 'dispositifs-coercitifs', + 'Dispositifs > Hors dispositif public': 'hors-dispositif-public', + 'Mode opératoire > Travaux': 'travaux', + 'Mode opératoire > Occupation': 'occupation', + 'Mode opératoire > Mutation': 'mutation', + 'Blocage > Blocage involontaire': 'blocage-involontaire', + 'Blocage > Blocage volontaire': 'blocage-volontaire', + 'Blocage > Immeuble / Environnement': 'immeuble-environnement', + 'Blocage > Tiers en cause': 'tiers-en-cause' + }); + const categoryAfter = mapping.get(categoryBefore); + if (!categoryAfter) { + throw new Error(`Category missing to map from ${categoryBefore}`); + } + + const precisionAfter = precisionsByCategory + .get(categoryAfter) + ?.find((precision) => precision.label === label); + if (!precisionAfter) { + throw new PrecisionNotFoundError(categoryAfter, label); + } + + return precisionAfter; }) - .filter(isNotNull); + .map((precision) => precision.id); if (matched.length > 0) { controller.enqueue({ From 1bd2e9733e117cbad1473b742a7f149f24252228 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 09:44:24 +0100 Subject: [PATCH 11/25] fix(server): ensure writing to the old and new precisions --- .../src/controllers/housingController.test.ts | 125 +++++++++++++++++- server/src/controllers/housingController.ts | 80 +++++++---- server/src/models/PrecisionApi.ts | 8 ++ server/src/repositories/housingRepository.ts | 6 +- 4 files changed, 193 insertions(+), 26 deletions(-) diff --git a/server/src/controllers/housingController.test.ts b/server/src/controllers/housingController.test.ts index d1984d41a..5b3a0e157 100644 --- a/server/src/controllers/housingController.test.ts +++ b/server/src/controllers/housingController.test.ts @@ -66,10 +66,18 @@ import { HousingDTO, HousingUpdatePayloadDTO, Occupancy, - OCCUPANCY_VALUES + OCCUPANCY_VALUES, + PRECISION_CATEGORY_VALUES } from '@zerologementvacant/models'; import { EstablishmentApi } from '~/models/EstablishmentApi'; import { UserApi, UserRoles } from '~/models/UserApi'; +import { + HousingPrecisionDBO, + HousingPrecisions, + PrecisionDBO, + Precisions +} from '~/repositories/precisionRepository'; +import { wasPrecision, wasVacancyReason } from '~/models/PrecisionApi'; describe('Housing API', () => { const { app } = createServer(); @@ -584,6 +592,18 @@ describe('Housing API', () => { expect(status).toBe(constants.HTTP_STATUS_NOT_FOUND); }); + it('should throw if one of the precisions was not found', async () => { + payload.precisions = [faker.string.uuid()]; + + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_NOT_FOUND); + }); + it('should throw if the user is a visitor', async () => { const { status } = await request(app) .put(testRoute(housing.id)) @@ -633,6 +653,109 @@ describe('Housing API', () => { }); }); + it('should update the old precisions', async () => { + const referential = await Precisions(); + // Send one precision from each category + const precisions = PRECISION_CATEGORY_VALUES.map((category) => { + return referential.find( + (precision) => precision.category === category + ) as PrecisionDBO; + }); + payload.precisions = precisions.map((precision) => precision.id); + + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const actualHousing = await Housing() + .where({ + geo_code: housing.geoCode, + id: housing.id + }) + .first(); + const precisionsBefore = precisions.filter((precision) => + wasPrecision(precision.category) + ); + expect(actualHousing?.precisions).toHaveLength(precisionsBefore.length); + expect(actualHousing?.precisions).toSatisfyAll( + (actualPrecision) => { + const label = actualPrecision.split(' > ').at(-1); + return precisions.some((precision) => precision.label === label); + } + ); + }); + + it('should update the old vacancy reasons', async () => { + const referential = await Precisions(); + // Send one precision from each category + const precisions = PRECISION_CATEGORY_VALUES.map((category) => { + return referential.find( + (precision) => precision.category === category + ) as PrecisionDBO; + }); + payload.precisions = precisions.map((precision) => precision.id); + + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + const actualHousing = await Housing() + .where({ + geo_code: housing.geoCode, + id: housing.id + }) + .first(); + const vacancyReasonsBefore = precisions.filter((precision) => + wasVacancyReason(precision.category) + ); + expect(actualHousing?.vacancy_reasons).toHaveLength( + vacancyReasonsBefore.length + ); + expect(actualHousing?.vacancy_reasons).toSatisfyAll( + (actualPrecision) => { + const label = actualPrecision.split(' > ').at(-1); + return precisions.some((precision) => precision.label === label); + } + ); + }); + + it('should update the new precisions', async () => { + const referential = await Precisions(); + // Send one precision from each category + const precisions = PRECISION_CATEGORY_VALUES.map((category) => { + return referential.find( + (precision) => precision.category === category + ) as PrecisionDBO; + }); + payload.precisions = precisions.map((precision) => precision.id); + + const { status } = await request(app) + .put(testRoute(housing.id)) + .send(payload) + .type('json') + .use(tokenProvider(user)); + + expect(status).toBe(constants.HTTP_STATUS_OK); + + const actualPrecisions = await HousingPrecisions().where({ + housing_geo_code: housing.geoCode, + housing_id: housing.id + }); + expect(actualPrecisions).toIncludeSameMembers( + precisions.map((precision) => ({ + precision_id: precision.id, + housing_geo_code: housing.geoCode, + housing_id: housing.id + })) + ); + }); + it('should not create events if there is no change', async () => { const payload: HousingUpdatePayloadDTO = { status: toHousingStatus(housing.status), diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index 1926b38a1..ba84ffdc6 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -47,6 +47,15 @@ import createDatafoncierHousingRepository from '~/repositories/datafoncierHousin import createDatafoncierOwnersRepository from '~/repositories/datafoncierOwnersRepository'; import fp from 'lodash/fp'; import { startTransaction } from '~/infra/database/transaction'; +import precisionRepository, { + Precisions +} from '~/repositories/precisionRepository'; +import PrecisionMissingError from '~/errors/precisionMissingError'; +import { + toOldPrecision, + wasPrecision, + wasVacancyReason +} from '~/models/PrecisionApi'; interface HousingPathParams extends Record { id: string; @@ -328,42 +337,67 @@ async function updateNext( HousingUpdatePayloadDTO >; - const housing = await housingRepository.findOne({ - id: params.id, - geoCode: establishment.geoCodes, - includes: ['owner'] - }); + const precisionIds = (body.precisions ?? []).concat( + body.vacancyReasons ?? [] + ); + const [housing, precisions] = await Promise.all([ + housingRepository.findOne({ + id: params.id, + geoCode: establishment.geoCodes, + includes: ['owner'] + }), + Precisions().whereIn('id', precisionIds) + ]); if (!housing) { throw new HousingMissingError(params.id); } + if (precisions.length < precisionIds.length) { + throw new PrecisionMissingError(...precisionIds); + } + const deprecatedPrecisions: string[] = precisions + .filter((precision) => wasPrecision(precision.category)) + .map(toOldPrecision); + const deprecatedVacancyReasons: string[] = precisions + .filter((precision) => wasVacancyReason(precision.category)) + .map(toOldPrecision); const updated: HousingApi = { ...housing, status: fromHousingStatus(body.status), subStatus: body.subStatus, - precisions: body.precisions ?? undefined, - vacancyReasons: body.vacancyReasons ?? undefined, + precisions: deprecatedPrecisions?.length ? deprecatedPrecisions : null, + vacancyReasons: deprecatedVacancyReasons?.length + ? deprecatedVacancyReasons + : null, occupancy: body.occupancy, occupancyIntended: body.occupancyIntended ?? undefined }; + await startTransaction(async () => { - await housingRepository.update(updated); - await createHousingUpdateEvents( - housing, - { - statusUpdate: { - status: fromHousingStatus(body.status), - subStatus: body.subStatus, - precisions: body.precisions, - vacancyReasons: body.vacancyReasons + await Promise.all([ + housingRepository.update(updated), + precisionRepository.link(housing, precisions), + createHousingUpdateEvents( + housing, + { + statusUpdate: { + status: fromHousingStatus(body.status), + subStatus: body.subStatus, + precisions: deprecatedPrecisions?.length + ? deprecatedPrecisions + : null, + vacancyReasons: deprecatedVacancyReasons?.length + ? deprecatedVacancyReasons + : null + }, + occupancyUpdate: { + occupancy: body.occupancy, + occupancyIntended: body.occupancyIntended + } }, - occupancyUpdate: { - occupancy: body.occupancy, - occupancyIntended: body.occupancyIntended - } - }, - auth.userId - ); + auth.userId + ) + ]); }); response.status(constants.HTTP_STATUS_OK).json(toHousingDTO(updated)); diff --git a/server/src/models/PrecisionApi.ts b/server/src/models/PrecisionApi.ts index f869f0cbe..1517ecbaa 100644 --- a/server/src/models/PrecisionApi.ts +++ b/server/src/models/PrecisionApi.ts @@ -100,6 +100,14 @@ export const VACANCY_REASON_TRANSITION_MAPPING: Map< 'tiers-en-cause': ['Blocage', 'Tiers en cause'] }); +export function wasPrecision(category: PrecisionCategory): boolean { + return PRECISION_TRANSITION_MAPPING.has(category); +} + +export function wasVacancyReason(category: PrecisionCategory): boolean { + return VACANCY_REASON_TRANSITION_MAPPING.has(category); +} + export function toOldPrecision(precision: PrecisionApi): string { const mapping = PRECISION_TRANSITION_MAPPING.get(precision.category) ?? diff --git a/server/src/repositories/housingRepository.ts b/server/src/repositories/housingRepository.ts index 0e03e2aff..7c37f195f 100644 --- a/server/src/repositories/housingRepository.ts +++ b/server/src/repositories/housingRepository.ts @@ -371,8 +371,10 @@ async function update(housing: HousingApi): Promise { occupancy_intended: housing.occupancyIntended ?? null, status: housing.status, sub_status: housing.subStatus ?? null, - precisions: housing.precisions ?? null, - vacancy_reasons: housing.vacancyReasons ?? null + precisions: housing.precisions?.length ? housing.precisions : null, + vacancy_reasons: housing.vacancyReasons?.length + ? housing.vacancyReasons + : null }); } From 6f8972dda03e28210f58d6dd25c3bc86c5076b48 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 09:44:45 +0100 Subject: [PATCH 12/25] fix(frontend): use only the well formed precisions --- .../src/components/HousingEdition/HousingEditionSideMenu.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index f3cb23e5d..f60c3b54b 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -440,6 +440,9 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { const precisions = housing?.precisions ?.concat(housing?.vacancyReasons ?? []) + // Only keep the well formed precisions and vacancy reasons + // like `Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne` + ?.filter((precision) => precision.split(' > ').length === 3) ?.map((precision) => toNewPrecision(precisionOptions, precision)) ?? []; const mechanisms = precisions.filter((precision) => isPrecisionMechanismCategory(precision.category) From 61fea15e21bda7ba3e5e45c852897dfc8b94bf37 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 10:10:39 +0100 Subject: [PATCH 13/25] feat: replace the hardcoded precision list by a service --- .../HousingEdition/HousingEditionSideMenu.tsx | 287 +----------------- frontend/src/services/api.service.ts | 1 + frontend/src/services/housing.service.ts | 3 +- frontend/src/services/precision.service.ts | 13 + packages/models/src/Precision.ts | 12 +- server/src/models/PrecisionApi.ts | 8 + .../src/repositories/precisionController.ts | 19 ++ .../src/repositories/precisionRepository.ts | 6 + server/src/routers/protected.ts | 3 + 9 files changed, 69 insertions(+), 283 deletions(-) create mode 100644 frontend/src/services/precision.service.ts create mode 100644 server/src/repositories/precisionController.ts diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index f60c3b54b..0a146fd12 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -40,6 +40,7 @@ import { toNewPrecision } from '../../models/Precision'; import createPrecisionModalNext from '../Precision/PrecisionModalNext'; import { useState } from 'react'; import { PrecisionTabId } from '../Precision/PrecisionTabs'; +import { useFindPrecisionsQuery } from '../../services/precision.service'; interface HousingEditionSideMenuProps { housing: Housing | null; @@ -168,282 +169,18 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function MobilisationTab(): ElementOf { - // TODO: implement `GET /precisions` to remove this hardcoded list - const precisionOptions: Precision[] = [ - { - id: 'df989b58-c8a6-4f4d-8240-d69c2b9150ce', - category: 'dispositifs-incitatifs', - label: 'Conventionnement avec travaux' - }, - { - id: '9457efb2-0bde-4964-ab67-3085187855ae', - category: 'dispositifs-incitatifs', - label: 'Conventionnement sans travaux' - }, - { - id: '4fc3cbf2-e564-49ab-bead-85e3be8c2a84', - category: 'dispositifs-incitatifs', - label: 'Aides locales travaux' - }, - { - id: 'f329d2ba-ecde-4e70-904e-ac0137ac8a1b', - category: 'dispositifs-incitatifs', - label: 'Aides à la gestion locative' - }, - { - id: '8905fd8c-364a-4701-ac75-5f8817c454f0', - category: 'dispositifs-incitatifs', - label: 'Intermédiation Locative (IML)' - }, - { - id: 'bca88947-2a67-4bc9-ab72-37eb7b062113', - category: 'dispositifs-incitatifs', - label: 'Dispositif fiscal' - }, - { - id: '5d0e2fde-9653-4f2c-8587-277fe1ad33a0', - category: 'dispositifs-incitatifs', - label: 'Prime locale vacance' - }, - { - id: '53f7b197-6f9b-405f-9808-fa342e335ad5', - category: 'dispositifs-incitatifs', - label: 'Prime vacance France Ruralités' - }, - { - id: 'ad8d2d73-2222-42f8-9bc4-3ba648aa2bbc', - category: 'dispositifs-incitatifs', - label: 'Ma Prime Renov' - }, - { - id: 'f9f7f336-e003-4a2d-b4a5-b2d3d9745215', - category: 'dispositifs-incitatifs', - label: 'Prime Rénovation Globale' - }, - { - id: 'df5118e4-de5b-4cdd-9d0b-1196c58cbb88', - category: 'dispositifs-incitatifs', - label: 'Prime locale rénovation énergétique' - }, - { - id: '252909d6-f61e-42a8-a608-87971ee60602', - category: 'dispositifs-incitatifs', - label: 'Accompagnement à la vente' - }, - { - id: '5165ae82-65d2-4f04-b8c5-ccd063f09178', - category: 'dispositifs-incitatifs', - label: 'Autre' - }, - { - id: '3fc430c3-4748-4523-b188-746134f39355', - category: 'dispositifs-coercitifs', - label: 'ORI - TIROIR' - }, - { - id: '4601da9d-0d20-4870-aaae-b72315bcab79', - category: 'dispositifs-coercitifs', - label: 'Bien sans maître' - }, - { - id: '60cf309b-f186-4abd-a643-11500555edbb', - category: 'dispositifs-coercitifs', - label: 'Abandon manifeste' - }, - { - id: 'da792337-a808-4eb4-85a9-1aa2a712e914', - category: 'dispositifs-coercitifs', - label: 'DIA - préemption' - }, - { - id: '418eee4f-45fb-4ec3-8c85-e48d5c83c64d', - category: 'dispositifs-coercitifs', - label: 'Procédure d’habitat indigne' - }, - { - id: 'a5469c8f-64ef-44db-8315-6bf647039849', - category: 'dispositifs-coercitifs', - label: 'Permis de louer' - }, - { - id: '360eee86-6fe6-4b80-bf1d-789a8c4d7919', - category: 'dispositifs-coercitifs', - label: 'Permis de diviser' - }, - { - id: '99474b59-0f26-4341-8226-29abf540393e', - category: 'dispositifs-coercitifs', - label: 'Autre' - }, - { - id: 'dc3be923-0d7b-4a03-adeb-93a5018e03c9', - category: 'hors-dispositif-public', - label: - 'Accompagné par un professionnel (architecte, agent immobilier, etc.)' - }, - { - id: 'c3645101-df88-49bb-94ef-7b93a99f678d', - category: 'hors-dispositif-public', - label: 'Propriétaire autonome' - }, - { - id: '174dd3df-fad9-4e85-b2f3-ec287c3e32d9', - category: 'blocage-involontaire', - label: 'Mise en location ou vente infructueuse' - }, - { - id: '7d442747-d27c-4621-918c-65f416a68d1d', - category: 'blocage-involontaire', - label: 'Succession difficile, indivision en désaccord' - }, - { - id: '6bec7b77-2f02-47f1-98c6-36aaaeb81faf', - category: 'blocage-involontaire', - label: 'Défaut d’entretien / Nécessité de travaux' - }, - { - id: '94caba4c-4f93-4181-ad64-107acb5149c5', - category: 'blocage-involontaire', - label: 'Problème de financement / Dossier non-éligible' - }, - { - id: '87da78a3-ac64-49a2-a03d-1eeeb568ec84', - category: 'blocage-involontaire', - label: 'Manque de conseils en amont de l’achat' - }, - { - id: '083638a8-6638-44d6-ab63-58ae31d5543e', - category: 'blocage-involontaire', - label: 'En incapacité (âge, handicap, précarité ...)' - }, - { - id: 'dcc49583-3145-4022-a4b5-58f70e846ff8', - category: 'blocage-volontaire', - label: 'Réserve personnelle ou pour une autre personne' - }, - { - id: 'a3291e85-4641-46e3-bcbd-e6776ec5ad9d', - category: 'blocage-volontaire', - label: 'Stratégie de gestion' - }, - { - id: '14144fa6-87f0-4214-be1f-e59ebf62ed6f', - category: 'blocage-volontaire', - label: 'Mauvaise expérience locative' - }, - { - id: '95f74ebc-8aa1-4762-b0bb-a8871769814c', - category: 'blocage-volontaire', - label: 'Montants des travaux perçus comme trop importants' - }, - { - id: '86dff4f5-5eb7-4610-963b-8cc9b29c100b', - category: 'blocage-volontaire', - label: 'Refus catégorique, sans raison' - }, - { - id: '69619020-9614-4c08-8f2f-2d75f22650f0', - category: 'immeuble-environnement', - label: 'Pas d’accès indépendant' - }, - { - id: '2e68a3de-8f1f-49a4-b723-15b6eb6665ca', - category: 'immeuble-environnement', - label: 'Immeuble dégradé' - }, - { - id: '5508da10-c523-432a-9033-d55d830aae48', - category: 'immeuble-environnement', - label: 'Ruine / Immeuble à démolir' - }, - { - id: '131c99a8-d6e6-41cd-a539-80fcd53cadc2', - category: 'immeuble-environnement', - label: 'Nuisances à proximité' - }, - { - id: '06403671-87e9-416f-817b-e6352277ca1d', - category: 'immeuble-environnement', - label: 'Risques Naturels / Technologiques' - }, - { - id: 'ef404dac-5f34-45ce-bc25-46b1c3a9b47c', - category: 'tiers-en-cause', - label: 'Entreprise(s) en défaut' - }, - { - id: '6ddae597-ebcd-4553-9cf3-533fd175f225', - category: 'tiers-en-cause', - label: 'Copropriété en désaccord' - }, - { - id: 'c96e46e4-1062-486c-8e20-17cfbe5d87ae', - category: 'tiers-en-cause', - label: 'Expertise judiciaire' - }, - { - id: '6e090e41-29e9-4156-9eb6-da3842f0bb02', - category: 'tiers-en-cause', - label: 'Autorisation d’urbanisme refusée / Blocage ABF' - }, - { - id: '96790167-2a81-4dbe-8f5a-39dbbdbc6cd5', - category: 'tiers-en-cause', - label: 'Interdiction de location' - }, - { - id: '47ec5b6b-5114-4303-98ee-ce029e17d73c', - category: 'travaux', - label: 'À venir' - }, - { - id: 'e7a3f116-0dc0-4afa-9ecb-c0fed50cdc94', - category: 'travaux', - label: 'En cours' - }, - { - id: 'eb5b46b3-efb0-4ffe-827c-cf94f7b9fc5c', - category: 'travaux', - label: 'Terminés' - }, - { - id: 'e95ae7cb-c916-44a7-97de-49a4c4853fe8', - category: 'occupation', - label: 'À venir' - }, - { - id: '0b799bf9-2803-432d-bc07-8071864fc5ac', - category: 'occupation', - label: 'En cours' - }, - { - id: '9fe7eec0-0eea-4ffb-9058-c284ce99168e', - category: 'occupation', - label: 'Nouvelle occupation' - }, - { - id: 'fee7a6c5-4f71-4844-b92b-7cb4c5441549', - category: 'mutation', - label: 'À venir' - }, - { - id: 'e8781301-966d-40dc-9358-6e1f4af1ea76', - category: 'mutation', - label: 'En cours' - }, - { - id: 'c7b29831-9dec-4e99-9ec9-4998ca99b763', - category: 'mutation', - label: 'Effectuée' - } - ]; + const { data } = useFindPrecisionsQuery(); + + const precisionOptions = data ?? []; const precisions = - housing?.precisions - ?.concat(housing?.vacancyReasons ?? []) - // Only keep the well formed precisions and vacancy reasons - // like `Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne` - ?.filter((precision) => precision.split(' > ').length === 3) - ?.map((precision) => toNewPrecision(precisionOptions, precision)) ?? []; + housing?.precisions?.length && precisionOptions.length + ? housing.precisions + .concat(housing?.vacancyReasons ?? []) + // Only keep the well formed precisions and vacancy reasons + // like `Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne` + .filter((precision) => precision.split(' > ').length === 3) + .map((precision) => toNewPrecision(precisionOptions, precision)) + : []; const mechanisms = precisions.filter((precision) => isPrecisionMechanismCategory(precision.category) ); diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index e61162f45..34c8c001b 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -38,6 +38,7 @@ export const zlvApi = createApi({ 'Note', 'Owner', 'OwnerProspect', + 'Precision', 'Prospect', 'Settings', 'Stats', diff --git a/frontend/src/services/housing.service.ts b/frontend/src/services/housing.service.ts index f0251b509..b1e6edfdb 100644 --- a/frontend/src/services/housing.service.ts +++ b/frontend/src/services/housing.service.ts @@ -146,8 +146,7 @@ export const housingApi = zlvApi.injectEndpoints({ 'occupancyIntended', 'status', 'subStatus', - 'precisions', - 'vacancyReasons' + 'precisions' ], payload ) diff --git a/frontend/src/services/precision.service.ts b/frontend/src/services/precision.service.ts new file mode 100644 index 000000000..9e158257e --- /dev/null +++ b/frontend/src/services/precision.service.ts @@ -0,0 +1,13 @@ +import { Precision } from '@zerologementvacant/models'; +import { zlvApi } from './api.service'; + +export const precisionAPI = zlvApi.injectEndpoints({ + endpoints: (builder) => ({ + findPrecisions: builder.query({ + query: () => 'precisions', + providesTags: () => ['Precision'] + }) + }) +}); + +export const { useFindPrecisionsQuery } = precisionAPI; diff --git a/packages/models/src/Precision.ts b/packages/models/src/Precision.ts index eaadfd057..a33b4708d 100644 --- a/packages/models/src/Precision.ts +++ b/packages/models/src/Precision.ts @@ -1,3 +1,9 @@ +export interface Precision { + id: string; + category: PrecisionCategory; + label: string; +} + export const PRECISION_CATEGORY_VALUES = [ 'dispositifs-incitatifs', 'dispositifs-coercitifs', @@ -46,9 +52,3 @@ export function isPrecisionEvolutionCategory( ): category is (typeof PRECISION_EVOLUTION_CATEGORY_VALUES)[number] { return PRECISION_EVOLUTION_CATEGORY_VALUES.includes(category); } - -export interface Precision { - id: string; - category: PrecisionCategory; - label: string; -} diff --git a/server/src/models/PrecisionApi.ts b/server/src/models/PrecisionApi.ts index 1517ecbaa..6e78b22ec 100644 --- a/server/src/models/PrecisionApi.ts +++ b/server/src/models/PrecisionApi.ts @@ -6,6 +6,14 @@ export interface PrecisionApi extends Precision { order: number; } +export function toPrecisionDTO(precision: PrecisionApi): Precision { + return { + id: precision.id, + label: precision.label, + category: precision.category + }; +} + export const PRECISION_TREE_VALUES: OrderedMap< string, ReadonlyArray diff --git a/server/src/repositories/precisionController.ts b/server/src/repositories/precisionController.ts new file mode 100644 index 000000000..c8edc2ca7 --- /dev/null +++ b/server/src/repositories/precisionController.ts @@ -0,0 +1,19 @@ +import { Request, Response } from 'express'; +import { constants } from 'http2'; + +import { Precision } from '@zerologementvacant/models'; +import precisionRepository from '~/repositories/precisionRepository'; +import { toPrecisionDTO } from '~/models/PrecisionApi'; + +async function find(request: Request, response: Response) { + const precisions = await precisionRepository.find(); + response + .status(constants.HTTP_STATUS_OK) + .json(precisions.map(toPrecisionDTO)); +} + +const precisionController = { + find +}; + +export default precisionController; diff --git a/server/src/repositories/precisionRepository.ts b/server/src/repositories/precisionRepository.ts index b00668493..04e8d6c05 100644 --- a/server/src/repositories/precisionRepository.ts +++ b/server/src/repositories/precisionRepository.ts @@ -21,6 +21,11 @@ export type HousingPrecisionDBO = { precision_id: string; }; +async function find(): Promise { + const precisions = await Precisions().select().orderBy(['category', 'order']); + return precisions; +} + async function link( housing: HousingApi, precisions: ReadonlyArray @@ -45,6 +50,7 @@ async function link( } const precisionRepository = { + find, link }; diff --git a/server/src/routers/protected.ts b/server/src/routers/protected.ts index 6114c9d09..39334002f 100644 --- a/server/src/routers/protected.ts +++ b/server/src/routers/protected.ts @@ -30,6 +30,7 @@ import validatorNext from '~/middlewares/validator-next'; import { paginationSchema } from '~/models/PaginationApi'; import sortApi from '~/models/SortApi'; import { UserRoles } from '~/models/UserApi'; +import precisionController from '~/repositories/precisionController'; const router = Router(); @@ -90,6 +91,8 @@ router.post( housingController.update ); +router.get('/precisions', precisionController.find); + router.get('/groups', groupController.list); router.post( '/groups', From 07b9ccf26c55d8097e35cddd5e40af8d2f21abb0 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 10:30:23 +0100 Subject: [PATCH 14/25] fix(server): fix precision and vacancy reason comparisons --- server/src/controllers/housingController.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index ba84ffdc6..38b82b39c 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -528,12 +528,12 @@ async function createHousingUpdateEvents( (housingApi.status !== statusUpdate.status || housingApi.subStatus !== statusUpdate.subStatus || !_.isEqual( - housingApi.precisions ?? null, - statusUpdate.precisions ?? null + housingApi.precisions?.length ? housingApi.precisions : null, + statusUpdate.precisions?.length ? statusUpdate.precisions : null ) || !_.isEqual( - housingApi.vacancyReasons ?? null, - statusUpdate.vacancyReasons ?? null + housingApi.vacancyReasons?.length ? housingApi.vacancyReasons : null, + statusUpdate.vacancyReasons?.length ? statusUpdate.vacancyReasons : null )) ) { await eventRepository.insertHousingEvent({ From 2dbf8c77d283235a5dff94c4fa49d8e61fa768df Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 10:41:02 +0100 Subject: [PATCH 15/25] fix(frontend): fix type error --- frontend/src/services/precision.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/precision.service.ts b/frontend/src/services/precision.service.ts index 9e158257e..ae6b87090 100644 --- a/frontend/src/services/precision.service.ts +++ b/frontend/src/services/precision.service.ts @@ -3,7 +3,7 @@ import { zlvApi } from './api.service'; export const precisionAPI = zlvApi.injectEndpoints({ endpoints: (builder) => ({ - findPrecisions: builder.query({ + findPrecisions: builder.query({ query: () => 'precisions', providesTags: () => ['Precision'] }) From 312edc1a7adff4bd5b227cee0f07f0d995c1ff08 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 10:56:32 +0100 Subject: [PATCH 16/25] feat(frontend): add extra-large size to the PrecisionModal --- .../Precision/PrecisionModalNext.tsx | 2 +- .../ConfirmationModalNext.tsx | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Precision/PrecisionModalNext.tsx b/frontend/src/components/Precision/PrecisionModalNext.tsx index 2cbefee28..f9034d3f8 100644 --- a/frontend/src/components/Precision/PrecisionModalNext.tsx +++ b/frontend/src/components/Precision/PrecisionModalNext.tsx @@ -33,7 +33,7 @@ function createPrecisionModalNext() { { onSubmit(internalValue); }} diff --git a/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx index ed8b35a13..2ecae6f6d 100644 --- a/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx +++ b/frontend/src/components/modals/ConfirmationModal/ConfirmationModalNext.tsx @@ -1,5 +1,5 @@ import { createModal, ModalProps } from '@codegouvfr/react-dsfr/Modal'; -import { JSX } from 'react'; +import { JSX, useEffect } from 'react'; export type ConfirmationModalOptions = { id: string; @@ -9,7 +9,8 @@ export type ConfirmationModalOptions = { isOpenedByDefault?: boolean; }; -export interface ConfirmationModalProps extends ModalProps { +export interface ConfirmationModalProps extends Omit { + size?: ModalProps['size'] | 'extra-large'; onSubmit?(): void; } @@ -22,6 +23,17 @@ export function createConfirmationModal(options: ConfirmationModalOptions) { return { ...modal, Component(props: ConfirmationModalProps): JSX.Element { + const { size, onSubmit, ...rest } = props; + + useEffect(() => { + if (size === 'extra-large') { + const container = document + .getElementById(options.id) + ?.querySelector('.fr-col-12'); + container?.classList?.remove('fr-col-md-10', 'fr-col-lg-8'); + } + }, [size]); + return ( ); } From 6bb2fc760a5d535620b12cb54e46d48eaa16438e Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 11:19:27 +0100 Subject: [PATCH 17/25] test(frontend): add precision handlers --- frontend/src/mocks/handlers/data.ts | 9 +++++++++ frontend/src/mocks/handlers/index.ts | 4 +++- frontend/src/mocks/handlers/precision-handlers.ts | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 frontend/src/mocks/handlers/precision-handlers.ts diff --git a/frontend/src/mocks/handlers/data.ts b/frontend/src/mocks/handlers/data.ts index 5cc48054b..d4766d086 100644 --- a/frontend/src/mocks/handlers/data.ts +++ b/frontend/src/mocks/handlers/data.ts @@ -11,6 +11,8 @@ import { HousingDTO, NoteDTO, OwnerDTO, + Precision, + PRECISION_CATEGORY_VALUES, ProspectDTO, SignupLinkDTO, UserDTO @@ -108,6 +110,12 @@ const housingOwners = new Map< }) ); +const precisions: Precision[] = PRECISION_CATEGORY_VALUES.map((category) => ({ + id: faker.string.uuid(), + category: category, + label: faker.word.sample() +})); + const housingEvents = new Map[]>(); const housingNotes = new Map(); @@ -131,6 +139,7 @@ const data = { housingNotes, housingOwners, owners, + precisions, prospects, signupLinks, users diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index 7cb49f6c6..e9c10360b 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -10,9 +10,10 @@ import { groupHandlers } from './group-handlers'; import { housingHandlers } from './housing-handlers'; import { noteHandlers } from './note-handlers'; import { ownerHandlers } from './owner-handlers'; +import { precisionHandlers } from './precision-handlers'; +import { prospectHandlers } from './prospect-handlers'; import { signupLinksHandlers } from './signup-links-handlers'; import { userHandlers } from './user-handlers'; -import { prospectHandlers } from './prospect-handlers'; export const handlers: RequestHandler[] = [ ...authHandlers, @@ -25,6 +26,7 @@ export const handlers: RequestHandler[] = [ ...housingHandlers, ...noteHandlers, ...ownerHandlers, + ...precisionHandlers, ...prospectHandlers, ...signupLinksHandlers, ...userHandlers diff --git a/frontend/src/mocks/handlers/precision-handlers.ts b/frontend/src/mocks/handlers/precision-handlers.ts new file mode 100644 index 000000000..45ad93bde --- /dev/null +++ b/frontend/src/mocks/handlers/precision-handlers.ts @@ -0,0 +1,15 @@ +import { http, HttpResponse, RequestHandler } from 'msw'; + +import { Precision } from '@zerologementvacant/models'; +import config from '../../utils/config'; +import data from './data'; + +export const precisionHandlers: RequestHandler[] = [ + // Fetch the referential of precisions + http.get( + `${config.apiEndpoint}/api/precisions`, + async () => { + return HttpResponse.json(data.precisions); + } + ) +]; From d0ce12a5d2f6c6a64b0bc706dc01d531c49a5c5a Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 11:36:41 +0100 Subject: [PATCH 18/25] fix: typo in existing precisions and vacancy reasons --- frontend/src/models/Precision.ts | 9 +- server/src/models/PrecisionApi.ts | 11 +- .../scripts/migrate-precisions/index.test.ts | 137 ++++++++---------- .../src/scripts/migrate-precisions/index.ts | 20 +-- 4 files changed, 85 insertions(+), 92 deletions(-) diff --git a/frontend/src/models/Precision.ts b/frontend/src/models/Precision.ts index e63cd18e4..d79b44a82 100644 --- a/frontend/src/models/Precision.ts +++ b/frontend/src/models/Precision.ts @@ -15,10 +15,11 @@ export const PRECISION_TRANSITION_MAPPING: Map = Map( export const VACANCY_REASON_TRANSITION_MAPPING: Map = Map({ - 'Blocage > Blocage involontaire': 'blocage-involontaire', - 'Blocage > Blocage volontaire': 'blocage-volontaire', - 'Blocage > Immeuble / Environnement': 'immeuble-environnement', - 'Blocage > Tiers en cause': 'tiers-en-cause' + 'Liés au propriétaire > Blocage involontaire': 'blocage-involontaire', + 'Liés au propriétaire > Blocage volontaire': 'blocage-volontaire', + 'Extérieurs au propriétaire > Immeuble / Environnement': + 'immeuble-environnement', + 'Extérieurs au propriétaire > Tiers en cause': 'tiers-en-cause' }); /** diff --git a/server/src/models/PrecisionApi.ts b/server/src/models/PrecisionApi.ts index 6e78b22ec..564dab04b 100644 --- a/server/src/models/PrecisionApi.ts +++ b/server/src/models/PrecisionApi.ts @@ -102,10 +102,13 @@ export const VACANCY_REASON_TRANSITION_MAPPING: Map< PrecisionCategory, ReadonlyArray > = Map({ - 'blocage-involontaire': ['Blocage', 'Blocage involontaire'], - 'blocage-volontaire': ['Blocage', 'Blocage volontaire'], - 'immeuble-environnement': ['Blocage', 'Immeuble / Environnement'], - 'tiers-en-cause': ['Blocage', 'Tiers en cause'] + 'blocage-involontaire': ['Liés au propriétaire', 'Blocage involontaire'], + 'blocage-volontaire': ['Liés au propriétaire', 'Blocage volontaire'], + 'immeuble-environnement': [ + 'Extérieurs au propriétaire', + 'Immeuble / Environnement' + ], + 'tiers-en-cause': ['Extérieurs au propriétaire', 'Tiers en cause'] }); export function wasPrecision(category: PrecisionCategory): boolean { diff --git a/server/src/scripts/migrate-precisions/index.test.ts b/server/src/scripts/migrate-precisions/index.test.ts index df29ab7db..6845b6f74 100644 --- a/server/src/scripts/migrate-precisions/index.test.ts +++ b/server/src/scripts/migrate-precisions/index.test.ts @@ -1,11 +1,13 @@ -import { parse } from 'jsonlines'; +import { parse, stringify } from 'jsonlines'; import fs from 'node:fs'; import path from 'node:path'; -import { Readable, Transform } from 'node:stream'; +import { Readable, Transform, Writable } from 'node:stream'; import { run } from '~/scripts/migrate-precisions/index'; import { WritableStream } from 'node:stream/web'; import { PrecisionDBO, Precisions } from '~/repositories/precisionRepository'; import { List } from 'immutable'; +import { faker } from '@faker-js/faker/locale/fr'; +import { unlink } from 'node:fs/promises'; describe('Migrate precisions', () => { const input = path.join(__dirname, 'input.jsonl'); @@ -138,218 +140,205 @@ describe('Migrate precisions', () => { }, { - from: 'Points de blocage > Blocage involontaire > Mise en location ou vente infructueuse', + from: 'Liés au propriétaire > Blocage involontaire > Mise en location ou vente infructueuse', to: { category: 'blocage-involontaire', label: 'Mise en location ou vente infructueuse' } }, { - from: 'Points de blocage > Blocage involontaire > Succession difficile, indivision en désaccord', + from: 'Liés au propriétaire > Blocage involontaire > Succession difficile, indivision en désaccord', to: { category: 'blocage-involontaire', label: 'Succession difficile, indivision en désaccord' } }, { - from: 'Points de blocage > Blocage involontaire > Défaut d’entretien / Nécessité de travaux', + from: 'Liés au propriétaire > Blocage involontaire > Défaut d’entretien / Nécessité de travaux', to: { category: 'blocage-involontaire', label: 'Défaut d’entretien / Nécessité de travaux' } }, { - from: 'Points de blocage > Blocage involontaire > Problème de financement / Dossier non-éligible', + from: 'Liés au propriétaire > Blocage involontaire > Problème de financement / Dossier non-éligible', to: { category: 'blocage-involontaire', label: 'Problème de financement / Dossier non-éligible' } }, { - from: 'Points de blocage > Blocage involontaire > Manque de conseils en amont de l’achat', + from: 'Liés au propriétaire > Blocage involontaire > Manque de conseils en amont de l’achat', to: { category: 'blocage-involontaire', label: 'Manque de conseils en amont de l’achat' } }, { - from: 'Points de blocage > Blocage involontaire > En incapacité (âge, handicap, précarité ...)', + from: 'Liés au propriétaire > Blocage involontaire > En incapacité (âge, handicap, précarité ...)', to: { category: 'blocage-involontaire', label: 'En incapacité (âge, handicap, précarité ...)' } }, { - from: 'Points de blocage > Blocage volontaire > Réserve personnelle ou pour une autre personne', + from: 'Liés au propriétaire > Blocage volontaire > Réserve personnelle ou pour une autre personne', to: { category: 'blocage-volontaire', label: 'Réserve personnelle ou pour une autre personne' } }, { - from: 'Points de blocage > Blocage volontaire > Stratégie de gestion', + from: 'Liés au propriétaire > Blocage volontaire > Stratégie de gestion', to: { category: 'blocage-volontaire', label: 'Stratégie de gestion' } }, { - from: 'Points de blocage > Blocage volontaire > Mauvaise expérience locative', + from: 'Liés au propriétaire > Blocage volontaire > Mauvaise expérience locative', to: { category: 'blocage-volontaire', label: 'Mauvaise expérience locative' } }, { - from: 'Points de blocage > Blocage volontaire > Montants des travaux perçus comme trop importants', + from: 'Liés au propriétaire > Blocage volontaire > Montants des travaux perçus comme trop importants', to: { category: 'blocage-volontaire', label: 'Montants des travaux perçus comme trop importants' } }, { - from: 'Points de blocage > Blocage volontaire > Refus catégorique, sans raison', + from: 'Liés au propriétaire > Blocage volontaire > Refus catégorique, sans raison', to: { category: 'blocage-volontaire', label: 'Refus catégorique, sans raison' } }, { - from: 'Points de blocage > Immeuble / Environnement > Pas d’accès indépendant', + from: 'Extérieurs au propriétaire > Immeuble / Environnement > Pas d’accès indépendant', to: { category: 'immeuble-environnement', label: 'Pas d’accès indépendant' } }, { - from: 'Points de blocage > Immeuble / Environnement > Immeuble dégradé', + from: 'Extérieurs au propriétaire > Immeuble / Environnement > Immeuble dégradé', to: { category: 'immeuble-environnement', label: 'Immeuble dégradé' } }, { - from: 'Points de blocage > Immeuble / Environnement > Ruine / Immeuble à démolir', + from: 'Extérieurs au propriétaire > Immeuble / Environnement > Ruine / Immeuble à démolir', to: { category: 'immeuble-environnement', label: 'Ruine / Immeuble à démolir' } }, { - from: 'Points de blocage > Immeuble / Environnement > Nuisances à proximité', + from: 'Extérieurs au propriétaire > Immeuble / Environnement > Nuisances à proximité', to: { category: 'immeuble-environnement', label: 'Nuisances à proximité' } }, { - from: 'Points de blocage > Immeuble / Environnement > Risques Naturels / Technologiques', + from: 'Extérieurs au propriétaire > Immeuble / Environnement > Risques Naturels / Technologiques', to: { category: 'immeuble-environnement', label: 'Risques Naturels / Technologiques' } }, { - from: 'Points de blocage > Tiers en cause > Entreprise(s) en défaut', + from: 'Extérieurs au propriétaire > Tiers en cause > Entreprise(s) en défaut', to: { category: 'tiers-en-cause', label: 'Entreprise(s) en défaut' } }, { - from: 'Points de blocage > Tiers en cause > Copropriété en désaccord', + from: 'Extérieurs au propriétaire > Tiers en cause > Copropriété en désaccord', to: { category: 'tiers-en-cause', label: 'Copropriété en désaccord' } }, { - from: 'Points de blocage > Tiers en cause > Expertise judiciaire', + from: 'Extérieurs au propriétaire > Tiers en cause > Expertise judiciaire', to: { category: 'tiers-en-cause', label: 'Expertise judiciaire' } }, { - from: 'Points de blocage > Tiers en cause > Autorisation d’urbanisme refusée / Blocage ABF', + from: 'Extérieurs au propriétaire > Tiers en cause > Autorisation d’urbanisme refusée / Blocage ABF', to: { category: 'tiers-en-cause', label: 'Autorisation d’urbanisme refusée / Blocage ABF' } }, { - from: 'Points de blocage > Tiers en cause > Interdiction de location', + from: 'Extérieurs au propriétaire > Tiers en cause > Interdiction de location', to: { category: 'tiers-en-cause', label: 'Interdiction de location' } }, { - from: 'Évolutions du logement > Travaux > À venir', + from: 'Mode opératoire > Travaux > À venir', to: { category: 'travaux', label: 'À venir' } }, { - from: 'Évolutions du logement > Travaux > En cours', + from: 'Mode opératoire > Travaux > En cours', to: { category: 'travaux', label: 'En cours' } }, { - from: 'Évolutions du logement > Travaux > Terminés', + from: 'Mode opératoire > Travaux > Terminés', to: { category: 'travaux', label: 'Terminés' } }, { - from: 'Évolutions du logement > Occupation > À venir', + from: 'Mode opératoire > Occupation > À venir', to: { category: 'occupation', label: 'À venir' } }, { - from: 'Évolutions du logement > Occupation > En cours', + from: 'Mode opératoire > Occupation > En cours', to: { category: 'occupation', label: 'En cours' } }, { - from: 'Évolutions du logement > Occupation > Nouvelle occupation', + from: 'Mode opératoire > Occupation > Nouvelle occupation', to: { category: 'occupation', label: 'Nouvelle occupation' } }, { - from: 'Évolutions du logement > Mutation > À venir', + from: 'Mode opératoire > Mutation > À venir', to: { category: 'mutation', label: 'À venir' } }, { - from: 'Évolutions du logement > Mutation > En cours', + from: 'Mode opératoire > Mutation > En cours', to: { category: 'mutation', label: 'En cours' } }, { - from: 'Évolutions du logement > Mutation > Effectuée', + from: 'Mode opératoire > Mutation > Effectuée', to: { category: 'mutation', label: 'Effectuée' } } ]; - let housings: ReadonlyArray<{ - geo_code: string; - id: string; - precisions: string[]; - }>; - // const housings = Array.from( - // { - // length: faker.number.int({ min: 1_000, max: 10_000 }) - // }, - // () => { - // const precisions = faker.helpers - // .arrayElements(faker.helpers.arrayElements(mapping, { min: 1, max: 4 })) - // .map((precision) => precision.from); - // return { - // geo_code: faker.location.zipCode(), - // id: faker.string.uuid(), - // precisions: precisions - // }; - // } - // ).concat({ - // geo_code: faker.location.zipCode(), - // id: faker.string.uuid(), - // precisions: [ - // 'Étude faisabilité', - // 'Demande de pièces', - // 'Informations transmises - Encore à convaincre', - // 'Informations transmises - rendez-vous à fixer', - // "N'est plus un logement" - // ] - // }); + const housings = Array.from( + { + length: faker.number.int({ min: 1_000, max: 10_000 }) + }, + () => { + const precisions = faker.helpers + .arrayElements(faker.helpers.arrayElements(mapping, { min: 1, max: 4 })) + .map((precision) => precision.from); + return { + geo_code: faker.location.zipCode(), + id: faker.string.uuid(), + precisions: precisions + }; + } + ).concat({ + geo_code: faker.location.zipCode(), + id: faker.string.uuid(), + precisions: [ + 'Étude faisabilité', + 'Demande de pièces', + 'Informations transmises - Encore à convaincre', + 'Informations transmises - rendez-vous à fixer', + "N'est plus un logement" + ] + }); beforeAll(async () => { - housings = (await fs - .createReadStream(input, 'utf8') - .pipe(parse()) - .toArray()) as ReadonlyArray<{ - geo_code: string; - id: string; - precisions: string[]; - }>; - // await ReadableStream.from(housings) - // .pipeThrough(Transform.toWeb(stringify())) - // .pipeTo(Writable.toWeb(fs.createWriteStream(input, 'utf8'))); + await ReadableStream.from(housings) + .pipeThrough(Transform.toWeb(stringify())) + .pipeTo(Writable.toWeb(fs.createWriteStream(input, 'utf8'))); }); afterAll(async () => { try { - // await Promise.all([unlink(input), unlink(output)]); + await Promise.all([unlink(input), unlink(output)]); } catch { console.log(`No file ${input} found. Skipping clean up...`); } diff --git a/server/src/scripts/migrate-precisions/index.ts b/server/src/scripts/migrate-precisions/index.ts index a61242709..5776c6995 100644 --- a/server/src/scripts/migrate-precisions/index.ts +++ b/server/src/scripts/migrate-precisions/index.ts @@ -57,10 +57,12 @@ function mapFilter(precisions: ReadonlyArray) { 'Mode opératoire > Travaux': 'travaux', 'Mode opératoire > Occupation': 'occupation', 'Mode opératoire > Mutation': 'mutation', - 'Blocage > Blocage involontaire': 'blocage-involontaire', - 'Blocage > Blocage volontaire': 'blocage-volontaire', - 'Blocage > Immeuble / Environnement': 'immeuble-environnement', - 'Blocage > Tiers en cause': 'tiers-en-cause' + 'Liés au propriétaire > Blocage involontaire': + 'blocage-involontaire', + 'Liés au propriétaire > Blocage volontaire': 'blocage-volontaire', + 'Extérieurs au propriétaire > Immeuble / Environnement': + 'immeuble-environnement', + 'Extérieurs au propriétaire > Tiers en cause': 'tiers-en-cause' }); const categoryAfter = mapping.get(categoryBefore); if (!categoryAfter) { @@ -104,9 +106,7 @@ class PrecisionNotFoundError extends Error { } } -run() - // .then(() => process.exit(0)) - .catch((error) => { - console.error(error); - process.exit(1); - }); +run().catch((error) => { + console.error(error); + process.exit(1); +}); From 192fc5534183d391f9d4fc0180d006bd5896e3d1 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Fri, 24 Jan 2025 12:25:26 +0100 Subject: [PATCH 19/25] fix: send null values instead of empty strings --- .../HousingEdition/HousingEditionSideMenu.tsx | 29 ++++++++++--------- .../components/HousingList/HousingList.tsx | 8 +++-- frontend/src/services/housing.service.ts | 18 +++++------- packages/models/src/HousingDTO.ts | 1 - .../schemas/src/housing-update-payload.ts | 8 ----- .../src/test/housing-update-payload.test.ts | 5 ---- .../src/controllers/housingController.test.ts | 6 +--- server/src/controllers/housingController.ts | 4 +-- 8 files changed, 29 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index 0a146fd12..b50c8646d 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -81,7 +81,7 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { occupancy: props.housing?.occupancy ?? Occupancy.UNKNOWN, occupancyIntended: props.housing?.occupancyIntended ?? Occupancy.UNKNOWN, status: props.housing?.status ?? HousingStatus.NEVER_CONTACTED, - subStatus: props.housing?.subStatus ?? '', + subStatus: props.housing?.subStatus ?? null, note: '' }, mode: 'onSubmit', @@ -114,6 +114,18 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } }); + const { data } = useFindPrecisionsQuery(); + const precisionOptions = data ?? []; + const precisions = + housing?.precisions?.length && precisionOptions.length + ? housing.precisions + .concat(housing?.vacancyReasons ?? []) + // Only keep the well formed precisions and vacancy reasons + // like `Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne` + .filter((precision) => precision.split(' > ').length === 3) + .map((precision) => toNewPrecision(precisionOptions, precision)) + : []; + function submit() { if (housing) { const { note, ...payload } = form.getValues(); @@ -129,7 +141,8 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { occupancy: payload.occupancy as Occupancy, occupancyIntended: payload.occupancyIntended as Occupancy | null, status: payload.status as HousingStatus, - subStatus: payload.subStatus + subStatus: payload.subStatus, + precisions: precisions.map((p) => p.id) }); } @@ -169,18 +182,6 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function MobilisationTab(): ElementOf { - const { data } = useFindPrecisionsQuery(); - - const precisionOptions = data ?? []; - const precisions = - housing?.precisions?.length && precisionOptions.length - ? housing.precisions - .concat(housing?.vacancyReasons ?? []) - // Only keep the well formed precisions and vacancy reasons - // like `Dispositifs > Dispositifs incitatifs > Réserve personnelle ou pour une autre personne` - .filter((precision) => precision.split(' > ').length === 3) - .map((precision) => toNewPrecision(precisionOptions, precision)) - : []; const mechanisms = precisions.filter((precision) => isPrecisionMechanismCategory(precision.category) ); diff --git a/frontend/src/components/HousingList/HousingList.tsx b/frontend/src/components/HousingList/HousingList.tsx index 6b009e933..0daa2ae8e 100644 --- a/frontend/src/components/HousingList/HousingList.tsx +++ b/frontend/src/components/HousingList/HousingList.tsx @@ -59,7 +59,7 @@ const HousingList = ({ const [pagination, setPagination] = useState(DefaultPagination); const [sort, setSort] = useState(); - const [updatingHousing, setUpdatingHousing] = useState(null); + const [updatingHousing, setUpdatingHousing] = useState(null); const { housingList } = useHousingList({ filters, @@ -243,7 +243,7 @@ const HousingList = ({ title="Mettre à jour" size="small" priority="secondary" - onClick={() => setUpdatingHousing(housing)} + onClick={() => setUpdatingHousing(housing.id)} > Mettre à jour @@ -337,7 +337,9 @@ const HousingList = ({ )} housing.id === updatingHousing) ?? null + } expand={!!updatingHousing} onClose={() => { setUpdatingHousing(null); diff --git a/frontend/src/services/housing.service.ts b/frontend/src/services/housing.service.ts index b1e6edfdb..b285f0fa3 100644 --- a/frontend/src/services/housing.service.ts +++ b/frontend/src/services/housing.service.ts @@ -14,7 +14,6 @@ import { import { parseOwner } from './owner.service'; import { HousingCount } from '../models/HousingCount'; import { zlvApi } from './api.service'; -import fp from 'lodash/fp'; export interface FindOptions extends PaginationOptions, @@ -140,16 +139,13 @@ export const housingApi = zlvApi.injectEndpoints({ query: ({ id, ...payload }) => ({ url: `housing/${id}`, method: 'PUT', - body: fp.pick( - [ - 'occupancy', - 'occupancyIntended', - 'status', - 'subStatus', - 'precisions' - ], - payload - ) + body: { + occupancy: payload.occupancy, + occupancyIntended: payload.occupancyIntended ?? null, + status: payload.status, + subStatus: payload.subStatus?.length ? payload.subStatus : null, + precisions: payload.precisions?.length ? payload.precisions : null + } satisfies HousingUpdatePayloadDTO }), invalidatesTags: (result, error, payload) => [ { type: 'Housing', id: payload.id }, diff --git a/packages/models/src/HousingDTO.ts b/packages/models/src/HousingDTO.ts index 58e93a8a5..87d1c6d38 100644 --- a/packages/models/src/HousingDTO.ts +++ b/packages/models/src/HousingDTO.ts @@ -51,7 +51,6 @@ export type HousingUpdatePayloadDTO = // Optional, nullable keys subStatus?: string | null; precisions?: string[] | null; - vacancyReasons?: string[] | null; occupancyIntended?: Occupancy | null; }; diff --git a/packages/schemas/src/housing-update-payload.ts b/packages/schemas/src/housing-update-payload.ts index 7d872061b..d03cf7180 100644 --- a/packages/schemas/src/housing-update-payload.ts +++ b/packages/schemas/src/housing-update-payload.ts @@ -25,14 +25,6 @@ export const housingUpdatePayload: ObjectSchema = .transform((value) => Array.isArray(value) && value.length === 0 ? null : value ), - vacancyReasons: array() - .of(string().trim().required()) - .nullable() - .optional() - .default(null) - .transform((value) => - Array.isArray(value) && value.length === 0 ? null : value - ), occupancyIntended: string() .oneOf(OCCUPANCY_VALUES) .nullable() diff --git a/packages/schemas/src/test/housing-update-payload.test.ts b/packages/schemas/src/test/housing-update-payload.test.ts index f07561426..bb6aa3d5f 100644 --- a/packages/schemas/src/test/housing-update-payload.test.ts +++ b/packages/schemas/src/test/housing-update-payload.test.ts @@ -21,11 +21,6 @@ describe('Housing update payload', () => { fc.constant(null), fc.constant(undefined) ), - vacancyReasons: fc.oneof( - fc.array(fc.stringMatching(/\S/), { minLength: 1 }), - fc.constant(null), - fc.constant(undefined) - ), occupancyIntended: fc.oneof( fc.constantFrom(...OCCUPANCY_VALUES), fc.constant(null), diff --git a/server/src/controllers/housingController.test.ts b/server/src/controllers/housingController.test.ts index 5b3a0e157..7f07ed966 100644 --- a/server/src/controllers/housingController.test.ts +++ b/server/src/controllers/housingController.test.ts @@ -568,7 +568,6 @@ describe('Housing API', () => { ), subStatus: null, precisions: null, - vacancyReasons: null, occupancy: faker.helpers.arrayElement( OCCUPANCY_VALUES.filter( (occupancy) => occupancy !== housing.occupancy @@ -627,7 +626,6 @@ describe('Housing API', () => { status: payload.status, subStatus: null, precisions: payload.precisions, - vacancyReasons: payload.vacancyReasons, occupancy: payload.occupancy, occupancyIntended: payload.occupancyIntended }); @@ -647,7 +645,6 @@ describe('Housing API', () => { status: payload.status as unknown as HousingStatusApi.Blocked, sub_status: null, precisions: payload.precisions, - vacancy_reasons: payload.vacancyReasons, occupancy: payload.occupancy, occupancy_intended: payload.occupancyIntended }); @@ -762,8 +759,7 @@ describe('Housing API', () => { subStatus: housing.subStatus, occupancy: housing.occupancy, occupancyIntended: housing.occupancyIntended, - precisions: housing.precisions, - vacancyReasons: housing.vacancyReasons + precisions: housing.precisions }; const { status } = await request(app) diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index 38b82b39c..e1a48a452 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -337,9 +337,7 @@ async function updateNext( HousingUpdatePayloadDTO >; - const precisionIds = (body.precisions ?? []).concat( - body.vacancyReasons ?? [] - ); + const precisionIds = body.precisions ?? []; const [housing, precisions] = await Promise.all([ housingRepository.findOne({ id: params.id, From 2000d40b015a2000b9e7e23cee9c09c8c1c19120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Guillois?= Date: Thu, 30 Jan 2025 10:38:09 +0100 Subject: [PATCH 20/25] fix: UI adjustments and add 'view more' button --- .../HousingDetailsSubCardMobilisation.tsx | 4 +- .../housing-details-card.module.scss | 4 + .../HousingEdition/HousingEditionSideMenu.tsx | 110 ++++++++++++++---- .../housing-edition.module.scss | 9 +- .../components/Precision/PrecisionTabs.tsx | 4 +- .../Precision/precision-modal.module.scss | 3 + 6 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/Precision/precision-modal.module.scss diff --git a/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx b/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx index 2454d5659..af9625f53 100644 --- a/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx +++ b/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx @@ -74,7 +74,7 @@ function HousingDetailsCardMobilisation({ housing, campaigns }: Props) { <>Aucun dispositif indiqué ) : ( housing.precisions?.map((precision, index) => ( - + {precision.startsWith('Dispositif') ? precision.split(OptionTreeSeparator).reverse()[0] : precision @@ -97,7 +97,7 @@ function HousingDetailsCardMobilisation({ housing, campaigns }: Props) { housing.vacancyReasons?.map((vacancyReason, index) => ( {vacancyReason.split(OptionTreeSeparator).reverse()[0]} diff --git a/frontend/src/components/HousingDetails/housing-details-card.module.scss b/frontend/src/components/HousingDetails/housing-details-card.module.scss index f849d9c4f..d88a85a96 100644 --- a/frontend/src/components/HousingDetails/housing-details-card.module.scss +++ b/frontend/src/components/HousingDetails/housing-details-card.module.scss @@ -45,3 +45,7 @@ .titleInline { display: flex; } + +.tag { + margin: 8px; +} diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index b50c8646d..dbf5cba72 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -10,6 +10,7 @@ import { fromJS } from 'immutable'; import { FormProvider, useController, useForm } from 'react-hook-form'; import { ElementOf } from 'ts-essentials'; import * as yup from 'yup'; +import styles from './housing-edition.module.scss'; import { HOUSING_STATUS_VALUES, @@ -50,6 +51,7 @@ interface HousingEditionSideMenuProps { } const WIDTH = '700px'; +const DISPLAY_TAGS = 6; const schema = yup.object({ occupancy: yup @@ -76,6 +78,10 @@ const precisionModal = createPrecisionModalNext(); function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { const { housing, expand, onClose } = props; + const [showAllMechanisms, setShowAllMechanisms] = useState(false); + const [showAllBlockingPoints, setShowAllBlockingPoints] = useState(false); + const [showAllEvolutions, setShowAllEvolutions] = useState(false); + const form = useForm({ values: { occupancy: props.housing?.occupancy ?? Occupancy.UNKNOWN, @@ -158,6 +164,27 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { form.reset(); } + interface UseFilteredPrecisionsResult { + totalCount: number; + filteredItems: Precision[]; + remainingCount: number; + } + + function useFilteredPrecisions( + categoryFilter: (category: "dispositifs-incitatifs" | "dispositifs-coercitifs" | "hors-dispositif-public" | "blocage-involontaire" | "blocage-volontaire" | "immeuble-environnement" | "tiers-en-cause" | "travaux" | "occupation" | "mutation") => boolean, + showAll: boolean + ): UseFilteredPrecisionsResult { + const allItems = precisions.filter((precision) => + categoryFilter(precision.category) + ); + + return { + totalCount: allItems.length, + filteredItems: allItems.slice(0, showAll ? allItems.length : DISPLAY_TAGS), + remainingCount: Math.max(0, allItems.length - DISPLAY_TAGS), + }; + } + function OccupationTab(): ElementOf { return { content: ( @@ -182,15 +209,33 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function MobilisationTab(): ElementOf { - const mechanisms = precisions.filter((precision) => - isPrecisionMechanismCategory(precision.category) - ); - const blockingPoints = precisions.filter((precision) => - isPrecisionBlockingPointCategory(precision.category) - ); - const evolutions = precisions.filter((precision) => - isPrecisionEvolutionCategory(precision.category) - ); + + const { + totalCount: totalMechanisms, + filteredItems: filteredMechanisms, + remainingCount: moreMechanisms, + } = useFilteredPrecisions(isPrecisionMechanismCategory, showAllMechanisms); + + const { + totalCount: totalBlockingPoints, + filteredItems: filteredBlockingPoints, + remainingCount: moreBlockingPoints, + } = useFilteredPrecisions(isPrecisionBlockingPointCategory, showAllBlockingPoints); + + const { + totalCount: totalEvolutions, + filteredItems: filteredEvolutions, + remainingCount: moreEvolutions, + } = useFilteredPrecisions(isPrecisionEvolutionCategory, showAllEvolutions); + + interface ToggleShowAllProps { + setShowAll: React.Dispatch>; + } + + function toggleShowAll({ setShowAll }: ToggleShowAllProps): void { + setShowAll((prev) => !prev); + } + const [tab, setTab] = useState('dispositifs'); const { field: statusField, fieldState: statusFieldState } = @@ -211,6 +256,8 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } } + const subStatusDisabled = getSubStatusOptions(statusField.value as HousingStatus) === undefined; + return { content: ( @@ -235,13 +282,11 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { }} /> - Dispositifs ({mechanisms.length}) + Dispositifs ({totalMechanisms}) + + )} - Points de blocages ({blockingPoints.length}) + Points de blocages ({totalBlockingPoints}) + + )} - Évolutions du logement ({evolutions.length}) + Évolutions du logement ({totalEvolutions}) + + )} - + {columnProps.title} => ({ label: option.label, diff --git a/frontend/src/components/Precision/precision-modal.module.scss b/frontend/src/components/Precision/precision-modal.module.scss new file mode 100644 index 000000000..1ef1e2994 --- /dev/null +++ b/frontend/src/components/Precision/precision-modal.module.scss @@ -0,0 +1,3 @@ +.icon { + color: var(--blue-france-113); +} From e4fd76891b6e44bac48bebd36ce29319166bd162 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 3 Feb 2025 11:32:56 +0100 Subject: [PATCH 21/25] refactor(frontend): import PrecisionCategory; replace px by rem --- .../HousingDetailsSubCardMobilisation.tsx | 4 +- .../housing-details-card.module.scss | 4 - .../HousingEdition/HousingEditionSideMenu.tsx | 78 +++++++++++++------ .../housing-edition.module.scss | 4 +- .../components/Precision/PrecisionTabs.tsx | 9 ++- 5 files changed, 67 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx b/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx index af9625f53..28f878efa 100644 --- a/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx +++ b/frontend/src/components/HousingDetails/HousingDetailsSubCardMobilisation.tsx @@ -74,7 +74,7 @@ function HousingDetailsCardMobilisation({ housing, campaigns }: Props) { <>Aucun dispositif indiqué ) : ( housing.precisions?.map((precision, index) => ( - + {precision.startsWith('Dispositif') ? precision.split(OptionTreeSeparator).reverse()[0] : precision @@ -97,7 +97,7 @@ function HousingDetailsCardMobilisation({ housing, campaigns }: Props) { housing.vacancyReasons?.map((vacancyReason, index) => ( {vacancyReason.split(OptionTreeSeparator).reverse()[0]} diff --git a/frontend/src/components/HousingDetails/housing-details-card.module.scss b/frontend/src/components/HousingDetails/housing-details-card.module.scss index d88a85a96..f849d9c4f 100644 --- a/frontend/src/components/HousingDetails/housing-details-card.module.scss +++ b/frontend/src/components/HousingDetails/housing-details-card.module.scss @@ -45,7 +45,3 @@ .titleInline { display: flex; } - -.tag { - margin: 8px; -} diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index dbf5cba72..6b0d98789 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -20,7 +20,8 @@ import { isPrecisionMechanismCategory, Occupancy, OCCUPANCY_VALUES, - Precision + Precision, + PrecisionCategory } from '@zerologementvacant/models'; import { Housing, HousingUpdate } from '../../models/Housing'; import AppLink from '../_app/AppLink/AppLink'; @@ -39,7 +40,7 @@ import { useUpdateHousingNextMutation } from '../../services/housing.service'; import { useNotification } from '../../hooks/useNotification'; import { toNewPrecision } from '../../models/Precision'; import createPrecisionModalNext from '../Precision/PrecisionModalNext'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { PrecisionTabId } from '../Precision/PrecisionTabs'; import { useFindPrecisionsQuery } from '../../services/precision.service'; @@ -171,17 +172,20 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function useFilteredPrecisions( - categoryFilter: (category: "dispositifs-incitatifs" | "dispositifs-coercitifs" | "hors-dispositif-public" | "blocage-involontaire" | "blocage-volontaire" | "immeuble-environnement" | "tiers-en-cause" | "travaux" | "occupation" | "mutation") => boolean, - showAll: boolean - ): UseFilteredPrecisionsResult { + categoryFilter: (category: PrecisionCategory) => boolean, + showAll: boolean + ): UseFilteredPrecisionsResult { const allItems = precisions.filter((precision) => categoryFilter(precision.category) ); return { totalCount: allItems.length, - filteredItems: allItems.slice(0, showAll ? allItems.length : DISPLAY_TAGS), - remainingCount: Math.max(0, allItems.length - DISPLAY_TAGS), + filteredItems: allItems.slice( + 0, + showAll ? allItems.length : DISPLAY_TAGS + ), + remainingCount: Math.max(0, allItems.length - DISPLAY_TAGS) }; } @@ -209,23 +213,25 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } function MobilisationTab(): ElementOf { - const { totalCount: totalMechanisms, filteredItems: filteredMechanisms, - remainingCount: moreMechanisms, + remainingCount: moreMechanisms } = useFilteredPrecisions(isPrecisionMechanismCategory, showAllMechanisms); const { totalCount: totalBlockingPoints, filteredItems: filteredBlockingPoints, - remainingCount: moreBlockingPoints, - } = useFilteredPrecisions(isPrecisionBlockingPointCategory, showAllBlockingPoints); + remainingCount: moreBlockingPoints + } = useFilteredPrecisions( + isPrecisionBlockingPointCategory, + showAllBlockingPoints + ); const { totalCount: totalEvolutions, filteredItems: filteredEvolutions, - remainingCount: moreEvolutions, + remainingCount: moreEvolutions } = useFilteredPrecisions(isPrecisionEvolutionCategory, showAllEvolutions); interface ToggleShowAllProps { @@ -256,7 +262,8 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { } } - const subStatusDisabled = getSubStatusOptions(statusField.value as HousingStatus) === undefined; + const subStatusDisabled = + getSubStatusOptions(statusField.value as HousingStatus) === undefined; return { content: ( @@ -326,13 +333,22 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { {filteredMechanisms.map((precision) => ( - {precision.label} + + {precision.label} + ))} {moreMechanisms > 0 && ( - )} @@ -371,13 +387,22 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { {filteredBlockingPoints.map((precision) => ( - {precision.label} + + {precision.label} + ))} {moreBlockingPoints > 0 && ( - )} @@ -412,13 +437,22 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { {filteredEvolutions.map((precision) => ( - {precision.label} + + {precision.label} + ))} {moreEvolutions > 0 && ( - )} diff --git a/frontend/src/components/HousingEdition/housing-edition.module.scss b/frontend/src/components/HousingEdition/housing-edition.module.scss index 516f76230..8c46115c2 100644 --- a/frontend/src/components/HousingEdition/housing-edition.module.scss +++ b/frontend/src/components/HousingEdition/housing-edition.module.scss @@ -1,4 +1,4 @@ .tag { - margin-right: 8px; - margin-bottom: 8px; + margin-right: 0.5rem; + margin-bottom: 0.5rem; } diff --git a/frontend/src/components/Precision/PrecisionTabs.tsx b/frontend/src/components/Precision/PrecisionTabs.tsx index 6c76b429e..d99954364 100644 --- a/frontend/src/components/Precision/PrecisionTabs.tsx +++ b/frontend/src/components/Precision/PrecisionTabs.tsx @@ -9,6 +9,7 @@ import { ElementOf } from 'ts-essentials'; import styles from './precision-modal.module.scss'; import { Precision, PrecisionCategory } from '@zerologementvacant/models'; +import classNames from 'classnames'; interface PrecisionTabs { tab: PrecisionTabId; @@ -54,11 +55,15 @@ function PrecisionTabs(props: PrecisionTabs) { return ( <> - + {columnProps.title} => ({ label: option.label, From 6ddd37402502c2691f4930f88883a752b42e4cea Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 3 Feb 2025 12:49:16 +0100 Subject: [PATCH 22/25] fix(frontend): replace housing evolution checkboxes by radio buttons --- .../components/Precision/PrecisionTabs.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Precision/PrecisionTabs.tsx b/frontend/src/components/Precision/PrecisionTabs.tsx index d99954364..bb6c766e7 100644 --- a/frontend/src/components/Precision/PrecisionTabs.tsx +++ b/frontend/src/components/Precision/PrecisionTabs.tsx @@ -1,5 +1,6 @@ import { fr, FrIconClassName, RiIconClassName } from '@codegouvfr/react-dsfr'; import Checkbox, { CheckboxProps } from '@codegouvfr/react-dsfr/Checkbox'; +import RadioButtons from '@codegouvfr/react-dsfr/RadioButtons'; import Tabs from '@codegouvfr/react-dsfr/Tabs'; import Grid from '@mui/material/Unstable_Grid2'; import Typography from '@mui/material/Typography'; @@ -34,10 +35,20 @@ function PrecisionTabs(props: PrecisionTabs) { function onChange(event: ChangeEvent) { if (event.target.checked) { - const precision = props.options.find( + const option = props.options.find( (option) => option.id === event.target.value - ); - props.onChange([...props.value, precision as Precision]); + ) as Precision; + + if (event.target.type === 'radio') { + props.onChange( + props.value + // Remove mutually exclusive options + .filter((selected) => selected.category !== option.category) + .concat(option) + ); + } else { + props.onChange([...props.value, option as Precision]); + } } else { props.onChange( props.value.filter((precision) => precision.id !== event.target.value) @@ -49,9 +60,15 @@ function PrecisionTabs(props: PrecisionTabs) { category: PrecisionCategory; icon: FrIconClassName | RiIconClassName; title: string; + /** + * @default 'checkbox' + */ + input?: 'checkbox' | 'radio'; } function PrecisionColumn(columnProps: PrecisionColumnProps) { + const Fieldset = columnProps.input === 'radio' ? RadioButtons : Checkbox; + return ( <> @@ -63,7 +80,7 @@ function PrecisionTabs(props: PrecisionTabs) { /> {columnProps.title} - => ({ label: option.label, @@ -165,6 +182,7 @@ function PrecisionTabs(props: PrecisionTabs) { @@ -173,6 +191,7 @@ function PrecisionTabs(props: PrecisionTabs) { @@ -181,6 +200,7 @@ function PrecisionTabs(props: PrecisionTabs) { From d888117e1203853f8617fb96ff597f8cd1cfa6c8 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 3 Feb 2025 13:04:06 +0100 Subject: [PATCH 23/25] feat(frontend): specify the precision category for housing evolutions --- .../components/HousingEdition/HousingEditionSideMenu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index 6b0d98789..02446b7b3 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -7,6 +7,7 @@ import Grid from '@mui/material/Unstable_Grid2'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { fromJS } from 'immutable'; +import fp from 'lodash/fp'; import { FormProvider, useController, useForm } from 'react-hook-form'; import { ElementOf } from 'ts-essentials'; import * as yup from 'yup'; @@ -411,7 +412,7 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { {filteredEvolutions.map((precision) => ( - {precision.label} + {fp.startCase(precision.category.replace('-', ' '))} :  + {precision.label.toLowerCase()} ))} From 2c7498f6fe6f7413d230bf84526004cca56f7670 Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Mon, 3 Feb 2025 16:47:47 +0100 Subject: [PATCH 24/25] test(server): fix precision repository test --- server/src/repositories/test/precisionRepository.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/test/precisionRepository.test.ts b/server/src/repositories/test/precisionRepository.test.ts index 7cc43894e..e7c91f3bb 100644 --- a/server/src/repositories/test/precisionRepository.test.ts +++ b/server/src/repositories/test/precisionRepository.test.ts @@ -10,12 +10,14 @@ import { formatHousingRecordApi, Housing } from '~/repositories/housingRepository'; +import { HousingApi } from '~/models/HousingApi'; describe('Precision repository', () => { describe('link', () => { - const housing = genHousingApi(); + let housing: HousingApi; - beforeAll(async () => { + beforeEach(async () => { + housing = genHousingApi(); await Housing().insert(formatHousingRecordApi(housing)); }); From d477f25d564bbc8bbb7ef542b770b6ae70084e0c Mon Sep 17 00:00:00 2001 From: Andrea Gueugnaut Date: Tue, 4 Feb 2025 15:43:15 +0100 Subject: [PATCH 25/25] fix(frontend): fix select opacity on Chrome; add an empty option --- frontend/src/App.scss | 1 + .../HousingEdition/HousingEditionSideMenu.tsx | 1 - .../HousingEdition/HousingStatusSelect.tsx | 9 +++- .../_app/AppSelect/AppSelectNext.tsx | 42 ++++++++++++++----- .../AppSelect/app-select-next.module.scss | 7 ++++ frontend/src/reset.scss | 5 +++ .../views/Housing/test/HousingView.test.tsx | 36 +++++++++++++++- 7 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/_app/AppSelect/app-select-next.module.scss create mode 100644 frontend/src/reset.scss diff --git a/frontend/src/App.scss b/frontend/src/App.scss index f1096a4e6..e12c22096 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -1,3 +1,4 @@ +@use "reset"; @use "colors"; @use "dsfr-fix"; @use "src/components/Map/housing-popup-overrides"; diff --git a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx index 02446b7b3..d75ac4dd5 100644 --- a/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx +++ b/frontend/src/components/HousingEdition/HousingEditionSideMenu.tsx @@ -294,7 +294,6 @@ function HousingEditionSideMenu(props: HousingEditionSideMenuProps) { label="Sous-statut de suivi" name="subStatus" multiple={false} - value={subStatusDisabled ? null : form.getValues().subStatus} options={ getSubStatusOptions(statusField.value as HousingStatus) ?? [] } diff --git a/frontend/src/components/HousingEdition/HousingStatusSelect.tsx b/frontend/src/components/HousingEdition/HousingStatusSelect.tsx index 614878ccc..b59960740 100644 --- a/frontend/src/components/HousingEdition/HousingStatusSelect.tsx +++ b/frontend/src/components/HousingEdition/HousingStatusSelect.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useId, useRef, useState } from 'react'; import classNames from 'classnames'; import { useOutsideClick } from '../../hooks/useOutsideClick'; import { SelectOption } from '../../models/SelectOption'; @@ -34,6 +34,8 @@ const HousingStatusSelect = ({ setShowOptions(false); }; + const inputId = useId(); + return (
- +