diff --git a/administration/src/App.tsx b/administration/src/App.tsx index 42e93cbbf..761e4746f 100644 --- a/administration/src/App.tsx +++ b/administration/src/App.tsx @@ -2,7 +2,7 @@ import React from 'react' import Navigation from './components/Navigation' import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client' import { setContext } from '@apollo/client/link/context' -import { BrowserRouter, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom' import CreateCardsController from './components/create-cards/CreateCardsController' import styled from 'styled-components' import RegionProvider from './RegionProvider' @@ -12,6 +12,7 @@ import KeepAliveToken from './KeepAliveToken' import ApplicationsController from './components/applications/ApplicationsController' import { ProjectConfigProvider } from './project-configs/ProjectConfigContext' import HomeController from './components/home/HomeController' +import RegionsController from './components/regions/RegionController' import MetaTagsManager from './components/MetaTagsManager' import { AppToasterProvider } from './components/AppToaster' import UserSettingsController from './components/user-settings/UserSettingsController' @@ -20,6 +21,8 @@ import ForgotPasswordController from './components/auth/ForgotPasswordController import ManageUsersController from './components/users/ManageUsersController' import ApplyController from './application/components/ApplyController' import { createUploadLink } from 'apollo-upload-client' +import { Role } from './generated/graphql' +import DataPrivacyPolicy from './components/DataPrivacyPolicy' if (!process.env.REACT_APP_API_BASE_URL) { throw new Error('REACT_APP_API_BASE_URL is not set!') @@ -39,6 +42,9 @@ const createClient = (token?: string) => new ApolloClient({ link: createAuthLink(token).concat(httpLink), cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { fetchPolicy: 'network-only' }, + }, }) const Main = styled.div` @@ -48,6 +54,8 @@ const Main = styled.div` justify-content: center; ` +const isRegionAdmin = (role: Role): boolean => role === Role.RegionAdmin + const App = () => ( @@ -59,6 +67,7 @@ const App = () => ( } /> + } /> } /> } /> ( path={'/applications'} element={} /> + + ) : ( + + ) + } + /> } /> } /> } /> diff --git a/administration/src/ErrorHandler.tsx b/administration/src/ErrorHandler.tsx new file mode 100644 index 000000000..7cdb6ab48 --- /dev/null +++ b/administration/src/ErrorHandler.tsx @@ -0,0 +1,19 @@ +import { Button, Card, H3 } from '@blueprintjs/core' +import React, { ReactElement } from 'react' + +type ErrorHandlerProps = { + refetch: () => void +} + +const ErrorHandler = ({ refetch }: ErrorHandlerProps): ReactElement => { + return ( + +

Ein Fehler ist aufgetreten.

+ +
+ ) +} + +export default ErrorHandler diff --git a/administration/src/application/components/ApplyController.tsx b/administration/src/application/components/ApplyController.tsx index 8317a28ba..1e3610bb6 100644 --- a/administration/src/application/components/ApplyController.tsx +++ b/administration/src/application/components/ApplyController.tsx @@ -3,7 +3,7 @@ import '@fontsource/roboto/400.css' import '@fontsource/roboto/500.css' import '@fontsource/roboto/700.css' -import { useAddBlueEakApplicationMutation } from '../../generated/graphql' +import { useAddBlueEakApplicationMutation, useGetDataPolicyQuery } from '../../generated/graphql' import { DialogActions } from '@mui/material' import useVersionedLocallyStoredState from '../useVersionedLocallyStoredState' import DiscardAllInputsButton from './DiscardAllInputsButton' @@ -13,12 +13,14 @@ import { useCallback, useMemo, useState } from 'react' import { SnackbarProvider, useSnackbar } from 'notistack' import styled from 'styled-components' import ApplicationErrorBoundary from '../ApplicationErrorBoundary' +import { useAppToaster } from '../../components/AppToaster' // This env variable is determined by '../../../application_commit.sh'. It holds the hash of the last commit to the // application form. const lastCommitForApplicationForm = process.env.REACT_APP_APPLICATION_COMMIT as string export const applicationStorageKey = 'applicationState' +const regionId = 1 // TODO: Add a mechanism to retrieve the regionId const SuccessContent = styled.div` white-space: pre-line; @@ -33,22 +35,29 @@ const ApplyController = () => { const [addBlueEakApplication, { loading }] = useAddBlueEakApplicationMutation({ onError: error => { console.error(error) - enqueueSnackbar('Beim Absenden des Antrags is ein Fehler aufgetreten', { variant: 'error' }) + enqueueSnackbar('Beim Absenden des Antrags ist ein Fehler aufgetreten.', { variant: 'error' }) }, onCompleted: result => { if (result) { setState(() => ApplicationForm.initialState) setFormSubmitted(true) } else { - enqueueSnackbar('Beim Absenden des Antrags is ein Fehler aufgetreten.', { variant: 'error' }) + enqueueSnackbar('Beim Absenden des Antrags ist ein Fehler aufgetreten.', { variant: 'error' }) } }, }) + const appToaster = useAppToaster() const { status, state, setState } = useVersionedLocallyStoredState( ApplicationForm.initialState, applicationStorageKey, lastCommitForApplicationForm ) + const { loading: loadingPolicy, data: policyData } = useGetDataPolicyQuery({ + variables: { regionId: regionId }, + // TODO: Add proper error handling and a refetch button when regionId query is implemented + // TODO: Use enqueueSnackbar from notistack instead of the appToaster + onError: () => appToaster?.show({ intent: 'danger', message: 'Datenschutzerklärung konnte nicht geladen werden' }), + }) const arrayBufferManagerInitialized = useInitializeGlobalArrayBuffersManager() const getArrayBufferKeys = useMemo( () => (status === 'loading' ? null : () => ApplicationForm.getArrayBufferKeys(state)), @@ -90,10 +99,16 @@ const ApplyController = () => { {formSubmitted ? ( {successText} ) : ( - + )} - {loading || formSubmitted ? null : } + {loading || loadingPolicy || formSubmitted ? null : } diff --git a/administration/src/application/components/BasicDialog.tsx b/administration/src/application/components/BasicDialog.tsx new file mode 100644 index 000000000..2cbbdab19 --- /dev/null +++ b/administration/src/application/components/BasicDialog.tsx @@ -0,0 +1,44 @@ +import CloseIcon from '@mui/icons-material/Close' +import { Breakpoint, Dialog, DialogContent, DialogContentText, DialogTitle } from '@mui/material' +import styled from 'styled-components' + +const StyledDialogTitle = styled(DialogTitle)` + display: flex; + justify-content: space-between; +` + +const StyledCloseIcon = styled(CloseIcon)` + cursor: pointer; +` + +const StyledDialogText = styled(DialogContentText)` + white-space: pre-line; +` + +const BasicDialog = ({ + open, + onUpdateOpen, + title, + content, + maxWidth, +}: { + open: boolean + onUpdateOpen: (open: boolean) => void + title: string + content: string + maxWidth?: Breakpoint | false +}) => { + return ( + onUpdateOpen(false)} maxWidth={maxWidth}> + + {title} + onUpdateOpen(false)} /> + + + {content} + + + ) +} + +export default BasicDialog diff --git a/administration/src/application/components/forms/ApplicationForm.tsx b/administration/src/application/components/forms/ApplicationForm.tsx index 1b5161fb0..f745594bc 100644 --- a/administration/src/application/components/forms/ApplicationForm.tsx +++ b/administration/src/application/components/forms/ApplicationForm.tsx @@ -18,7 +18,7 @@ export type ApplicationFormState = { } type ValidatedInput = [RegionId, BlueCardApplicationInput] type Options = {} -type AdditionalProps = { onSubmit: () => void; loading: boolean } +type AdditionalProps = { onSubmit: () => void; loading: boolean; privacyPolicy: string } const ApplicationForm: Form = { initialState: { activeStep: 0, @@ -56,7 +56,7 @@ const ApplicationForm: Form { + Component: ({ state, setState, onSubmit, loading, privacyPolicy }) => { const personalDataStep = useFormAsStep( 'Persönliche Angaben', PersonalDataForm, @@ -76,7 +76,7 @@ const ApplicationForm: Form = { initialState: { acceptedDataPrivacy: CheckboxForm.initialState, @@ -46,23 +50,46 @@ const StepSendForm: Form ( - <> - - - - ), + Component: ({ state, setState, privacyPolicy }) => { + const [openPrivacyPolicy, setOpenPrivacyPolicy] = useState(false) + const PrivacyLabel = ( +
+ Ich erkläre mich damit einverstanden, dass meine Daten zum Zwecke der Antragsverarbeitung gespeichert werden und + akzeptiere die{' '} + + . +
+ ) + return ( + <> + + + + + + ) + }, } export default StepSendForm diff --git a/administration/src/application/components/primitive-inputs/CheckboxForm.tsx b/administration/src/application/components/primitive-inputs/CheckboxForm.tsx index 8f1bcd4fa..6b4f8d3fe 100644 --- a/administration/src/application/components/primitive-inputs/CheckboxForm.tsx +++ b/administration/src/application/components/primitive-inputs/CheckboxForm.tsx @@ -1,12 +1,12 @@ import { Checkbox, FormControl, FormControlLabel, FormGroup, FormHelperText } from '@mui/material' -import { useContext, useState } from 'react' +import { useContext, useState, ReactElement } from 'react' import { Form } from '../../FormType' import { FormContext } from '../SteppedSubForms' export type CheckboxFormState = { checked: boolean } type ValidatedInput = boolean type Options = { required: true; notCheckedErrorMessage: string } | { required: false } -type AdditionalProps = { label: string } +type AdditionalProps = { label: string | ReactElement } const CheckboxForm: Form = { initialState: { checked: false }, getArrayBufferKeys: () => [], diff --git a/administration/src/components/DataPrivacyPolicy.tsx b/administration/src/components/DataPrivacyPolicy.tsx new file mode 100644 index 000000000..7e8fafd1c --- /dev/null +++ b/administration/src/components/DataPrivacyPolicy.tsx @@ -0,0 +1,24 @@ +import { H1 } from '@blueprintjs/core' +import React, { ReactElement } from 'react' +import styled from 'styled-components' +import { dataPrivacyBaseHeadline, dataPrivacyBaseText } from '../constants/dataPrivacyBase' + +const Content = styled.div` + white-space: pre-line; + margin-top: 2rem; +` +const Container = styled.div` + max-width: 60%; + display: flex; + flex-direction: column; + align-self: center; +` + +const DataPrivacyPolicy = (): ReactElement => ( + +

{dataPrivacyBaseHeadline}

+ {dataPrivacyBaseText} +
+) + +export default DataPrivacyPolicy diff --git a/administration/src/components/applications/ApplicationsController.tsx b/administration/src/components/applications/ApplicationsController.tsx index 7282791d0..891b55c34 100644 --- a/administration/src/components/applications/ApplicationsController.tsx +++ b/administration/src/components/applications/ApplicationsController.tsx @@ -1,8 +1,9 @@ import React, { useContext } from 'react' -import { Button, Card, H3, Spinner } from '@blueprintjs/core' +import { Spinner } from '@blueprintjs/core' import { RegionContext } from '../../RegionProvider' import ApplicationsOverview from './ApplicationsOverview' import { Region, useGetApplicationsQuery } from '../../generated/graphql' +import ErrorHandler from '../../ErrorHandler' const ApplicationsController = (props: { region: Region; token: string }) => { const { loading, error, data, refetch } = useGetApplicationsQuery({ @@ -10,15 +11,7 @@ const ApplicationsController = (props: { region: Region; token: string }) => { onError: error => console.error(error), }) if (loading) return - else if (error || !data) - return ( - -

Ein Fehler ist aufgetreten.

- -
- ) + else if (error || !data) return else return } diff --git a/administration/src/components/home/HomeController.tsx b/administration/src/components/home/HomeController.tsx index 355f28f2d..4923fb690 100644 --- a/administration/src/components/home/HomeController.tsx +++ b/administration/src/components/home/HomeController.tsx @@ -3,29 +3,47 @@ import { Button, H3 } from '@blueprintjs/core' import { Role } from '../../generated/graphql' import { AuthContext } from '../../AuthProvider' import { NavLink } from 'react-router-dom' +import styled from 'styled-components' + +const StyledButton = styled(Button)` + margin: 10px; +` + +const Container = styled.div` + display: flex; + align-items: center; + flex-direction: column; +` const HomeController = () => { const role = useContext(AuthContext).data?.administrator.role return ( -
+

Wählen Sie eine Aktion aus:

{role === Role.RegionAdmin || role === Role.RegionManager ? ( <> -
+ ) } diff --git a/administration/src/components/regions/RegionController.tsx b/administration/src/components/regions/RegionController.tsx new file mode 100644 index 000000000..04f74bd44 --- /dev/null +++ b/administration/src/components/regions/RegionController.tsx @@ -0,0 +1,32 @@ +import React, { ReactElement, useContext } from 'react' +import { RegionContext } from '../../RegionProvider' +import RegionOverview from './RegionOverview' +import { Region, useGetDataPolicyQuery } from '../../generated/graphql' +import ErrorHandler from '../../ErrorHandler' +import { Spinner } from '@blueprintjs/core' + +const RegionController = (props: { region: Region }) => { + const { region } = props + const { loading, error, data, refetch } = useGetDataPolicyQuery({ + variables: { regionId: region.id }, + onError: error => console.error(error), + }) + if (loading) return + else if (error || !data) return + else return +} + +const ControllerWithRegion = (): ReactElement => { + const region = useContext(RegionContext) + if (region === null) { + return ( +
+

Sie sind nicht berechtigt diese Seite aufzurufen.

+
+ ) + } else { + return + } +} + +export default ControllerWithRegion diff --git a/administration/src/components/regions/RegionOverview.tsx b/administration/src/components/regions/RegionOverview.tsx new file mode 100644 index 000000000..196fbe886 --- /dev/null +++ b/administration/src/components/regions/RegionOverview.tsx @@ -0,0 +1,105 @@ +import { Button, Card, H3, TextArea } from '@blueprintjs/core' +import React, { ReactElement, useState } from 'react' +import styled from 'styled-components' +import { useUpdateDataPolicyMutation } from '../../generated/graphql' +import { useAppToaster } from '../AppToaster' + +const Content = styled.div` + padding: 0 6rem; + width: 100%; + z-index: 0; + flex-grow: 1; + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + align-items: center; + flex-direction: column; +` +const Label = styled(H3)` + text-align: center; + margin: 15px; +` +const ButtonBar = styled(({ stickyTop: number, ...rest }) => )<{ stickyTop: number }>` + width: 100%; + padding: 15px; + background: #fafafa; + position: sticky; + z-index: 1; + top: ${props => props.stickyTop}px; + display: flex; + flex-direction: row; + justify-content: flex-end; + + & button { + margin: 5px; + } +` + +const CharCounter = styled.span<{ $hasError: boolean }>` + text-align: center; + align-self: flex-start; + color: ${props => (props.$hasError ? 'red' : 'black')}; + margin: 15px; +` + +type RegionOverviewProps = { + dataPrivacyPolicy: string + regionId: number +} + +const MAX_CHARS = 20000 + +const RegionOverview = ({ dataPrivacyPolicy, regionId }: RegionOverviewProps): ReactElement => { + const appToaster = useAppToaster() + const [dataPrivacyText, setDataPrivacyText] = useState(dataPrivacyPolicy) + const [updateDataPrivacy] = useUpdateDataPolicyMutation({}) + const maxCharsExceeded = dataPrivacyText.length > MAX_CHARS + + const onSave = async () => { + if (maxCharsExceeded) { + appToaster?.show({ + intent: 'danger', + message: `Unzulässige Zeichenlänge der Datenschutzerklärung. Maximal ${MAX_CHARS} Zeichen erlaubt.`, + }) + } else { + try { + const result = await updateDataPrivacy({ variables: { regionId, text: dataPrivacyText } }) + if (result.errors) { + console.error(result.errors) + appToaster?.show({ intent: 'danger', message: 'Fehler beim Speichern der Datenschutzerklärung.' }) + } else { + appToaster?.show({ + intent: 'success', + message: 'Datenschutzerklärung erfolgreich geändert.', + }) + } + } catch (e) { + console.error(e) + appToaster?.show({ intent: 'danger', message: 'Fehler beim Speichern der Datenschutzerklärung' }) + } + } + } + + return ( + <> + +