diff --git a/src/api/types/sokDTO.ts b/src/api/types/sokDTO.ts new file mode 100644 index 00000000..e14faf2a --- /dev/null +++ b/src/api/types/sokDTO.ts @@ -0,0 +1,4 @@ +export interface SokDTO { + initials: string; + birthdate: Date; +} diff --git a/src/components/NewOversiktTable.tsx b/src/components/NewOversiktTable.tsx index 5b35e9a6..44249c93 100644 --- a/src/components/NewOversiktTable.tsx +++ b/src/components/NewOversiktTable.tsx @@ -3,25 +3,19 @@ import { Checkbox, Table } from '@navikt/ds-react'; import { VeilederColumn } from '@/components/VeilederColumn'; import { PersonData } from '@/api/types/personregisterTypes'; import { PersonRadVirksomhetColumn } from '@/components/PersonRadVirksomhetColumn'; -import { OppfolgingstilfelleDTO } from '@/api/types/personoversiktTypes'; import { FristDataCell } from '@/components/FristDataCell'; import { Sorting, SortingKey, useSorting } from '@/hooks/useSorting'; import { LinkSyfomodiaperson } from '@/components/LinkSyfomodiaperson'; import { toLastnameFirstnameFormat } from '@/utils/stringUtil'; -import { getHendelser } from '@/utils/hendelseColumnUtils'; +import { + getHendelser, + getVarighetOppfolgingstilfelle, +} from '@/utils/hendelseColumnUtils'; import { useTabType } from '@/context/tab/TabTypeContext'; import { OverviewTabType } from '@/konstanter'; import * as Amplitude from '@/utils/amplitude'; import { EventType } from '@/utils/amplitude'; -function getVarighetOppfolgingstilfelle( - oppfolgingstilfelle: OppfolgingstilfelleDTO | undefined -): string { - return oppfolgingstilfelle - ? `${oppfolgingstilfelle.varighetUker} uker` - : 'Ukjent'; -} - interface Props { personListe: [string, PersonData][]; selectedRows: string[]; diff --git a/src/components/sokperson/SokPerson.tsx b/src/components/sokperson/SokPerson.tsx new file mode 100644 index 00000000..a2f79cc5 --- /dev/null +++ b/src/components/sokperson/SokPerson.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import { + BodyShort, + Box, + Button, + Heading, + HStack, + Modal, + TextField, + VStack, +} from '@navikt/ds-react'; +import { useSokPerson } from '@/data/personoversiktHooks'; +import { PersonOversiktStatusDTO } from '@/api/types/personoversiktTypes'; +import { SokDTO } from '@/api/types/sokDTO'; +import SokPersonResultat from '@/components/sokperson/SokPersonResultat'; +import { MagnifyingGlassIcon } from '@navikt/aksel-icons'; +import { isNumeric, removePunctuation } from '@/utils/stringUtil'; +import { parseDateString } from '@/utils/dateUtils'; + +const texts = { + buttonText: 'Søk etter sykmeldt', + header: 'Søk etter sykmeldt', + info: + 'Her kan du søke opp sykmeldte personer basert på initialer og fødselsdato.', + validation: { + initials: 'Vennligst angi gyldige initialer', + birthdate: 'Vennligst angi en gyldig fødselsdato', + }, +}; + +export default function SokPerson() { + const [isModalOpen, setIsModalOpen] = useState(false); + const [nameInitials, setNameInitials] = useState(''); + const [birthdate, setBirthdate] = useState(''); + const sokPerson = useSokPerson(); + const [searchResult, setSearchResult] = useState< + PersonOversiktStatusDTO[] | undefined + >(undefined); + const [isFormError, setIsFormError] = useState(false); + + const parseBirthdate = (birthdate: string): Date | null => { + const cleanedDateStr = removePunctuation(birthdate); + + if (cleanedDateStr.length < 6 || !isNumeric(cleanedDateStr)) { + return null; + } else { + return parseDateString(cleanedDateStr); + } + }; + + const validInitials = (initials: string): boolean => { + return initials.length <= 3 && initials.length !== 0; + }; + + const handleSubmit = () => { + const parsedBirthdate = parseBirthdate(birthdate); + if (validInitials(nameInitials) && parsedBirthdate) { + const requestDTO: SokDTO = { + initials: nameInitials.toLowerCase(), + birthdate: parsedBirthdate, + }; + sokPerson.mutate(requestDTO, { + onSuccess: (data) => { + setSearchResult(data); + }, + }); + } else { + setIsFormError(true); + } + }; + + return ( + + + setIsModalOpen(false)} + > + + + {texts.header} + + + + + {texts.info} + + setNameInitials(e.target.value)} + error={ + isFormError && !validInitials(nameInitials) + ? texts.validation.initials + : undefined + } + /> + setBirthdate(e.target.value)} + error={ + isFormError && parseBirthdate(birthdate) === null + ? texts.validation.birthdate + : undefined + } + /> + + + {searchResult && ( + + )} + + + + + ); +} diff --git a/src/components/sokperson/SokPersonResultat.tsx b/src/components/sokperson/SokPersonResultat.tsx new file mode 100644 index 00000000..e467e174 --- /dev/null +++ b/src/components/sokperson/SokPersonResultat.tsx @@ -0,0 +1,102 @@ +import React, { ReactElement } from 'react'; +import { useSorting } from '@/hooks/useSorting'; +import { PersonOversiktStatusDTO } from '@/api/types/personoversiktTypes'; +import { BodyShort, Box, Table } from '@navikt/ds-react'; +import { LinkSyfomodiaperson } from '@/components/LinkSyfomodiaperson'; +import { toLastnameFirstnameFormat } from '@/utils/stringUtil'; +import { PersonRadVirksomhetColumn } from '@/components/PersonRadVirksomhetColumn'; +import { VeilederColumn } from '@/components/VeilederColumn'; +import { FristDataCell } from '@/components/FristDataCell'; +import { + getHendelser, + getVarighetOppfolgingstilfelle, +} from '@/utils/hendelseColumnUtils'; +import { toPersonData } from '@/utils/toPersondata'; + +const texts = { + noResults: { + first: 'Fant ingen sykmeldte personer for søkeparameterne.', + second: + 'Det kan hende personen ikke er sykmeldt eller at du ikke har tilgang å se personen.', + }, +}; + +interface Props { + sokeresultater: PersonOversiktStatusDTO[]; +} + +export default function SokPersonResultat({ + sokeresultater, +}: Props): ReactElement { + const { columns } = useSorting(); + + const personer = Object.entries(toPersonData(sokeresultater, [])); + + return personer.length === 0 ? ( + + {texts.noResults.first} + {texts.noResults.second} + + ) : ( + + + + {columns.map((col, index) => ( + + {col.sortingText} + + ))} + + + + + {personer.map(([fnr, persondata], index) => ( + + + {persondata.navn.length > 0 && ( + + )} + + + {persondata.navn.length > 0 ? ( + fnr + ) : ( + + )} + + + + + + + + + {getVarighetOppfolgingstilfelle( + persondata.latestOppfolgingstilfelle + )} + + + + {getHendelser(persondata).map((hendelse, index) => ( +

+ {hendelse} +

+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/toolbar/ToolbarWrapper.tsx b/src/components/toolbar/ToolbarWrapper.tsx index e6d9819a..dbb7304e 100644 --- a/src/components/toolbar/ToolbarWrapper.tsx +++ b/src/components/toolbar/ToolbarWrapper.tsx @@ -2,6 +2,7 @@ import React, { ReactElement, useState } from 'react'; import Toolbar from './Toolbar'; import { Label } from '@navikt/ds-react'; import { PAGINATED_NUMBER_OF_ITEMS } from '@/components/toolbar/PaginationContainer'; +import SokPerson from '@/components/sokperson/SokPerson'; export interface ToolbarWrapperProps { alleMarkert: boolean; @@ -38,15 +39,18 @@ const ToolbarWrapper = (props: ToolbarWrapperProps): ReactElement => { return ( <> -
- - {props.markertePersoner.length > 0 && ( +
+
- )} + {props.markertePersoner.length > 0 && ( + + )} +
+
diff --git a/src/data/personoversiktHooks.ts b/src/data/personoversiktHooks.ts index a86740c1..4a08cad9 100644 --- a/src/data/personoversiktHooks.ts +++ b/src/data/personoversiktHooks.ts @@ -1,9 +1,9 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { PersonOversiktStatusDTO, PersonOversiktUbehandletStatusDTO, } from '@/api/types/personoversiktTypes'; -import { get } from '@/api/axios'; +import { get, post } from '@/api/axios'; import { useAktivEnhet } from '@/context/aktivEnhet/AktivEnhetContext'; import { useNotifications } from '@/context/notification/NotificationContext'; import { FetchPersonoversiktFailed } from '@/context/notification/Notifications'; @@ -12,6 +12,7 @@ import { useAsyncError } from '@/data/useAsyncError'; import { minutesToMillis } from '@/utils/timeUtils'; import { useMemo } from 'react'; import { PERSONOVERSIKT_ROOT } from '@/apiConstants'; +import { SokDTO } from '@/api/types/sokDTO'; const isUbehandlet = (ubehandletStatus: PersonOversiktUbehandletStatusDTO) => { return Object.values(ubehandletStatus).some((value) => value); @@ -88,3 +89,13 @@ export const usePersonoversiktQuery = () => { ), }; }; + +export const useSokPerson = () => { + const path = `${PERSONOVERSIKT_ROOT}/search`; + const postSok = (sokDTO: SokDTO) => + post(path, sokDTO); + + return useMutation({ + mutationFn: postSok, + }); +}; diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index d5cef4da..960f5078 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -4,7 +4,10 @@ import { mockUnleash } from '@/mocks/mockUnleash'; import { mockSyfoveileder } from '@/mocks/syfoveileder/mockSyfoveileder'; import { mockSyfoperson } from '@/mocks/syfoperson/mockSyfoperson'; import { mockPersontildeling } from '@/mocks/persontildeling/mockPersontildeling'; -import { mockPersonoversikt } from '@/mocks/personoversikt/mockPersonoversikt'; +import { + mockPersonoversikt, + mockSokPerson, +} from '@/mocks/personoversikt/mockPersonoversikt'; import { mockModiacontextholder } from '@/mocks/modiacontextholder/mockModiacontextholder'; import { mockFlexjar } from '@/mocks/flexjar/mockFlexjar'; import { mockEreg } from '@/mocks/ereg/mockEreg'; @@ -22,6 +25,7 @@ const handlers: HttpHandler[] = [ mockSyfoperson(generatedPersons), mockPersontildeling, mockPersonoversikt(generatedPersons), + mockSokPerson(), ...mockModiacontextholder, ]; diff --git a/src/mocks/personoversikt/mockPersonoversikt.ts b/src/mocks/personoversikt/mockPersonoversikt.ts index f47988b5..1a1d8bce 100644 --- a/src/mocks/personoversikt/mockPersonoversikt.ts +++ b/src/mocks/personoversikt/mockPersonoversikt.ts @@ -5,6 +5,7 @@ import { MockPerson, } from '../mockUtils'; import { http, HttpResponse } from 'msw'; +import { SokDTO } from '@/api/types/sokDTO'; const personoversiktEnhet = (generatedPersons: MockPerson[]) => [ ...personoversiktEnhetMock, @@ -15,3 +16,16 @@ export const mockPersonoversikt = (generatedPersons: MockPerson[]) => http.get(`${PERSONOVERSIKT_ROOT}/enhet/:id`, () => HttpResponse.json(personoversiktEnhet(generatedPersons)) ); + +export function mockSokPerson() { + return http.post(`${PERSONOVERSIKT_ROOT}/search`, async ({ request }) => { + const requestBody = (await request.json()) as SokDTO; + console.log('requestBody', requestBody); + const results = personoversiktEnhetMock.filter( + (person) => + person.navn.toLowerCase().substring(0, 1) === + requestBody.initials.toLowerCase().substring(0, 1) + ); + return HttpResponse.json(results); + }); +} diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 6d223fb9..a45bf684 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -64,3 +64,18 @@ export const getWeeksBetween = (date1: Date, date2: Date): number => { export const addWeeks = (date: Date, numberOfWeeks: number): Date => { return dayjs(date).add(numberOfWeeks, 'weeks').toDate(); }; + +export function parseDateString(dateString: string): Date { + const day = dateString.slice(0, 2); + const month = dateString.slice(2, 4); + let year = dateString.slice(4); + const today = new Date(); + + if (year.length === 2) { + const todayYear = today.getFullYear().toString().substring(2, 4); + year = + parseInt(year, 10) < parseInt(todayYear, 10) ? `20${year}` : `19${year}`; + } + + return new Date(`${year}-${month}-${day}`); +} diff --git a/src/utils/hendelseColumnUtils.ts b/src/utils/hendelseColumnUtils.ts index 095a4931..8567f086 100644 --- a/src/utils/hendelseColumnUtils.ts +++ b/src/utils/hendelseColumnUtils.ts @@ -2,6 +2,7 @@ import { AktivitetskravStatus, OnskerOppfolging, Oppfolgingsgrunn, + OppfolgingstilfelleDTO, SenOppfolgingKandidatDTO, } from '@/api/types/personoversiktTypes'; import { PersonData } from '@/api/types/personregisterTypes'; @@ -156,3 +157,11 @@ export function getHendelser(personData: PersonData): string[] { return hendelser; } + +export function getVarighetOppfolgingstilfelle( + oppfolgingstilfelle: OppfolgingstilfelleDTO | undefined +): string { + return oppfolgingstilfelle + ? `${oppfolgingstilfelle.varighetUker} uker` + : 'Ukjent'; +} diff --git a/src/utils/stringUtil.ts b/src/utils/stringUtil.ts index 5aa3c5be..0b96c685 100644 --- a/src/utils/stringUtil.ts +++ b/src/utils/stringUtil.ts @@ -17,3 +17,11 @@ export const toLastnameFirstnameFormat = (navn: string): string => { return nameList.map(capitalizeHyphenatedWords).join(' '); }; + +export function isNumeric(str: string): boolean { + return /^\d+$/.test(str); +} + +export function removePunctuation(str: string): string { + return str.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, ''); +} diff --git a/test/components/SokPersonTest.tsx b/test/components/SokPersonTest.tsx new file mode 100644 index 00000000..ed82ccf1 --- /dev/null +++ b/test/components/SokPersonTest.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { NotificationProvider } from '@/context/notification/NotificationContext'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AktivEnhetProvider } from '@/context/aktivEnhet/AktivEnhetContext'; +import { beforeEach, describe, it, expect } from 'vitest'; +import { testQueryClient } from '../testQueryClient'; +import SokPerson from '@/components/sokperson/SokPerson'; +import userEvent from '@testing-library/user-event'; +import { SokDTO } from '@/api/types/sokDTO'; +import { parseDateString } from '@/utils/dateUtils'; + +let queryClient: QueryClient; + +const renderSokPerson = () => + render( + + + + + + + + ); + +describe('SokPerson', () => { + beforeEach(() => { + queryClient = testQueryClient(); + }); + + it('should render SokPerson with fields', async () => { + renderSokPerson(); + + const modalButton = screen.getByRole('button', { + name: 'Søk etter sykmeldt', + }); + expect(modalButton).to.exist; + + await userEvent.click(modalButton); + + expect(screen.getByRole('dialog', { name: 'Søk etter sykmeldt' })).to.exist; + expect(screen.getByRole('heading', { name: 'Søk etter sykmeldt' })).to + .exist; + expect( + screen.getByText( + 'Her kan du søke opp sykmeldte personer basert på initialer og fødselsdato.' + ) + ).to.exist; + expect(screen.getByRole('textbox', { name: 'Initialer' })).to.exist; + expect(screen.getByRole('textbox', { name: 'Fødselsdato' })).to.exist; + expect(screen.getByRole('button', { name: 'Søk' })).to.exist; + }); + + it('should render validation errors for fields', async () => { + renderSokPerson(); + + await userEvent.click( + screen.getByRole('button', { + name: 'Søk etter sykmeldt', + }) + ); + + await userEvent.click(screen.getByRole('button', { name: 'Søk' })); + + expect(screen.getByText('Vennligst angi gyldige initialer')).to.exist; + expect(screen.getByText('Vennligst angi en gyldig fødselsdato')).to.exist; + }); + + it('should send correct parameters', async () => { + renderSokPerson(); + + await userEvent.click( + screen.getByRole('button', { + name: 'Søk etter sykmeldt', + }) + ); + + const initialsInput = screen.getByRole('textbox', { name: 'Initialer' }); + const birthdateInput = screen.getByRole('textbox', { name: 'Fødselsdato' }); + + const initialsValue = 'kk'; + const birthdateValue = '101010'; + + fireEvent.change(initialsInput, { + target: { value: initialsValue }, + }); + + fireEvent.change(birthdateInput, { + target: { value: birthdateValue }, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Søk' })); + + await waitFor(() => { + const sokMutation = queryClient.getMutationCache().getAll()[0]; + const expectedSokDTO: SokDTO = { + initials: initialsValue, + birthdate: parseDateString(birthdateValue), + }; + if (!sokMutation) { + throw new Error('Mutation not found'); + } + expect(sokMutation.state.variables).to.deep.equal(expectedSokDTO); + }); + }); +});