diff --git a/.deploy/fp-avdelingsleder/dev-gcp-teamforeldrepenger.json b/.deploy/fp-avdelingsleder/dev-gcp-teamforeldrepenger.json new file mode 100644 index 00000000000..dbbee1588dd --- /dev/null +++ b/.deploy/fp-avdelingsleder/dev-gcp-teamforeldrepenger.json @@ -0,0 +1,25 @@ +{ + "app": "fp-avdelingsleder", + "ingresses": ["https://fp-avdelingsleder.intern.dev.nav.no"], + "minReplicas": "1", + "maxReplicas": "2", + "env": "development", + "groups": [ + "27e77109-fef2-48ce-a174-269074490353", + "8cddda87-0a22-4d35-9186-a2c32a6ab450", + "e6508a2a-2e74-450e-ad24-eb1b2b4625c6" + ], + "externals": ["fpsak-api.dev-fss-pub.nais.io", "fplos.dev-fss-pub.nais.io"], + "proxyRedirects": [ + { + "path": "/fpsak/api", + "url": "https://fpsak-api.dev-fss-pub.nais.io", + "scope": "api://dev-fss.teamforeldrepenger.fpsak/.default" + }, + { + "path": "/fplos/api", + "url": "https://fplos.dev-fss-pub.nais.io", + "scope": "api://dev-fss.teamforeldrepenger.fplos/.default" + } + ] +} diff --git a/.deploy/fp-avdelingsleder/prod-gcp-teamforeldrepenger.json b/.deploy/fp-avdelingsleder/prod-gcp-teamforeldrepenger.json new file mode 100644 index 00000000000..88176d24b03 --- /dev/null +++ b/.deploy/fp-avdelingsleder/prod-gcp-teamforeldrepenger.json @@ -0,0 +1,25 @@ +{ + "app": "fp-avdelingsleder", + "ingresses": ["https://fp-avdelingsleder.intern.nav.no"], + "minReplicas": "2", + "maxReplicas": "4", + "env": "production", + "groups": [ + "73107205-17ec-4a07-a56e-e0a8542f90c9", + "77f05833-ebfd-45fb-8be7-88eca8e7418f", + "1a59da27-4c55-4a9d-8480-6abd1a856cd2" + ], + "externals": ["fpsak-api.prod-fss-pub.nais.io", "fplos.prod-fss-pub.nais.io"], + "proxyRedirects": [ + { + "path": "/fpsak/api", + "url": "https://fpsak-api.prod-fss-pub.nais.io", + "scope": "api://prod-fss.teamforeldrepenger.fpsak/.default" + }, + { + "path": "/fplos/api", + "url": "https://fplos.prod-fss-pub.nais.io", + "scope": "api://prod-fss.teamforeldrepenger.fplos/.default" + } + ] +} diff --git a/.deploy/dev-gcp-teamforeldrepenger.json b/.deploy/fp-frontend-app/dev-gcp-teamforeldrepenger.json similarity index 83% rename from .deploy/dev-gcp-teamforeldrepenger.json rename to .deploy/fp-frontend-app/dev-gcp-teamforeldrepenger.json index 118b6631e69..0c2d439fd94 100644 --- a/.deploy/dev-gcp-teamforeldrepenger.json +++ b/.deploy/fp-frontend-app/dev-gcp-teamforeldrepenger.json @@ -1,4 +1,5 @@ { + "app": "fp-frontend", "ingresses": ["https://fpsak.intern.dev.nav.no"], "minReplicas": "1", "maxReplicas": "2", @@ -8,7 +9,12 @@ "8cddda87-0a22-4d35-9186-a2c32a6ab450", "e6508a2a-2e74-450e-ad24-eb1b2b4625c6" ], - "externals": ["fpsak-api.dev-fss-pub.nais.io", "fplos.dev-fss-pub.nais.io", "fptilbake.dev-fss-pub.nais.io", "fpfordel.dev-fss-pub.nais.io"], + "externals": [ + "fpsak-api.dev-fss-pub.nais.io", + "fplos.dev-fss-pub.nais.io", + "fptilbake.dev-fss-pub.nais.io", + "fpfordel.dev-fss-pub.nais.io" + ], "proxyRedirects": [ { "path": "/fpsak/api", diff --git a/.deploy/prod-gcp-teamforeldrepenger.json b/.deploy/fp-frontend-app/prod-gcp-teamforeldrepenger.json similarity index 83% rename from .deploy/prod-gcp-teamforeldrepenger.json rename to .deploy/fp-frontend-app/prod-gcp-teamforeldrepenger.json index 2cf84bbe83d..b5a251e8bca 100644 --- a/.deploy/prod-gcp-teamforeldrepenger.json +++ b/.deploy/fp-frontend-app/prod-gcp-teamforeldrepenger.json @@ -1,4 +1,5 @@ { + "app": "fp-frontend", "ingresses": ["https://fpsak.intern.nav.no"], "minReplicas": "2", "maxReplicas": "4", @@ -8,7 +9,12 @@ "77f05833-ebfd-45fb-8be7-88eca8e7418f", "1a59da27-4c55-4a9d-8480-6abd1a856cd2" ], - "externals": ["fpsak-api.prod-fss-pub.nais.io", "fplos.prod-fss-pub.nais.io", "fptilbake.prod-fss-pub.nais.io", "fpfordel.prod-fss-pub.nais.io"], + "externals": [ + "fpsak-api.prod-fss-pub.nais.io", + "fplos.prod-fss-pub.nais.io", + "fptilbake.prod-fss-pub.nais.io", + "fpfordel.prod-fss-pub.nais.io" + ], "proxyRedirects": [ { "path": "/fpsak/api", diff --git a/.deploy/naiserator.yaml b/.deploy/naiserator.yaml index bb9505cffce..c912cb6c28c 100644 --- a/.deploy/naiserator.yaml +++ b/.deploy/naiserator.yaml @@ -1,7 +1,7 @@ apiVersion: "nais.io/v1alpha1" kind: "Application" metadata: - name: fp-frontend + name: {{app}} namespace: teamforeldrepenger labels: team: teamforeldrepenger diff --git a/.github/workflows/build-deploy-branch.yml b/.github/workflows/build-deploy-branch.yml index e49202133be..a7e894f3f02 100644 --- a/.github/workflows/build-deploy-branch.yml +++ b/.github/workflows/build-deploy-branch.yml @@ -6,6 +6,13 @@ on: description: 'Branch name' type: string required: true + app: + required: true + type: choice + description: 'Applikasjon som skal deployes fra branch' + options: + - fp-frontend + - fp-avdelingsleder jobs: build-app: @@ -20,6 +27,7 @@ jobs: node-version: '22.17.1' push-image: true branch: ${{ inputs.branch }} + bake-target: ${{ inputs.app }} run-knip: true secrets: inherit @@ -31,6 +39,9 @@ jobs: uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main with: gar: true + branch: ${{ inputs.branch }} image: ${{ needs.build-app.outputs.build-version }} cluster: dev-gcp + image_suffix: '/${{ inputs.app }}' + deploy_context: '/${{ inputs.app }}' secrets: inherit diff --git a/.github/workflows/build-fp-avdelingsleder.yml b/.github/workflows/build-fp-avdelingsleder.yml new file mode 100644 index 00000000000..f57b681e202 --- /dev/null +++ b/.github/workflows/build-fp-avdelingsleder.yml @@ -0,0 +1,56 @@ +name: Bygg og deploy fp-avdelingsleder +on: + workflow_dispatch: + push: + paths: + - 'apps/fp-avdelingsleder/**' + - 'packages/**' + - 'server/**' + - '.deploy/fp-avdelingsleder/**' + - '.deploy/naiserator.yaml' + - '.github/workflows/build-fp-avdelingsleder.yml' + - 'Dockerfile' + - 'yarn.lock' + +jobs: + build-app: + name: Build + permissions: + contents: read + packages: write + id-token: write + uses: navikt/fp-frontend/.github/workflows/build.yml@los-app # midlertidig til vi er på master + with: + runs-on: 'ubuntu-latest-8-cores' + node-version: '22.17.1' + build-image: ${{ github.ref_name == 'master' }} # default: true + push-image: ${{ github.ref_name == 'master' }} # default: false + run-knip: true + app: fp-avdelingsleder + secrets: inherit + + deploy-dev: + name: Deploy dev + permissions: + id-token: write + if: github.ref_name == 'master' + needs: build-app + uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main + with: + gar: true + image: ${{ needs.build-app.outputs.build-version }} + cluster: dev-gcp + secrets: inherit + + deploy-prod: + name: Deploy prod + permissions: + id-token: write + if: github.ref_name == 'master' + needs: [build-app, deploy-dev] + uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main + with: + gar: true + image: ${{ needs.build-app.outputs.build-version }} + cluster: prod-gcp + secrets: inherit diff --git a/.github/workflows/build-fp-frontend.yml b/.github/workflows/build-fp-frontend.yml new file mode 100644 index 00000000000..01908f959b1 --- /dev/null +++ b/.github/workflows/build-fp-frontend.yml @@ -0,0 +1,54 @@ +name: Bygg og deploy fp-frontend +on: + push: + branches: + - '**' + paths-ignore: + - '**.md' + - '**.MD' + - '.gitignore' + - '.editorconfig' + - 'LICENCE' + - 'CODEOWNERS' + +jobs: + build-app: + name: Build + permissions: + contents: read + packages: write + id-token: write + uses: navikt/fp-gha-workflows/.github/workflows/build-app-frontend-yarn.yml@main + with: + runs-on: 'ubuntu-latest-8-cores' + node-version: '22.17.1' + build-image: ${{ github.ref_name == 'master' }} # default: true + push-image: ${{ github.ref_name == 'master' }} # default: false + run-knip: true + secrets: inherit + + deploy-dev: + name: Deploy dev + permissions: + id-token: write + if: github.ref_name == 'master' + needs: build-app + uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main + with: + gar: true + image: ${{ needs.build-app.outputs.build-version }} + cluster: dev-gcp + secrets: inherit + + deploy-prod: + name: Deploy prod + permissions: + id-token: write + if: github.ref_name == 'master' + needs: [build-app, deploy-dev] + uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main + with: + gar: true + image: ${{ needs.build-app.outputs.build-version }} + cluster: prod-gcp + secrets: inherit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2aa6198ba08..e235ad00781 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,54 +1,109 @@ -name: Bygg og deploy +name: Build app on: - push: - branches: - - '**' - paths-ignore: - - '**.md' - - '**.MD' - - '.gitignore' - - '.editorconfig' - - 'LICENCE' - - 'CODEOWNERS' - + workflow_call: + inputs: + runs-on: + required: false + type: string + description: Miljø versjon som skal brukes til bygg. + default: 'ubuntu-latest' + node-version: + required: false + type: string + description: Node version som brukes. + default: '22' + build-image: + required: false + type: boolean + description: Skal docker image bygges? + default: true + run-knip: + required: false + type: boolean + description: Skal knip kjøres? + default: false + push-image: + required: false + type: boolean + description: Skal docker image pushes? + default: false + branch: + required: false + type: string + description: Alternativ branch å bygge fra + app: + required: false + type: string + description: App som skal bygges + outputs: + build-version: + description: 'Build version' + value: ${{ jobs.build-and-test.outputs.build-version }} jobs: - build-app: - name: Build + build-and-test: + name: Build and test permissions: - contents: read packages: write id-token: write - uses: navikt/fp-gha-workflows/.github/workflows/build-app-frontend-yarn.yml@main - with: - runs-on: 'ubuntu-latest-8-cores' - node-version: '22.17.1' - build-image: ${{ github.ref_name == 'master' }} # default: true - push-image: ${{ github.ref_name == 'master' }} # default: false - run-knip: true - secrets: inherit - - deploy-dev: - name: Deploy dev - permissions: - id-token: write - if: github.ref_name == 'master' - needs: build-app - uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main - with: - gar: true - image: ${{ needs.build-app.outputs.build-version }} - cluster: dev-gcp - secrets: inherit - - deploy-prod: - name: Deploy prod - permissions: - id-token: write - if: github.ref_name == 'master' - needs: [build-app, deploy-dev] - uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main - with: - gar: true - image: ${{ needs.build-app.outputs.build-version }} - cluster: prod-gcp - secrets: inherit + runs-on: ${{ (github.event.pull_request.user.login == 'dependabot[bot]' && 'ubuntu-latest') || inputs.runs-on }} + env: + TZ: 'Europe/Oslo' + outputs: + build-version: ${{ steps.generate-build-version.outputs.build-version }} + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v4 + with: + ref: ${{ (inputs.branch != '' && inputs.branch) || '' }} + fetch-depth: 0 + - name: Sette yarn-config + run: | + yarn config set npmScopes.navikt.npmRegistryServer "https://npm.pkg.github.com" + yarn config set npmScopes.navikt.npmAlwaysAuth true + yarn config set npmScopes.navikt.npmAuthToken $NPM_AUTH_TOKEN + env: + NPM_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + - name: Set up NODE med yarn + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # ratchet:actions/setup-node@v3 + with: + node-version: ${{ inputs.node-version }} + cache: 'yarn' + cache-dependency-path: | + node_modules/.cache/nx + yarn.lock + - name: Installere dependencies + run: yarn install --immutable + - name: Build + run: yarn build-${{ inputs.app }} + - name: Run linting + run: yarn stylelint && yarn eslint:changed && yarn tsc:changed + - name: Run tests + id: run-tests + run: yarn test:changed + - name: Knip it + if: inputs.run-knip + run: yarn knip --no-exit-code --reporter=markdown >> "$GITHUB_STEP_SUMMARY" + - name: Opprett release med Sentry + if: github.ref_name == 'master' + run: yarn sentry-release + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: Bygg server + run: cd ./server && yarn install --immutable && yarn build + - name: Generate build version + id: generate-build-version + run: | + echo "build-version=$(date +%Y.%m.%d.%H%M%S)-$(echo $GITHUB_SHA | cut -c1-7)" >> $GITHUB_OUTPUT + - name: Print build version + run: echo "Generated build-version is ${{ steps.generate-build-version.outputs.build-version }}" + - name: Bygg og push docker image + if: inputs.build-image + uses: navikt/fp-gha-workflows/.github/actions/build-push-docker-image@main # ratchet:exclude + with: + build-version: ${{ steps.generate-build-version.outputs.build-version }} + push-image: ${{ inputs.push-image }} + - name: Archive code coverage results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 + with: + name: code-coverage-report + path: coverage diff --git a/.github/workflows/deploy-manuelt.yml b/.github/workflows/deploy-manuelt.yml index 8d9ab5e60d0..1d4cd7a912e 100644 --- a/.github/workflows/deploy-manuelt.yml +++ b/.github/workflows/deploy-manuelt.yml @@ -13,10 +13,17 @@ on: options: - dev - prod + app: + required: true + type: choice + description: 'Application to deploy ()' + options: + - fp-frontend + - los-avdelingsleder jobs: deploy: - name: Deploy dev + name: Deploy permissions: id-token: write uses: navikt/fp-gha-workflows/.github/workflows/deploy.yml@main @@ -24,4 +31,6 @@ jobs: gar: true image: ${{ inputs.image }} cluster: ${{ inputs.environment }}-gcp + image_suffix: '/${{ inputs.app }}' + deploy_context: '/${{ inputs.app }}' secrets: inherit diff --git a/apps/fp-avdelingsleder/.storybook/main.ts b/apps/fp-avdelingsleder/.storybook/main.ts new file mode 100644 index 00000000000..29f16ba63bd --- /dev/null +++ b/apps/fp-avdelingsleder/.storybook/main.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-default-export +export { config as default } from '../../../.storybook/main-storybook'; diff --git a/apps/fp-avdelingsleder/.storybook/preview.tsx b/apps/fp-avdelingsleder/.storybook/preview.tsx new file mode 100644 index 00000000000..1bcddfcd447 --- /dev/null +++ b/apps/fp-avdelingsleder/.storybook/preview.tsx @@ -0,0 +1 @@ +export { default } from '../../../.storybook/preview-storybook'; diff --git a/apps/fp-avdelingsleder/eslint.config.mjs b/apps/fp-avdelingsleder/eslint.config.mjs new file mode 100644 index 00000000000..ceb7e1709ba --- /dev/null +++ b/apps/fp-avdelingsleder/eslint.config.mjs @@ -0,0 +1 @@ +export { default } from '@navikt/fp-config-eslint'; diff --git a/apps/fp-avdelingsleder/i18n/nb_NO.json b/apps/fp-avdelingsleder/i18n/nb_NO.json new file mode 100644 index 00000000000..233170d80e6 --- /dev/null +++ b/apps/fp-avdelingsleder/i18n/nb_NO.json @@ -0,0 +1,159 @@ +{ + "Dekorator.Foreldrepenger": "LOS - Avdelingsleder", + "Dekorator.Rettskilde": "Rettskildene", + "Dekorator.Systemrutine": "Systemrutine", + + "AvdelingslederIndex.Behandlingskoer": "Behandlingskøer", + "AvdelingslederIndex.Saksbehandlere": "Saksbehandlere", + "AvdelingslederIndex.Grupper": "Grupper", + "AvdelingslederIndex.Nokkeltall": "Nøkkeltall", + "AvdelingslederIndex.Reservasjoner": "Reservasjoner", + + "IkkeTilgangTilKode6AvdelingPanel.HarIkkeTilgang": "Du har ikke tilgang til denne avdelingen", + "IkkeTilgangTilAvdelingslederPanel.HarIkkeTilgang": "Du har ikke tilgang til å bruke dette programmet", + + "LeggTilSaksbehandlerForm.LeggTil": "Legg til saksbehandler", + "LeggTilSaksbehandlerForm.Brukerident": "Brukerident", + "LeggTilSaksbehandlerForm.Sok": "Søk", + "LeggTilSaksbehandlerForm.LeggTilIListen": "Legg til i listen", + "LeggTilSaksbehandlerForm.Nullstill": "Nullstill", + "LeggTilSaksbehandlerForm.FinnesAllerede": "Saksbehandler finnes allerede i listen", + "LeggTilSaksbehandlerForm.FinnesIkke": "Kan ikke finne brukerident", + + "EndreSakslisterPanel.KnyttetMotSaksbehandlere": "Behandlingskøen benyttes av disse saksbehandlerne", + + "Avdelingsvelger.Avdeling": "Avdeling", + + "SorteringVelger.Sortering": "Sortering", + "SorteringVelger.FiltrerPaTidsintervall": "Ta kun med behandlinger med dato", + "SorteringVelger.Fom": "F.o.m.", + "SorteringVelger.Tom": "T.o.m.", + "SorteringVelger.DagerMedBindestrek": "dager frem -", + "SorteringVelger.Bindestrek": "-", + "SorteringVelger.Dager": "dager frem", + "SorteringVelger.DynamiskPeriode": "Dynamisk periode", + "SorteringVelger.FiltrerPaHeltall": "Ta kun med behandlinger mellom", + "SorteringVelger.Valuta": "kr", + "SorteringVelger.Fra": "Fra", + "SorteringVelger.Til": "Til", + + "UtvalgskriterierForSakslisteForm.Utvalgskriterier": "Utvalgskriterier", + "UtvalgskriterierForSakslisteForm.Navn": "Navn", + "UtvalgskriterierForSakslisteForm.NyListe": "Ny behandlingskø", + + "AndreKriterierVelger.TilBeslutter": "Til beslutter", + "AndreKriterierVelger.RegistrerPapirsoknad": "Registrer papirsøknad", + "AndreKriterierVelger.AndreKriterier": "Andre kriterier", + "AndreKriterierVelger.TaMed": "Ta med i køen", + "AndreKriterierVelger.Fjern": "Fjern fra køen", + + "GjeldendeSakslisterTabell.GjeldendeLister": "Gjeldende behandlingskøer", + "GjeldendeSakslisterTabell.IngenLister": "Ingen behandlingskøer er laget", + "GjeldendeSakslisterTabell.Listenavn": "Navn", + "GjeldendeSakslisterTabell.LeggTilListe": "Legg til behandlingskø", + "GjeldendeSakslisterTabell.Behandlingtype": "Behandlingstype", + "GjeldendeSakslisterTabell.Stonadstype": "Stønadstype", + "GjeldendeSakslisterTabell.Alle": "Alle", + "GjeldendeSakslisterTabell.AntallSaksbehandlere": "Antall saksbehandlere", + "GjeldendeSakslisterTabell.AntallBehandlinger": "Antall behandlinger", + "GjeldendeSakslisterTabell.OppgaverForAvdeling": "Antall åpne behandlinger totalt", + + "AntallOppgaverForSaksliste.HentingAvAntallOppgaverFeilet": "Henting av antall feilet", + "AntallOppgaverForSaksliste.HentingAvAntallOppgaverHentes": "Henter antall oppgaver for behandlingskøen", + + "FagsakYtelseTypeVelger.Stonadstype": "Stønadstype", + + "BehandlingstypeVelger.Behandlingstype": "Behandlingstype", + + "SaksbehandlereForSakslisteForm.Saksbehandlere": "Saksbehandlere", + "SaksbehandlereForSakslisteForm.IngenSaksbehandlere": "Avdelingen har ingen saksbehandlere tilknyttet", + "SaksbehandlereForSakslisteForm.Gruppenavn": "Gruppenavn", + "SaksbehandlereForSakslisteForm.AntallSaksbehandlere": "Antall tilknyttet køen", + "SaksbehandlereForSakslisteForm.VisAlle": "Alle saksbehandlere", + + "SletteSaksbehandlerModal.SletteModal": "Ønsker du å slette saksbehandler?", + "SletteSaksbehandlerModal.SletteSaksbehandler": "Ønsker du å slette {saksbehandlerNavn}?", + "SletteSaksbehandlerModal.Ja": "Ja", + "SletteSaksbehandlerModal.Nei": "Nei", + + "FordelingAvBehandlingstypePanel.Fordeling": "Antall åpne oppgaver nå", + "FordelingAvBehandlingstypePanel.Alle": "Alle", + "FordelingAvBehandlingstypeGraf.TilBehandling": "Til behandling", + "FordelingAvBehandlingstypeGraf.TilBeslutter": "Til beslutter", + + "TilBehandlingPanel.TilBehandling": "Antall åpne oppgaver pr dato", + "TilBehandlingPanel.ToSisteUker": "2 siste uker", + "TilBehandlingPanel.FireSisteUker": "4 siste uker", + "TilBehandlingPanel.Alle": "Alle", + + "VentefristUtløperPanel.SattPaVent": "Førstegangsbehandlinger på vent fordelt på utløp av ventefrist", + "VentefristUtløperPanel.Alle": "Alle", + + "OppgaverSomErApneEllerPaVentPanel.Apne": "Åpne behandlinger foreldrepenger fordelt på første uttaksdag fra søknad", + "OppgaverSomErApneEllerPaVentGraf.PaVent": "På vent", + "OppgaverSomErApneEllerPaVentGraf.IkkePaVent": "Ikke på vent", + "OppgaverSomErApneEllerPaVentGraf.AntallPaVent": "På vent: {antall}", + "OppgaverSomErApneEllerPaVentGraf.AntallIkkePaVent": "Ikke på vent: {antall}", + "OppgaverSomErApneEllerPaVentGraf.AntallGraf": "Antall", + "OppgaverSomErApneEllerPaVentGraf.Ukjent": "Ukjent", + "OppgaverSomErApneEllerPaVentGraf.Dato": "dato", + "OppgaverSomErApneEllerPaVentGraf.0": "Jan", + "OppgaverSomErApneEllerPaVentGraf.1": "Feb", + "OppgaverSomErApneEllerPaVentGraf.2": "Mars", + "OppgaverSomErApneEllerPaVentGraf.3": "April", + "OppgaverSomErApneEllerPaVentGraf.4": "Mai", + "OppgaverSomErApneEllerPaVentGraf.5": "Juni", + "OppgaverSomErApneEllerPaVentGraf.6": "Juli", + "OppgaverSomErApneEllerPaVentGraf.7": "Aug", + "OppgaverSomErApneEllerPaVentGraf.8": "Sept", + "OppgaverSomErApneEllerPaVentGraf.9": "Okt", + "OppgaverSomErApneEllerPaVentGraf.10": "Nov", + "OppgaverSomErApneEllerPaVentGraf.11": "Des", + + "ReservasjonerTabell.Navn": "Navn", + "ReservasjonerTabell.Saksnr": "Saksnummer", + "ReservasjonerTabell.BehandlingType": "Type", + "ReservasjonerTabell.ReservertTil": "Reservert til", + "ReservasjonerTabell.ReservertTilFormat": "{time} - {date}", + "ReservasjonerTabell.Endre": "Endre", + "ReservasjonerTabell.Flytt": "Flytt", + "ReservasjonerTabell.Slett": "Slett", + "ReservasjonerTabell.Reservasjoner": "Reservasjoner for avdelingen", + "ReservasjonerTabell.IngenReservasjoner": "Ingen reservasjoner funnet", + + "OppgaveReservasjonEndringDatoModal.Header": "Velg dato som reservasjonen avsluttes", + "OppgaveReservasjonEndringDatoModal.Ok": "OK", + "OppgaveReservasjonEndringDatoModal.Avbryt": "Avbryt", + + "FlyttReservasjonModal.FlyttReservasjon": "Flytt reservasjonen til annen saksbehandler", + "FlyttReservasjonModal.Brukerident": "Brukerident", + "FlyttReservasjonModal.Sok": "Søk", + "FlyttReservasjonModal.Begrunn": "Begrunn flytting av reservasjonen", + "FlyttReservasjonModal.Ok": "OK", + "FlyttReservasjonModal.Avbryt": "Avbryt", + + "SaksbehandlereTabell.AnsattVed": "Ansatt ved avdeling", + "SaksbehandlereTabell.Navn": "Navn", + "SaksbehandlereTabell.Brukerident": "Brukerident", + "SaksbehandlereTabell.IngenSaksbehandlere": "Ingen saksbehandlere lagt til", + + "OppgaverPerForsteStonadsdagPanel.FordeltPaForsteStonadsdag": "Antall åpne oppgaver for førstegangsbehandlinger fordelt på første stønadsdag - alle ytelser", + + "SletteSakslisteModal.SletteModal": "Ønsker du å slette behandlingskøen?", + "SletteSakslisteModal.SletteSaksliste": "Ønsker du å slette {sakslisteNavn}?", + "SletteSakslisteModal.Ja": "Ja", + "SletteSakslisteModal.Nei": "Nei", + + "GrupperPanel.OpprettGruppe": "Opprett gruppe", + + "GrupperTabell.Grupper": "Grupper", + "GrupperTabell.Id": "Id", + "GrupperTabell.Navn": "Navn", + "GrupperTabell.AntallSaksbehandlere": "Antall saksbehandlere", + "GrupperTabell.IngenGrupper": "Ingen grupper", + + "GruppeSaksbehandlere.ValgteSaksbehandlere": "Valgte saksbehandlere", + "GruppeSaksbehandlere.Ingen": "Ingen valgte saksbehandlere", + "GruppeSaksbehandlere.Navn": "Navn på gruppe", + "GruppeSaksbehandlere.VelgSaksbehandlere": "Velg saksbehandlere" +} diff --git a/apps/fp-avdelingsleder/index.html b/apps/fp-avdelingsleder/index.html new file mode 100644 index 00000000000..cbfe3ca829f --- /dev/null +++ b/apps/fp-avdelingsleder/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Los Avdelingsleder + + + +
+ + diff --git a/apps/fp-avdelingsleder/package.json b/apps/fp-avdelingsleder/package.json new file mode 100644 index 00000000000..804c16ab484 --- /dev/null +++ b/apps/fp-avdelingsleder/package.json @@ -0,0 +1,67 @@ +{ + "name": "@navikt/fp-los-avdelingsleder", + "license": "MIT", + "private": true, + "module": "src/main.tsx", + "type": "module", + "exports": { + ".": "./index.html" + }, + "scripts": { + "test": "vitest", + "test:watch": "vitest --watch=true", + "tsc": "tsc --pretty", + "eslint": "eslint \"src/**/*.ts*\" --color", + "eslint:fix": "eslint --fix \"src/**/*.ts*\" --color", + "stylelint": "stylelint \"src/**/*.module.css\"", + "prettier": "prettier --write src", + "dev": "vite serve", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf ./dist ./node_modules ./coverage ./.storybook-static-build", + "build-storybook": "storybook build -o .storybook-static-build", + "storybook": "storybook dev --quiet -p 7020" + }, + "dependencies": { + "@navikt/aksel-icons": "7.26.0", + "@navikt/ds-css": "7.26.0", + "@navikt/ds-react": "7.26.0", + "@navikt/fp-kodeverk": "workspace:*", + "@navikt/fp-los-felles": "workspace:*", + "@navikt/fp-types": "workspace:*", + "@navikt/fp-utils": "workspace:*", + "@navikt/ft-form-hooks": "9.0.1", + "@navikt/ft-form-validators": "4.1.1", + "@navikt/ft-ui-komponenter": "6.0.1", + "@navikt/ft-utils": "3.7.1", + "@tanstack/react-query": "5.84.1", + "dayjs": "1.11.13", + "history": "5.3.0", + "ky": "1.8.2", + "lodash.debounce": "4.0.8", + "react": "19.1.1", + "react-hook-form": "7.62.0", + "react-intl": "7.1.11", + "react-router-dom": "7.8.0" + }, + "devDependencies": { + "@navikt/fp-config-eslint": "workspace:*", + "@navikt/fp-config-typescript": "workspace:*", + "@navikt/fp-config-vite": "workspace:*", + "@navikt/fp-storybook-utils": "workspace:*", + "@storybook/react": "9.1.1", + "@storybook/react-vite": "9.1.1", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/lodash.debounce": "4.0.9", + "eslint": "9.32.0", + "msw": "2.10.4", + "msw-storybook-addon": "2.0.5", + "storybook": "9.1.1", + "stylelint": "16.23.1", + "typescript": "5.9.2", + "vite": "7.1.1", + "vitest": "3.2.4" + } +} diff --git a/apps/fp-avdelingsleder/public/favicon.ico b/apps/fp-avdelingsleder/public/favicon.ico new file mode 100644 index 00000000000..4e741d3f6d5 Binary files /dev/null and b/apps/fp-avdelingsleder/public/favicon.ico differ diff --git a/apps/fp-avdelingsleder/src/app/LosAppIndex.tsx b/apps/fp-avdelingsleder/src/app/LosAppIndex.tsx new file mode 100644 index 00000000000..150f1691fc5 --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/LosAppIndex.tsx @@ -0,0 +1,170 @@ +import { type ComponentProps, useMemo, useState } from 'react'; +import { RawIntlProvider } from 'react-intl'; +import { Link, useLocation } from 'react-router-dom'; + +import { Theme } from '@navikt/ds-react'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { createIntl, parseQueryString } from '@navikt/ft-utils'; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { HTTPError } from 'ky'; + +import { ForbiddenPage, UnauthorizedPage } from '@navikt/fp-sak-infosider'; + +import { ErrorType, type FpError } from '../data/error/errorType'; +import { useRestApiError, useRestApiErrorDispatcher } from '../data/error/RestApiErrorContext'; +import { initFetchOptions } from '../data/fplosAvdelingslederApi'; +import { Dekorator } from './components/Dekorator'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { Home } from './components/Home'; + +import '../globalCss/global.module.css'; + +import messages from '../../i18n/nb_NO.json'; + +import '@navikt/ds-css/darkside'; +import '@navikt/ds-css-internal'; +import '@navikt/ft-form-hooks/dist/style.css'; +import '@navikt/ft-plattform-komponenter/dist/style.css'; +import '@navikt/ft-ui-komponenter/dist/style.css'; + +const EMPTY_ARRAY = new Array(); + +const intl = createIntl(messages); + +export const LosAppIndexWrapper = () => { + const { addErrorMessage } = useRestApiErrorDispatcher(); + const queryClient = useMemo(() => createQueryClient(getErrorHandler(addErrorMessage)), []); + + return ( + + + + + + + ); +}; + +const LosAppIndex = () => { + const [headerHeight, setHeaderHeight] = useState(0); + const [crashMessage, setCrashMessage] = useState(); + const [theme, setTheme] = useState['theme']>('light'); + + const initFetchQuery = useQuery(initFetchOptions()); + + const location = useLocation(); + + const setSiteHeight = (newHeaderHeight: number): void => { + document.documentElement.setAttribute('style', `height: calc(100% - ${newHeaderHeight}px)`); + setHeaderHeight(newHeaderHeight); + }; + + const addErrorMessageAndSetAsCrashed = (error: FpError) => { + setCrashMessage( + error.type === ErrorType.GENERAL_ERROR + ? error.message + : 'Det oppstod en feilsituasjon som ikke blir håndtert korrekt', + ); + }; + + const errorMessages = useRestApiError() ?? EMPTY_ARRAY; + const queryStrings = parseQueryString(location.search); + const hasForbiddenErrors = errorMessages.some(o => o.type === ErrorType.REQUEST_FORBIDDEN); + const hasUnauthorizedErrors = errorMessages.some(o => o.type === ErrorType.REQUEST_UNAUTHORIZED); + const hasForbiddenOrUnauthorizedErrors = hasForbiddenErrors || hasUnauthorizedErrors; + const shouldRenderHome = !crashMessage && !hasForbiddenOrUnauthorizedErrors; + + if (initFetchQuery.isPending || !initFetchQuery.data) { + return ; + } + + const navAnsatt = initFetchQuery.data.innloggetBruker; + + return ( + + + + + {shouldRenderHome && } + + {hasForbiddenErrors && {tekst}} />} + {hasUnauthorizedErrors && {tekst}} />} + + + ); +}; + +const createQueryClient = (errorHandler: (error: Error) => void) => + new QueryClient({ + defaultOptions: { + queries: { + retry: retryHandler(), + }, + mutations: { + retry: retryHandler(), + }, + }, + queryCache: new QueryCache({ + onError: errorHandler, + }), + mutationCache: new MutationCache({ + onError: errorHandler, + }), + }); + +const ZERO_RETRIES = false; + +const retryHandler = () => { + if (import.meta.env.MODE === 'test') { + return ZERO_RETRIES; + } + + return (failureCount: number, error: Error) => { + if (error instanceof HTTPError) { + if (error.response.status === 401 || error.response.status === 403) { + return ZERO_RETRIES; + } + if (error.response.status === 500) { + return failureCount < 1; + } + } + return failureCount < 3; + }; +}; + +const getErrorHandler = (addErrorMessage: (data: FpError) => void) => async (error: Error) => { + // eslint-disable-next-line no-console + console.log(error); + + if (error instanceof HTTPError) { + if (error.response.status === 403) { + addErrorMessage({ type: ErrorType.REQUEST_FORBIDDEN, message: error.message }); + } else if (error.response.status === 401) { + addErrorMessage({ type: ErrorType.REQUEST_UNAUTHORIZED, message: error.message }); + } else if (error.response.status === 504 || error.response.status === 404) { + addErrorMessage({ + type: ErrorType.REQUEST_GATEWAY_TIMEOUT_OR_NOT_FOUND, + //@ts-expect-error Fiks + location: error.response?.config?.url, + }); + } else { + try { + const feildataJson = await error.response.json(); + addErrorMessage({ type: ErrorType.GENERAL_ERROR, message: feildataJson.feilmelding ?? error.message }); + } catch { + addErrorMessage({ type: ErrorType.GENERAL_ERROR, message: error.message }); + } + } + } else { + addErrorMessage({ type: ErrorType.GENERAL_ERROR, message: error.message }); + } +}; diff --git a/apps/fp-avdelingsleder/src/app/components/AvdelingslederIndex.tsx b/apps/fp-avdelingsleder/src/app/components/AvdelingslederIndex.tsx new file mode 100644 index 00000000000..4e8088238f0 --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/AvdelingslederIndex.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; + +import { Box, Heading, HStack, Select, Tabs } from '@navikt/ds-react'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { formatQueryString, parseQueryString } from '@navikt/ft-utils'; +import { useQuery } from '@tanstack/react-query'; +import { type Location } from 'history'; + +import type { NavAnsatt } from '@navikt/fp-types'; +import { useTrackRouteParam } from '@navikt/fp-utils'; + +import { EndreSakslisterPanel } from '../../behandlingskoer/EndreSakslisterPanel'; +import { IkkeTilgangTilAvdelingslederPanel } from '../../components/IkkeTilgangTilAvdelingslederPanel'; +import { + avdelingerOptions, + losKodeverkOptions, + saksbehandlareForAvdelingOptions, +} from '../../data/fplosAvdelingslederApi'; +import { + getValueFromLocalStorage, + removeValueFromLocalStorage, + setValueInLocalStorage, +} from '../../data/localStorageHelper'; +import { GrupperPanel } from '../../grupper/GrupperPanel'; +import { NokkeltallPanel } from '../../nokkeltall/NokkeltallPanel'; +import { ReservasjonerTabell } from '../../reservasjoner/ReservasjonerTabell'; +import { SaksbehandlerePanel } from '../../saksbehandlere/SaksbehandlerePanel'; +import type { Avdeling } from '../../typer/avdelingTsType'; +import { AvdelingslederPanels } from './avdelingslederPanels'; + +import styles from './avdelingslederIndex.module.css'; + +interface Props { + navAnsatt?: NavAnsatt; +} + +export const AvdelingslederIndex = ({ navAnsatt }: Props) => { + const navigate = useNavigate(); + const [valgtAvdelingEnhet, setValgtAvdelingEnhet] = useState(); + + const { selected: activeAvdelingslederPanelTemp, location } = useTrackRouteParam({ + paramName: 'fane', + isQueryParam: true, + }); + + const alleKodeverkQuery = useQuery(losKodeverkOptions()); + + useEffect(() => { + if (alleKodeverkQuery.isError) { + setLosErIkkeTilgjengelig(); + } + }, [alleKodeverkQuery.isError]); + + const { data: filtrerteAvdelinger, status: avdelingerStatus } = useQuery({ + ...avdelingerOptions(!!navAnsatt?.kanOppgavestyre), + select: avdelinger => avdelinger.filter(a => !!navAnsatt?.kanBehandleKode6 || !a.kreverKode6), + }); + + const { data: avdelingensSaksbehandlere } = useQuery(saksbehandlareForAvdelingOptions(valgtAvdelingEnhet)); + + useEffect(() => { + if (avdelingerStatus === 'success') { + setAvdeling(setValgtAvdelingEnhet, filtrerteAvdelinger, valgtAvdelingEnhet); + } + }, [avdelingerStatus]); + + const getAvdelingslederPanelLocation = (avdelingslederPanel: string) => ({ + ...location, + search: updateQueryParams(location.search, { fane: avdelingslederPanel }), + }); + const activeAvdelingslederPanel = activeAvdelingslederPanelTemp || getPanelFromUrlOrDefault(location); + + if (!navAnsatt?.kanOppgavestyre) { + return ; + } + if (alleKodeverkQuery.isPending || avdelingerStatus !== 'success' || valgtAvdelingEnhet === undefined) { + return ; + } + + return ( +
+ + + + { + navigate(getAvdelingslederPanelLocation(avdelingslederPanel)); + }} + className={styles.paddingHeader} + > + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + {activeAvdelingslederPanel === AvdelingslederPanels.BEHANDLINGSKOER && ( + + )} + {activeAvdelingslederPanel === AvdelingslederPanels.SAKSBEHANDLERE && ( + + )} + {activeAvdelingslederPanel === AvdelingslederPanels.GRUPPER && ( + + )} + {activeAvdelingslederPanel === AvdelingslederPanels.NOKKELTALL && ( + + )} + {activeAvdelingslederPanel === AvdelingslederPanels.RESERVASJONER && ( + + )} + +
+ ); +}; + +const nasjonalEnhet = '4867'; + +const setAvdeling = ( + setValgtAvdeling: (avdelingEnhet: string) => void, + avdelinger: Avdeling[], + valgtAvdelingEnhet?: string, +) => { + if (avdelinger.length > 0 && !valgtAvdelingEnhet) { + let valgtEnhet = avdelinger.some(a => a.avdelingEnhet === nasjonalEnhet) + ? nasjonalEnhet + : avdelinger[0].avdelingEnhet; + const lagretAvdelingEnhet = getValueFromLocalStorage('avdelingEnhet'); + if (lagretAvdelingEnhet) { + if (avdelinger.some(a => a.avdelingEnhet === lagretAvdelingEnhet)) { + valgtEnhet = lagretAvdelingEnhet; + } else { + removeValueFromLocalStorage('avdelingEnhet'); + } + } + setValgtAvdeling(valgtEnhet); + } +}; + +const emptyQueryString = (queryString: string) => queryString === '?' || !queryString; + +const updateQueryParams = (queryString: string, nextParams: Record) => { + const prevParams = emptyQueryString(queryString) ? {} : parseQueryString(queryString); + return formatQueryString({ + ...prevParams, + ...nextParams, + }); +}; + +const getPanelFromUrlOrDefault = (location: Location) => { + const panelFromUrl = parseQueryString(location.search); + return panelFromUrl['avdelingsleder'] ?? AvdelingslederPanels.BEHANDLINGSKOER; +}; diff --git a/apps/fp-avdelingsleder/src/app/components/Dekorator.tsx b/apps/fp-avdelingsleder/src/app/components/Dekorator.tsx new file mode 100644 index 00000000000..abc8c0c938a --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/Dekorator.tsx @@ -0,0 +1,168 @@ +import React, { type ComponentProps } from 'react'; +import { type IntlShape, useIntl } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; + +import type { Theme } from '@navikt/ds-react'; +import { dateFormat, decodeHtmlEntity, timeFormat } from '@navikt/ft-utils'; + +import { ApiPollingStatus, RETTSKILDE_URL, SYSTEMRUTINE_URL } from '@navikt/fp-konstanter'; +import { DekoratorMedFeilviserSakIndex, type Feilmelding } from '@navikt/fp-sak-dekorator'; +import type { NavAnsatt } from '@navikt/fp-types'; + +import { ErrorType, type FpError } from '../../data/error/errorType'; +import { useRestApiError, useRestApiErrorDispatcher } from '../../data/error/RestApiErrorContext'; + +type QueryStrings = { + errorcode?: string; + errormessage?: string; +}; + +interface Props { + queryStrings: QueryStrings; + setSiteHeight: (headerHeight: number) => void; + crashMessage?: string; + hideErrorMessages?: boolean; + theme: ComponentProps['theme']; + setTheme: (theme: ComponentProps['theme']) => void; + navAnsatt: NavAnsatt; +} + +export const Dekorator = ({ + queryStrings, + setSiteHeight, + crashMessage, + hideErrorMessages = false, + theme, + setTheme, + navAnsatt, +}: Props) => { + const intl = useIntl(); + + const errorMessages = useRestApiError(); + const { removeErrorMessages } = useRestApiErrorDispatcher(); + + const navigate = useNavigate(); + const visLos = (e: React.SyntheticEvent) => { + if (e.type === 'click') { + navigate('/'); + } + if (e.type === 'contextmenu') { + window.open('/', '_newtab'); + } + e.preventDefault(); + }; + + const eksterneLenker = [ + { + tekst: intl.formatMessage({ id: 'Dekorator.Rettskilde' }), + href: RETTSKILDE_URL, + }, + { + tekst: intl.formatMessage({ id: 'Dekorator.Systemrutine' }), + href: SYSTEMRUTINE_URL, + }, + ]; + + return ( + + ); +}; + +const addIfNotExists = (feilmeldinger: Feilmelding[], nyFeilmelding: Feilmelding) => { + if ( + !feilmeldinger.some( + feil => feil.melding === nyFeilmelding.melding && feil.tilleggsInfo === nyFeilmelding.tilleggsInfo, + ) + ) { + feilmeldinger.push(nyFeilmelding); + } +}; + +const formaterFeilmeldinger = ( + intl: IntlShape, + alleFeilmeldinger: FpError[], + queryStringFeilmeldinger: QueryStrings, + crashMessage?: string, +): Feilmelding[] => { + const feilmeldinger: Feilmelding[] = []; + + if (queryStringFeilmeldinger.errorcode) { + addIfNotExists(feilmeldinger, { melding: intl.formatMessage({ id: queryStringFeilmeldinger.errorcode }) }); + } + if (queryStringFeilmeldinger.errormessage) { + addIfNotExists(feilmeldinger, { melding: queryStringFeilmeldinger.errormessage }); + } + if (crashMessage) { + addIfNotExists(feilmeldinger, { melding: crashMessage }); + } + + alleFeilmeldinger.forEach(feilmelding => { + switch (feilmelding.type) { + case ErrorType.POLLING_HALTED_OR_DELAYED: + if (feilmelding.status === ApiPollingStatus.HALTED) { + const decoded = decodeHtmlEntity(feilmelding.message); + addIfNotExists(feilmeldinger, { + melding: intl.formatMessage({ id: 'Rest.ErrorMessage.General' }), + tilleggsInfo: decoded ? parseErrorDetails(decoded) : undefined, + }); + } + if (feilmelding.status === ApiPollingStatus.DELAYED) { + addIfNotExists(feilmeldinger, { + melding: intl.formatMessage( + { id: 'Rest.ErrorMessage.DownTime' }, + { + date: dateFormat(feilmelding.eta), + time: timeFormat(feilmelding.eta), + message: feilmelding.message, + }, + ), + }); + } + break; + case ErrorType.POLLING_TIMEOUT: + addIfNotExists(feilmeldinger, { + melding: intl.formatMessage({ id: 'Rest.ErrorMessage.PollingTimeout' }, { location: feilmelding.location }), + }); + break; + case ErrorType.REQUEST_GATEWAY_TIMEOUT_OR_NOT_FOUND: + addIfNotExists(feilmeldinger, { + melding: intl.formatMessage( + { id: 'Rest.ErrorMessage.GatewayTimeoutOrNotFound' }, + { + contextPath: feilmelding.location ? feilmelding.location.split('/')[1]?.toUpperCase() : '', + location: feilmelding.location, + }, + ), + }); + break; + case ErrorType.REQUEST_FORBIDDEN: + case ErrorType.REQUEST_UNAUTHORIZED: + case ErrorType.GENERAL_ERROR: + default: + addIfNotExists(feilmeldinger, { + melding: feilmelding.message, + }); + } + }); + + return feilmeldinger; +}; + +const parseErrorDetails = (details: string) => { + try { + return JSON.parse(details); + } catch { + return 'Kunne ikke tolke feildetaljer'; + } +}; diff --git a/apps/fp-avdelingsleder/src/app/components/ErrorBoundary.tsx b/apps/fp-avdelingsleder/src/app/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..95cb099bcf1 --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/ErrorBoundary.tsx @@ -0,0 +1,88 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +import { ErrorMessage } from '@navikt/ds-react'; +import { captureException, withScope } from '@sentry/browser'; + +import { ErrorPage } from '@navikt/fp-sak-infosider'; + +import { ErrorType, type FpError } from '../../data/error/errorType'; + +const isDevelopment = import.meta.env.MODE === 'development'; + +interface OwnProps { + errorMessageCallback: (error: FpError) => void; + children: ReactNode; + errorMessage?: string; + showChild?: boolean; +} + +interface State { + hasError: boolean; +} + +export class ErrorBoundary extends Component { + static defaultProps = { + showChild: false, + }; + + constructor(props: OwnProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + const { errorMessageCallback } = this.props; + + if (!isDevelopment) { + withScope(scope => { + Object.entries(info).forEach(entry => { + scope.setExtra(entry[0], entry[1]); + captureException(error); + }); + }); + } + + const errorStrings = info.componentStack + ? [ + error.toString(), + info.componentStack + .split('\n') + .map(line => line.trim()) + .find(line => !!line), + ].join(' ') + : error.toString(); + + errorMessageCallback({ type: ErrorType.GENERAL_ERROR, message: errorStrings }); + + // eslint-disable-next-line no-console + console.error(error); + } + + override render(): ReactNode { + const { children, showChild, errorMessage } = this.props; + const { hasError } = this.state; + + if (hasError) { + if (errorMessage) { + return ( + + {errorMessage} + + ); + } + return ( + <> + {showChild &&
{children}
} + + + ); + } + + return children; + } +} diff --git a/apps/fp-avdelingsleder/src/app/components/Home.tsx b/apps/fp-avdelingsleder/src/app/components/Home.tsx new file mode 100644 index 00000000000..94c3cea0c4c --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/Home.tsx @@ -0,0 +1,29 @@ +import { Link, Route, Routes } from 'react-router-dom'; + +import { NotFoundPage } from '@navikt/fp-sak-infosider'; +import type { NavAnsatt } from '@navikt/fp-types'; + +import { AvdelingslederIndex } from './AvdelingslederIndex'; + +import styles from './home.module.css'; + +interface Props { + headerHeight: number; + navAnsatt?: NavAnsatt; +} + +/** + * Home + * + * Wrapper for sideinnholdet som vises under header. + */ +export const Home = ({ headerHeight, navAnsatt }: Props) => { + return ( +
+ + } /> + {tekst}} />} /> + +
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/app/components/avdelingslederIndex.module.css b/apps/fp-avdelingsleder/src/app/components/avdelingslederIndex.module.css new file mode 100644 index 00000000000..c91cb59baf9 --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/avdelingslederIndex.module.css @@ -0,0 +1,9 @@ +.container { + background-color: var(--ax-bg-default); + padding-top: 15px; +} + +.paddingHeader { + box-shadow: inset 0 -1px 0 0 var(--ax-neutral-400); + padding-left: 100px; +} diff --git a/apps/fp-avdelingsleder/src/app/components/avdelingslederPanels.ts b/apps/fp-avdelingsleder/src/app/components/avdelingslederPanels.ts new file mode 100644 index 00000000000..5ca76cf543b --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/avdelingslederPanels.ts @@ -0,0 +1,7 @@ +export const AvdelingslederPanels = { + BEHANDLINGSKOER: 'behandlingskoer', + SAKSBEHANDLERE: 'saksbehandlere', + GRUPPER: 'grupper', + NOKKELTALL: 'nokkeltall', + RESERVASJONER: 'reservasjoner', +}; diff --git a/apps/fp-avdelingsleder/src/app/components/home.module.css b/apps/fp-avdelingsleder/src/app/components/home.module.css new file mode 100644 index 00000000000..0c30906d181 --- /dev/null +++ b/apps/fp-avdelingsleder/src/app/components/home.module.css @@ -0,0 +1,4 @@ +.content { + font-family: 'Source Sans Pro', Arial, sans-serif; + height: 100%; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.stories.tsx new file mode 100644 index 00000000000..11427d23494 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.stories.tsx @@ -0,0 +1,78 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { AndreKriterierType, BehandlingType, FagsakYtelseType, KøSortering } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../data/fplosAvdelingslederApi'; +import type { SakslisteAvdeling } from '../typer/sakslisteAvdelingTsType'; +import { EndreSakslisterPanel } from './EndreSakslisterPanel'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const SAKSLISTER = [ + { + sakslisteId: 1, + navn: 'test', + saksbehandlerIdenter: [], + sortering: { + sorteringType: KøSortering.BEHANDLINGSFRIST, + fra: 1, + til: 4, + erDynamiskPeriode: true, + }, + behandlingTyper: [BehandlingType.FORSTEGANGSSOKNAD], + fagsakYtelseTyper: [FagsakYtelseType.FORELDREPENGER], + andreKriterier: [ + { + andreKriterierType: AndreKriterierType.TIL_BESLUTTER, + inkluder: true, + }, + { + andreKriterierType: AndreKriterierType.PAPIRSOKNAD, + inkluder: false, + }, + ], + }, +] satisfies SakslisteAvdeling[]; + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/EndreSakslisterPanel', + component: EndreSakslisterPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.OPPGAVE_ANTALL, () => HttpResponse.json(1)), + http.get(LosUrl.SAKSLISTER_FOR_AVDELING, () => HttpResponse.json(SAKSLISTER)), + http.post(LosUrl.LAGRE_SAKSLISTE_NAVN, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_INTERVALL, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_DYNAMISK_PERIDE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_FAGSAK_YTELSE_TYPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_BEHANDLINGSTYPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_ANDRE_KRITERIER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + render: args => { + const { data: kodeverkLos } = useQuery(losKodeverkOptions()); + + return kodeverkLos ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + valgtAvdelingEnhet: 'NAV Oslo', + avdelingensSaksbehandlere: [], + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.tsx new file mode 100644 index 00000000000..eb7658e24cb --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/EndreSakslisterPanel.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { ArrowDownIcon } from '@navikt/aksel-icons'; +import { HStack } from '@navikt/ds-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { + oppgaverForAvdelingAntallOptions, + opprettNySaksliste, + sakslisterForAvdelingOptions, +} from '../data/fplosAvdelingslederApi'; +import { GjeldendeSakslisterTabell } from './GjeldendeSakslisterTabell'; +import { SaksbehandlereForSakslisteForm } from './saksbehandlerForm/SaksbehandlereForSakslisteForm'; +import { UtvalgskriterierForSakslisteForm } from './sakslisteForm/UtvalgskriterierForSakslisteForm'; + +import styles from './endreSakslisterPanel.module.css'; + +interface Props { + valgtAvdelingEnhet: string; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; +} + +export const EndreSakslisterPanel = ({ valgtAvdelingEnhet, avdelingensSaksbehandlere }: Props) => { + const intl = useIntl(); + const [valgtSakslisteId, setValgtSakslisteId] = useState(); + + const { data: oppgaverForAvdelingAntall } = useQuery(oppgaverForAvdelingAntallOptions(valgtAvdelingEnhet)); + const { data: sakslister, refetch: refetchSakslister } = useQuery(sakslisterForAvdelingOptions(valgtAvdelingEnhet)); + + const { mutate: lagNySakslisteOgHentAvdelingensSakslisterPåNytt, data: nySakslisteObject } = useMutation({ + mutationFn: () => opprettNySaksliste(valgtAvdelingEnhet), + onSuccess: () => { + setValgtSakslisteId(undefined); + refetchSakslister(); + }, + }); + + const nyId = nySakslisteObject ? parseInt(nySakslisteObject.sakslisteId, 10) : undefined; + const valgtSakId = valgtSakslisteId !== undefined ? valgtSakslisteId : nyId; + + const valgtSaksliste = sakslister.find(s => s.sakslisteId === valgtSakId); + + return ( + setValgtSakslisteId(undefined)} + > +
+ {valgtSakId && valgtSaksliste && ( + + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.spec.tsx new file mode 100644 index 00000000000..7f7375a9faf --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.spec.tsx @@ -0,0 +1,53 @@ +import { composeStories } from '@storybook/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './GjeldendeSakslisterTabell.stories'; + +const { TabellNårDetIkkeFinnesBehandlingskøer, TabellNårDetFinnesEnBehandlingskø } = composeStories(stories); + +describe('GjeldendeSakslisterTabell', () => { + it('skal vise at ingen behandlingskøer er laget og så legge til en ny kø', async () => { + await applyRequestHandlers(TabellNårDetIkkeFinnesBehandlingskøer.parameters['msw']); + render(); + expect(await screen.findByText('Ingen behandlingskøer er laget')).toBeInTheDocument(); + expect(screen.queryByText('Navn')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Legg til behandlingskø')); + + expect(await screen.findByText('Navn')).toBeInTheDocument(); + expect(await screen.findByText('Ny liste')).toBeInTheDocument(); + }); + + it('skal vise slette kø ved å trykke på ikon for sletting', async () => { + await applyRequestHandlers(TabellNårDetFinnesEnBehandlingskø.parameters['msw']); + render(); + expect(await screen.findByText('Navn')).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole('img')[1]); + + expect(await screen.findByText('Ønsker du å slette Saksliste 1?')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Ja')); + + expect(screen.queryByText('Ønsker du å slette Saksliste 1?')).not.toBeInTheDocument(); + }); + + it('skal legge til en ny kø ved bruk av tastaturet (enter)', async () => { + await applyRequestHandlers(TabellNårDetIkkeFinnesBehandlingskøer.parameters['msw']); + render(); + expect(await screen.findByText('Ingen behandlingskøer er laget')).toBeInTheDocument(); + expect(screen.queryByText('Navn')).not.toBeInTheDocument(); + + await fireEvent.keyDown(screen.getByText('Legg til behandlingskø'), { + key: 'Enter', + code: 'Enter', + keyCode: 13, + charCode: 13, + }); + + expect(screen.getByText('Navn')).toBeInTheDocument(); + expect(screen.getByText('Ny liste')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.stories.tsx new file mode 100644 index 00000000000..ee5da192d37 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; + +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { action } from 'storybook/actions'; + +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../data/fplosAvdelingslederApi'; +import { GjeldendeSakslisterTabell } from './GjeldendeSakslisterTabell'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/GjeldendeSakslisterTabell', + component: GjeldendeSakslisterTabell, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.post(LosUrl.SLETT_SAKSLISTE, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + setValgtSakslisteId: action('button-click'), + resetValgtSakslisteId: action('button-click'), + lagNySaksliste: action('button-click'), + valgtAvdelingEnhet: '1', + children:
test
, + }, + render: storyArgs => { + const [args, setArgs] = useState(storyArgs); + + const { data: kodeverkLos } = useQuery(losKodeverkOptions()); + + const lagNySaksliste = () => { + args.lagNySaksliste?.(); + setArgs(oldArgs => ({ + ...oldArgs, + sakslister: oldArgs.sakslister.concat({ + sakslisteId: oldArgs.sakslister.length === 1 ? 1 : 2, + navn: 'Ny liste', + saksbehandlerIdenter: [], + }), + })); + }; + + return kodeverkLos ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const TabellNårDetIkkeFinnesBehandlingskøer: Story = { + args: { + sakslister: [], + }, +}; + +export const TabellNårDetFinnesEnBehandlingskø: Story = { + args: { + sakslister: [ + { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: ['R23233'], + }, + ], + oppgaverForAvdelingAntall: 1, + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.tsx new file mode 100644 index 00000000000..b617466e2b0 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/GjeldendeSakslisterTabell.tsx @@ -0,0 +1,243 @@ +import { type KeyboardEvent, type ReactElement, useEffect, useRef, useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { PlusCircleIcon, XMarkIcon } from '@navikt/aksel-icons'; +import { BodyShort, Detail, HStack, Label, Link, Loader, Table, VStack } from '@navikt/ds-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +import { LosUrl, oppgaveAntallOptions, slettSaksliste } from '../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../data/useLosKodeverk'; +import type { SakslisteAvdeling } from '../typer/sakslisteAvdelingTsType'; +import { SletteSakslisteModal } from './SletteSakslisteModal'; + +import styles from './gjeldendeSakslisterTabell.module.css'; + +const headerTextCodes = [ + 'GjeldendeSakslisterTabell.Listenavn', + 'GjeldendeSakslisterTabell.Stonadstype', + 'GjeldendeSakslisterTabell.Behandlingtype', + 'GjeldendeSakslisterTabell.AntallSaksbehandlere', + 'GjeldendeSakslisterTabell.AntallBehandlinger', +]; + +const formatStonadstyper = ( + fagsakYtelseTyper: LosKodeverkMedNavn<'FagsakYtelseType'>[], + valgteFagsakYtelseTyper?: string[], +) => { + if (!valgteFagsakYtelseTyper || valgteFagsakYtelseTyper.length === 0) { + return ; + } + + return valgteFagsakYtelseTyper + .map(fyt => { + const type = fagsakYtelseTyper.find(def => def.kode === fyt); + return type ? type.navn : ''; + }) + .sort((a, b) => a.localeCompare(b)) + .join(', '); +}; + +const formatBehandlingstyper = ( + behandlingTyper: LosKodeverkMedNavn<'BehandlingType'>[], + valgteBehandlingTyper?: string[], +) => { + if ( + !valgteBehandlingTyper || + valgteBehandlingTyper.length === 0 || + valgteBehandlingTyper.length === behandlingTyper.length + ) { + return ; + } + + return valgteBehandlingTyper + .map(bt => { + const type = behandlingTyper.find(def => def.kode === bt); + return type ? type.navn : ''; + }) + .sort((a, b) => a.localeCompare(b)) + .join(', '); +}; + +interface Props { + sakslister: SakslisteAvdeling[]; + setValgtSakslisteId: (sakslisteId?: number) => void; + valgtSakslisteId?: number; + valgtAvdelingEnhet: string; + oppgaverForAvdelingAntall?: number; + lagNySaksliste: () => void; + resetValgtSakslisteId: () => void; + children: ReactElement; +} + +const wait = (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +export const GjeldendeSakslisterTabell = ({ + sakslister, + valgtAvdelingEnhet, + setValgtSakslisteId, + valgtSakslisteId, + oppgaverForAvdelingAntall, + lagNySaksliste, + resetValgtSakslisteId, + children, +}: Props) => { + const queryClient = useQueryClient(); + + const [valgtSakslisteForSletting, setValgtSakslisteForSletting] = useState(); + const tabRef = useRef<(HTMLDivElement | null)[]>([]); + + const behandlingTyper = useLosKodeverk('BehandlingType'); + const fagsakYtelseTyper = useLosKodeverk('FagsakYtelseType'); + + const { mutate: fjernSaksliste } = useMutation({ + mutationFn: (values: { sakslisteId: number }) => slettSaksliste(values.sakslisteId, valgtAvdelingEnhet), + onSuccess: () => { + resetValgtSakslisteId(); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + useEffect(() => { + tabRef.current = tabRef.current.slice(0, sakslister.length); + }, [sakslister]); + + const setValgtSaksliste = async (isOpen: boolean, id: number): Promise => { + // Må vente 100 ms før en byttar behandlingskø i tabell. Dette fordi lagring av navn skjer som blur-event. Så i tilfellet + // der en endrer navn og så trykker direkte på en annen behandlingskø vil ikke lagringen skje før etter at ny kø er valgt. + await wait(100); + + if (id) { + setValgtSakslisteId(isOpen ? id : undefined); + } + return Promise.resolve(); + }; + + const lagNySakslisteFn = (event: KeyboardEvent): void => { + if (event.keyCode === 13) { + lagNySaksliste(); + } + }; + + const fjernSakslisteOgSkjulModal = (saksliste: SakslisteAvdeling): void => { + setValgtSakslisteForSletting(undefined); + fjernSaksliste({ sakslisteId: saksliste.sakslisteId }); + }; + + return ( + + + +
+ + + + {oppgaverForAvdelingAntall ?? '0'} +
+
+ {sakslister.length === 0 && ( + + + + )} + {sakslister.length > 0 && ( + + + + + {headerTextCodes.map(code => ( + + + + ))} + + + + + {sakslister.map((saksliste, index) => ( + setValgtSaksliste(isOpen, saksliste.sakslisteId)} + content={saksliste.sakslisteId === valgtSakslisteId ? children : undefined} + open={saksliste.sakslisteId === valgtSakslisteId} + expandOnRowClick + > + {saksliste.navn} + {formatStonadstyper(fagsakYtelseTyper, saksliste.fagsakYtelseTyper)} + {formatBehandlingstyper(behandlingTyper, saksliste.behandlingTyper)} + + {saksliste.saksbehandlerIdenter.length > 0 ? saksliste.saksbehandlerIdenter.length : ''} + + + + + +
{ + tabRef.current[index] = el; + }} + > + setValgtSakslisteForSletting(saksliste)} + onKeyDown={() => setValgtSakslisteForSletting(saksliste)} + /> +
+
+
+ ))} +
+
+ )} + + + + + + + {valgtSakslisteForSletting && ( + setValgtSakslisteForSletting(undefined)} + submit={fjernSakslisteOgSkjulModal} + /> + )} +
+ ); +}; + +const AntallOppgaverForSaksliste = ({ + valgtAvdelingEnhet, + sakslisteId, +}: { + valgtAvdelingEnhet: string; + sakslisteId: number; +}) => { + const intl = useIntl(); + const { data: antallOppgaver, isFetching, isError } = useQuery(oppgaveAntallOptions(sakslisteId, valgtAvdelingEnhet)); + + if (isError) { + return ; + } + + return isFetching ? ( + + ) : ( + antallOppgaver + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.spec.tsx new file mode 100644 index 00000000000..49c2e7da079 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.spec.tsx @@ -0,0 +1,34 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import * as stories from './SletteSakslisteModal.stories'; + +const { Default } = composeStories(stories); + +describe('SletteSakslisteModal', () => { + it('skal vise modal for sletting av saksliste og så svare ja', async () => { + const submit = vi.fn(); + render(); + expect(await screen.findByText('Ønsker du å slette Saksliste 1?')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Ja')); + + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit).toHaveBeenNthCalledWith(1, { + navn: 'Saksliste 1', + saksbehandlerIdenter: [], + sakslisteId: 1, + }); + }); + + it('skal vise modal for sletting av saksliste og så svare nei', async () => { + const cancel = vi.fn(); + render(); + expect(await screen.findByText('Ønsker du å slette Saksliste 1?')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Nei')); + + await waitFor(() => expect(cancel).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.stories.tsx new file mode 100644 index 00000000000..f50412606dd --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import { getIntlDecorator } from '@navikt/fp-storybook-utils'; + +import { SletteSakslisteModal } from './SletteSakslisteModal'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/SletteSakslisteModal', + component: SletteSakslisteModal, + decorators: [withIntl], + args: { + cancel: action('button-click'), + submit: action('button-click'), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: [], + }, + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.tsx new file mode 100644 index 00000000000..9817422baf7 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/SletteSakslisteModal.tsx @@ -0,0 +1,51 @@ +import { FormattedMessage, useIntl } from 'react-intl'; + +import { Button, Heading, Modal } from '@navikt/ds-react'; + +import type { SakslisteAvdeling } from '../typer/sakslisteAvdelingTsType'; + +import styles from './sletteSakslisteModal.module.css'; + +interface Props { + valgtSaksliste: SakslisteAvdeling; + cancel: () => void; + submit: (saksliste: SakslisteAvdeling) => void; +} + +/** + * SletteSakslisteModal + * + * Modal som lar en avdelingsleder fjerne sakslister. + */ +export const SletteSakslisteModal = ({ valgtSaksliste, cancel, submit }: Props) => { + const intl = useIntl(); + return ( + + + + + + + + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/endreSakslisterPanel.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/endreSakslisterPanel.module.css new file mode 100644 index 00000000000..4ba4a77f3ad --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/endreSakslisterPanel.module.css @@ -0,0 +1,13 @@ +.text { + padding-top: 10px; +} + +.leftCol { + width: 40%; +} + +.arrow { + color: var(--ax-neutral-500); + height: 35px; + width: 35px; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/gjeldendeSakslisterTabell.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/gjeldendeSakslisterTabell.module.css new file mode 100644 index 00000000000..481e5e6bf84 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/gjeldendeSakslisterTabell.module.css @@ -0,0 +1,29 @@ +.isSelected { + background-color: var(--ax-neutral-200); +} + +.addCircleIcon, +.imageText { + display: inline-block; + margin-right: 1ex; + vertical-align: text-bottom; +} + +.grayBox { + background-color: var(--ax-neutral-200); + padding: 5px 15px 5px 15px; +} + +.margin { + margin-left: auto; +} + +.addPeriode { + cursor: pointer; + width: 200px; +} + +.removeImage { + color: var(--ax-danger-500); + margin-top: 5px; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.spec.tsx new file mode 100644 index 00000000000..e142122b184 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.spec.tsx @@ -0,0 +1,38 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './SaksbehandlereForSakslisteForm.stories'; + +const { IngenSaksbehandlere, ToSaksbehandlere, SaksbehandlereSomErGruppert } = composeStories(stories); + +describe('SaksbehandlereForSakslisteForm', () => { + it('skal vise tekst som viser at ingen saksbehandlere er tilknyttet', async () => { + await applyRequestHandlers(IngenSaksbehandlere.parameters['msw']); + render(); + expect(await screen.findByText('Saksbehandlere')).toBeInTheDocument(); + expect(await screen.findByText('Avdelingen har ingen saksbehandlere tilknyttet')).toBeInTheDocument(); + }); + + it('skal vise to saksbehandlere i listen', async () => { + await applyRequestHandlers(ToSaksbehandlere.parameters['msw']); + render(); + expect(await screen.findByText('Saksbehandlere')).toBeInTheDocument(); + expect(await screen.findByText('Espen Utvikler')).toBeInTheDocument(); + expect(screen.getByText('Steffen')).toBeInTheDocument(); + }); + + it.skip('skal vise gruppe og liste med alle saksbehandlere', async () => { + await applyRequestHandlers(SaksbehandlereSomErGruppert.parameters['msw']); + render(); + expect(await screen.findByText('Saksbehandlere')).toBeInTheDocument(); + expect(screen.getByText('Gruppenavn')).toBeInTheDocument(); + expect(screen.getByText('Gruppe 1')).toBeInTheDocument(); + expect(screen.getByText('Antall tilknyttet køen')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + + expect(screen.getAllByText('Steffen')).toHaveLength(2); + expect(screen.getAllByText('Espen Utvikler')).toHaveLength(2); + expect(screen.getByText('Eirik')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.stories.tsx new file mode 100644 index 00000000000..e7d644a5a73 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; + +import { getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { LosUrl } from '../../data/fplosAvdelingslederApi'; +import { SaksbehandlereForSakslisteForm } from './SaksbehandlereForSakslisteForm'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/SaksbehandlereForSakslisteForm', + component: SaksbehandlereForSakslisteForm, + decorators: [withIntl, withQueryClient], + args: { + valgtAvdelingEnhet: 'Nav Vikafossen', + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const IngenSaksbehandlere: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_GRUPPER, () => HttpResponse.json()), + http.post(LosUrl.LAGRE_SAKSLISTE_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: ['S34354'], + }, + avdelingensSaksbehandlere: [], + }, +}; + +export const ToSaksbehandlere: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_GRUPPER, () => HttpResponse.json()), + http.post(LosUrl.LAGRE_SAKSLISTE_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: ['S34354'], + }, + avdelingensSaksbehandlere: [ + { + brukerIdent: 'E23232', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'S34354', + navn: 'Steffen', + ansattAvdeling: 'Avdeling Å', + }, + ], + }, +}; + +export const TreSaksbehandlere: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_GRUPPER, () => HttpResponse.json()), + http.post(LosUrl.LAGRE_SAKSLISTE_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: ['S34354'], + }, + avdelingensSaksbehandlere: [ + { + brukerIdent: 'E23232', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'S34354', + navn: 'Steffen', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'E24353', + navn: 'Eirik', + ansattAvdeling: 'Avdeling Å', + }, + ], + }, +}; + +export const SaksbehandlereSomErGruppert: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_GRUPPER, () => + HttpResponse.json({ + saksbehandlerGrupper: [ + { + gruppeId: 1, + gruppeNavn: 'Gruppe 1', + saksbehandlere: [ + { + brukerIdent: 'E23232', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'S34354', + navn: 'Steffen', + ansattAvdeling: 'Avdeling Å', + }, + ], + }, + ], + }), + ), + http.post(LosUrl.LAGRE_SAKSLISTE_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'Saksliste 1', + saksbehandlerIdenter: ['S34354'], + }, + avdelingensSaksbehandlere: [ + { + brukerIdent: 'E23232', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'S34354', + navn: 'Steffen', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'E24353', + navn: 'Eirik', + ansattAvdeling: 'Avdeling Å', + }, + ], + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.tsx new file mode 100644 index 00000000000..24e644ae788 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/SaksbehandlereForSakslisteForm.tsx @@ -0,0 +1,146 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { BodyShort, Box, ExpansionCard, Label, Table, VStack } from '@navikt/ds-react'; +import { RhfForm } from '@navikt/ft-form-hooks'; +import { useQuery } from '@tanstack/react-query'; + +import { type SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { grupperOptions } from '../../data/fplosAvdelingslederApi'; +import type { SaksbehandlerGruppe } from '../../typer/saksbehandlereOgSaksbehandlerGrupper'; +import type { SakslisteAvdeling } from '../../typer/sakslisteAvdelingTsType'; +import { ValgAvSaksbehandlere } from './ValgAvSaksbehandlere'; + +type FormValues = { + reserverTil: string; +}; + +interface Props { + valgtSaksliste: SakslisteAvdeling; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; + valgtAvdelingEnhet: string; +} + +export const SaksbehandlereForSakslisteForm = ({ + avdelingensSaksbehandlere = [], + valgtSaksliste, + valgtAvdelingEnhet, +}: Props) => { + const intl = useIntl(); + + const sorterteAvdelingensSaksbehandlere = avdelingensSaksbehandlere.toSorted((saksbehandler1, saksbehandler2) => + saksbehandler1.navn.localeCompare(saksbehandler2.navn), + ); + + const { data: grupper } = useQuery(grupperOptions(valgtAvdelingEnhet)); + + const defaultValues = valgtSaksliste.saksbehandlerIdenter.reduce( + (acc, brukerIdent) => ({ ...acc, [brukerIdent]: true }), + {}, + ); + + const formMethods = useForm({ + defaultValues, + }); + + useEffect(() => { + formMethods.reset(defaultValues); + }, [valgtSaksliste.sakslisteId]); + + const harGrupper = + grupper && + grupper.saksbehandlerGrupper.length > 0 && + !grupper.saksbehandlerGrupper.every(sg => sg.saksbehandlere.length === 0); + + return ( + formMethods={formMethods}> + + + + {sorterteAvdelingensSaksbehandlere.length === 0 && ( + + + + )} + + {harGrupper && ( + + + + + + + + + + + + + + {grupper.saksbehandlerGrupper.map(sg => ( + ({ + brukerIdent: sb.brukerIdent, + navn: sb.navn, + }))} + valgtSaksliste={valgtSaksliste} + valgtAvdelingEnhet={valgtAvdelingEnhet} + /> + } + expandOnRowClick + > + {sg.gruppeNavn} + {antallTilknyttetSaksliste(valgtSaksliste, sg)} + + ))} + +
+ )} +
+ {sorterteAvdelingensSaksbehandlere.length > 0 && !harGrupper && ( + + )} + {harGrupper && ( + + + + + + + + + + + )} +
+
+ + ); +}; + +const antallTilknyttetSaksliste = (saksliste: SakslisteAvdeling, gruppe: SaksbehandlerGruppe) => { + let matchCount = 0; + saksliste.saksbehandlerIdenter.forEach(ident => { + const matches = gruppe.saksbehandlere.filter(saksbehandler => saksbehandler.brukerIdent === ident); + matchCount += matches.length; + }); + return matchCount; +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/ValgAvSaksbehandlere.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/ValgAvSaksbehandlere.tsx new file mode 100644 index 00000000000..95b5b0367ea --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/saksbehandlerForm/ValgAvSaksbehandlere.tsx @@ -0,0 +1,75 @@ +import { useFormContext } from 'react-hook-form'; + +import { HStack, VStack } from '@navikt/ds-react'; +import { RhfCheckbox } from '@navikt/ft-form-hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { lagreSakslisteSaksbehandler, LosUrl } from '../../data/fplosAvdelingslederApi'; +import type { SakslisteAvdeling } from '../../typer/sakslisteAvdelingTsType'; + +interface Props { + valgtSaksliste: SakslisteAvdeling; + valgtAvdelingEnhet: string; + saksbehandlere: { + brukerIdent: string; + navn: string; + }[]; +} + +export const ValgAvSaksbehandlere = ({ valgtSaksliste, valgtAvdelingEnhet, saksbehandlere }: Props) => { + const queryClient = useQueryClient(); + + // TODO (TOR) Manglar type + const { control } = useFormContext(); + + const { mutate: knyttSaksbehandlerTilSaksliste } = useMutation({ + mutationFn: (values: { brukerIdent: string; checked: boolean }) => + lagreSakslisteSaksbehandler(valgtSaksliste.sakslisteId, values.brukerIdent, values.checked, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const pos = Math.ceil(saksbehandlere.length / 2); + const avdelingensSaksbehandlereVenstreListe = saksbehandlere.slice(0, pos); + const avdelingensSaksbehandlereHoyreListe = saksbehandlere.slice(pos); + + return ( + + + {avdelingensSaksbehandlereVenstreListe.map(s => ( + + knyttSaksbehandlerTilSaksliste({ + brukerIdent: s.brukerIdent, + checked: isChecked, + }) + } + /> + ))} + + + {avdelingensSaksbehandlereHoyreListe.map(s => ( + + knyttSaksbehandlerTilSaksliste({ + brukerIdent: s.brukerIdent, + checked: isChecked, + }) + } + /> + ))} + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.spec.tsx new file mode 100644 index 00000000000..e720654a0e0 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.spec.tsx @@ -0,0 +1,37 @@ +import { composeStories } from '@storybook/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './UtvalgskriterierForSakslisteForm.stories'; + +const { MedGittNavn, MedDefaultNavn } = composeStories(stories); + +describe('UtvalgskriterierForSakslisteForm', () => { + it('skal vise sakslistenavn som saksbehandler har skrive inn', async () => { + await applyRequestHandlers(MedGittNavn.parameters['msw']); + render(); + expect(await screen.findByText('Navn')).toBeInTheDocument(); + expect(await screen.findByLabelText('Navn')).toHaveValue('liste'); + }); + + it('skal vise default sakslistenavn', async () => { + await applyRequestHandlers(MedDefaultNavn.parameters['msw']); + render(); + expect(await screen.findByText('Navn')).toBeInTheDocument(); + expect(await screen.findByLabelText('Navn')).toHaveValue('Ny behandlingskø'); + }); + + it('skal vise feilmelding når en fjerner nok tegn til at navnet blir færre enn 3 tegn langt', async () => { + await applyRequestHandlers(MedGittNavn.parameters['msw']); + const { getByLabelText } = render(); + + expect(await screen.findByText('Navn')).toBeInTheDocument(); + + const navnInput = getByLabelText('Navn'); + await userEvent.type(navnInput, '{Backspace}{Backspace}{Backspace}'); + await fireEvent.blur(navnInput); + + expect(await screen.findByText('Du må skrive minst 3 tegn')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.stories.tsx new file mode 100644 index 00000000000..ab309e8ff3f --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.stories.tsx @@ -0,0 +1,103 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { AndreKriterierType, BehandlingType, FagsakYtelseType, KøSortering } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../data/fplosAvdelingslederApi'; +import { UtvalgskriterierForSakslisteForm } from './UtvalgskriterierForSakslisteForm'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/UtvalgskriterierForSakslisteForm', + component: UtvalgskriterierForSakslisteForm, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.OPPGAVE_ANTALL, () => HttpResponse.json(1)), + http.post(LosUrl.LAGRE_SAKSLISTE_NAVN, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_INTERVALL, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_DYNAMISK_PERIDE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_TIDSINTERVALL_DATO, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_FAGSAK_YTELSE_TYPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_BEHANDLINGSTYPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_ANDRE_KRITERIER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtAvdelingEnhet: '', + }, + render: args => { + const { data: kodeverkLos } = useQuery(losKodeverkOptions()); + + return kodeverkLos ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const MedGittNavn: Story = { + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: 'liste', + saksbehandlerIdenter: [], + sortering: { + sorteringType: KøSortering.BEHANDLINGSFRIST, + fra: 1, + til: 4, + erDynamiskPeriode: true, + }, + behandlingTyper: [BehandlingType.FORSTEGANGSSOKNAD], + fagsakYtelseTyper: [FagsakYtelseType.FORELDREPENGER], + andreKriterier: [ + { + andreKriterierType: AndreKriterierType.TIL_BESLUTTER, + inkluder: true, + }, + { + andreKriterierType: AndreKriterierType.PAPIRSOKNAD, + inkluder: false, + }, + ], + }, + }, +}; + +export const MedDefaultNavn: Story = { + args: { + valgtSaksliste: { + sakslisteId: 1, + navn: undefined, + saksbehandlerIdenter: [], + sortering: { + sorteringType: KøSortering.BEHANDLINGSFRIST, + fra: 1, + til: 4, + erDynamiskPeriode: true, + }, + behandlingTyper: [BehandlingType.FORSTEGANGSSOKNAD], + fagsakYtelseTyper: [FagsakYtelseType.FORELDREPENGER], + andreKriterier: [ + { + andreKriterierType: AndreKriterierType.TIL_BESLUTTER, + inkluder: true, + }, + { + andreKriterierType: AndreKriterierType.PAPIRSOKNAD, + inkluder: false, + }, + ], + }, + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.tsx new file mode 100644 index 00000000000..83380cd0879 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/UtvalgskriterierForSakslisteForm.tsx @@ -0,0 +1,148 @@ +import { useForm } from 'react-hook-form'; +import { FormattedMessage, type IntlShape, useIntl } from 'react-intl'; + +import { Box, Heading, HStack, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfTextField } from '@navikt/ft-form-hooks'; +import { hasValidName, maxLength, minLength, required } from '@navikt/ft-form-validators'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { lagreSakslisteNavn, LosUrl } from '../../data/fplosAvdelingslederApi'; +import type { SakslisteAvdeling } from '../../typer/sakslisteAvdelingTsType'; +import { AndreKriterierVelger } from './filtrering/AndreKriterierVelger'; +import { BehandlingstypeVelger } from './filtrering/BehandlingstypeVelger'; +import { FagsakYtelseTypeVelger } from './filtrering/FagsakYtelseTypeVelger'; +import { SorteringVelger } from './sortering/SorteringVelger'; +import { useDebounce } from './useDebounce'; + +import styles from './utvalgskriterierForSakslisteForm.module.css'; + +const minLength3 = minLength(3); +const maxLength100 = maxLength(100); + +type FormValues = { + sakslisteId: number; + navn: string; + sortering?: string; + erDynamiskPeriode?: boolean; + fra?: string; + til?: string; + fomDato?: string; + tomDato?: string; +}; + +const buildDefaultValues = (intl: IntlShape, valgtSaksliste: SakslisteAvdeling): FormValues => { + const behandlingTypes = valgtSaksliste.behandlingTyper + ? valgtSaksliste.behandlingTyper.reduce((acc, bt) => ({ ...acc, [bt]: true }), {}) + : {}; + const fagsakYtelseTypes = valgtSaksliste.fagsakYtelseTyper + ? valgtSaksliste.fagsakYtelseTyper.reduce((acc, fyt) => ({ ...acc, [fyt]: true }), {}) + : {}; + + const andreKriterierTyper = valgtSaksliste.andreKriterier + ? valgtSaksliste.andreKriterier.reduce((acc, ak) => ({ ...acc, [ak.andreKriterierType]: true }), {}) + : {}; + const andreKriterierInkluder = valgtSaksliste.andreKriterier + ? valgtSaksliste.andreKriterier.reduce( + (acc, ak) => ({ ...acc, [`${ak.andreKriterierType}_inkluder`]: ak.inkluder }), + {}, + ) + : {}; + + return { + sakslisteId: valgtSaksliste.sakslisteId, + navn: valgtSaksliste.navn ?? intl.formatMessage({ id: 'UtvalgskriterierForSakslisteForm.NyListe' }), + sortering: valgtSaksliste.sortering ? valgtSaksliste.sortering.sorteringType : undefined, + fomDato: valgtSaksliste.sortering ? valgtSaksliste.sortering.fomDato : undefined, + tomDato: valgtSaksliste.sortering ? valgtSaksliste.sortering.tomDato : undefined, + fra: valgtSaksliste.sortering ? valgtSaksliste.sortering.fra?.toString() : undefined, + til: valgtSaksliste.sortering ? valgtSaksliste.sortering.til?.toString() : undefined, + erDynamiskPeriode: valgtSaksliste.sortering ? valgtSaksliste.sortering.erDynamiskPeriode : undefined, + ...andreKriterierTyper, + ...andreKriterierInkluder, + ...behandlingTypes, + ...fagsakYtelseTypes, + }; +}; + +interface Props { + valgtSaksliste: SakslisteAvdeling; + valgtAvdelingEnhet: string; +} + +export const UtvalgskriterierForSakslisteForm = ({ valgtSaksliste, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + + const { mutate: lagreSakslistensNavn } = useMutation({ + mutationFn: (values: { sakslisteId: number; navn: string; avdelingEnhet: string }) => + lagreSakslisteNavn(values.sakslisteId, values.navn, values.avdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const formMethods = useForm({ + defaultValues: buildDefaultValues(intl, valgtSaksliste), + }); + + const values = formMethods.watch(); + + const tranformValues = (nyttNavn: string): void => { + lagreSakslistensNavn({ + sakslisteId: valgtSaksliste.sakslisteId, + navn: nyttNavn, + avdelingEnhet: valgtAvdelingEnhet, + }); + }; + const lagreNavn = useDebounce('navn', tranformValues, formMethods.trigger); + + return ( + + + + + + + + lagreNavn(value)} + className={styles.bredde} + /> + +
+ + +
+
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.spec.tsx new file mode 100644 index 00000000000..d869772106e --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.spec.tsx @@ -0,0 +1,35 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './AndreKriterierVelger.stories'; + +const { Default } = composeStories(stories); + +describe('AndreKriterierVelger', () => { + it('skal vise checkboxer for andre kriterier der Til beslutter er valgt fra før', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Til beslutter')).toBeInTheDocument(); + expect(getByLabelText('Til beslutter')).toBeChecked(); + expect(getByLabelText('Ta med i køen')).toBeChecked(); + expect(getByLabelText('Fjern fra køen')).not.toBeChecked(); + }); + + it('skal velge Registrer papirsøknad og fjerne dette fra køen', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getAllByLabelText } = render(); + expect(await screen.findByText('Registrer papirsøknad')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Registrer papirsøknad')); + + expect(getAllByLabelText('Ta med i køen')[1]).toBeChecked(); + expect(getAllByLabelText('Fjern fra køen')[1]).not.toBeChecked(); + + await userEvent.click(getAllByLabelText('Fjern fra køen')[1]); + + await waitFor(() => expect(getAllByLabelText('Fjern fra køen')[1]).toBeChecked()); + expect(getAllByLabelText('Ta med i køen')[1]).not.toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.stories.tsx new file mode 100644 index 00000000000..d65402225bf --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.stories.tsx @@ -0,0 +1,59 @@ +import { useForm } from 'react-hook-form'; + +import { RhfForm } from '@navikt/ft-form-hooks'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { AndreKriterierType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { AndreKriterierVelger } from './AndreKriterierVelger'; + +import messages from '../../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/AndreKriterierVelger', + component: AndreKriterierVelger, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.post(LosUrl.LAGRE_SAKSLISTE_ANDRE_KRITERIER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSakslisteId: 1, + valgtAvdelingEnhet: 'Nav Vikafossen', + }, + render: args => { + const formMethods = useForm({ + defaultValues: { + [AndreKriterierType.TIL_BESLUTTER]: true, + [`${AndreKriterierType.TIL_BESLUTTER}_inkluder`]: true, + }, + }); + + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + + return alleKodeverk ? ( + + + + ) : ( + + ); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.tsx new file mode 100644 index 00000000000..f2ca3bcf635 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/AndreKriterierVelger.tsx @@ -0,0 +1,103 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { Label, VStack } from '@navikt/ds-react'; +import { RhfCheckbox, RhfRadioGroup } from '@navikt/ft-form-hooks'; +import { ArrowBox } from '@navikt/ft-ui-komponenter'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { lagreSakslisteAndreKriterier, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../../../data/useLosKodeverk'; + +import styles from './andreKriterierVelger.module.css'; + +interface Props { + valgtSakslisteId: number; + valgtAvdelingEnhet: string; +} + +export const AndreKriterierVelger = ({ valgtSakslisteId, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + const { setValue, watch, control } = useFormContext(); + + const values = watch(); + + const andreKriterierTyper = useLosKodeverk('AndreKriterierType'); + + const { mutate: lagreAndreKriterier } = useMutation({ + mutationFn: (valuesToStore: { andreKriterierType: string; checked: boolean; inkluder: boolean }) => + lagreSakslisteAndreKriterier( + valgtSakslisteId, + valgtAvdelingEnhet, + valuesToStore.andreKriterierType, + valuesToStore.checked, + valuesToStore.inkluder, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + return ( + + + {andreKriterierTyper.map(akt => ( +
+ { + setValue(`${akt.kode}_inkluder`, true); + return lagreAndreKriterier({ + andreKriterierType: akt.kode, + checked: isChecked, + inkluder: true, + }); + }} + /> + {values[akt.kode] && ( +
+ + + lagreAndreKriterier({ + andreKriterierType: akt.kode, + checked: true, + inkluder: skalInkludere, + }) + } + radios={[ + { + value: 'true', + label: , + }, + { + value: 'false', + label: , + }, + ]} + /> + +
+ )} +
+ ))} +
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.spec.tsx new file mode 100644 index 00000000000..3c512e13928 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.spec.tsx @@ -0,0 +1,29 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './BehandlingstypeVelger.stories'; + +const { Default } = composeStories(stories); + +describe('BehandlingstypeVelger', () => { + it('skal vise checkboxer for behandlingstyper', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Behandlingstype')).toBeInTheDocument(); + expect(getByLabelText('Førstegangsbehandling')).toBeChecked(); + expect(getByLabelText('Klage')).not.toBeChecked(); + }); + + it('skal velge klage', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Behandlingstype')).toBeInTheDocument(); + expect(getByLabelText('Klage')).not.toBeChecked(); + + await userEvent.click(screen.getByText('Klage')); + + await waitFor(() => expect(getByLabelText('Klage')).toBeChecked()); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.stories.tsx new file mode 100644 index 00000000000..ee628bf43b8 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.stories.tsx @@ -0,0 +1,58 @@ +import { useForm } from 'react-hook-form'; + +import { RhfForm } from '@navikt/ft-form-hooks'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { BehandlingstypeVelger } from './BehandlingstypeVelger'; + +import messages from '../../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/BehandlingstypeVelger', + component: BehandlingstypeVelger, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.post(LosUrl.LAGRE_SAKSLISTE_BEHANDLINGSTYPE, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSakslisteId: 1, + valgtAvdelingEnhet: 'Nav Vikafossen', + }, + render: args => { + const formMethods = useForm({ + defaultValues: { + [BehandlingType.FORSTEGANGSSOKNAD]: true, + }, + }); + + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + + return alleKodeverk ? ( + + + + ) : ( + + ); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.tsx new file mode 100644 index 00000000000..7622f11f2f2 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/BehandlingstypeVelger.tsx @@ -0,0 +1,79 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { Label, VStack } from '@navikt/ds-react'; +import { RhfCheckbox } from '@navikt/ft-form-hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; + +import { lagreSakslisteBehandlingstype, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../../../data/useLosKodeverk'; + +const behandlingstypeOrder = Object.values(BehandlingType); + +interface Props { + valgtSakslisteId: number; + valgtAvdelingEnhet: string; +} + +export const BehandlingstypeVelger = ({ valgtSakslisteId, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + + // TODO (TOR) Manglar type + const { control } = useFormContext(); + + const { mutate: lagreBehandlingstype } = useMutation({ + mutationFn: (valuesToStore: { behandlingType: string; checked: boolean }) => + lagreSakslisteBehandlingstype( + valgtSakslisteId, + valgtAvdelingEnhet, + valuesToStore.behandlingType, + valuesToStore.checked, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const alleBehandlingTyper = useLosKodeverk('BehandlingType'); + + const behandlingTyper = behandlingstypeOrder.map(kode => alleBehandlingTyper.find(bt => bt.kode === kode)); + + return ( + + + {behandlingTyper + .map(bt => { + if (!bt) { + return null; + } + return ( + + lagreBehandlingstype({ + behandlingType: bt.kode, + checked: isChecked, + }) + } + /> + ); + }) + .filter(bt => !!bt)} + + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.spec.tsx new file mode 100644 index 00000000000..29ea169afb1 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.spec.tsx @@ -0,0 +1,23 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './FagsakYtelseTypeVelger.stories'; + +const { Default } = composeStories(stories); + +describe('FagsakYtelseTypeVelger', () => { + it('skal vise checkboxer for stønadstyper og så velge engangsstønad', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Stønadstype')).toBeInTheDocument(); + expect(getByLabelText('Foreldrepenger')).toBeChecked(); + expect(getByLabelText('Engangsstønad')).toBeChecked(); + + await userEvent.click(screen.getByText('Engangsstønad')); + + await waitFor(() => expect(getByLabelText('Engangsstønad')).not.toBeChecked()); + expect(getByLabelText('Foreldrepenger')).toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.stories.tsx new file mode 100644 index 00000000000..064c4c5934e --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.stories.tsx @@ -0,0 +1,59 @@ +import { useForm } from 'react-hook-form'; + +import { RhfForm } from '@navikt/ft-form-hooks'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { FagsakYtelseType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { FagsakYtelseTypeVelger } from './FagsakYtelseTypeVelger'; + +import messages from '../../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/FagsakYtelseTypeVelger', + component: FagsakYtelseTypeVelger, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.post(LosUrl.LAGRE_SAKSLISTE_FAGSAK_YTELSE_TYPE, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSakslisteId: 1, + valgtAvdelingEnhet: 'Nav Vikafossen', + }, + render: args => { + const formMethods = useForm({ + defaultValues: { + [FagsakYtelseType.FORELDREPENGER]: true, + [FagsakYtelseType.ENGANGSSTONAD]: true, + }, + }); + + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + + return alleKodeverk ? ( + + + + ) : ( + + ); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.tsx new file mode 100644 index 00000000000..e42b3f892b1 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/FagsakYtelseTypeVelger.tsx @@ -0,0 +1,63 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { Label, VStack } from '@navikt/ds-react'; +import { RhfCheckbox } from '@navikt/ft-form-hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { lagreSakslisteFagsakYtelseType, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../../../data/useLosKodeverk'; + +interface Props { + valgtSakslisteId: number; + valgtAvdelingEnhet: string; +} + +export const FagsakYtelseTypeVelger = ({ valgtSakslisteId, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + + // TODO (TOR) Manglar type + const { control } = useFormContext(); + + const { mutate: lagreFagsakYtelseType } = useMutation({ + mutationFn: (values: { sakslisteId: number; avdelingEnhet: string; fagsakYtelseType: string; checked: boolean }) => + lagreSakslisteFagsakYtelseType(values.sakslisteId, values.avdelingEnhet, values.fagsakYtelseType, values.checked), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const alleFagsakYtelseTyper = useLosKodeverk('FagsakYtelseType'); + + return ( + + + {alleFagsakYtelseTyper.map(fyt => ( + type.kode === fyt.kode)?.navn ?? ''} + onChange={isChecked => + lagreFagsakYtelseType({ + sakslisteId: valgtSakslisteId, + avdelingEnhet: valgtAvdelingEnhet, + fagsakYtelseType: fyt.kode, + checked: isChecked, + }) + } + /> + ))} + + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/andreKriterierVelger.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/andreKriterierVelger.module.css new file mode 100644 index 00000000000..68215e72238 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/filtrering/andreKriterierVelger.module.css @@ -0,0 +1,4 @@ +.arrowbox { + padding-top: 10px; + width: 75%; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/BelopSorteringValg.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/BelopSorteringValg.tsx new file mode 100644 index 00000000000..7e4cfe38c5c --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/BelopSorteringValg.tsx @@ -0,0 +1,87 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { Detail, HStack } from '@navikt/ds-react'; +import { RhfTextField } from '@navikt/ft-form-hooks'; +import { hasValidPosOrNegInteger } from '@navikt/ft-form-validators'; +import { ArrowBox } from '@navikt/ft-ui-komponenter'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { lagreSakslisteSorteringIntervall, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { useDebounce } from '../useDebounce'; + +import styles from './sorteringVelger.module.css'; + +interface Props { + valgtSakslisteId: number; + valgtAvdelingEnhet: string; +} + +export const BelopSorteringValg = ({ valgtSakslisteId, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + + // TODO (TOR) Manglar type + const { watch, trigger, control } = useFormContext(); + const fraVerdi = watch('fra'); + const tilVerdi = watch('til'); + + const { mutate: lagreSakslisteSorteringTidsintervallDager } = useMutation({ + mutationFn: (valuesToStore: { fra: number; til: number }) => + lagreSakslisteSorteringIntervall(valgtSakslisteId, valuesToStore.fra, valuesToStore.til, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const lagreFra = (nyFraVerdi: number) => + lagreSakslisteSorteringTidsintervallDager({ + fra: nyFraVerdi, + til: tilVerdi, + }); + const lagreTil = (nyTilVerdi: number) => + lagreSakslisteSorteringTidsintervallDager({ + fra: fraVerdi, + til: nyTilVerdi, + }); + + const lagreFraDebounce = useDebounce('fra', lagreFra, trigger); + const lagreTilDebounce = useDebounce('til', lagreTil, trigger); + + return ( + + + + + + lagreFraDebounce(value)} + /> + + + + lagreTilDebounce(value)} + /> + + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/DatoSorteringValg.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/DatoSorteringValg.tsx new file mode 100644 index 00000000000..778744ca097 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/DatoSorteringValg.tsx @@ -0,0 +1,220 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { Detail, HStack, VStack } from '@navikt/ds-react'; +import { RhfCheckbox, RhfDatepicker, RhfTextField } from '@navikt/ft-form-hooks'; +import { hasValidDate, hasValidPosOrNegInteger } from '@navikt/ft-form-validators'; +import { ArrowBox, DateLabel } from '@navikt/ft-ui-komponenter'; +import { ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +import { + lagreSakslisteSorteringDynamiskPeriode, + lagreSakslisteSorteringIntervall, + lagreSakslisteSorteringTidsintervallDato, + LosUrl, +} from '../../../data/fplosAvdelingslederApi'; +import { useDebounce } from '../useDebounce'; + +import styles from './sorteringVelger.module.css'; + +dayjs.extend(customParseFormat); + +interface Props { + valgtSakslisteId: number; + valgtAvdelingEnhet: string; + erDynamiskPeriode: boolean; +} + +export const DatoSorteringValg = ({ valgtSakslisteId, valgtAvdelingEnhet, erDynamiskPeriode }: Props) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + + const { mutate: lagreSakslisteSorteringTidsintervallDager } = useMutation({ + mutationFn: (valuesToStore: { fra: number; til: number }) => + lagreSakslisteSorteringIntervall(valgtSakslisteId, valuesToStore.fra, valuesToStore.til, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const { mutate: lagreSakslisteSorteringErDynamiskPeriode } = useMutation({ + mutationFn: () => lagreSakslisteSorteringDynamiskPeriode(valgtSakslisteId, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const { mutate: lagreSorteringTidsintervallDato } = useMutation({ + mutationFn: (valuesToStore: { fomDato?: string; tomDato?: string }) => + lagreSakslisteSorteringTidsintervallDato( + valgtSakslisteId, + valgtAvdelingEnhet, + valuesToStore.fomDato, + valuesToStore.tomDato, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + // TODO (TOR) Manglar typing for useFormContext + const { watch, control } = useFormContext(); + const fraVerdi = watch('fra'); + const tilVerdi = watch('til'); + const fomDatoVerdi = watch('fomDato'); + const tomDatoVerdi = watch('tomDato'); + + const lagreFra = (nyFraVerdi: number) => + lagreSakslisteSorteringTidsintervallDager({ + fra: nyFraVerdi, + til: tilVerdi, + }); + const lagreTil = (nyTilVerdi: number) => + lagreSakslisteSorteringTidsintervallDager({ + fra: fraVerdi, + til: nyTilVerdi, + }); + + const lagreFomDato = getLagreDatoFn(lagreSorteringTidsintervallDato, true, tomDatoVerdi); + const lagreTomDato = getLagreDatoFn(lagreSorteringTidsintervallDato, false, fomDatoVerdi); + + const lagreFomDatoDebounce = useDebounce('fomDato', lagreFomDato); + const lagreTomDatoDebounce = useDebounce('tomDato', lagreTomDato); + + return ( +
+ + + + + + {erDynamiskPeriode && ( + +
+ lagreFra(value)} + /> + {(fraVerdi || fraVerdi === 0) && ( + + + + )} +
+ + + +
+ lagreTil(value)} + /> + {(tilVerdi || tilVerdi === 0) && ( + + + + )} +
+ + + +
+ )} + {!erDynamiskPeriode && ( + + + + + + + + )} + lagreSakslisteSorteringErDynamiskPeriode()} + /> +
+
+
+ ); +}; + +const finnDato = (antallDager: number) => dayjs().add(antallDager, 'd').format(); + +const getLagreDatoFn = + ( + lagreSorteringTidsintervallDato: (valuesToStore: { fomDato?: string; tomDato?: string }) => void, + erFomDato: boolean, + annenDato?: string, + ) => + (inputdato: string) => { + let dato; + if (inputdato) { + dato = dayjs(inputdato); + } + if (!dato || dato.isValid()) { + const d = dato ? dato.format(ISO_DATE_FORMAT) : dato; + + const params = erFomDato + ? { + fomDato: d, + tomDato: annenDato, + } + : { + fomDato: annenDato, + tomDato: d, + }; + + return lagreSorteringTidsintervallDato(params); + } + return undefined; + }; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.spec.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.spec.tsx new file mode 100644 index 00000000000..0d71bbd4ef4 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.spec.tsx @@ -0,0 +1,100 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './SorteringVelger.stories'; + +const { + SorteringsvelgerNårMangeBehandlingstyperErValgt, + SorteringsvelgerNårKunTilbakekrevingErValgt, + SorteringsvelgerNårDynamiskPeriodeErValgt, +} = composeStories(stories); + +describe('SorteringVelger', () => { + it('skal vise tre sorteringsvalg når mange behandlingstyper er valgt', async () => { + await applyRequestHandlers(SorteringsvelgerNårMangeBehandlingstyperErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + expect(await screen.findByLabelText('Dato for behandlingsfrist')).toBeChecked(); + expect(screen.getByLabelText('Dato for opprettelse av behandling')).not.toBeChecked(); + expect(screen.getByLabelText('Dato for første stønadsdag')).not.toBeChecked(); + expect(screen.queryByText('Feilutbetalt beløp')).not.toBeInTheDocument(); + expect(screen.queryByText('Dato for første feilutbetaling')).not.toBeInTheDocument(); + }); + + it('skal vise datovelger der dynamisk periode ikke er valgt', async () => { + await applyRequestHandlers(SorteringsvelgerNårMangeBehandlingstyperErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + expect(await screen.findByText('Ta kun med behandlinger med dato')).toBeInTheDocument(); + expect(screen.getByText('F.o.m.')).toBeInTheDocument(); + expect(screen.getByText('T.o.m.')).toBeInTheDocument(); + + expect(screen.getByLabelText('Dynamisk periode')).not.toBeChecked(); + }); + + it('skal vise datovelger der dynamisk periode er valgt', async () => { + await applyRequestHandlers(SorteringsvelgerNårDynamiskPeriodeErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + expect(await screen.findByText('Ta kun med behandlinger med dato')).toBeInTheDocument(); + expect(screen.getByText('F.o.m.')).toBeInTheDocument(); + expect(screen.getByText('T.o.m.')).toBeInTheDocument(); + + expect(screen.getByLabelText('Dynamisk periode')).toBeChecked(); + }); + + it('skal vise vis beløpvelger når Feilutbetalt beløp er valgt', async () => { + await applyRequestHandlers(SorteringsvelgerNårKunTilbakekrevingErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Feilutbetalt beløp')); + + expect(await screen.findByText('Ta kun med behandlinger mellom')).toBeInTheDocument(); + expect(screen.getAllByText('kr')[0]).toBeInTheDocument(); + expect(screen.getAllByText('kr')[1]).toBeInTheDocument(); + }); + + it('skal vise feilmelding når en skriver inn bokstaver i fra-beløpfelt', async () => { + await applyRequestHandlers(SorteringsvelgerNårKunTilbakekrevingErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Feilutbetalt beløp')); + + expect(await screen.findByText('Ta kun med behandlinger mellom')).toBeInTheDocument(); + + const fraInput = screen.getAllByRole('textbox')[0]; + await userEvent.type(fraInput, 'bokstaver'); + + expect(await screen.findByText('Feltet kan kun inneholde tall')).toBeInTheDocument(); + }); + + it('skal vise feilmelding når en skriver inn bokstaver i til-beløpfelt', async () => { + await applyRequestHandlers(SorteringsvelgerNårKunTilbakekrevingErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Feilutbetalt beløp')); + + expect(await screen.findByText('Ta kun med behandlinger mellom')).toBeInTheDocument(); + + const tilInput = screen.getAllByRole('textbox')[1]; + await userEvent.type(tilInput, 'bokstaver'); + + expect(await screen.findByText('Feltet kan kun inneholde tall')).toBeInTheDocument(); + }); + + it('skal vise fem sorteringsvalg når kun tilbakekreving er valgt', async () => { + await applyRequestHandlers(SorteringsvelgerNårKunTilbakekrevingErValgt.parameters['msw']); + render(); + expect(await screen.findByText('Dato for behandlingsfrist')).toBeInTheDocument(); + expect(await screen.findByLabelText('Dato for behandlingsfrist')).toBeChecked(); + expect(screen.getByLabelText('Dato for opprettelse av behandling')).not.toBeChecked(); + expect(screen.getByLabelText('Dato for første stønadsdag')).not.toBeChecked(); + expect(screen.getByLabelText('Feilutbetalt beløp')).not.toBeChecked(); + expect(screen.getByLabelText('Dato for første feilutbetaling')).not.toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.stories.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.stories.tsx new file mode 100644 index 00000000000..308a9b13b05 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.stories.tsx @@ -0,0 +1,84 @@ +import { useForm } from 'react-hook-form'; + +import { RhfForm } from '@navikt/ft-form-hooks'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType, KøSortering } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { SorteringVelger } from './SorteringVelger'; + +import messages from '../../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/behandlingskoer/SorteringVelger', + component: SorteringVelger, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_INTERVALL, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_DYNAMISK_PERIDE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LAGRE_SAKSLISTE_SORTERING_TIDSINTERVALL_DATO, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtSakslisteId: 1, + valgtAvdelingEnhet: 'Nav Vikafossen', + }, + render: args => { + const formMethods = useForm({ + defaultValues: { + sortering: KøSortering.BEHANDLINGSFRIST, + fra: 2, + til: 3, + fomDato: '2020-01-10', + tomDato: '2020-10-01', + erDynamiskPeriode: args.erDynamiskPeriode, + }, + }); + + const { data: kodeverkLos } = useQuery(losKodeverkOptions()); + + return kodeverkLos ? ( + + + + ) : ( + + ); + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const SorteringsvelgerNårMangeBehandlingstyperErValgt: Story = { + args: { + valgteBehandlingtyper: [BehandlingType.FORSTEGANGSSOKNAD, BehandlingType.DOKUMENTINNSYN], + erDynamiskPeriode: false, + }, +}; + +export const SorteringsvelgerNårDynamiskPeriodeErValgt: Story = { + args: { + valgteBehandlingtyper: [BehandlingType.FORSTEGANGSSOKNAD, BehandlingType.DOKUMENTINNSYN], + erDynamiskPeriode: true, + }, +}; + +export const SorteringsvelgerNårKunTilbakekrevingErValgt: Story = { + args: { + valgteBehandlingtyper: [BehandlingType.TILBAKEKREVING], + erDynamiskPeriode: false, + }, +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.tsx b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.tsx new file mode 100644 index 00000000000..ae863c1838d --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/SorteringVelger.tsx @@ -0,0 +1,100 @@ +import { useFormContext } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { RhfRadioGroup } from '@navikt/ft-form-hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; + +import { lagreSakslisteSortering, LosUrl } from '../../../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../../../data/useLosKodeverk'; +import { BelopSorteringValg } from './BelopSorteringValg'; +import { DatoSorteringValg } from './DatoSorteringValg'; + +const bareTilbakekrevingValgt = (valgteBehandlingtyper?: string[]) => + valgteBehandlingtyper && + valgteBehandlingtyper.some( + type => type === BehandlingType.TILBAKEKREVING || type === BehandlingType.TILBAKEKREVING_REVURDERING, + ) && + !valgteBehandlingtyper.some( + type => type !== BehandlingType.TILBAKEKREVING && type !== BehandlingType.TILBAKEKREVING_REVURDERING, + ); + +interface Props { + valgtSakslisteId: number; + valgteBehandlingtyper?: string[]; + valgtAvdelingEnhet: string; + erDynamiskPeriode: boolean; +} + +export const SorteringVelger = ({ + valgtSakslisteId, + valgteBehandlingtyper, + valgtAvdelingEnhet, + erDynamiskPeriode, +}: Props) => { + const queryClient = useQueryClient(); + + // TODO (TOR) typing på useFormContext + const { resetField, control } = useFormContext(); + + const { mutate: lagreSortering } = useMutation({ + mutationFn: (valuesToStore: { sorteringType: string }) => + lagreSakslisteSortering(valgtSakslisteId, valuesToStore.sorteringType, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_ANTALL, valgtSakslisteId, valgtAvdelingEnhet], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL], + }); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING], + }); + }, + }); + + const koSorteringer = useLosKodeverk('KøSortering'); + + return ( + } + onChange={sorteringType => { + resetField('fra', { defaultValue: '' }); + resetField('til', { defaultValue: '' }); + resetField('fomDato', { defaultValue: '' }); + resetField('tomDato', { defaultValue: '' }); + resetField('erDynamiskPeriode', { defaultValue: '' }); + + return lagreSortering({ + sorteringType, + }); + }} + radios={koSorteringer + .filter( + koSortering => + koSortering.feltkategori !== 'TILBAKEKREVING' || bareTilbakekrevingValgt(valgteBehandlingtyper), + ) + .map(koSortering => ({ + value: koSortering.kode as string, + label: koSortering.navn, + element: ( + <> + {koSortering.felttype === 'DATO' && ( + + )} + {koSortering.felttype === 'HELTALL' && ( + + )} + + ), + }))} + /> + ); +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/sorteringVelger.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/sorteringVelger.module.css new file mode 100644 index 00000000000..99bdbad0a2f --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/sortering/sorteringVelger.module.css @@ -0,0 +1,27 @@ +.dager { + padding-top: 40px; +} + +.dagerMedBindestrek { + padding-right: 20px; + padding-top: 40px; +} + +.beløp { + padding-right: 20px; + padding-top: 20px; +} + +.tomDato { + padding-right: 20px; +} + +.dato { + margin-bottom: 5px; + width: 60px; +} + +.arrowBoxWidth { + margin-top: 10px; + width: 420px; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/useDebounce.ts b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/useDebounce.ts new file mode 100644 index 00000000000..d25f64a7fbf --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/useDebounce.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect } from 'react'; +import { type FieldValues, type Path, useFormContext, type UseFormTrigger } from 'react-hook-form'; + +import debounce from 'lodash.debounce'; + +const getTimeoutValue = () => (import.meta.env.MODE === 'test' ? 0 : 1000); + +export const useDebounce = ( + feltNavn: Path, + funksjon: (verdier: VALUE) => void, + trigger?: UseFormTrigger, +) => { + const context = useFormContext(); + const validationTrigger = trigger || context.trigger; + + const lagre = useCallback( + debounce((verdi: VALUE) => { + validationTrigger(feltNavn).then(isValid => isValid && funksjon(verdi)); + }, getTimeoutValue()), + [funksjon], + ); + + useEffect(() => () => lagre.cancel(), []); + + return lagre; +}; diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/utvalgskriterierForSakslisteForm.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/utvalgskriterierForSakslisteForm.module.css new file mode 100644 index 00000000000..b8a78791842 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sakslisteForm/utvalgskriterierForSakslisteForm.module.css @@ -0,0 +1,3 @@ +.bredde { + width: 300px; +} diff --git a/apps/fp-avdelingsleder/src/behandlingskoer/sletteSakslisteModal.module.css b/apps/fp-avdelingsleder/src/behandlingskoer/sletteSakslisteModal.module.css new file mode 100644 index 00000000000..687cc358010 --- /dev/null +++ b/apps/fp-avdelingsleder/src/behandlingskoer/sletteSakslisteModal.module.css @@ -0,0 +1,8 @@ +.submitButton { + margin-right: 10px; + margin-top: 10px; +} + +.cancelButton { + margin-top: 10px; +} diff --git a/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.spec.tsx b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.spec.tsx new file mode 100644 index 00000000000..f2c0dcd5f06 --- /dev/null +++ b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.spec.tsx @@ -0,0 +1,13 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; + +import * as stories from './IkkeTilgangTilAvdelingslederPanel.stories'; + +const { IkkeTilgangTilAvdelingsleder } = composeStories(stories); + +describe('IkkeTilgangTilAvdelingslederPanel', () => { + it('skal vise side for ikke tilgang til avdelingsleder', async () => { + render(); + expect(await screen.findByText('Du har ikke tilgang til å bruke dette programmet')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.stories.tsx b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.stories.tsx new file mode 100644 index 00000000000..d904764418a --- /dev/null +++ b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { getIntlDecorator } from '@navikt/fp-storybook-utils'; + +import { IkkeTilgangTilAvdelingslederPanel } from './IkkeTilgangTilAvdelingslederPanel'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/IkkeTilgangTilAvdelingslederPanel', + component: IkkeTilgangTilAvdelingslederPanel, + decorators: [withIntl], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const IkkeTilgangTilAvdelingsleder: Story = {}; diff --git a/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.tsx b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.tsx new file mode 100644 index 00000000000..925868bed3e --- /dev/null +++ b/apps/fp-avdelingsleder/src/components/IkkeTilgangTilAvdelingslederPanel.tsx @@ -0,0 +1,13 @@ +import { FormattedMessage } from 'react-intl'; + +import { Box, Heading } from '@navikt/ds-react'; + +import styles from './ikkeTilgangTilAvdelingslederPanel.module.css'; + +export const IkkeTilgangTilAvdelingslederPanel = () => ( + + + + + +); diff --git a/apps/fp-avdelingsleder/src/components/ikkeTilgangTilAvdelingslederPanel.module.css b/apps/fp-avdelingsleder/src/components/ikkeTilgangTilAvdelingslederPanel.module.css new file mode 100644 index 00000000000..d2a4fa1591f --- /dev/null +++ b/apps/fp-avdelingsleder/src/components/ikkeTilgangTilAvdelingslederPanel.module.css @@ -0,0 +1,3 @@ +.container { + text-align: center; +} diff --git a/apps/fp-avdelingsleder/src/data/StoreValuesInLocalStorage.tsx b/apps/fp-avdelingsleder/src/data/StoreValuesInLocalStorage.tsx new file mode 100644 index 00000000000..805bd11daad --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/StoreValuesInLocalStorage.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +import { setValueInLocalStorage } from './localStorageHelper'; + +//TODO Skriv om til hook + +interface Props { + stateKey: string; + values: unknown; +} + +/** + * StoreValuesInLocalStorage + * + * Lagrer verdier i localstorage når komponenten blir kastet. Brukt for å mellomlagre form-state + * ved navigering fra og til komponenter som har en final-form. + */ +export const StoreValuesInLocalStorage = ({ stateKey, values }: Props): null => { + useEffect(() => { + setValueInLocalStorage(stateKey, JSON.stringify(values)); + }, [values]); + + return null; +}; diff --git a/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.spec.tsx b/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.spec.tsx new file mode 100644 index 00000000000..df17cbc239b --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.spec.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; + +import { render, screen } from '@testing-library/react'; + +import { ErrorType } from './errorType'; +import { RestApiErrorProvider, useRestApiError, useRestApiErrorDispatcher } from './RestApiErrorContext'; + +const TestErrorMessage = ({ skalFjerne = false }) => { + const { addErrorMessage, removeErrorMessages } = useRestApiErrorDispatcher(); + useEffect(() => { + addErrorMessage({ type: ErrorType.GENERAL_ERROR, message: 'Feilmeldingstest 1' }); + addErrorMessage({ type: ErrorType.GENERAL_ERROR, message: 'Feilmeldingstest 2' }); + + if (skalFjerne) { + removeErrorMessages(); + } + }, []); + + const feilmeldinger = useRestApiError(); + return ( + <> + Feilmeldinger: + {feilmeldinger.map(feil => ( + {feil.type === ErrorType.GENERAL_ERROR ? feil.message : ''} + ))} + + ); +}; + +describe('RestApiErrorContext', () => { + it('skal legge til feilmelding og så hente alle i kontekst', async () => { + render( + + + , + ); + + expect(await screen.findByText('Feilmeldingstest 1')).toBeInTheDocument(); + expect(screen.getByText('Feilmeldingstest 2')).toBeInTheDocument(); + }); + + it('skal legge til feilmelding og så fjerne alle i kontekst', async () => { + render( + + + , + ); + + expect(await screen.findByText('Feilmeldinger:')).toBeInTheDocument(); + expect(screen.queryByText('Feilmeldingstest 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Feilmeldingstest 2')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.tsx b/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.tsx new file mode 100644 index 00000000000..a9af2959da5 --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/error/RestApiErrorContext.tsx @@ -0,0 +1,74 @@ +import { createContext, type JSX, type ReactNode, useContext, useReducer } from 'react'; + +import type { FpError } from './errorType'; + +const defaultInitialState = { + errors: [], +}; + +type Action = { type: 'add'; data: FpError } | { type: 'remove' }; +type Dispatch = (action: Action) => void; +type State = { errors: FpError[] }; + +const RestApiErrorStateContext = createContext(defaultInitialState); +const RestApiErrorDispatchContext = createContext(undefined); + +interface Props { + children: ReactNode; + initialState?: State; +} + +/** + * Tilbyr kontekst for lagring av feilmeldinger. + */ +export const RestApiErrorProvider = ({ children, initialState }: Props): JSX.Element => { + const [state, dispatch] = useReducer((oldState: State, action: Action) => { + switch (action.type) { + case 'add': + return { + errors: oldState.errors.concat(action.data), + }; + case 'remove': + return defaultInitialState; + default: + throw new Error(); + } + }, initialState || defaultInitialState); + + return ( + + {children} + + ); +}; + +/** + * Hook som tilbyr funksjoner for å legge til eller fjerne feil i kontekst. + */ +export const useRestApiErrorDispatcher = () => { + const dispatch = useContext(RestApiErrorDispatchContext); + + const addErrorMessage = (data: FpError) => { + if (dispatch) { + dispatch({ type: 'add', data }); + } + }; + const removeErrorMessages = () => { + if (dispatch) { + dispatch({ type: 'remove' }); + } + }; + + return { + addErrorMessage, + removeErrorMessages, + }; +}; + +/** + * Hook som henter alle feilmeldinger registrert i kontekst. + */ +export const useRestApiError = () => { + const state = useContext(RestApiErrorStateContext); + return state.errors; +}; diff --git a/apps/fp-avdelingsleder/src/data/error/errorType.ts b/apps/fp-avdelingsleder/src/data/error/errorType.ts new file mode 100644 index 00000000000..482c3c645f7 --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/error/errorType.ts @@ -0,0 +1,58 @@ +import { ApiPollingStatus } from '@navikt/fp-konstanter'; + +export enum ErrorType { + GENERAL_ERROR = 'GENERAL_ERROR', + POLLING_TIMEOUT = 'POLLING_TIMEOUT', + POLLING_HALTED_OR_DELAYED = 'POLLING_HALTED_OR_DELAYED', + REQUEST_GATEWAY_TIMEOUT_OR_NOT_FOUND = 'REQUEST_GATEWAY_TIMEOUT_OR_NOT_FOUND', + REQUEST_FORBIDDEN = 'REQUEST_FORBIDDEN', + REQUEST_UNAUTHORIZED = 'REQUEST_UNAUTHORIZED', +} + +type GeneralError = { + type: ErrorType.GENERAL_ERROR; + message: string; +}; + +type PollingTimeoutError = { + type: ErrorType.POLLING_TIMEOUT; + message: string; + location: string; +}; + +type RequestForbiddenError = { + type: ErrorType.REQUEST_FORBIDDEN; + message: string; +}; + +type RequestUnauthorizedError = { + type: ErrorType.REQUEST_UNAUTHORIZED; + message: string; +}; + +type RequestGatewayTimeoutOrNotFoundError = { + type: ErrorType.REQUEST_GATEWAY_TIMEOUT_OR_NOT_FOUND; + location: string; +}; + +type PollingHaltedError = { + type: ErrorType.POLLING_HALTED_OR_DELAYED; + status: ApiPollingStatus.HALTED; + message: string; +}; + +type PollingDelayedError = { + type: ErrorType.POLLING_HALTED_OR_DELAYED; + status: ApiPollingStatus.DELAYED; + message: string; + eta: string; +}; + +export type FpError = + | GeneralError + | PollingTimeoutError + | RequestForbiddenError + | RequestUnauthorizedError + | RequestGatewayTimeoutOrNotFoundError + | PollingHaltedError + | PollingDelayedError; diff --git a/apps/fp-avdelingsleder/src/data/fplosAvdelingslederApi.ts b/apps/fp-avdelingsleder/src/data/fplosAvdelingslederApi.ts new file mode 100644 index 00000000000..b0dce35dd3c --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/fplosAvdelingslederApi.ts @@ -0,0 +1,387 @@ +import { queryOptions } from '@tanstack/react-query'; +import ky from 'ky'; + +import type { Oppgave, SaksbehandlerProfil } from '@navikt/fp-los-felles'; +import type { AlleKodeverkLos, ApiLink, NavAnsatt } from '@navikt/fp-types'; + +import type { Avdeling } from '../typer/avdelingTsType'; +import type { BehandlingVentefrist } from '../typer/behandlingVentefristTsType'; +import type { OppgaverForAvdeling } from '../typer/oppgaverForAvdelingTsType'; +import type { OppgaveForDato } from '../typer/oppgaverForDatoTsType'; +import type { OppgaverForForsteStonadsdag } from '../typer/oppgaverForForsteStonadsdagTsType'; +import type { OppgaverSomErApneEllerPaVent } from '../typer/oppgaverSomErApneEllerPaVentTsType'; +import type { Reservasjon } from '../typer/reservasjonTsType'; +import type { SaksbehandlereOgSaksbehandlerGrupper } from '../typer/saksbehandlereOgSaksbehandlerGrupper'; +import type { SakslisteAvdeling } from '../typer/sakslisteAvdelingTsType'; + +type BehandlendeEnheter = { + enhetId: string; + enhetNavn: string; +}[]; + +export type InitDataFpSak = { + behandlendeEnheter: BehandlendeEnheter; + innloggetBruker: NavAnsatt; + links: ApiLink[]; + sakLinks: ApiLink[]; +}; + +const kyExtended = ky.extend({ + retry: 0, + timeout: 15000, + hooks: { + beforeRequest: [ + request => { + const navCallId = `CallId_${new Date().getTime()}_${Math.floor(Math.random() * 1000000000)}`; + request.headers.set('Nav-Callid', navCallId); + }, + ], + }, +}); + +//MÅ være en gyldig URL for at KY skal fungere i test +const isTest = import.meta.env.MODE === 'test'; +const wrapUrl = (url: string) => (isTest ? `https://www.test.com${url}` : url); + +export const FagsakUrl = { + INIT_FETCH: wrapUrl('/fpsak/api/init-fetch'), +}; + +export const LosUrl = { + KODEVERK_LOS: wrapUrl('/fplos/api/kodeverk'), + AVDELINGER: wrapUrl('/fplos/api/avdelingsleder/avdelinger'), + SAKSBEHANDLERE_FOR_AVDELING: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere'), + OPPGAVE_AVDELING_ANTALL: wrapUrl('/fplos/api/avdelingsleder/oppgaver/avdelingantall'), + SAKSLISTER_FOR_AVDELING: wrapUrl('/fplos/api/avdelingsleder/sakslister'), + OPPRETT_NY_SAKSLISTE: wrapUrl('/fplos/api/avdelingsleder/sakslister'), + OPPGAVE_ANTALL: wrapUrl('/fplos/api/avdelingsleder/oppgaver/antall'), + LAGRE_SAKSLISTE_NAVN: wrapUrl('/fplos/api/avdelingsleder/sakslister/navn'), + LAGRE_SAKSLISTE_SAKSBEHANDLER: wrapUrl('/fplos/api/avdelingsleder/sakslister/saksbehandler'), + LAGRE_SAKSLISTE_SORTERING: wrapUrl('/fplos/api/avdelingsleder/sakslister/sortering'), + LAGRE_SAKSLISTE_SORTERING_INTERVALL: wrapUrl('/fplos/api/avdelingsleder/sakslister/sortering-numerisk-intervall'), + LAGRE_SAKSLISTE_SORTERING_DYNAMISK_PERIDE: wrapUrl( + '/fplos/api/avdelingsleder/sakslister/sortering-tidsintervall-type', + ), + LAGRE_SAKSLISTE_SORTERING_TIDSINTERVALL_DATO: wrapUrl( + '/fplos/api/avdelingsleder/sakslister/sortering-tidsintervall-dato', + ), + LAGRE_SAKSLISTE_FAGSAK_YTELSE_TYPE: wrapUrl('/fplos/api/avdelingsleder/sakslister/ytelsetyper'), + LAGRE_SAKSLISTE_BEHANDLINGSTYPE: wrapUrl('/fplos/api/avdelingsleder/sakslister/behandlingstype'), + LAGRE_SAKSLISTE_ANDRE_KRITERIER: wrapUrl('/fplos/api/avdelingsleder/sakslister/andre-kriterier'), + HENT_OPPGAVER_FOR_AVDELING: wrapUrl('/fplos/api/avdelingsleder/nøkkeltall/behandlinger-under-arbeid'), + HENT_OPPGAVER_PER_DATO: wrapUrl('/fplos/api/avdelingsleder/nøkkeltall/behandlinger-under-arbeid-historikk'), + HENT_OPPGAVER_APNE_ELLER_PA_VENT: wrapUrl('/fplos/api/avdelingsleder/nøkkeltall/åpne-behandlinger'), + HENT_BEHANDLINGER_FRISTUTLOP: wrapUrl('/fplos/api/avdelingsleder/nøkkeltall/frist-utløp'), + HENT_OPPGAVER_PER_FORSTE_STONADSDAG: wrapUrl('/fplos/api/avdelingsleder/nøkkeltall/behandlinger-første-stønadsdag'), + RESERVASJONER_FOR_AVDELING: wrapUrl('/fplos/api/avdelingsleder/reservasjoner'), + SLETT_SAKSLISTE: wrapUrl('/fplos/api/avdelingsleder/sakslister/slett'), + HENT_GRUPPER: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper'), + OPPRETT_GRUPPE: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper/opprett-gruppe'), + LEGG_SAKSBEHANDLER_TIL_GRUPPE: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper/legg-til-saksbehandler'), + FJERN_SAKSBEHANDLER_FRA_GRUPPE: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper/fjern-saksbehandler'), + ENDRE_GRUPPENAVN: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper/endre-gruppenavn'), + SLETT_GRUPPE: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/grupper/slett-saksbehandlergruppe'), + AVDELINGSLEDER_OPPHEVER_RESERVASJON: wrapUrl('/fplos/api/avdelingsleder/reservasjoner/opphev'), + FLYTT_RESERVASJON: wrapUrl('/fplos/api/reservasjon/flytt-reservasjon'), + ENDRE_OPPGAVERESERVASJON: wrapUrl('/fplos/api/reservasjon/endre-varighet'), + FLYTT_RESERVASJON_SAKSBEHANDLER_SOK: wrapUrl('/fplos/api/reservasjon/flytt-reservasjon/søk'), + SLETT_SAKSBEHANDLER: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/slett'), + SAKSBEHANDLER_SOK: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere/søk'), + OPPRETT_NY_SAKSBEHANDLER: wrapUrl('/fplos/api/avdelingsleder/saksbehandlere'), +}; + +export const initFetchOptions = () => + queryOptions({ + queryKey: [FagsakUrl.INIT_FETCH], + queryFn: () => kyExtended.get(FagsakUrl.INIT_FETCH).json(), + staleTime: Infinity, + }); + +export const oppgaverForAvdelingAntallOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.OPPGAVE_AVDELING_ANTALL, avdelingEnhet], + queryFn: () => kyExtended.get(LosUrl.OPPGAVE_AVDELING_ANTALL, { searchParams: { avdelingEnhet } }).json(), + }); + +export const sakslisterForAvdelingOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.SAKSLISTER_FOR_AVDELING, avdelingEnhet], + queryFn: () => + kyExtended.get(LosUrl.SAKSLISTER_FOR_AVDELING, { searchParams: { avdelingEnhet } }).json(), + initialData: [], + }); + +export const saksbehandlareForAvdelingOptions = (avdelingEnhet?: string) => + queryOptions({ + queryKey: [LosUrl.SAKSBEHANDLERE_FOR_AVDELING, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.SAKSBEHANDLERE_FOR_AVDELING, { searchParams: { avdelingEnhet: avdelingEnhet ?? '' } }) + .json(), + initialData: [], + enabled: !!avdelingEnhet, + }); + +export const oppgaveAntallOptions = (sakslisteId: number, avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.OPPGAVE_ANTALL, sakslisteId, avdelingEnhet], + queryFn: () => + kyExtended.get(LosUrl.OPPGAVE_ANTALL, { searchParams: { sakslisteId, avdelingEnhet } }).json(), + }); + +export const losKodeverkOptions = () => + queryOptions({ + queryKey: [LosUrl.KODEVERK_LOS], + queryFn: () => kyExtended.get(LosUrl.KODEVERK_LOS).json(), + staleTime: Infinity, + }); + +export const grupperOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_GRUPPER, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.HENT_GRUPPER, { searchParams: { avdelingEnhet } }) + .json(), + }); + +export const avdelingerOptions = (isEnabled: boolean) => + queryOptions({ + queryKey: [LosUrl.AVDELINGER], + queryFn: () => kyExtended.get(LosUrl.AVDELINGER).json(), + enabled: isEnabled, + }); + +export const oppgaverPerFørsteStønadsdagOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_OPPGAVER_PER_FORSTE_STONADSDAG, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.HENT_OPPGAVER_PER_FORSTE_STONADSDAG, { searchParams: { avdelingEnhet } }) + .json(), + initialData: [], + }); + +export const oppgaverÅpneEllerPåVentOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_OPPGAVER_APNE_ELLER_PA_VENT, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.HENT_OPPGAVER_APNE_ELLER_PA_VENT, { searchParams: { avdelingEnhet } }) + .json(), + initialData: [], + }); + +export const oppgaverForAvdelingOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_OPPGAVER_FOR_AVDELING, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.HENT_OPPGAVER_FOR_AVDELING, { searchParams: { avdelingEnhet } }) + .json(), + initialData: [], + }); + +export const oppgaverPerDatoOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_OPPGAVER_PER_DATO, avdelingEnhet], + queryFn: () => + kyExtended.get(LosUrl.HENT_OPPGAVER_PER_DATO, { searchParams: { avdelingEnhet } }).json(), + initialData: [], + }); + +export const behandlingerFristUtløptOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.HENT_BEHANDLINGER_FRISTUTLOP, avdelingEnhet], + queryFn: () => + kyExtended + .get(LosUrl.HENT_BEHANDLINGER_FRISTUTLOP, { searchParams: { avdelingEnhet } }) + .json(), + initialData: [], + }); + +export const reservasjonerForAvdelingOptions = (avdelingEnhet: string) => + queryOptions({ + queryKey: [LosUrl.RESERVASJONER_FOR_AVDELING, avdelingEnhet], + queryFn: () => + kyExtended.get(LosUrl.RESERVASJONER_FOR_AVDELING, { searchParams: { avdelingEnhet } }).json(), + initialData: [], + }); + +export const opprettNySaksliste = (avdelingEnhet: string) => + kyExtended.post(LosUrl.OPPRETT_NY_SAKSLISTE, { json: { avdelingEnhet } }).json<{ sakslisteId: string }>(); + +export const lagreSakslisteNavn = (sakslisteId: number, navn: string, avdelingEnhet: string) => + kyExtended.post(LosUrl.LAGRE_SAKSLISTE_NAVN, { json: { sakslisteId, navn, avdelingEnhet } }).json(); + +export const lagreSakslisteSaksbehandler = ( + sakslisteId: number, + brukerIdent: string, + checked: boolean, + avdelingEnhet: string, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_SAKSBEHANDLER, { json: { sakslisteId, brukerIdent, checked, avdelingEnhet } }) + .json(); + +export const lagreSakslisteSortering = (sakslisteId: number, sakslisteSorteringValg: string, avdelingEnhet: string) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_SORTERING, { json: { sakslisteId, sakslisteSorteringValg, avdelingEnhet } }) + .json(); + +export const lagreSakslisteSorteringIntervall = ( + sakslisteId: number, + fra: number, + til: number, + avdelingEnhet: string, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_SORTERING_INTERVALL, { json: { sakslisteId, fra, til, avdelingEnhet } }) + .json(); + +export const lagreSakslisteSorteringDynamiskPeriode = (sakslisteId: number, avdelingEnhet: string) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_SORTERING_DYNAMISK_PERIDE, { + json: { sakslisteId, avdelingEnhet }, + }) + .json(); + +export const lagreSakslisteSorteringTidsintervallDato = ( + sakslisteId: number, + avdelingEnhet: string, + fomDato?: string, + tomDato?: string, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_SORTERING_TIDSINTERVALL_DATO, { + json: { sakslisteId, avdelingEnhet, fomDato, tomDato }, + }) + .json(); + +export const lagreSakslisteFagsakYtelseType = ( + sakslisteId: number, + avdelingEnhet: string, + fagsakYtelseType: string, + checked: boolean, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_FAGSAK_YTELSE_TYPE, { + json: { sakslisteId, avdelingEnhet, fagsakYtelseType, checked }, + }) + .json(); + +export const lagreSakslisteBehandlingstype = ( + sakslisteId: number, + avdelingEnhet: string, + behandlingType: string, + checked: boolean, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_BEHANDLINGSTYPE, { + json: { sakslisteId, avdelingEnhet, behandlingType, checked }, + }) + .json(); + +export const lagreSakslisteAndreKriterier = ( + sakslisteId: number, + avdelingEnhet: string, + andreKriterierType: string, + checked: boolean, + inkluder: boolean, +) => + kyExtended + .post(LosUrl.LAGRE_SAKSLISTE_ANDRE_KRITERIER, { + json: { sakslisteId, avdelingEnhet, andreKriterierType, checked, inkluder }, + }) + .json(); + +export const slettSaksliste = (sakslisteId: number, avdelingEnhet: string) => + kyExtended + .post(LosUrl.SLETT_SAKSLISTE, { + json: { sakslisteId, avdelingEnhet }, + }) + .json(); + +export const opprettGruppe = (avdelingEnhet: string) => + kyExtended + .post(LosUrl.OPPRETT_GRUPPE, { + json: { avdelingEnhet }, + }) + .json(); + +export const leggSaksbehandlerTilGruppe = (brukerIdent: string, avdelingEnhet: string, gruppeId: number) => + kyExtended + .post(LosUrl.LEGG_SAKSBEHANDLER_TIL_GRUPPE, { + json: { brukerIdent, avdelingEnhet, gruppeId }, + }) + .json(); + +export const fjernSaksbehandlerFraGruppe = (brukerIdent: string, avdelingEnhet: string, gruppeId: number) => + kyExtended + .post(LosUrl.FJERN_SAKSBEHANDLER_FRA_GRUPPE, { + json: { brukerIdent, avdelingEnhet, gruppeId }, + }) + .json(); + +export const endreGruppenavn = (gruppeId: number, gruppeNavn: string, avdelingEnhet: string) => + kyExtended + .post(LosUrl.ENDRE_GRUPPENAVN, { + json: { gruppeId, gruppeNavn, avdelingEnhet }, + }) + .json(); + +export const slettGruppe = (gruppeId: number, avdelingEnhet: string) => + kyExtended + .post(LosUrl.SLETT_GRUPPE, { + json: { gruppeId, avdelingEnhet }, + }) + .json(); + +export const opphevReservasjon = (oppgaveId: number) => + kyExtended + .post(LosUrl.AVDELINGSLEDER_OPPHEVER_RESERVASJON, { + json: { oppgaveId }, + }) + .json(); + +export const flyttReservasjon = (oppgaveId: number, brukerIdent: string, begrunnelse: string) => + kyExtended + .post(LosUrl.FLYTT_RESERVASJON, { + json: { oppgaveId, brukerIdent, begrunnelse }, + }) + .json(); + +export const endreReservasjon = (oppgaveId: number, reserverTil: string) => + kyExtended + .post(LosUrl.ENDRE_OPPGAVERESERVASJON, { + json: { oppgaveId, reserverTil }, + }) + .json(); + +export const flyttReservasjonSaksbehandlerSøk = (brukerIdent: string) => + kyExtended + .post(LosUrl.FLYTT_RESERVASJON_SAKSBEHANDLER_SOK, { + json: { brukerIdent }, + }) + .json(); + +export const slettSaksbehandler = (brukerIdent: string, avdelingEnhet: string) => + kyExtended + .post(LosUrl.SLETT_SAKSBEHANDLER, { + json: { brukerIdent, avdelingEnhet }, + }) + .json(); + +export const saksbehandlgerSøk = (brukerIdent: string) => + kyExtended + .post(LosUrl.SAKSBEHANDLER_SOK, { + json: { brukerIdent }, + }) + .json(); + +export const opprettNySaksbehandler = (brukerIdent: string, avdelingEnhet: string) => + kyExtended + .post(LosUrl.OPPRETT_NY_SAKSBEHANDLER, { + json: { brukerIdent, avdelingEnhet }, + }) + .json(); diff --git a/apps/fp-avdelingsleder/src/data/localStorageHelper.ts b/apps/fp-avdelingsleder/src/data/localStorageHelper.ts new file mode 100644 index 00000000000..d7e201aaf83 --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/localStorageHelper.ts @@ -0,0 +1,12 @@ +export const getValueFromLocalStorage = (key: string): string | undefined => { + const value = window.localStorage.getItem(key); + return value !== 'undefined' && value !== null ? value : undefined; +}; + +export const setValueInLocalStorage = (key: string, value: string): void => { + window.localStorage.setItem(key, value); +}; + +export const removeValueFromLocalStorage = (key: string): void => { + window.localStorage.removeItem(key); +}; diff --git a/apps/fp-avdelingsleder/src/data/useLosKodeverk.ts b/apps/fp-avdelingsleder/src/data/useLosKodeverk.ts new file mode 100644 index 00000000000..2d022cd05e9 --- /dev/null +++ b/apps/fp-avdelingsleder/src/data/useLosKodeverk.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { AlleKodeverkLos, LosKodeverkType } from '@navikt/fp-types'; + +import { losKodeverkOptions } from './fplosAvdelingslederApi'; + +/** + * Hook som henter et gitt kodeverk fra respons som allerede er hentet fra backend. + */ +export const useLosKodeverk = (kodeverkType: T): AlleKodeverkLos[T] => { + const alleKodeverk = useQuery(losKodeverkOptions()).data; + + if (!alleKodeverk) { + throw new Error('Kodeverk for LOS er ikke lastet inn'); + } + if (!alleKodeverk[kodeverkType]) { + throw new Error(`Kodeverk ${kodeverkType} for LOS finnes ikke`); + } + + return alleKodeverk[kodeverkType]; +}; diff --git a/apps/fp-avdelingsleder/src/globalCss/global.module.css b/apps/fp-avdelingsleder/src/globalCss/global.module.css new file mode 100644 index 00000000000..65ab3092c60 --- /dev/null +++ b/apps/fp-avdelingsleder/src/globalCss/global.module.css @@ -0,0 +1,15 @@ +html { + height: 100%; +} + +body { + background-color: var(--ax-bg-default); + color: var(--ax-text-neutral); + height: 100%; + line-height: 1.42857143; + margin: 0; +} + +:global(#app) { + height: 100%; +} diff --git a/apps/fp-avdelingsleder/src/grupper/GruppeSaksbehandlere.tsx b/apps/fp-avdelingsleder/src/grupper/GruppeSaksbehandlere.tsx new file mode 100644 index 00000000000..09e8357b6e6 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/GruppeSaksbehandlere.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { XMarkIcon } from '@navikt/aksel-icons'; +import { BodyShort, HStack, Label, UNSAFE_Combobox, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfTextField } from '@navikt/ft-form-hooks'; +import { hasValidName, maxLength, minLength, required } from '@navikt/ft-form-validators'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { useDebounce } from '../behandlingskoer/sakslisteForm/useDebounce'; +import { + endreGruppenavn, + fjernSaksbehandlerFraGruppe, + leggSaksbehandlerTilGruppe, + LosUrl, +} from '../data/fplosAvdelingslederApi'; +import type { SaksbehandlerGruppe } from '../typer/saksbehandlereOgSaksbehandlerGrupper'; + +import styles from './gruppeSaksbehandlere.module.css'; + +const minLength3 = minLength(3); +const maxLength100 = maxLength(100); + +const sortGrupperteSaksbehandlere = (saksbehandlere: SaksbehandlerProfil[]) => + [...saksbehandlere].sort((saksbehandler1, saksbehandler2) => saksbehandler1.navn.localeCompare(saksbehandler2.navn)); + +const sortAvdelingensSaksbehandlere = ( + saksbehandlere: SaksbehandlerProfil[], + grupperteSaksbehandlere: SaksbehandlerProfil[], +) => + saksbehandlere + .filter(s => !grupperteSaksbehandlere.some(gs => gs.brukerIdent === s.brukerIdent)) + .sort((saksbehandler1, saksbehandler2) => saksbehandler1.navn.localeCompare(saksbehandler2.navn)); + +interface Props { + valgAvdeldingEnhet: string; + saksbehandlerGruppe: SaksbehandlerGruppe; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; +} + +export const GruppeSaksbehandlere = ({ valgAvdeldingEnhet, saksbehandlerGruppe, avdelingensSaksbehandlere }: Props) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + + const { mutate: leggTilSaksbehandler } = useMutation({ + mutationFn: (valuesToStore: { brukerIdent: string; gruppeId: number }) => + leggSaksbehandlerTilGruppe(valuesToStore.brukerIdent, valgAvdeldingEnhet, valuesToStore.gruppeId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.HENT_GRUPPER], + }); + }, + }); + + const { mutate: fjernSaksbehandler } = useMutation({ + mutationFn: (valuesToStore: { brukerIdent: string; gruppeId: number }) => + fjernSaksbehandlerFraGruppe(valuesToStore.brukerIdent, valgAvdeldingEnhet, valuesToStore.gruppeId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.HENT_GRUPPER], + }); + }, + }); + + const { mutate: endreNavnPåGruppe } = useMutation({ + mutationFn: (valuesToStore: { gruppeId: number; gruppeNavn: string }) => + endreGruppenavn(valuesToStore.gruppeId, valuesToStore.gruppeNavn, valgAvdeldingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.HENT_GRUPPER], + }); + }, + }); + + const formMethods = useForm({ + defaultValues: { + navn: saksbehandlerGruppe.gruppeNavn, + }, + }); + + const sorterteGrupperteSaksbehandlere = sortGrupperteSaksbehandlere(saksbehandlerGruppe.saksbehandlere); + const sorterteSaksbehandlereForAvdeling = sortAvdelingensSaksbehandlere( + avdelingensSaksbehandlere, + sorterteGrupperteSaksbehandlere, + ); + + const options = sorterteSaksbehandlereForAvdeling.map(sb => `${sb.navn} (${sb.brukerIdent})`); + + const lagreNavnDebounce = useDebounce( + 'navn', + (navn: string) => endreNavnPåGruppe({ gruppeId: saksbehandlerGruppe.gruppeId, gruppeNavn: navn }), + formMethods.trigger, + ); + + const [filteredOptions, setFilteredOptions] = useState([]); + const [filterValue, setFilterValue] = useState(''); + + const filterOptions = (searchTerm: string | undefined) => { + if (searchTerm?.trim()) { + setFilteredOptions(options.filter(option => option.toLowerCase().includes(searchTerm.toLowerCase()))); + } else { + setFilteredOptions(options); + } + }; + + const toggleSelected = (option: string, isSelected: boolean) => { + const selectedOption = filteredOptions.find(o => o.toLowerCase().includes(option?.toLowerCase())); + const navnOgBrukerIdent = selectedOption?.replace(')', '').split(' ('); + const alreadySelected = sorterteGrupperteSaksbehandlere.some( + gs => navnOgBrukerIdent && gs.brukerIdent === navnOgBrukerIdent[1], + ); + + if (selectedOption && isSelected && !alreadySelected && navnOgBrukerIdent) { + leggTilSaksbehandler({ brukerIdent: navnOgBrukerIdent[1], gruppeId: saksbehandlerGruppe.gruppeId }); + } + }; + + useEffect(() => filterOptions(filterValue), [saksbehandlerGruppe, filterValue]); + + return ( + + + lagreNavnDebounce(value)} + className={styles.navn} + /> + + + + + + {sorterteGrupperteSaksbehandlere.length === 0 && ( + + + + )} + {sorterteGrupperteSaksbehandlere.map(saksbehandler => ( + +
{`${saksbehandler.navn} (${saksbehandler.brukerIdent})`}
+
+ + fjernSaksbehandler({ + brukerIdent: saksbehandler.brukerIdent, + gruppeId: saksbehandlerGruppe.gruppeId, + }) + } + onKeyDown={() => + fjernSaksbehandler({ + brukerIdent: saksbehandler.brukerIdent, + gruppeId: saksbehandlerGruppe.gruppeId, + }) + } + /> +
+
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/grupper/GrupperPanel.spec.tsx b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.spec.tsx new file mode 100644 index 00000000000..b14706f79e1 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.spec.tsx @@ -0,0 +1,18 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './GrupperPanel.stories'; + +const { Default } = composeStories(stories); + +describe('GrupperPanel', () => { + it('skal vise gruppe i tabell', async () => { + await applyRequestHandlers(Default.parameters['msw']); + render(); + expect(await screen.findByText('Grupper')).toBeInTheDocument(); + expect(screen.getByText('Id')).toBeInTheDocument(); + expect(screen.getByText('Navn')).toBeInTheDocument(); + expect(screen.getByText('Antall saksbehandlere')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/grupper/GrupperPanel.stories.tsx b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.stories.tsx new file mode 100644 index 00000000000..fbe5df2ee74 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; + +import { getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { LosUrl } from '../data/fplosAvdelingslederApi'; +import type { SaksbehandlereOgSaksbehandlerGrupper } from '../typer/saksbehandlereOgSaksbehandlerGrupper'; +import { GrupperPanel } from './GrupperPanel'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const AVDELING_SAKSBEHANDLERE = [ + { + brukerIdent: 'ident1', + navn: 'Anders Utvikler', + ansattAvdeling: null, + }, + { + brukerIdent: 'ident12', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'ident4', + navn: 'Olga Utvikler', + ansattAvdeling: 'Avdeling Å', + }, + { + brukerIdent: 'ident3', + navn: 'Klara Utvikler', + ansattAvdeling: 'Avdeling Å', + }, +]; + +const SAKSBEHANDLERE_OG_SAKSBEHANDLER_GRUPPER = { + saksbehandlerGrupper: [ + { + gruppeId: 1, + gruppeNavn: 'Dette er navnet på en gruppe', + saksbehandlere: [ + { + brukerIdent: 'ident1', + navn: 'Anders Utvikler', + }, + ], + }, + ], +} as SaksbehandlereOgSaksbehandlerGrupper; + +const meta = { + title: 'los/avdelingsleder/grupper/GrupperPanel', + component: GrupperPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_GRUPPER, () => HttpResponse.json(SAKSBEHANDLERE_OG_SAKSBEHANDLER_GRUPPER)), + http.post(LosUrl.OPPRETT_GRUPPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.LEGG_SAKSBEHANDLER_TIL_GRUPPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.SLETT_GRUPPE, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.FJERN_SAKSBEHANDLER_FRA_GRUPPE, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtAvdelingEnhet: '1', + avdelingensSaksbehandlere: AVDELING_SAKSBEHANDLERE, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/grupper/GrupperPanel.tsx b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.tsx new file mode 100644 index 00000000000..2798a2c5e89 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/GrupperPanel.tsx @@ -0,0 +1,50 @@ +import { FormattedMessage } from 'react-intl'; + +import { Button, VStack } from '@navikt/ds-react'; +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { grupperOptions, opprettGruppe } from '../data/fplosAvdelingslederApi'; +import { GrupperTabell } from './GrupperTabell'; + +interface Props { + valgtAvdelingEnhet: string; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; +} + +export const GrupperPanel = ({ valgtAvdelingEnhet, avdelingensSaksbehandlere }: Props) => { + const { data: grupper, refetch: hentGrupperPåNytt } = useQuery(grupperOptions(valgtAvdelingEnhet)); + + const { mutate: lagGruppe, status } = useMutation({ + mutationFn: () => opprettGruppe(valgtAvdelingEnhet), + onSuccess: () => { + hentGrupperPåNytt(); + }, + }); + + if (!grupper) { + return ; + } + + return ( + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/grupper/GrupperTabell.tsx b/apps/fp-avdelingsleder/src/grupper/GrupperTabell.tsx new file mode 100644 index 00000000000..65d09261093 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/GrupperTabell.tsx @@ -0,0 +1,90 @@ +import { FormattedMessage } from 'react-intl'; + +import { XMarkIcon } from '@navikt/aksel-icons'; +import { BodyShort, Label, Table } from '@navikt/ds-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { LosUrl, slettGruppe } from '../data/fplosAvdelingslederApi'; +import type { SaksbehandlereOgSaksbehandlerGrupper } from '../typer/saksbehandlereOgSaksbehandlerGrupper'; +import { GruppeSaksbehandlere } from './GruppeSaksbehandlere'; + +import styles from './grupperTabell.module.css'; + +interface Props { + valgAvdeldingEnhet: string; + grupper: SaksbehandlereOgSaksbehandlerGrupper; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; +} + +export const GrupperTabell = ({ valgAvdeldingEnhet, grupper, avdelingensSaksbehandlere }: Props) => { + const queryClient = useQueryClient(); + + const { mutate: fjernGruppe } = useMutation({ + mutationFn: (valuesToStore: { gruppeId: number }) => slettGruppe(valuesToStore.gruppeId, valgAvdeldingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.HENT_GRUPPER], + }); + }, + }); + + return ( + <> + + {grupper.saksbehandlerGrupper.length > 0 && ( + + + + + + + + + + + + + + + + + + {grupper.saksbehandlerGrupper?.map(saksbehandlerGruppe => ( + + } + > + {saksbehandlerGruppe.gruppeId} + {saksbehandlerGruppe.gruppeNavn ?? '-'} + {saksbehandlerGruppe.saksbehandlere.length} + + fjernGruppe({ gruppeId: saksbehandlerGruppe.gruppeId })} + onKeyDown={() => fjernGruppe({ gruppeId: saksbehandlerGruppe.gruppeId })} + /> + + + ))} + +
+ )} + {grupper.saksbehandlerGrupper.length === 0 && ( + + + + )} + + ); +}; diff --git a/apps/fp-avdelingsleder/src/grupper/gruppeSaksbehandlere.module.css b/apps/fp-avdelingsleder/src/grupper/gruppeSaksbehandlere.module.css new file mode 100644 index 00000000000..643a143c89e --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/gruppeSaksbehandlere.module.css @@ -0,0 +1,14 @@ +.navn { + width: 400px; +} + +.saksbehandlerCombo { + width: 400px; +} + +.removeIcon { + color: var(--ax-danger-500); + cursor: pointer; + height: 25px; + width: 25px; +} diff --git a/apps/fp-avdelingsleder/src/grupper/grupperTabell.module.css b/apps/fp-avdelingsleder/src/grupper/grupperTabell.module.css new file mode 100644 index 00000000000..ce49c3658c5 --- /dev/null +++ b/apps/fp-avdelingsleder/src/grupper/grupperTabell.module.css @@ -0,0 +1,6 @@ +.removeIcon { + color: var(--ax-danger-500); + cursor: pointer; + height: 25px; + width: 25px; +} diff --git a/apps/fp-avdelingsleder/src/kodeverk/behandlingVenteStatus.ts b/apps/fp-avdelingsleder/src/kodeverk/behandlingVenteStatus.ts new file mode 100644 index 00000000000..1a9df193005 --- /dev/null +++ b/apps/fp-avdelingsleder/src/kodeverk/behandlingVenteStatus.ts @@ -0,0 +1,4 @@ +export enum BehandlingVenteStatus { + PA_VENT = 'PÅ_VENT', + IKKE_PA_VENT = 'IKKE_PÅ_VENT', +} diff --git a/apps/fp-avdelingsleder/src/main.tsx b/apps/fp-avdelingsleder/src/main.tsx new file mode 100644 index 00000000000..f440a0aa076 --- /dev/null +++ b/apps/fp-avdelingsleder/src/main.tsx @@ -0,0 +1,42 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; + +import { breadcrumbsIntegration, init } from '@sentry/browser'; +import dayjs from 'dayjs'; + +import { LosAppIndexWrapper } from './app/LosAppIndex'; +import { RestApiErrorProvider } from './data/error/RestApiErrorContext'; + +import 'dayjs/locale/nb.js'; + +dayjs.locale('nb'); + +const app = document.getElementById('root'); +if (app === null) { + throw new Error('No app element'); +} + +const environment = window.location.hostname; +const isDevelopment = import.meta.env.MODE === 'development'; + +if (!isDevelopment) { + init({ + dsn: 'https://d1b7de8cc42949569da03849b47d3ea1@sentry.gc.nav.no/17', + release: import.meta.env['SENTRY_RELEASE'] ?? 'unknown', + environment, + integrations: [breadcrumbsIntegration({ console: false })], + }); +} + +const root = createRoot(app); + +root.render( + + + + + + + , +); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/NokkeltallPanel.tsx b/apps/fp-avdelingsleder/src/nokkeltall/NokkeltallPanel.tsx new file mode 100644 index 00000000000..1c0291cac9e --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/NokkeltallPanel.tsx @@ -0,0 +1,42 @@ +import { VStack } from '@navikt/ds-react'; + +import { getValueFromLocalStorage } from '../data/localStorageHelper'; +import { OppgaverPerForsteStonadsdagPanel } from './antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel'; +import { OppgaverSomErApneEllerPaVentPanel } from './apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel'; +import { FordelingAvBehandlingstypePanel } from './fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel'; +import { TilBehandlingPanel } from './tilBehandling/TilBehandlingPanel'; +import { VentefristUtløperPanel } from './ventefristUtløper/VentefristUtløperPanel'; + +interface Props { + valgtAvdelingEnhet: string; +} + +export const NokkeltallPanel = ({ valgtAvdelingEnhet }: Props) => { + const height = 300; + + return ( + + + + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagGraf.tsx b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagGraf.tsx new file mode 100644 index 00000000000..acfc6ea339a --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagGraf.tsx @@ -0,0 +1,94 @@ +import { DDMMYYYY_DATE_FORMAT } from '@navikt/ft-utils'; +import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; + +import { ReactECharts } from '@navikt/fp-los-felles'; + +import type { OppgaverForForsteStonadsdag } from '../../typer/oppgaverForForsteStonadsdagTsType'; + +dayjs.extend(isSameOrBefore); +dayjs.extend(isSameOrAfter); + +interface Koordinat { + x: number; + y: number; +} + +interface Props { + height: number; + oppgaverPerForsteStonadsdag: OppgaverForForsteStonadsdag[]; +} + +export const OppgaverPerForsteStonadsdagGraf = ({ height, oppgaverPerForsteStonadsdag }: Props) => { + const koordinater = lagKoordinater(oppgaverPerForsteStonadsdag); + const data = lagDatastruktur(koordinater); + return ( + dayjs(params.value).format(DDMMYYYY_DATE_FORMAT), + }, + }, + }, + toolbox: { + feature: { + saveAsImage: { + title: 'Lagre ', + name: 'Antall_førstegangsbehandlinger_fordelt_på_første_stønadsdag', + }, + }, + }, + xAxis: { + type: 'time', + axisLabel: { + formatter: '{dd}.{MM}.{yyyy}', + }, + }, + yAxis: { + type: 'value', + }, + series: [ + { + data, + type: 'line', + areaStyle: {}, + }, + ], + color: ['#337c9b'], + }} + /> + ); +}; + +const lagKoordinater = (oppgaverPerForsteStonadsdag: OppgaverForForsteStonadsdag[]): Koordinat[] => + oppgaverPerForsteStonadsdag.map(o => ({ + x: dayjs(o.forsteStonadsdag).startOf('day').toDate().getTime(), + y: o.antall, + })); + +const lagDatastruktur = (koordinater: Koordinat[]): number[][] => { + const nyeKoordinater = []; + const periodeStart = koordinater + .map(koordinat => dayjs(koordinat.x)) + .reduce( + (tidligesteDato, dato) => (tidligesteDato.isSameOrBefore(dato) ? tidligesteDato : dato), + dayjs().startOf('day'), + ) + .toDate(); + const periodeSlutt = koordinater + .map(koordinat => dayjs(koordinat.x)) + .reduce((senesteDato, dato) => (senesteDato.isSameOrAfter(dato) ? senesteDato : dato), dayjs().startOf('day')) + .toDate(); + + for (let dato = dayjs(periodeStart); dato.isSameOrBefore(periodeSlutt); dato = dato.add(1, 'days')) { + const funnetKoordinat = koordinater.find(k => dayjs(k.x).isSame(dato)); + nyeKoordinater.push([dato.toDate().getTime(), funnetKoordinat ? funnetKoordinat.y : 0]); + } + return nyeKoordinater; +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.spec.tsx b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.spec.tsx new file mode 100644 index 00000000000..896f9f6022a --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.spec.tsx @@ -0,0 +1,18 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; + +import * as stories from './OppgaverPerForsteStonadsdagPanel.stories'; + +const { Default } = composeStories(stories); + +describe('OppgaverPerForsteStonadsdagPanel', () => { + // TODO echarts-testing + it.skip('skal rendre graf', async () => { + render(); + expect( + await screen.findByText( + 'Antall åpne oppgaver for førstegangsbehandlinger fordelt på første stønadsdag - alle ytelser', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.stories.tsx b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.stories.tsx new file mode 100644 index 00000000000..454791bd429 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.stories.tsx @@ -0,0 +1,66 @@ +import { ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import type { Meta, StoryObj } from '@storybook/react'; +import dayjs from 'dayjs'; +import { http, HttpResponse } from 'msw'; + +import { getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { LosUrl } from '../../data/fplosAvdelingslederApi'; +import { OppgaverPerForsteStonadsdagPanel } from './OppgaverPerForsteStonadsdagPanel'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/nokkeltall/OppgaverPerForsteStonadsdagPanel', + component: OppgaverPerForsteStonadsdagPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.HENT_OPPGAVER_PER_FORSTE_STONADSDAG.replaceAll('ø', '%C3%B8'), () => + HttpResponse.json([ + { + forsteStonadsdag: dayjs().subtract(14, 'd').format(ISO_DATE_FORMAT), + antall: 10, + }, + { + forsteStonadsdag: dayjs().subtract(13, 'd').format(ISO_DATE_FORMAT), + antall: 9, + }, + { + forsteStonadsdag: dayjs().subtract(12, 'd').format(ISO_DATE_FORMAT), + antall: 6, + }, + { + forsteStonadsdag: dayjs().subtract(11, 'd').format(ISO_DATE_FORMAT), + antall: 11, + }, + { + forsteStonadsdag: dayjs().subtract(10, 'd').format(ISO_DATE_FORMAT), + antall: 15, + }, + { + forsteStonadsdag: dayjs().subtract(9, 'd').format(ISO_DATE_FORMAT), + antall: 20, + }, + { + forsteStonadsdag: dayjs().subtract(8, 'd').format(ISO_DATE_FORMAT), + antall: 13, + }, + ]), + ), + ], + }, + }, + args: { + height: 300, + valgtAvdelingEnhet: '1', + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.tsx b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.tsx new file mode 100644 index 00000000000..f358fca12e1 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/antallBehandlingerPerForsteStonadsdag/OppgaverPerForsteStonadsdagPanel.tsx @@ -0,0 +1,25 @@ +import { FormattedMessage } from 'react-intl'; + +import { Label, VStack } from '@navikt/ds-react'; +import { useQuery } from '@tanstack/react-query'; + +import { oppgaverPerFørsteStønadsdagOptions } from '../../data/fplosAvdelingslederApi'; +import { OppgaverPerForsteStonadsdagGraf } from './OppgaverPerForsteStonadsdagGraf'; + +interface Props { + height: number; + valgtAvdelingEnhet: string; +} + +export const OppgaverPerForsteStonadsdagPanel = ({ height, valgtAvdelingEnhet }: Props) => { + const { data: oppgaverPerForsteStonadsdag } = useQuery(oppgaverPerFørsteStønadsdagOptions(valgtAvdelingEnhet)); + + return ( + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentGraf.tsx b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentGraf.tsx new file mode 100644 index 00000000000..b55dc02d5b4 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentGraf.tsx @@ -0,0 +1,211 @@ +import { type IntlShape, useIntl } from 'react-intl'; + +import dayjs from 'dayjs'; + +import { ReactECharts } from '@navikt/fp-los-felles'; + +import { BehandlingVenteStatus } from '../../kodeverk/behandlingVenteStatus'; +import type { OppgaverSomErApneEllerPaVent } from '../../typer/oppgaverSomErApneEllerPaVentTsType'; + +const UKJENT_DATO = 'UKJENT_DATO'; + +const getYearText = (month: number, intl: IntlShape): string => + intl.formatMessage({ id: `OppgaverSomErApneEllerPaVentGraf.${month}` }); + +interface KoordinatDatoEllerUkjent { + x: string; + y: number; +} + +interface Props { + height: number; + oppgaverApneEllerPaVent: OppgaverSomErApneEllerPaVent[]; +} + +export const OppgaverSomErApneEllerPaVentGraf = ({ height, oppgaverApneEllerPaVent }: Props) => { + const intl = useIntl(); + const paVentTekst = intl.formatMessage({ id: 'OppgaverSomErApneEllerPaVentGraf.PaVent' }); + const ikkePaVentTekst = intl.formatMessage({ id: 'OppgaverSomErApneEllerPaVentGraf.IkkePaVent' }); + const ukjentTekst = intl.formatMessage({ id: 'OppgaverSomErApneEllerPaVentGraf.Ukjent' }); + const datoTekst = intl.formatMessage({ id: 'OppgaverSomErApneEllerPaVentGraf.Dato' }); + + const oppgaverPaVentPerDato = finnAntallPerDato( + oppgaverApneEllerPaVent.filter(o => o.behandlingVenteStatus === BehandlingVenteStatus.PA_VENT), + ); + const oppgaverIkkePaVentPerDato = finnAntallPerDato( + oppgaverApneEllerPaVent.filter(o => o.behandlingVenteStatus === BehandlingVenteStatus.IKKE_PA_VENT), + ); + + const [periodeStart, periodeSlutt] = finnGrafPeriode(oppgaverApneEllerPaVent); + + const { koordinaterPaVent, koordinaterIkkePaVent } = fyllInnManglendeDatoerOgSorterEtterDato( + oppgaverPaVentPerDato, + oppgaverIkkePaVentPerDato, + periodeStart, + periodeSlutt, + ); + + return ( + { + const dato = dayjs(params.value); + if (dato.isSame(periodeSlutt)) { + return `${ukjentTekst} ${datoTekst}`; + } + return `${getYearText(dato.month(), intl)} - ${dato.year()}`; + }, + }, + }, + }, + toolbox: { + feature: { + saveAsImage: { + title: 'Lagre ', + name: 'Status_åpne_behandlinger', + }, + }, + }, + legend: { + data: [paVentTekst, ikkePaVentTekst], + top: 'top', + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: [ + { + type: 'category', + boundaryGap: false, + axisLabel: { + formatter: value => { + const dato = dayjs(value); + const erSiste = dato.isSame(periodeSlutt); + const maned = erSiste ? ukjentTekst : getYearText(dato.month(), intl); + const ar = erSiste ? datoTekst : dato.year(); + + return `${maned}\n${ar}`; + }, + }, + }, + ], + yAxis: [ + { + type: 'value', + name: intl.formatMessage({ id: 'OppgaverSomErApneEllerPaVentGraf.AntallGraf' }), + }, + ], + series: [ + { + name: paVentTekst, + type: 'bar', + stack: 'total', + label: { + show: true, + }, + emphasis: { + focus: 'series', + }, + data: koordinaterPaVent, + }, + { + name: ikkePaVentTekst, + type: 'bar', + stack: 'total', + label: { + show: true, + }, + emphasis: { + focus: 'series', + }, + data: koordinaterIkkePaVent, + }, + ], + color: ['#85d5f0', '#38a161'], + }} + /> + ); +}; + +const finnGrafPeriode = (oppgaverSomErApneEllerPaVent: OppgaverSomErApneEllerPaVent[]): dayjs.Dayjs[] => { + // Data er garantert å gå inntil 6 mnd tilbake og 10 mnd fram i tid. Utliggerne havner på første/siste mnd + let periodeStart = dayjs().subtract(6, 'M'); + let periodeSlutt = dayjs().add(1, 'M'); + + oppgaverSomErApneEllerPaVent + .filter(oppgave => !!oppgave.førsteUttakMåned) + .forEach(oppgave => { + const dato = dayjs(oppgave.førsteUttakMåned); + if (dato.isBefore(periodeStart)) { + periodeStart = dato; + } + if (dato.isAfter(periodeSlutt)) { + periodeSlutt = dato; + } + }); + + // Ekstra kolonne for data med ukjent dato + return [dayjs(periodeStart.startOf('month')), dayjs(periodeSlutt.add(1, 'months').startOf('month'))]; +}; + +const finnAntallPerDato = ( + oppgaverSomErApneEllerPaVent: OppgaverSomErApneEllerPaVent[], +): KoordinatDatoEllerUkjent[] => { + const antallPerDatoOgUkjent = oppgaverSomErApneEllerPaVent.reduce( + (acc, oppgave) => { + const { førsteUttakMåned, antall } = oppgave; + const key = førsteUttakMåned ?? UKJENT_DATO; + return { + ...acc, + [key]: acc[key] ? acc[key] + antall : antall, + }; + }, + {} as Record, + ); + + return Object.keys(antallPerDatoOgUkjent).map(k => ({ x: k, y: antallPerDatoOgUkjent[k] })); +}; + +const lagKoordinatForDato = (dato: dayjs.Dayjs, oppgaver: KoordinatDatoEllerUkjent[]): (number | Date)[] => { + const eksisterendeDato = oppgaver.filter(o => o.x !== UKJENT_DATO).find(o => dayjs(o.x).isSame(dato)); + return [ + eksisterendeDato ? dayjs(eksisterendeDato.x).toDate() : dato.toDate(), + eksisterendeDato ? eksisterendeDato.y : 0, + ]; +}; + +const fyllInnManglendeDatoerOgSorterEtterDato = ( + oppgaverPaVent: KoordinatDatoEllerUkjent[], + oppgaverIkkePaVent: KoordinatDatoEllerUkjent[], + periodeStart: dayjs.Dayjs, + periodeSlutt: dayjs.Dayjs, +): { koordinaterPaVent: (number | Date)[][]; koordinaterIkkePaVent: (number | Date)[][] } => { + const koordinaterPaVent: (number | Date)[][] = []; + const koordinaterIkkePaVent: (number | Date)[][] = []; + + // For å unngå kollisjon med Y-akse + koordinaterPaVent.push([periodeStart.subtract(1, 'months').startOf('month').toDate(), 0]); + let dato = dayjs(periodeStart); + do { + koordinaterPaVent.push(lagKoordinatForDato(dato, oppgaverPaVent)); + koordinaterIkkePaVent.push(lagKoordinatForDato(dato, oppgaverIkkePaVent)); + dato = dayjs(dato.add(1, 'month')); + } while (dato.isBefore(periodeSlutt)); + + koordinaterPaVent.push([periodeSlutt.toDate(), oppgaverPaVent.find(d => d.x === UKJENT_DATO)?.y ?? 0]); + koordinaterIkkePaVent.push([periodeSlutt.toDate(), oppgaverIkkePaVent.find(d => d.x === UKJENT_DATO)?.y ?? 0]); + + return { + koordinaterPaVent, + koordinaterIkkePaVent, + }; +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.spec.tsx b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.spec.tsx new file mode 100644 index 00000000000..66d28c92bd4 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.spec.tsx @@ -0,0 +1,24 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './OppgaverSomErApneEllerPaVentPanel.stories'; + +const { Default } = composeStories(stories); + +describe('OppgaverSomErApneEllerPaVentPanel', () => { + // TODO echarts-testing + it.skip('skal vise graffilter', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect( + await screen.findByText('Åpne behandlinger foreldrepenger fordelt på første uttaksdag fra søknad'), + ).toBeInTheDocument(); + + expect(getByLabelText('Førstegangsbehandling')).toBeChecked(); + expect(getByLabelText('Klage')).toBeChecked(); + expect(getByLabelText('Revurdering')).toBeChecked(); + expect(getByLabelText('Innsyn')).toBeChecked(); + expect(getByLabelText('Anke')).toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.stories.tsx b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.stories.tsx new file mode 100644 index 00000000000..ec6b30dd87a --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.stories.tsx @@ -0,0 +1,97 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../data/fplosAvdelingslederApi'; +import { BehandlingVenteStatus } from '../../kodeverk/behandlingVenteStatus'; +import { OppgaverSomErApneEllerPaVentPanel } from './OppgaverSomErApneEllerPaVentPanel'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const OPPGAVER_ÅPNE_ELLER_PÅ_VENT = [ + { + behandlingVenteStatus: BehandlingVenteStatus.PA_VENT, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + førsteUttakMåned: dayjs().startOf('month').format(ISO_DATE_FORMAT), + antall: 2, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.IKKE_PA_VENT, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + førsteUttakMåned: dayjs().startOf('month').format(ISO_DATE_FORMAT), + antall: 5, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.IKKE_PA_VENT, + behandlingType: BehandlingType.REVURDERING, + førsteUttakMåned: dayjs().startOf('month').subtract(4, 'M').format(ISO_DATE_FORMAT), + antall: 2, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.IKKE_PA_VENT, + behandlingType: BehandlingType.KLAGE, + antall: 2, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.PA_VENT, + behandlingType: BehandlingType.KLAGE, + antall: 6, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.PA_VENT, + behandlingType: BehandlingType.REVURDERING, + førsteUttakMåned: dayjs().startOf('month').subtract(4, 'M').format(ISO_DATE_FORMAT), + antall: 6, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.PA_VENT, + behandlingType: BehandlingType.DOKUMENTINNSYN, + førsteUttakMåned: dayjs().startOf('month').subtract(10, 'M').format(ISO_DATE_FORMAT), + antall: 3, + }, + { + behandlingVenteStatus: BehandlingVenteStatus.IKKE_PA_VENT, + behandlingType: BehandlingType.DOKUMENTINNSYN, + førsteUttakMåned: dayjs().startOf('month').subtract(10, 'M').format(ISO_DATE_FORMAT), + antall: 5, + }, +]; + +const meta = { + title: 'los/avdelingsleder/nokkeltall/OppgaverSomErApneEllerPaVentPanel', + component: OppgaverSomErApneEllerPaVentPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.HENT_OPPGAVER_APNE_ELLER_PA_VENT.replaceAll('ø', '%C3%B8').replaceAll('å', '%C3%A5'), () => + HttpResponse.json(OPPGAVER_ÅPNE_ELLER_PÅ_VENT), + ), + ], + }, + }, + args: { + height: 300, + valgtAvdelingEnhet: '1', + getValueFromLocalStorage: () => '', + }, + render: props => { + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + return alleKodeverk ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.tsx b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.tsx new file mode 100644 index 00000000000..c9e8a509acd --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/apneOgPaVentBehandlinger/OppgaverSomErApneEllerPaVentPanel.tsx @@ -0,0 +1,68 @@ +import { useForm } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { HStack, Label, VStack } from '@navikt/ds-react'; +import { RhfCheckbox, RhfForm } from '@navikt/ft-form-hooks'; +import { useQuery } from '@tanstack/react-query'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; + +import { oppgaverÅpneEllerPåVentOptions } from '../../data/fplosAvdelingslederApi'; +import { StoreValuesInLocalStorage } from '../../data/StoreValuesInLocalStorage'; +import { useLosKodeverk } from '../../data/useLosKodeverk'; +import { OppgaverSomErApneEllerPaVentGraf } from './OppgaverSomErApneEllerPaVentGraf'; + +const formName = 'oppgaverSomErApneEllerPaVent'; + +interface Props { + height: number; + valgtAvdelingEnhet: string; + getValueFromLocalStorage: (key: string) => string | undefined; +} + +export const OppgaverSomErApneEllerPaVentPanel = ({ height, valgtAvdelingEnhet, getValueFromLocalStorage }: Props) => { + const { data: oppgaverApneEllerPaVent } = useQuery(oppgaverÅpneEllerPåVentOptions(valgtAvdelingEnhet)); + + const behandlingTyper = useLosKodeverk('BehandlingType'); + const stringFromStorage = getValueFromLocalStorage(formName); + const lagredeVerdier = stringFromStorage ? JSON.parse(stringFromStorage) : undefined; + + const filtrerteBehandlingstyper = behandlingTyper.filter( + type => type.kode !== BehandlingType.TILBAKEKREVING && type.kode !== BehandlingType.TILBAKEKREVING_REVURDERING, + ); + + const formDefaultValues = Object.values(filtrerteBehandlingstyper).reduce( + (app, type) => ({ + ...app, + [type.kode]: true, + }), + {}, + ); + + // TODO (TOR) Mangler typing for useForm + const formMethods = useForm({ + defaultValues: lagredeVerdier ?? formDefaultValues, + }); + + const values = formMethods.watch(); + + return ( + + + + + + {filtrerteBehandlingstyper.map(type => ( + + ))} + + values[oav.behandlingType])} + /> + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypeGraf.tsx b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypeGraf.tsx new file mode 100644 index 00000000000..a19a9e9b04c --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypeGraf.tsx @@ -0,0 +1,117 @@ +import { useIntl } from 'react-intl'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; +import { ReactECharts } from '@navikt/fp-los-felles'; +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +import type { OppgaverForAvdeling } from '../../typer/oppgaverForAvdelingTsType'; + +const behandlingstypeOrder = [ + BehandlingType.TILBAKEKREVING_REVURDERING, + BehandlingType.TILBAKEKREVING, + BehandlingType.DOKUMENTINNSYN, + BehandlingType.KLAGE, + BehandlingType.REVURDERING, + BehandlingType.FORSTEGANGSSOKNAD, +]; + +interface Props { + height: number; + behandlingTyper: LosKodeverkMedNavn<'BehandlingType'>[]; + oppgaverForAvdeling: OppgaverForAvdeling[]; +} + +export const FordelingAvBehandlingstypeGraf = ({ height, oppgaverForAvdeling, behandlingTyper }: Props) => { + const intl = useIntl(); + const tilBehandlingTekst = intl.formatMessage({ id: 'FordelingAvBehandlingstypeGraf.TilBehandling' }); + const tilBeslutterTekst = intl.formatMessage({ id: 'FordelingAvBehandlingstypeGraf.TilBeslutter' }); + + const finnBehandlingTypeNavn = behandlingstypeOrder.map(t => { + const type = behandlingTyper.find(bt => bt.kode === t); + return type ? type.navn : ''; + }); + + const tilBehandlingData = slåSammen(oppgaverForAvdeling.filter(o => o.tilBehandling)); + const tilBeslutterData = slåSammen(oppgaverForAvdeling.filter(o => !o.tilBehandling)); + + return ( + + ); +}; + +const slåSammen = (oppgaverForAvdeling: OppgaverForAvdeling[]): number[] => { + const test = oppgaverForAvdeling.reduce( + (acc, o) => { + const index = behandlingstypeOrder.findIndex(bo => bo === o.behandlingType) + 1; + return { + ...acc, + [index]: acc[index] ? acc[index] + o.antall : o.antall, + }; + }, + {} as Record, + ); + + return behandlingstypeOrder.map((_b, index) => test[index + 1]); +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.spec.tsx b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.spec.tsx new file mode 100644 index 00000000000..89f041fe494 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.spec.tsx @@ -0,0 +1,21 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './FordelingAvBehandlingstypePanel.stories'; + +const { Default } = composeStories(stories); + +describe('FordelingAvBehandlingstypePanel', () => { + // TODO echarts-testing + it.skip('skal vise graffilter', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Antall åpne oppgaver nå')).toBeInTheDocument(); + + expect(getByLabelText('Foreldrepenger')).not.toBeChecked(); + expect(getByLabelText('Engangsstønad')).not.toBeChecked(); + expect(getByLabelText('Svangerskapspenger')).not.toBeChecked(); + expect(getByLabelText('Alle')).toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.stories.tsx b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.stories.tsx new file mode 100644 index 00000000000..cd928b27835 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.stories.tsx @@ -0,0 +1,78 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType, FagsakYtelseType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../data/fplosAvdelingslederApi'; +import { FordelingAvBehandlingstypePanel } from './FordelingAvBehandlingstypePanel'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const OPPGAVER_FOR_AVDELING = [ + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + tilBehandling: true, + antall: 10, + }, + { + fagsakYtelseType: FagsakYtelseType.ENGANGSSTONAD, + behandlingType: BehandlingType.KLAGE, + tilBehandling: true, + antall: 4, + }, + { + fagsakYtelseType: FagsakYtelseType.ENGANGSSTONAD, + behandlingType: BehandlingType.REVURDERING, + tilBehandling: true, + antall: 14, + }, + { + fagsakYtelseType: FagsakYtelseType.ENGANGSSTONAD, + behandlingType: BehandlingType.REVURDERING, + tilBehandling: false, + antall: 4, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.TILBAKEKREVING, + tilBehandling: false, + antall: 6, + }, +]; + +const meta = { + title: 'los/avdelingsleder/nokkeltall/FordelingAvBehandlingstypePanel', + component: FordelingAvBehandlingstypePanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.HENT_OPPGAVER_FOR_AVDELING.replace('ø', '%C3%B8'), () => + HttpResponse.json(OPPGAVER_FOR_AVDELING), + ), + ], + }, + }, + args: { + height: 300, + valgtAvdelingEnhet: '1', + getValueFromLocalStorage: () => '', + }, + render: props => { + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + return alleKodeverk ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.tsx b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.tsx new file mode 100644 index 00000000000..6196094795c --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/fordelingAvBehandlingstype/FordelingAvBehandlingstypePanel.tsx @@ -0,0 +1,103 @@ +import { useForm } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { Label, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfRadioGroup } from '@navikt/ft-form-hooks'; +import { useQuery } from '@tanstack/react-query'; + +import { FagsakYtelseType } from '@navikt/fp-kodeverk'; +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +import { oppgaverForAvdelingOptions } from '../../data/fplosAvdelingslederApi'; +import { StoreValuesInLocalStorage } from '../../data/StoreValuesInLocalStorage'; +import { useLosKodeverk } from '../../data/useLosKodeverk'; +import { FordelingAvBehandlingstypeGraf } from './FordelingAvBehandlingstypeGraf'; + +const finnFagsakYtelseTypeNavn = ( + fagsakYtelseTyper: LosKodeverkMedNavn<'FagsakYtelseType'>[], + valgtFagsakYtelseType: string, +) => { + const type = fagsakYtelseTyper.find(fyt => fyt.kode === valgtFagsakYtelseType); + return type ? type.navn : ''; +}; + +const ALLE_YTELSETYPER_VALGT = 'ALLE'; + +interface InitialValues { + valgtYtelseType: string; +} + +type FormValues = { + valgtYtelseType: string; +}; + +interface Props { + height: number; + valgtAvdelingEnhet: string; + getValueFromLocalStorage: (key: string) => string | undefined; +} + +const formName = 'fordelingAvBehandlingstype'; +const formDefaultValues: InitialValues = { valgtYtelseType: ALLE_YTELSETYPER_VALGT }; + +export const FordelingAvBehandlingstypePanel = ({ height, valgtAvdelingEnhet, getValueFromLocalStorage }: Props) => { + const { data: oppgaverForAvdeling } = useQuery(oppgaverForAvdelingOptions(valgtAvdelingEnhet)); + + const fagsakYtelseTyper = useLosKodeverk('FagsakYtelseType'); + const behandlingTyper = useLosKodeverk('BehandlingType'); + const stringFromStorage = getValueFromLocalStorage(formName); + const lagredeVerdier = stringFromStorage ? JSON.parse(stringFromStorage) : undefined; + + const formMethods = useForm({ + defaultValues: lagredeVerdier ?? formDefaultValues, + }); + + const values = formMethods.watch(); + + return ( + + + + + , + }, + ]} + /> + + values.valgtYtelseType === ALLE_YTELSETYPER_VALGT + ? true + : values.valgtYtelseType === ofa.fagsakYtelseType, + ) + : [] + } + /> + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingGraf.tsx b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingGraf.tsx new file mode 100644 index 00000000000..276682f33b6 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingGraf.tsx @@ -0,0 +1,187 @@ +import { DDMMYYYY_DATE_FORMAT, ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import dayjs from 'dayjs'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; +import { ReactECharts } from '@navikt/fp-los-felles'; +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +const behandlingstypeOrder = [ + BehandlingType.TILBAKEKREVING_REVURDERING, + BehandlingType.TILBAKEKREVING, + BehandlingType.DOKUMENTINNSYN, + BehandlingType.KLAGE, + BehandlingType.REVURDERING, + BehandlingType.FORSTEGANGSSOKNAD, +]; + +const behandlingstypeFarger = { + [BehandlingType.TILBAKEKREVING_REVURDERING]: '#ef5d28', + [BehandlingType.TILBAKEKREVING]: '#ff842f', + [BehandlingType.DOKUMENTINNSYN]: '#ffd23b', + [BehandlingType.KLAGE]: '#826ba1', + [BehandlingType.REVURDERING]: '#3385d1', + [BehandlingType.FORSTEGANGSSOKNAD]: '#85d5f0', + [BehandlingType.ANKE]: '#85d5f0', +}; + +export interface OppgaveForDatoGraf { + behandlingType: string; + opprettetDato: string; + antall: number; +} + +type Koordinat = { + x: Date; + y: number; +}; + +const keysFromObject = (object: T): (keyof T)[] => { + return Object.keys(object) as (keyof T)[]; +}; + +interface Props { + height: number; + behandlingTyper: LosKodeverkMedNavn<'BehandlingType'>[]; + oppgaverPerDato: OppgaveForDatoGraf[]; + isToUkerValgt: boolean; +} + +export const TilBehandlingGraf = ({ height, oppgaverPerDato, isToUkerValgt, behandlingTyper }: Props) => { + const periodeStart = dayjs() + .subtract(isToUkerValgt ? 2 : 4, 'w') + .add(1, 'd'); + const periodeSlutt = dayjs(); + + const koordinater = konverterTilKoordinaterGruppertPaBehandlingstype(oppgaverPerDato); + const data = fyllInnManglendeDatoerOgSorterEtterDato(koordinater, periodeStart, periodeSlutt); + + const alleBehandlingstyperSortert = behandlingTyper.map(bt => bt.kode).sort(sorterBehandlingtyper); + const sorterteBehandlingstyper = keysFromObject(data).sort(sorterBehandlingtyper); + const reversertSorterteBehandlingstyper = sorterteBehandlingstyper.slice().reverse(); + const farger = alleBehandlingstyperSortert.map(bt => behandlingstypeFarger[bt]); + + return ( + { + if (params.axisDimension === 'y') { + return parseInt(params.value as string, 10).toString(); + } + return dayjs(params.value).format(DDMMYYYY_DATE_FORMAT); + }, + }, + }, + }, + toolbox: { + feature: { + saveAsImage: { + title: 'Lagre ', + name: 'Antall_til_behandling', + }, + }, + }, + legend: { + data: reversertSorterteBehandlingstyper.map(type => finnBehandlingTypeNavn(behandlingTyper, type)), + top: 'top', + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: [ + { + type: 'time', + axisLabel: { + formatter: '{dd}.{MM}.{yyyy}', + }, + }, + ], + yAxis: [ + { + type: 'value', + }, + ], + series: alleBehandlingstyperSortert.map(type => ({ + name: finnBehandlingTypeNavn(behandlingTyper, type), + type: 'line', + stack: 'stackname', + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: data[type], + })), + color: farger, + }} + /> + ); +}; + +const sorterBehandlingtyper = (b1: BehandlingType, b2: BehandlingType): number => { + const index1 = behandlingstypeOrder.findIndex(bo => bo === b1); + const index2 = behandlingstypeOrder.findIndex(bo => bo === b2); + if (index1 === index2) { + return 0; + } + return index1 > index2 ? -1 : 1; +}; + +const finnBehandlingTypeNavn = ( + behandlingTyper: LosKodeverkMedNavn<'BehandlingType'>[], + behandlingTypeKode: BehandlingType, +): string => { + const type = behandlingTyper.find(bt => bt.kode === behandlingTypeKode); + return type ? type.navn : ''; +}; + +const konverterTilKoordinaterGruppertPaBehandlingstype = ( + oppgaverForAvdeling: OppgaveForDatoGraf[], +): Record => + oppgaverForAvdeling.reduce( + (acc, o) => { + const nyKoordinat = { + x: dayjs(o.opprettetDato).startOf('day').toDate(), + y: o.antall, + }; + + const eksisterendeKoordinater = acc[o.behandlingType]; + return { + ...acc, + [o.behandlingType]: eksisterendeKoordinater ? eksisterendeKoordinater.concat(nyKoordinat) : [nyKoordinat], + }; + }, + {} as Record, + ); + +const fyllInnManglendeDatoerOgSorterEtterDato = ( + data: Record, + periodeStart: dayjs.Dayjs, + periodeSlutt: dayjs.Dayjs, +): Record => + keysFromObject(data).reduce( + (acc, behandlingstype) => { + const behandlingstypeData = data[behandlingstype]; + const koordinater = []; + + for (let dato = dayjs(periodeStart); dato.isSameOrBefore(periodeSlutt); dato = dato.add(1, 'days')) { + const funnetDato = behandlingstypeData.find(d => dayjs(d.x).startOf('day').isSame(dato.startOf('day'))); + koordinater.push( + funnetDato ? [dayjs(funnetDato.x).format(ISO_DATE_FORMAT), funnetDato.y] : [dato.format(ISO_DATE_FORMAT), 0], + ); + } + + return { + ...acc, + [behandlingstype]: koordinater, + }; + }, + {} as Record, + ); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.spec.tsx b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.spec.tsx new file mode 100644 index 00000000000..473c323fd93 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.spec.tsx @@ -0,0 +1,24 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './TilBehandlingPanel.stories'; + +const { Default } = composeStories(stories); + +describe('TilBehandlingPanel', () => { + // TODO echarts-testing + it.skip('skal vise graffilter', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const { getByLabelText } = render(); + expect(await screen.findByText('Antall åpne oppgaver pr dato')).toBeInTheDocument(); + + expect((screen.getByText('2 siste uker') as HTMLOptionElement).selected).toBeTruthy(); + expect((screen.getByText('4 siste uker') as HTMLOptionElement).selected).toBeFalsy(); + + expect(getByLabelText('Foreldrepenger')).not.toBeChecked(); + expect(getByLabelText('Engangsstønad')).not.toBeChecked(); + expect(getByLabelText('Svangerskapspenger')).not.toBeChecked(); + expect(getByLabelText('Alle')).toBeChecked(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.stories.tsx b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.stories.tsx new file mode 100644 index 00000000000..b782c5d700c --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.stories.tsx @@ -0,0 +1,84 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType, FagsakYtelseType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../data/fplosAvdelingslederApi'; +import { TilBehandlingPanel } from './TilBehandlingPanel'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const OPPGAVER_PER_DATO = [ + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + opprettetDato: dayjs().format(ISO_DATE_FORMAT), + antall: 1, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + opprettetDato: dayjs().subtract(3, 'd').format(ISO_DATE_FORMAT), + antall: 2, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.KLAGE, + opprettetDato: dayjs().subtract(4, 'd').format(ISO_DATE_FORMAT), + antall: 2, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + opprettetDato: dayjs().subtract(4, 'd').format(ISO_DATE_FORMAT), + antall: 6, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.DOKUMENTINNSYN, + opprettetDato: dayjs().subtract(10, 'd').format(ISO_DATE_FORMAT), + antall: 3, + }, + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingType: BehandlingType.DOKUMENTINNSYN, + opprettetDato: dayjs().subtract(16, 'd').format(ISO_DATE_FORMAT), + antall: 3, + }, +]; + +const meta = { + title: 'los/avdelingsleder/nokkeltall/TilBehandlingPanel', + component: TilBehandlingPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.HENT_OPPGAVER_PER_DATO.replace('ø', '%C3%B8'), () => HttpResponse.json(OPPGAVER_PER_DATO)), + ], + }, + }, + args: { + height: 300, + valgtAvdelingEnhet: '1', + getValueFromLocalStorage: () => '', + }, + render: props => { + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + return alleKodeverk ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.tsx b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.tsx new file mode 100644 index 00000000000..5276097a263 --- /dev/null +++ b/apps/fp-avdelingsleder/src/nokkeltall/tilBehandling/TilBehandlingPanel.tsx @@ -0,0 +1,166 @@ +import { useForm } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { HStack, Label, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfRadioGroup, RhfSelect } from '@navikt/ft-form-hooks'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; + +import { FagsakYtelseType } from '@navikt/fp-kodeverk'; +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +import { oppgaverPerDatoOptions } from '../../data/fplosAvdelingslederApi'; +import { StoreValuesInLocalStorage } from '../../data/StoreValuesInLocalStorage'; +import { useLosKodeverk } from '../../data/useLosKodeverk'; +import type { OppgaveForDato } from '../../typer/oppgaverForDatoTsType'; +import { type OppgaveForDatoGraf, TilBehandlingGraf } from './TilBehandlingGraf'; + +dayjs.extend(isSameOrAfter); +dayjs.extend(isSameOrBefore); + +const ALLE_YTELSETYPER_VALGT = 'ALLE'; +const UKE_2 = '2'; + +type FormValues = { + ukevalg: string; + ytelseType: string; +}; + +const formName = 'tilBehandlingForm'; +const formDefaultValues = { ytelseType: ALLE_YTELSETYPER_VALGT, ukevalg: UKE_2 }; + +interface Props { + height: number; + valgtAvdelingEnhet: string; + getValueFromLocalStorage: (key: string) => string | undefined; +} + +export const TilBehandlingPanel = ({ height, valgtAvdelingEnhet, getValueFromLocalStorage }: Props) => { + const intl = useIntl(); + + const { data: oppgaverPerDato } = useQuery(oppgaverPerDatoOptions(valgtAvdelingEnhet)); + + const behandlingTyper = useLosKodeverk('BehandlingType'); + const fagsakYtelseTyper = useLosKodeverk('FagsakYtelseType'); + const stringFromStorage = getValueFromLocalStorage(formName); + + const lagredeVerdier = stringFromStorage ? JSON.parse(stringFromStorage) : undefined; + + const formMethods = useForm({ + defaultValues: lagredeVerdier ?? formDefaultValues, + }); + + const values = formMethods.watch(); + + return ( + formMethods={formMethods}> + + + + + ( + + ))} + /> + , + }, + ]} + /> + + + values.ytelseType === ALLE_YTELSETYPER_VALGT ? true : values.ytelseType === ofa.fagsakYtelseType, + ) + .filter(ofa => erDatoInnenforPeriode(ofa, values.ukevalg)), + ) + : [] + } + /> + + + ); +}; + +const uker = [ + { + kode: UKE_2, + tekstKode: 'TilBehandlingPanel.ToSisteUker', + }, + { + kode: '4', + tekstKode: 'TilBehandlingPanel.FireSisteUker', + }, +]; + +const erDatoInnenforPeriode = (oppgaveForAvdeling: OppgaveForDato, ukevalg: string): boolean => { + if (ukevalg === uker[1].kode) { + return true; + } + const toUkerSiden = dayjs().subtract(2, 'w'); + return dayjs(oppgaveForAvdeling.opprettetDato).isSameOrAfter(toUkerSiden); +}; + +const finnFagsakYtelseTypeNavn = ( + fagsakYtelseTyper: LosKodeverkMedNavn<'FagsakYtelseType'>[], + valgtFagsakYtelseType: string, +): string => { + const type = fagsakYtelseTyper.find(fyt => fyt.kode === valgtFagsakYtelseType); + return type ? type.navn : ''; +}; + +const slaSammenLikeBehandlingstyperOgDatoer = (oppgaverForAvdeling: OppgaveForDato[]): OppgaveForDatoGraf[] => { + const sammenslatte: OppgaveForDatoGraf[] = []; + + oppgaverForAvdeling.forEach(o => { + const index = sammenslatte.findIndex( + s => s.behandlingType === o.behandlingType && s.opprettetDato === o.opprettetDato, + ); + if (index === -1) { + sammenslatte.push(o); + } else { + sammenslatte[index] = { + behandlingType: sammenslatte[index].behandlingType, + opprettetDato: sammenslatte[index].opprettetDato, + antall: sammenslatte[index].antall + o.antall, + }; + } + }); + + return sammenslatte; +}; diff --git "a/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perGraf.tsx" "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perGraf.tsx" new file mode 100644 index 00000000000..97d8b8af3b6 --- /dev/null +++ "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perGraf.tsx" @@ -0,0 +1,101 @@ +import { DDMMYYYY_DATE_FORMAT } from '@navikt/ft-utils'; +import dayjs from 'dayjs'; +import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; + +import { ReactECharts } from '@navikt/fp-los-felles'; + +import type { BehandlingVentefrist } from '../../typer/behandlingVentefristTsType'; + +dayjs.extend(isSameOrBefore); + +interface Koordinat { + x: number; + y: number; +} + +interface Props { + height: number; + behandlingerPaVent: BehandlingVentefrist[]; +} + +export const VentefristUtløperGraf = ({ height, behandlingerPaVent }: Props) => { + const koordinater = lagKoordinater(behandlingerPaVent); + const data = lagDatastruktur(koordinater); + return ( + { + if (params.axisDimension === 'y') { + return parseInt(params.value as string, 10).toString(); + } + return dayjs(params.value).format(DDMMYYYY_DATE_FORMAT); + }, + }, + }, + }, + toolbox: { + feature: { + saveAsImage: { + title: 'Lagre ', + name: 'Antall_forstegangsbehandlinger_der_frist_utloper', + }, + }, + }, + xAxis: { + type: 'time', + axisLabel: { + formatter: '{dd}.{MM}.{yyyy}', + }, + }, + yAxis: { + type: 'value', + }, + series: [ + { + data, + type: 'line', + areaStyle: {}, + }, + ], + color: ['#337c9b'], + }} + /> + ); +}; + +const lagKoordinater = (oppgaverManueltPaVent: BehandlingVentefrist[]): Koordinat[] => + oppgaverManueltPaVent.map(o => ({ + x: dayjs(o.behandlingFrist).startOf('day').toDate().getTime(), + y: o.antall, + })); + +const lagDatastruktur = (koordinater: Koordinat[]): (number | Date)[][] => { + const nyeKoordinater = []; + const periodeStart = koordinater + .map(koordinat => dayjs(koordinat.x)) + .reduce( + (tidligesteDato, dato) => (tidligesteDato.isSameOrBefore(dato) ? tidligesteDato : dato), + dayjs().startOf('day'), + ) + .toDate(); + const periodeSlutt = koordinater + .map(koordinat => dayjs(koordinat.x)) + .reduce((senesteDato, dato) => (senesteDato.isSameOrAfter(dato) ? senesteDato : dato), dayjs().startOf('day')) + .toDate(); + + for (let dato = dayjs(periodeStart); dato.isSameOrBefore(periodeSlutt); dato = dato.add(1, 'days')) { + const sumY = koordinater + .filter(k => dayjs(k.x).isSame(dato)) + .map(k => k.y) + .reduce((sum, y) => sum + y, 0); + nyeKoordinater.push([dato.toDate(), sumY]); + } + + return nyeKoordinater; +}; diff --git "a/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.spec.tsx" "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.spec.tsx" new file mode 100644 index 00000000000..75c7959f56d --- /dev/null +++ "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.spec.tsx" @@ -0,0 +1,21 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; + +import * as stories from './VentefristUtløperPanel.stories'; + +const { Default } = composeStories(stories); + +describe('VentefristUtløperPanel', () => { + // TODO echarts-testing + it.skip('skal vise graffilter', async () => { + const { getByLabelText } = render(); + expect( + await screen.findByText('Førstegangsbehandlinger på vent fordelt på utløp av ventefrist'), + ).toBeInTheDocument(); + + expect(getByLabelText('Foreldrepenger')).not.toBeChecked(); + expect(getByLabelText('Engangsstønad')).not.toBeChecked(); + expect(getByLabelText('Svangerskapspenger')).not.toBeChecked(); + expect(getByLabelText('Alle')).toBeChecked(); + }); +}); diff --git "a/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.stories.tsx" "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.stories.tsx" new file mode 100644 index 00000000000..f8375fd4f94 --- /dev/null +++ "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.stories.tsx" @@ -0,0 +1,65 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import { ISO_DATE_FORMAT } from '@navikt/ft-utils'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { http, HttpResponse } from 'msw'; + +import { FagsakYtelseType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../../data/fplosAvdelingslederApi'; +import { VentefristUtløperPanel } from './VentefristUtløperPanel'; + +import messages from '../../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const BEHANDLINGER_PÅ_VENT = [ + { + fagsakYtelseType: FagsakYtelseType.FORELDREPENGER, + behandlingFrist: dayjs().format(ISO_DATE_FORMAT), + antall: 10, + }, + { + fagsakYtelseType: FagsakYtelseType.ENGANGSSTONAD, + behandlingFrist: dayjs().add(5, 'd').format(ISO_DATE_FORMAT), + antall: 4, + }, + { + fagsakYtelseType: FagsakYtelseType.ENGANGSSTONAD, + behandlingFrist: dayjs().add(5, 'w').format(ISO_DATE_FORMAT), + antall: 14, + }, +]; + +const meta = { + title: 'los/avdelingsleder/nokkeltall/VentefristUtløperPanel', + component: VentefristUtløperPanel, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.HENT_BEHANDLINGER_FRISTUTLOP.replaceAll('ø', '%C3%B8'), () => + HttpResponse.json(BEHANDLINGER_PÅ_VENT), + ), + ], + }, + }, + args: { + height: 300, + valgtAvdelingEnhet: '1', + getValueFromLocalStorage: () => '', + }, + render: props => { + //Må hente data til cache før testa komponent blir kalla + const alleKodeverk = useQuery(losKodeverkOptions()).data; + return alleKodeverk ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git "a/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.tsx" "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.tsx" new file mode 100644 index 00000000000..c3cf9f77cc2 --- /dev/null +++ "b/apps/fp-avdelingsleder/src/nokkeltall/ventefristUtl\303\270per/VentefristUtl\303\270perPanel.tsx" @@ -0,0 +1,94 @@ +import { useForm } from 'react-hook-form'; +import { FormattedMessage } from 'react-intl'; + +import { HStack, Label, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfRadioGroup } from '@navikt/ft-form-hooks'; +import { useQuery } from '@tanstack/react-query'; + +import { FagsakYtelseType } from '@navikt/fp-kodeverk'; +import type { LosKodeverkMedNavn } from '@navikt/fp-types'; + +import { behandlingerFristUtløptOptions } from '../../data/fplosAvdelingslederApi'; +import { StoreValuesInLocalStorage } from '../../data/StoreValuesInLocalStorage'; +import { useLosKodeverk } from '../../data/useLosKodeverk'; +import { VentefristUtløperGraf } from './VentefristUtløperGraf'; + +const finnFagsakYtelseTypeNavn = ( + fagsakYtelseTyper: LosKodeverkMedNavn<'FagsakYtelseType'>[], + valgtFagsakYtelseType: string, +): string => { + const type = fagsakYtelseTyper.find(fyt => fyt.kode === valgtFagsakYtelseType); + return type ? type.navn : ''; +}; + +const ALLE_YTELSETYPER_VALGT = 'ALLE'; + +const formName = 'ventefristUtløperForm'; +const formDefaultValues = { valgtYtelsetype: ALLE_YTELSETYPER_VALGT }; + +type FormValues = { + valgtYtelsetype: string; +}; + +interface Props { + height: number; + valgtAvdelingEnhet: string; + getValueFromLocalStorage: (key: string) => string | undefined; +} + +export const VentefristUtløperPanel = ({ height, valgtAvdelingEnhet, getValueFromLocalStorage }: Props) => { + const { data: behandlingerPaVent } = useQuery(behandlingerFristUtløptOptions(valgtAvdelingEnhet)); + + const fagsakYtelseTyper = useLosKodeverk('FagsakYtelseType'); + + const stringFromStorage = getValueFromLocalStorage(formName); + const lagredeVerdier = stringFromStorage ? JSON.parse(stringFromStorage) : undefined; + + const formMethods = useForm({ + defaultValues: lagredeVerdier ?? formDefaultValues, + }); + + const values = formMethods.watch(); + + return ( + formMethods={formMethods}> + + + + + , + }, + ]} + /> + + + values.valgtYtelsetype === ALLE_YTELSETYPER_VALGT ? true : values.valgtYtelsetype === ompv.fagsakYtelseType, + )} + /> + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.spec.tsx b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.spec.tsx new file mode 100644 index 00000000000..83cf97b7756 --- /dev/null +++ b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.spec.tsx @@ -0,0 +1,26 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './ReservasjonerTabell.stories'; + +const { ViseAtIngenReservasjonerBleFunnet, VisTabellMedReservasjoner } = composeStories(stories); + +describe('ReservasjonerTabell', () => { + it('skal vise tekst som viser at ingen reservasjoner er lagt til', async () => { + await applyRequestHandlers(ViseAtIngenReservasjonerBleFunnet.parameters['msw']); + render(); + + expect(await screen.findByText('Reservasjoner for avdelingen')).toBeInTheDocument(); + expect(await screen.findByText('Ingen reservasjoner funnet')).toBeInTheDocument(); + }); + + it('skal vise to reservasjoner i tabell', async () => { + await applyRequestHandlers(VisTabellMedReservasjoner.parameters['msw']); + render(); + + expect(await screen.findByText('Reservasjoner for avdelingen')).toBeInTheDocument(); + expect(await screen.findByText('Eirik Utvikler')).toBeInTheDocument(); + expect(screen.getByText('Espen Utvikler')).toBeInTheDocument(); + }); +}); diff --git a/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.stories.tsx b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.stories.tsx new file mode 100644 index 00000000000..718f8b61633 --- /dev/null +++ b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.stories.tsx @@ -0,0 +1,80 @@ +import { LoadingPanel } from '@navikt/ft-ui-komponenter'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useQuery } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; + +import { BehandlingType } from '@navikt/fp-kodeverk'; +import { alleKodeverkLos, getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { losKodeverkOptions, LosUrl } from '../data/fplosAvdelingslederApi'; +import { ReservasjonerTabell } from './ReservasjonerTabell'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/reservasjoner/ReservasjonerTabell', + component: ReservasjonerTabell, + decorators: [withIntl, withQueryClient], + + args: { + valgtAvdelingEnhet: '1', + }, + render: args => { + const { data: kodeverkLos } = useQuery(losKodeverkOptions()); + return kodeverkLos ? : ; + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const ViseAtIngenReservasjonerBleFunnet: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.RESERVASJONER_FOR_AVDELING, () => HttpResponse.json([])), + http.post(LosUrl.AVDELINGSLEDER_OPPHEVER_RESERVASJON, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.ENDRE_OPPGAVERESERVASJON, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.FLYTT_RESERVASJON_SAKSBEHANDLER_SOK, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.FLYTT_RESERVASJON, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, +}; + +export const VisTabellMedReservasjoner: Story = { + parameters: { + msw: { + handlers: [ + http.get(LosUrl.KODEVERK_LOS, () => HttpResponse.json(alleKodeverkLos)), + http.get(LosUrl.RESERVASJONER_FOR_AVDELING, () => + HttpResponse.json([ + { + reservertAvUid: 'wsfwer-sdsfd', + reservertAvNavn: 'Espen Utvikler', + reservertTilTidspunkt: '2020-01-10', + oppgaveId: 1, + oppgaveSaksNr: '122234', + behandlingType: BehandlingType.FORSTEGANGSSOKNAD, + }, + { + reservertAvUid: 'gtfbrt-tbrtb', + reservertAvNavn: 'Eirik Utvikler', + reservertTilTidspunkt: '2020-01-01', + oppgaveId: 2, + oppgaveSaksNr: '23454', + behandlingType: BehandlingType.KLAGE, + }, + ]), + ), + http.post(LosUrl.AVDELINGSLEDER_OPPHEVER_RESERVASJON, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.ENDRE_OPPGAVERESERVASJON, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.FLYTT_RESERVASJON_SAKSBEHANDLER_SOK, () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.FLYTT_RESERVASJON, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, +}; diff --git a/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.tsx b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.tsx new file mode 100644 index 00000000000..608d4e37d29 --- /dev/null +++ b/apps/fp-avdelingsleder/src/reservasjoner/ReservasjonerTabell.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { CalendarIcon, PersonGroupIcon, XMarkIcon } from '@navikt/aksel-icons'; +import { BodyShort, Label, Table, VStack } from '@navikt/ds-react'; +import { getDateAndTime } from '@navikt/ft-utils'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { FlyttReservasjonModal, OppgaveReservasjonEndringDatoModal } from '@navikt/fp-los-felles'; + +import { + endreReservasjon, + flyttReservasjon, + flyttReservasjonSaksbehandlerSøk, + opphevReservasjon, + reservasjonerForAvdelingOptions, +} from '../data/fplosAvdelingslederApi'; +import { useLosKodeverk } from '../data/useLosKodeverk'; +import type { Reservasjon } from '../typer/reservasjonTsType'; + +import styles from './reservasjonerTabell.module.css'; + +interface Props { + valgtAvdelingEnhet: string; +} + +export const ReservasjonerTabell = ({ valgtAvdelingEnhet }: Props) => { + const [showReservasjonEndringDatoModal, setShowReservasjonEndringDatoModal] = useState(false); + const [showFlyttReservasjonModal, setShowFlyttReservasjonModal] = useState(false); + const [valgtReservasjon, setValgtReservasjon] = useState(undefined); + + const behandlingTyper = useLosKodeverk('BehandlingType'); + + const { data: reservasjoner, refetch: hentAvdelingensReservasjoner } = useQuery( + reservasjonerForAvdelingOptions(valgtAvdelingEnhet), + ); + + const sorterteReservasjoner = reservasjoner.toSorted((reservasjon1, reservasjon2) => + reservasjon1.reservertAvNavn.localeCompare(reservasjon2.reservertAvNavn), + ); + + const { mutate: opphevOppgaveReservasjon } = useMutation({ + mutationFn: (valuesToStore: { oppgaveId: number }) => opphevReservasjon(valuesToStore.oppgaveId), + onSuccess: () => hentAvdelingensReservasjoner(), + }); + + const { + mutateAsync: hentSaksbehandler, + data: saksbehandler, + status: hentSaksbehandlerStatus, + reset: resetHentSaksbehandler, + } = useMutation({ + mutationFn: (valuesToStore: { brukerIdent: string }) => flyttReservasjonSaksbehandlerSøk(valuesToStore.brukerIdent), + }); + + const showReservasjonEndringDato = (reservasjon: Reservasjon): void => { + setShowReservasjonEndringDatoModal(true); + setValgtReservasjon(reservasjon); + }; + + const showFlytteModal = (reservasjon: Reservasjon): void => { + setShowFlyttReservasjonModal(true); + setValgtReservasjon(reservasjon); + }; + + const { mutateAsync: endreOppgavereservasjonRequest } = useMutation({ + mutationFn: (valuesToStore: { oppgaveId: number; reserverTil: string }) => + endreReservasjon(valuesToStore.oppgaveId, valuesToStore.reserverTil), + }); + + const endreOppgavereservasjon = async (reserverTil: string) => { + if (!valgtReservasjon) { + throw new Error('Reservasjon må være valgt'); + } + await endreOppgavereservasjonRequest({ oppgaveId: valgtReservasjon.oppgaveId, reserverTil }); + setShowReservasjonEndringDatoModal(false); + hentAvdelingensReservasjoner(); + }; + + const { mutateAsync: flyttOppgavereservasjonRequest } = useMutation({ + mutationFn: (valuesToStore: { oppgaveId: number; brukerIdent: string; begrunnelse: string }) => + flyttReservasjon(valuesToStore.oppgaveId, valuesToStore.brukerIdent, valuesToStore.begrunnelse), + }); + + const flyttOppgavereservasjon = async (params: { brukerIdent: string; begrunnelse: string }) => { + if (!valgtReservasjon) { + throw new Error('Reservasjon må være valgt'); + } + await flyttOppgavereservasjonRequest({ oppgaveId: valgtReservasjon.oppgaveId, ...params }); + hentAvdelingensReservasjoner(); + }; + + return ( + + + {sorterteReservasjoner.length === 0 && ( + + + + )} + {sorterteReservasjoner.length > 0 && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {sorterteReservasjoner.map(reservasjon => ( + + {reservasjon.reservertAvNavn} + {reservasjon.oppgaveSaksNr} + + {behandlingTyper.find(t => t.kode === reservasjon.behandlingType)?.navn} + + + + + + showReservasjonEndringDato(reservasjon)} + /> + + + showFlytteModal(reservasjon)} /> + + + opphevOppgaveReservasjon({ oppgaveId: reservasjon.oppgaveId })} + /> + + + ))} + +
+ )} + {valgtReservasjon && showReservasjonEndringDatoModal && ( + setShowReservasjonEndringDatoModal(false)} + reserverTilDefault={valgtReservasjon.reservertTilTidspunkt} + endreOppgavereservasjon={endreOppgavereservasjon} + /> + )} + {valgtReservasjon && showFlyttReservasjonModal && ( + setShowFlyttReservasjonModal(false)} + flyttOppgavereservasjon={flyttOppgavereservasjon} + hentSaksbehandler={(brukerIdent: string) => hentSaksbehandler({ brukerIdent })} + hentSaksbehandlerIsPending={hentSaksbehandlerStatus === 'pending'} + hentSaksbehandlerIsSuccess={hentSaksbehandlerStatus === 'success'} + saksbehandler={saksbehandler} + resetHentSaksbehandler={resetHentSaksbehandler} + /> + )} +
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/reservasjoner/reservasjonerTabell.module.css b/apps/fp-avdelingsleder/src/reservasjoner/reservasjonerTabell.module.css new file mode 100644 index 00000000000..4378fd1a8b2 --- /dev/null +++ b/apps/fp-avdelingsleder/src/reservasjoner/reservasjonerTabell.module.css @@ -0,0 +1,19 @@ +.removeIcon { + color: var(--ax-danger-500); + cursor: pointer; + height: 25px; + width: 25px; +} +.calendarIcon { + color: var(--ax-accent-700); + cursor: pointer; + height: 25px; + width: 25px; +} + +.flyttIcon { + color: var(--ax-accent-700); + cursor: pointer; + height: 25px; + width: 25px; +} diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.spec.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.spec.tsx new file mode 100644 index 00000000000..ad95a0c6615 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.spec.tsx @@ -0,0 +1,47 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './LeggTilSaksbehandlerForm.stories'; + +const { Default, SaksbehandlerFinnesIkke } = composeStories(stories); + +describe('LeggTilSaksbehandlerForm', () => { + it('skal vise at oppgitt brukerident ikke finnes', async () => { + await applyRequestHandlers(SaksbehandlerFinnesIkke.parameters['msw']); + const utils = render(); + + expect(await screen.findByText('Legg til saksbehandler')).toBeInTheDocument(); + + const brukerIdentInput = utils.getByLabelText('Brukerident'); + await userEvent.type(brukerIdentInput, 'TESTIDENT'); + + expect(await screen.findByText('Søk')).toBeInTheDocument(); + expect(screen.getByText('Søk')).toBeEnabled(); + + await userEvent.click(screen.getByText('Søk')); + + expect(await screen.findByText('Kan ikke finne brukerident')).toBeInTheDocument(); + expect(screen.getByText('Legg til i listen').closest('button')).toBeDisabled(); + }); + + it('skal finne brukerident og så legge saksbehandler til listen', async () => { + await applyRequestHandlers(Default.parameters['msw']); + const utils = render(); + + expect(await screen.findByText('Legg til saksbehandler')).toBeInTheDocument(); + + const brukerIdentInput = utils.getByLabelText('Brukerident'); + await userEvent.type(brukerIdentInput, 'TESTIDENT'); + + expect(await screen.findByText('Søk')).toBeInTheDocument(); + expect(screen.getByText('Søk')).toBeEnabled(); + + await userEvent.click(screen.getByText('Søk')); + + expect(await screen.findByText('Espen Utvikler')).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Legg til i listen')).toBeEnabled()); + }); +}); diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.stories.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.stories.tsx new file mode 100644 index 00000000000..97fa3a46664 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; + +import { getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { LosUrl } from '../data/fplosAvdelingslederApi'; +import { LeggTilSaksbehandlerForm } from './LeggTilSaksbehandlerForm'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const saksbehandler = { + brukerIdent: 'R232323', + navn: 'Espen Utvikler', + ansattAvdeling: 'Avdeling Å', +}; + +const meta = { + title: 'los/avdelingsleder/saksbehandlere/LeggTilSaksbehandlerForm', + component: LeggTilSaksbehandlerForm, + decorators: [withIntl, withQueryClient], + args: { + valgtAvdelingEnhet: '1', + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + parameters: { + msw: { + handlers: [ + http.post(LosUrl.SAKSBEHANDLER_SOK.replace('søk', 's%C3%B8k'), () => HttpResponse.json(saksbehandler)), + http.post(LosUrl.OPPRETT_NY_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + avdelingensSaksbehandlere: [], + }, +}; + +export const AlleredeLagtTil: Story = { + parameters: { + msw: { + handlers: [ + http.post(LosUrl.SAKSBEHANDLER_SOK.replace('søk', 's%C3%B8k'), () => HttpResponse.json(saksbehandler)), + http.post(LosUrl.OPPRETT_NY_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + avdelingensSaksbehandlere: [saksbehandler], + }, +}; + +export const SaksbehandlerFinnesIkke: Story = { + parameters: { + msw: { + handlers: [ + http.post(LosUrl.SAKSBEHANDLER_SOK.replace('søk', 's%C3%B8k'), () => new HttpResponse(null, { status: 200 })), + http.post(LosUrl.OPPRETT_NY_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + avdelingensSaksbehandlere: [saksbehandler], + }, +}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.tsx new file mode 100644 index 00000000000..57407700917 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/LeggTilSaksbehandlerForm.tsx @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { BodyShort, Button, HStack, Label, VStack } from '@navikt/ds-react'; +import { RhfForm, RhfTextField } from '@navikt/ft-form-hooks'; +import { required } from '@navikt/ft-form-validators'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { LosUrl, opprettNySaksbehandler, saksbehandlgerSøk } from '../data/fplosAvdelingslederApi'; + +import styles from './leggTilSaksbehandlerForm.module.css'; + +type FormValues = { + brukerIdent: string; +}; + +interface Props { + valgtAvdelingEnhet: string; + avdelingensSaksbehandlere: SaksbehandlerProfil[]; +} + +export const LeggTilSaksbehandlerForm = ({ valgtAvdelingEnhet, avdelingensSaksbehandlere }: Props) => { + const queryClient = useQueryClient(); + const intl = useIntl(); + + const formMethods = useForm(); + + const { + mutate: finnSaksbehandler, + data: saksbehandler, + status: saksbehandlerStatus, + reset: resetSaksbehandlerSøk, + } = useMutation({ + mutationFn: (valuesToStore: { brukerIdent: string }) => saksbehandlgerSøk(valuesToStore.brukerIdent), + }); + + const { mutate: leggTilSaksbehandler, isPending } = useMutation({ + mutationFn: (valuesToStore: { brukerIdent: string; avdelingEnhet: string }) => + opprettNySaksbehandler(valuesToStore.brukerIdent, valuesToStore.avdelingEnhet), + onSuccess: () => { + resetSaksbehandlerSøk(); + formMethods.reset(); + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSBEHANDLERE_FOR_AVDELING], + }); + }, + }); + + const erLagtTilAllerede = avdelingensSaksbehandlere.some( + s => saksbehandler && s.brukerIdent.toLowerCase() === saksbehandler.brukerIdent.toLowerCase(), + ); + + const leggTilSaksbehandlerFn = () => { + if (saksbehandler) { + leggTilSaksbehandler({ + brukerIdent: saksbehandler.brukerIdent, + avdelingEnhet: valgtAvdelingEnhet, + }); + } + }; + + const formattedText = useMemo((): string => { + if (saksbehandlerStatus === 'success' && !saksbehandler) { + return intl.formatMessage({ id: 'LeggTilSaksbehandlerForm.FinnesIkke' }); + } + if (!saksbehandler) { + return ''; + } + + return erLagtTilAllerede + ? `${saksbehandler.navn} (${intl.formatMessage({ id: 'LeggTilSaksbehandlerForm.FinnesAllerede' })})` + : saksbehandler.navn; + }, [saksbehandlerStatus, saksbehandler, erLagtTilAllerede]); + + return ( + finnSaksbehandler({ brukerIdent: values.brukerIdent })}> + + + + +
+ +
+
+ {saksbehandlerStatus === 'success' && ( + + {formattedText} + + + + + + )} +
+
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlerePanel.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlerePanel.tsx new file mode 100644 index 00000000000..321a44f126b --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlerePanel.tsx @@ -0,0 +1,21 @@ +import { VStack } from '@navikt/ds-react'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { LeggTilSaksbehandlerForm } from './LeggTilSaksbehandlerForm'; +import { SaksbehandlereTabell } from './SaksbehandlereTabell'; + +interface Props { + avdelingensSaksbehandlere: SaksbehandlerProfil[]; + valgtAvdelingEnhet: string; +} + +export const SaksbehandlerePanel = ({ avdelingensSaksbehandlere, valgtAvdelingEnhet }: Props) => ( + + + + +); diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.spec.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.spec.tsx new file mode 100644 index 00000000000..d55181fae26 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.spec.tsx @@ -0,0 +1,66 @@ +import { composeStories } from '@storybook/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { applyRequestHandlers } from 'msw-storybook-addon'; + +import * as stories from './SaksbehandlereTabell.stories'; + +const { Default, TomTabell, MedSaksbehandlerUtenAnsattAvdeling } = composeStories(stories); + +describe('SaksbehandlereTabell', () => { + it('skal vise to saksbehandlere i tabell', async () => { + await applyRequestHandlers(Default.parameters['msw']); + render(); + + expect(await screen.findByText('Navn')).toBeInTheDocument(); + + expect(screen.getByText('Navn')).toBeInTheDocument(); + expect(screen.getByText('Espen Utvikler')).toBeInTheDocument(); + expect(screen.getByText('Steffen')).toBeInTheDocument(); + + expect(screen.getByText('Brukerident')).toBeInTheDocument(); + expect(screen.getByText('R12122')).toBeInTheDocument(); + expect(screen.getByText('S53343')).toBeInTheDocument(); + }); + + it('skal vise tekst som viser at ingen saksbehandlere er lagt til', async () => { + render(); + expect(await screen.findByText('Ingen saksbehandlere lagt til')).toBeInTheDocument(); + }); + + it('skal fjerne en saksbehandler ved å trykk på fjern-knappen', async () => { + await applyRequestHandlers(Default.parameters['msw']); + render(); + + expect(await screen.findByText('Navn')).toBeInTheDocument(); + + await userEvent.click(screen.getAllByRole('img')[1]); + + expect(await screen.findByText('Ønsker du å slette Espen Utvikler?')).toBeInTheDocument(); + }); + + it('skal sortere saksbehandlere etter ansattAvdeling og navn', async () => { + await applyRequestHandlers(Default.parameters['msw']); + render(); + + const sortedNames = ['Hildegunn', 'Espen Utvikler', 'Steffen']; + + const rows = await screen.findAllByRole('row'); + + rows.slice(1).forEach((row, index) => { + expect(row).toHaveTextContent(sortedNames[index]); + }); + }); + + it('skal sortere saksbehandlere med ansattAvdeling null sist', async () => { + await applyRequestHandlers(MedSaksbehandlerUtenAnsattAvdeling.parameters['msw']); + render(); + const sortedNames = ['Hildegunn', 'Ukjent saksbehandler (X11111)']; + + const rows = await screen.findAllByRole('row'); + + rows.slice(1).forEach((row, index) => { + expect(row).toHaveTextContent(sortedNames[index]); + }); + }); +}); diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.stories.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.stories.tsx new file mode 100644 index 00000000000..d2e0ecf1c20 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse } from 'msw'; + +import { getIntlDecorator, withQueryClient } from '@navikt/fp-storybook-utils'; + +import { LosUrl } from '../data/fplosAvdelingslederApi'; +import { SaksbehandlereTabell } from './SaksbehandlereTabell'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/saksbehandlere/SaksbehandlereTabell', + component: SaksbehandlereTabell, + decorators: [withIntl, withQueryClient], + parameters: { + msw: { + handlers: [ + http.post(LosUrl.SLETT_SAKSBEHANDLER, () => new HttpResponse(null, { status: 200 })), + http.get(LosUrl.SAKSBEHANDLERE_FOR_AVDELING, () => new HttpResponse(null, { status: 200 })), + ], + }, + }, + args: { + valgtAvdelingEnhet: 'Nav Vikafossen', + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + saksbehandlere: [ + { + brukerIdent: 'R12122', + navn: 'Espen Utvikler', + ansattAvdeling: 'Oslo', + }, + { + brukerIdent: 'S53343', + navn: 'Steffen', + ansattAvdeling: 'Oslo', + }, + { + brukerIdent: 'H11111', + navn: 'Hildegunn', + ansattAvdeling: 'Drammen', + }, + ], + }, +}; + +export const TomTabell: Story = { + args: { + saksbehandlere: [], + }, +}; + +export const MedSaksbehandlerUtenAnsattAvdeling: Story = { + args: { + saksbehandlere: [ + { + brukerIdent: 'X1111', + navn: 'Ukjent saksbehandler (X11111)', + ansattAvdeling: null, + }, + { + brukerIdent: 'H11111', + navn: 'Hildegunn', + ansattAvdeling: 'Drammen', + }, + ], + }, +}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.tsx new file mode 100644 index 00000000000..fc4158e324a --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SaksbehandlereTabell.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import { XMarkIcon } from '@navikt/aksel-icons'; +import { BodyShort, Table, VStack } from '@navikt/ds-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import { LosUrl, slettSaksbehandler } from '../data/fplosAvdelingslederApi'; +import { SletteSaksbehandlerModal } from './SletteSaksbehandlerModal'; + +import styles from './saksbehandlereTabell.module.css'; + +interface Props { + saksbehandlere: SaksbehandlerProfil[]; + valgtAvdelingEnhet: string; +} + +export const SaksbehandlereTabell = ({ saksbehandlere, valgtAvdelingEnhet }: Props) => { + const queryClient = useQueryClient(); + const [valgtSaksbehandler, setValgtSaksbehandler] = useState(); + + const { mutate: fjernSaksbehandler } = useMutation({ + mutationFn: (valuesToStore: SaksbehandlerProfil) => + slettSaksbehandler(valuesToStore.brukerIdent, valgtAvdelingEnhet), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LosUrl.SAKSBEHANDLERE_FOR_AVDELING], + }); + setValgtSaksbehandler(undefined); + }, + }); + + const sorterteSaksbehandlere = saksbehandlere.toSorted((saksbehandler1, saksbehandler2) => { + const compareWithNullsLast = (a: string | null, b: string | null) => { + if (a != null && b != null) return a.localeCompare(b); + if (a == null && b == null) return 0; + return a == null ? 1 : -1; + }; + + const enhetComparison = compareWithNullsLast(saksbehandler1.ansattAvdeling, saksbehandler2.ansattAvdeling); + if (enhetComparison !== 0) { + return enhetComparison; + } + return compareWithNullsLast(saksbehandler1.navn, saksbehandler2.navn); + }); + + return ( + + {sorterteSaksbehandlere.length === 0 && ( + + + + )} + {sorterteSaksbehandlere.length > 0 && ( + + + + + + + + + + + + + + + + + {sorterteSaksbehandlere.map(saksbehandler => ( + + {saksbehandler.navn} + {saksbehandler.brukerIdent} + {saksbehandler.ansattAvdeling} + + setValgtSaksbehandler(saksbehandler)} + onKeyDown={() => setValgtSaksbehandler(saksbehandler)} + /> + + + ))} + +
+ )} + {valgtSaksbehandler && ( + setValgtSaksbehandler(undefined)} + fjernSaksbehandler={fjernSaksbehandler} + /> + )} +
+ ); +}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.spec.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.spec.tsx new file mode 100644 index 00000000000..4b6de7357cf --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import * as stories from './SletteSaksbehandlerModal.stories'; + +const { Default } = composeStories(stories); + +describe('SletteSaksbehandlerModal', () => { + it('skal vise modal og slette ved å trykk ja', async () => { + const fjernSaksbehandler = vi.fn(); + render(); + expect(await screen.findByText('Ønsker du å slette Espen Utvikler?')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Ja')); + + await waitFor(() => expect(fjernSaksbehandler).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.stories.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.stories.tsx new file mode 100644 index 00000000000..a252a3a570b --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import { getIntlDecorator } from '@navikt/fp-storybook-utils'; + +import { SletteSaksbehandlerModal } from './SletteSaksbehandlerModal'; + +import messages from '../../i18n/nb_NO.json'; + +const withIntl = getIntlDecorator(messages); + +const meta = { + title: 'los/avdelingsleder/saksbehandlere/SletteSaksbehandlerModal', + component: SletteSaksbehandlerModal, + decorators: [withIntl], + args: { + closeSletteModal: action('button-click'), + fjernSaksbehandler: action('button-click'), + valgtSaksbehandler: { + brukerIdent: 'R12122', + navn: 'Espen Utvikler', + ansattAvdeling: null, + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.tsx b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.tsx new file mode 100644 index 00000000000..4b02870e4ed --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/SletteSaksbehandlerModal.tsx @@ -0,0 +1,61 @@ +import { FormattedMessage, useIntl } from 'react-intl'; + +import { Button, Heading, Modal as NavModal } from '@navikt/ds-react'; + +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +import styles from './sletteSaksbehandlerModal.module.css'; + +type Props = Readonly<{ + valgtSaksbehandler: SaksbehandlerProfil; + closeSletteModal: () => void; + fjernSaksbehandler: (saksbehandler: SaksbehandlerProfil) => void; +}>; + +/** + * SletteSaksbehandlerModal + * + * Presentasjonskomponent. Modal som lar en avdelingsleder fjerne tilgjengelige saksbehandlere. + */ +export const SletteSaksbehandlerModal = ({ valgtSaksbehandler, closeSletteModal, fjernSaksbehandler }: Props) => { + const intl = useIntl(); + + return ( + + + + + + + + + + + + ); +}; diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/leggTilSaksbehandlerForm.module.css b/apps/fp-avdelingsleder/src/saksbehandlere/leggTilSaksbehandlerForm.module.css new file mode 100644 index 00000000000..84b25250931 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/leggTilSaksbehandlerForm.module.css @@ -0,0 +1,3 @@ +.button { + margin-top: 30px; +} diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/saksbehandlereTabell.module.css b/apps/fp-avdelingsleder/src/saksbehandlere/saksbehandlereTabell.module.css new file mode 100644 index 00000000000..ce49c3658c5 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/saksbehandlereTabell.module.css @@ -0,0 +1,6 @@ +.removeIcon { + color: var(--ax-danger-500); + cursor: pointer; + height: 25px; + width: 25px; +} diff --git a/apps/fp-avdelingsleder/src/saksbehandlere/sletteSaksbehandlerModal.module.css b/apps/fp-avdelingsleder/src/saksbehandlere/sletteSaksbehandlerModal.module.css new file mode 100644 index 00000000000..687cc358010 --- /dev/null +++ b/apps/fp-avdelingsleder/src/saksbehandlere/sletteSaksbehandlerModal.module.css @@ -0,0 +1,8 @@ +.submitButton { + margin-right: 10px; + margin-top: 10px; +} + +.cancelButton { + margin-top: 10px; +} diff --git a/apps/fp-avdelingsleder/src/typer/avdelingTsType.ts b/apps/fp-avdelingsleder/src/typer/avdelingTsType.ts new file mode 100644 index 00000000000..6d59ac63ca2 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/avdelingTsType.ts @@ -0,0 +1,5 @@ +export type Avdeling = Readonly<{ + avdelingEnhet: string; + navn: string; + kreverKode6: boolean; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/behandlingVentefristTsType.ts b/apps/fp-avdelingsleder/src/typer/behandlingVentefristTsType.ts new file mode 100644 index 00000000000..a6da9d63810 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/behandlingVentefristTsType.ts @@ -0,0 +1,5 @@ +export type BehandlingVentefrist = Readonly<{ + fagsakYtelseType: string; + behandlingFrist: string; + antall: number; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/oppgaverForAvdelingTsType.ts b/apps/fp-avdelingsleder/src/typer/oppgaverForAvdelingTsType.ts new file mode 100644 index 00000000000..318b339fb9a --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/oppgaverForAvdelingTsType.ts @@ -0,0 +1,6 @@ +export type OppgaverForAvdeling = Readonly<{ + fagsakYtelseType: string; + behandlingType: string; + tilBehandling: boolean; + antall: number; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/oppgaverForDatoTsType.ts b/apps/fp-avdelingsleder/src/typer/oppgaverForDatoTsType.ts new file mode 100644 index 00000000000..17b0f84d0ed --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/oppgaverForDatoTsType.ts @@ -0,0 +1,6 @@ +export type OppgaveForDato = Readonly<{ + fagsakYtelseType: string; + behandlingType: string; + opprettetDato: string; + antall: number; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/oppgaverForForsteStonadsdagTsType.ts b/apps/fp-avdelingsleder/src/typer/oppgaverForForsteStonadsdagTsType.ts new file mode 100644 index 00000000000..b72ec5288f7 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/oppgaverForForsteStonadsdagTsType.ts @@ -0,0 +1,4 @@ +export type OppgaverForForsteStonadsdag = Readonly<{ + forsteStonadsdag: string; + antall: number; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/oppgaverSomErApneEllerPaVentTsType.ts b/apps/fp-avdelingsleder/src/typer/oppgaverSomErApneEllerPaVentTsType.ts new file mode 100644 index 00000000000..9bf20f9027b --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/oppgaverSomErApneEllerPaVentTsType.ts @@ -0,0 +1,6 @@ +export type OppgaverSomErApneEllerPaVent = Readonly<{ + antall: number; + behandlingType: string; + behandlingVenteStatus: string; + førsteUttakMåned?: string; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/reservasjonTsType.ts b/apps/fp-avdelingsleder/src/typer/reservasjonTsType.ts new file mode 100644 index 00000000000..9c0348f3a27 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/reservasjonTsType.ts @@ -0,0 +1,8 @@ +export type Reservasjon = Readonly<{ + reservertAvUid: string; + reservertAvNavn: string; + reservertTilTidspunkt: string; + oppgaveId: number; + oppgaveSaksNr: string; + behandlingType: string; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/saksbehandlereOgSaksbehandlerGrupper.ts b/apps/fp-avdelingsleder/src/typer/saksbehandlereOgSaksbehandlerGrupper.ts new file mode 100644 index 00000000000..d7499c67010 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/saksbehandlereOgSaksbehandlerGrupper.ts @@ -0,0 +1,11 @@ +import type { SaksbehandlerProfil } from '@navikt/fp-los-felles'; + +export type SaksbehandlerGruppe = Readonly<{ + gruppeId: number; + gruppeNavn?: string; + saksbehandlere: SaksbehandlerProfil[]; +}>; + +export type SaksbehandlereOgSaksbehandlerGrupper = Readonly<{ + saksbehandlerGrupper: SaksbehandlerGruppe[]; +}>; diff --git a/apps/fp-avdelingsleder/src/typer/sakslisteAvdelingTsType.ts b/apps/fp-avdelingsleder/src/typer/sakslisteAvdelingTsType.ts new file mode 100644 index 00000000000..9749ba30af2 --- /dev/null +++ b/apps/fp-avdelingsleder/src/typer/sakslisteAvdelingTsType.ts @@ -0,0 +1,21 @@ +type AnnetKriterie = Readonly<{ + andreKriterierType: string; + inkluder: boolean; +}>; + +export type SakslisteAvdeling = Readonly<{ + sakslisteId: number; + navn?: string; + behandlingTyper?: string[]; + fagsakYtelseTyper?: string[]; + sortering?: { + sorteringType: string; + fra?: number; + til?: number; + fomDato?: string; + tomDato?: string; + erDynamiskPeriode: boolean; + }; + andreKriterier?: AnnetKriterie[]; + saksbehandlerIdenter: string[]; +}>; diff --git a/apps/fp-avdelingsleder/tsconfig.json b/apps/fp-avdelingsleder/tsconfig.json new file mode 100644 index 00000000000..ed23d31ec90 --- /dev/null +++ b/apps/fp-avdelingsleder/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@navikt/fp-config-typescript", + "include": ["./", "../../@types/externals.d.ts"] +} diff --git a/apps/fp-avdelingsleder/vite.config.ts b/apps/fp-avdelingsleder/vite.config.ts new file mode 100644 index 00000000000..66a0588f4ed --- /dev/null +++ b/apps/fp-avdelingsleder/vite.config.ts @@ -0,0 +1,26 @@ +/// +import { mergeConfig } from 'vite'; + +import { createSharedAppConfig } from '@navikt/fp-config-vite'; + +// eslint-disable-next-line import/no-default-export +export default mergeConfig(createSharedAppConfig(), { + server: { + port: 9010, + cors: { + origin: ['https://fpsak.intern.dev.nav.no', 'https://fpsak.intern.nav.no', 'http://localhost:9000'], + }, + proxy: { + '/fpsak/api': { + target: 'http://127.0.0.1:9000', + changeOrigin: false, + secure: false, + }, + '/fplos/api': { + target: 'http://127.0.0.1:9000', + changeOrigin: false, + secure: false, + }, + }, + }, +}); diff --git a/apps/fp-frontend-app/package.json b/apps/fp-frontend-app/package.json index 2ea1021e91c..162eb85633a 100644 --- a/apps/fp-frontend-app/package.json +++ b/apps/fp-frontend-app/package.json @@ -49,7 +49,7 @@ "@navikt/fp-journalforing": "workspace:*", "@navikt/fp-kodeverk": "workspace:*", "@navikt/fp-konstanter": "workspace:*", - "@navikt/fp-los-avdelingsleder": "workspace:*", + "@navikt/fp-los-avdelingsleder-old": "workspace:*", "@navikt/fp-los-saksbehandler": "workspace:*", "@navikt/fp-modal-sett-pa-vent": "workspace:*", "@navikt/fp-papirsoknad": "workspace:*", diff --git a/apps/fp-frontend-app/src/app/components/Home.tsx b/apps/fp-frontend-app/src/app/components/Home.tsx index 87f49c2c174..a8b43e07939 100644 --- a/apps/fp-frontend-app/src/app/components/Home.tsx +++ b/apps/fp-frontend-app/src/app/components/Home.tsx @@ -6,7 +6,7 @@ import { Heading } from '@navikt/ds-react'; import { useMutation } from '@tanstack/react-query'; import { OppgaveJournalføringIndex } from '@navikt/fp-journalforing'; -import { AvdelingslederIndex } from '@navikt/fp-los-avdelingsleder'; +import { AvdelingslederIndex } from '@navikt/fp-los-avdelingsleder-old'; import { SaksbehandlerIndex } from '@navikt/fp-los-saksbehandler'; import { NotFoundPage } from '@navikt/fp-sak-infosider'; import type { NavAnsatt } from '@navikt/fp-types'; diff --git a/package.json b/package.json index 07f6fd99717..662f780f95b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "clean": "lerna run clean --stream", "dev": "yarn lerna run dev --scope=@navikt/fp-frontend-app", "build": "lerna run build --stream", + "build-fp-avdelingsleder": "lerna run build --stream --scope=@navikt/fp-avdelingsleder", "up": "docker-compose up -d --remove-orphans --build", "down": "docker-compose down", "remove-node-modules": "find . -name \"node_modules\" -exec rm -rf '{}' +", diff --git a/packages/los/avdelingsleder/package.json b/packages/los/avdelingsleder/package.json index 12760723cd0..d395642dc89 100644 --- a/packages/los/avdelingsleder/package.json +++ b/packages/los/avdelingsleder/package.json @@ -1,5 +1,5 @@ { - "name": "@navikt/fp-los-avdelingsleder", + "name": "@navikt/fp-los-avdelingsleder-old", "license": "MIT", "private": true, "type": "module", diff --git a/yarn.lock b/yarn.lock index e15ad30ebfb..0bb21c5452b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2411,7 +2411,7 @@ __metadata: "@navikt/fp-journalforing": "workspace:*" "@navikt/fp-kodeverk": "workspace:*" "@navikt/fp-konstanter": "workspace:*" - "@navikt/fp-los-avdelingsleder": "workspace:*" + "@navikt/fp-los-avdelingsleder-old": "workspace:*" "@navikt/fp-los-saksbehandler": "workspace:*" "@navikt/fp-modal-sett-pa-vent": "workspace:*" "@navikt/fp-papirsoknad": "workspace:*" @@ -2572,9 +2572,54 @@ __metadata: languageName: unknown linkType: soft -"@navikt/fp-los-avdelingsleder@workspace:*, @navikt/fp-los-avdelingsleder@workspace:packages/los/avdelingsleder": +"@navikt/fp-los-avdelingsleder-old@workspace:*, @navikt/fp-los-avdelingsleder-old@workspace:packages/los/avdelingsleder": version: 0.0.0-use.local - resolution: "@navikt/fp-los-avdelingsleder@workspace:packages/los/avdelingsleder" + resolution: "@navikt/fp-los-avdelingsleder-old@workspace:packages/los/avdelingsleder" + dependencies: + "@navikt/aksel-icons": 7.26.0 + "@navikt/ds-css": 7.26.0 + "@navikt/ds-react": 7.26.0 + "@navikt/fp-config-eslint": "workspace:*" + "@navikt/fp-config-typescript": "workspace:*" + "@navikt/fp-config-vite": "workspace:*" + "@navikt/fp-kodeverk": "workspace:*" + "@navikt/fp-los-felles": "workspace:*" + "@navikt/fp-storybook-utils": "workspace:*" + "@navikt/fp-types": "workspace:*" + "@navikt/fp-utils": "workspace:*" + "@navikt/ft-form-hooks": 9.0.1 + "@navikt/ft-form-validators": 4.1.1 + "@navikt/ft-ui-komponenter": 6.0.1 + "@navikt/ft-utils": 3.7.1 + "@storybook/react": 9.1.1 + "@storybook/react-vite": 9.1.1 + "@tanstack/react-query": 5.84.1 + "@testing-library/dom": 10.4.1 + "@testing-library/react": 16.3.0 + "@testing-library/user-event": 14.6.1 + "@types/lodash.debounce": 4.0.9 + dayjs: 1.11.13 + eslint: 9.32.0 + history: 5.3.0 + ky: 1.8.2 + lodash.debounce: 4.0.8 + msw: 2.10.4 + msw-storybook-addon: 2.0.5 + react: 19.1.1 + react-hook-form: 7.62.0 + react-intl: 7.1.11 + react-router-dom: 7.8.0 + storybook: 9.1.1 + stylelint: 16.23.1 + typescript: 5.9.2 + vite: 7.1.1 + vitest: 3.2.4 + languageName: unknown + linkType: soft + +"@navikt/fp-los-avdelingsleder@workspace:apps/fp-los-avdelingsleder": + version: 0.0.0-use.local + resolution: "@navikt/fp-los-avdelingsleder@workspace:apps/fp-los-avdelingsleder" dependencies: "@navikt/aksel-icons": 7.26.0 "@navikt/ds-css": 7.26.0