diff --git a/public/locales/bg/person.json b/public/locales/bg/person.json new file mode 100644 index 000000000..007f129bf --- /dev/null +++ b/public/locales/bg/person.json @@ -0,0 +1,21 @@ +{ + "selectDialog": { + "notSelected": "Не сте избрали човек", + "select": "Избиране", + "personSelect": "Изберете човек", + "confirm": "Потвърждавам" + }, + "autocomplete": { + "personSearch": "Намерете човек" + }, + "info": { + "tel": "Тел", + "contact": "Контакти", + "address": "Адрес", + "email": "Имейл", + "general": "Генерална информация", + "createdAt": "Създаден в", + "company": "Компания", + "confirmedEmail": "Потвърден имейл" + } +} diff --git a/public/locales/bg/recurring-donation.json b/public/locales/bg/recurring-donation.json new file mode 100644 index 000000000..53a2eab02 --- /dev/null +++ b/public/locales/bg/recurring-donation.json @@ -0,0 +1,31 @@ +{ + "form-heading": "Добави", + "edit-form-heading": "Редактирай", + "recurring-donations": "Всички повтарящи се дарения", + "recurring-donation" : "Повтарящи се дарения", + "extSubscriptionId": "Абонамент", + "extCustomerId": "ID на клиент", + "currency": "Валута", + "amount": "Налични средства", + "status": "Статус", + "personId": "ID на потребител", + "vaultId": "ID на трезор", + "deleteTitle": "Сигурни ли сте?", + "deleteContent": "Това действие ще изтрие елемента завинаги!", + "actions": "Действия", + "alerts": { + "create": "Записът беше създаден успешно!", + "edit": "Записът беше редактиран успешно!", + "delete": "Записът беше изтрит успешно!", + "error": "Възникна грешка! Моля опитайте отново по-късно." + }, + "cta": { + "add": "Добави", + "confirm": "Потвърди", + "cancel": "Отказ", + "delete": "Изтрий", + "edit": "Редактирай", + "details": "Детайли", + "submit": "Изпрати" + } +} \ No newline at end of file diff --git a/public/locales/en/person.json b/public/locales/en/person.json new file mode 100644 index 000000000..43cbdcbe2 --- /dev/null +++ b/public/locales/en/person.json @@ -0,0 +1,21 @@ +{ + "selectDialog": { + "notSelected": "You haven't chosen a person", + "select": "Select", + "personSelect": "Person Select", + "confirm": "Confirm" + }, + "autocomplete": { + "personSearch": "Find a person" + }, + "info": { + "tel": "Тел", + "contact": "Contact info", + "address": "Address", + "email": "Email", + "general": "General information", + "createdAt": "Created at", + "company": "Company", + "confirmedEmail": "Confirmed email" + } +} diff --git a/src/common/hooks/person.ts b/src/common/hooks/person.ts index d65a5b198..496a70b24 100644 --- a/src/common/hooks/person.ts +++ b/src/common/hooks/person.ts @@ -1,14 +1,15 @@ import { useKeycloak } from '@react-keycloak/ssr' import { PersonResponse } from 'gql/person' import { KeycloakInstance } from 'keycloak-js' -import { useQuery } from 'react-query' +import { useQuery, UseQueryOptions } from 'react-query' import { endpoints } from 'service/apiEndpoints' import { authQueryFnFactory } from 'service/restRequests' -export const usePersonList = () => { +export const usePersonList = (options?: UseQueryOptions) => { const { keycloak } = useKeycloak() return useQuery( endpoints.person.list.url, authQueryFnFactory(keycloak?.token), + options, ) } diff --git a/src/common/hooks/recurringDonation.ts b/src/common/hooks/recurringDonation.ts new file mode 100644 index 000000000..280178adf --- /dev/null +++ b/src/common/hooks/recurringDonation.ts @@ -0,0 +1,34 @@ +import { KeycloakInstance } from 'keycloak-js' +import { useKeycloak } from '@react-keycloak/ssr' +import { QueryClient, useQuery } from 'react-query' + +import { endpoints } from 'service/apiEndpoints' +import { authQueryFnFactory } from 'service/restRequests' +import { RecurringDonationResponse } from 'gql/recurring-donation' + +export function useRecurringDonationList() { + const { keycloak } = useKeycloak() + return useQuery( + endpoints.recurringDonation.recurringDonation.url, + authQueryFnFactory(keycloak?.token), + ) +} + +export function useRecurringDonation(id: string) { + const { keycloak } = useKeycloak() + return useQuery( + endpoints.recurringDonation.getRecurringDonation(id).url, + authQueryFnFactory(keycloak?.token), + ) +} + +export async function prefetchRecurringDonationById( + client: QueryClient, + id: string, + token?: string, +) { + await client.prefetchQuery( + endpoints.recurringDonation.getRecurringDonation(id).url, + authQueryFnFactory(token), + ) +} diff --git a/src/common/hooks/useConfirm.ts b/src/common/hooks/useConfirm.ts new file mode 100644 index 000000000..9ed3c8bc0 --- /dev/null +++ b/src/common/hooks/useConfirm.ts @@ -0,0 +1,44 @@ +import { useState } from 'react' + +export type ConfirmProps = { + onConfirm?: () => void | Promise + onClose?: () => void | Promise +} +export type ConfirmHookProps = { + open: boolean + loading: boolean + openHandler: () => void + confirmHandler: () => void + closeHandler: () => void +} + +const useConfirm = ({ onConfirm, onClose }: ConfirmProps): ConfirmHookProps => { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + + return { + open, + loading, + openHandler: () => { + setOpen(true) + }, + confirmHandler: async () => { + setOpen(false) + if (typeof onConfirm === 'function') { + setLoading(true) + await onConfirm() + setLoading(false) + } + }, + closeHandler: async () => { + setOpen(false) + if (typeof onClose === 'function') { + setLoading(true) + await onClose() + setLoading(false) + } + }, + } +} + +export default useConfirm diff --git a/src/common/routes.ts b/src/common/routes.ts index dbad76f27..7da96770d 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -130,6 +130,11 @@ export const routes = { create: '/admin/transfers/create', view: (id: string) => `/admin/transfers/${id}`, }, + recurringDonation: { + index: '/admin/recurring-donation', + create: '/admin/recurring-donation/create', + view: (id: string) => `/admin/recurring-donation/${id}`, + }, }, dev: { openData: '/open-data', diff --git a/src/components/about-project/sections/TechStack.tsx b/src/components/about-project/sections/TechStack.tsx index d2dc1929e..b44d7ab9e 100644 --- a/src/components/about-project/sections/TechStack.tsx +++ b/src/components/about-project/sections/TechStack.tsx @@ -1,13 +1,19 @@ import { useTranslation } from 'next-i18next' + +import Heading from 'components/common/Heading' + import { Box, Grid, Theme, Typography } from '@mui/material' +import JoinLeftIcon from '@mui/icons-material/JoinLeft' +import ImportantDevicesIcon from '@mui/icons-material/ImportantDevices' +import SettingsIcon from '@mui/icons-material/Settings' +import CheckIcon from '@mui/icons-material/Check' import createStyles from '@mui/styles/createStyles' import makeStyles from '@mui/styles/makeStyles' -import Heading from 'components/common/Heading' - const rows = [ { + icon: , label: 'DevOps', items: [ 'about-project:tech-stack.docker', @@ -18,6 +24,7 @@ const rows = [ ], }, { + icon: , label: 'Frontend', items: [ 'TypeScript', @@ -33,6 +40,7 @@ const rows = [ ], }, { + icon: , label: 'Backend', items: ['TypeScript', 'Nest.js', 'PostgreSQL', 'Prisma', 'Jest', 'Sentry'], }, @@ -45,8 +53,21 @@ const useStyles = makeStyles((theme: Theme) => paddingBottom: theme.spacing(7), }, list: { - listStyle: 'disc', - paddingLeft: '2rem', + margin: '0 auto', + }, + listItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + }, + categoryWrapper: { + marginBottom: theme.spacing(6), + '&:nth-of-type(3)': { + marginBottom: 0, + }, + }, + categoryTitle: { + fontWeight: 600, }, }), ) @@ -66,13 +87,19 @@ export default function TechStack() { {t('about-project:tech-stack.title')} - - {rows.map(({ label, items }, section: number) => ( - - {label} - + + {rows.map(({ label, icon, items }, section: number) => ( + + + {icon} + + {label} + + + {items.map((line: string, key: number) => ( - + + {t(line)} ))} diff --git a/src/components/auth/profile/DonationTab.tsx b/src/components/auth/profile/DonationTab.tsx index 9d996f5e9..8a1781573 100644 --- a/src/components/auth/profile/DonationTab.tsx +++ b/src/components/auth/profile/DonationTab.tsx @@ -1,6 +1,3 @@ -import React from 'react' -import { truncate } from 'lodash' -import { makeStyles } from '@mui/styles' import { Box, Button, @@ -14,16 +11,20 @@ import { CircularProgress, useMediaQuery, } from '@mui/material' -import { useUserDonations } from 'common/hooks/donation' +import React from 'react' +import { sumBy, truncate } from 'lodash' +import { makeStyles } from '@mui/styles' +import { useTranslation } from 'next-i18next' -import ProfileTab from './ProfileTab' -import { ProfileTabs } from './tabs' import theme from 'common/theme' -import { useTranslation } from 'next-i18next' +import { money } from 'common/util/money' +import { useUserDonations } from 'common/hooks/donation' import { useCampaignList } from 'common/hooks/campaigns' -import { campaignListPictureUrl } from 'common/util/campaignImageUrls' import { useCurrentPerson } from 'common/util/useCurrentPerson' -import { money } from 'common/util/money' +import { campaignListPictureUrl } from 'common/util/campaignImageUrls' + +import { ProfileTabs } from './tabs' +import ProfileTab from './ProfileTab' import DonationTable from './DonationTable' const useStyles = makeStyles({ @@ -106,10 +107,10 @@ export default function DonationTab() { {t('profile:donations.recurringDonations')} {/* TODO: Use date-fns to format and localize the months, that the user has recurring donations when that is possible */} - Я, Ф, М, А 2022 + {/* Я, Ф, М, А 2022 */} - {money(userDonations.donations[0].amount)} + {money(sumBy(userDonations.donations, 'amount'))} diff --git a/src/components/auth/profile/DonationTable.tsx b/src/components/auth/profile/DonationTable.tsx index 3fd67eba6..79ae53f4f 100644 --- a/src/components/auth/profile/DonationTable.tsx +++ b/src/components/auth/profile/DonationTable.tsx @@ -13,19 +13,20 @@ import { Avatar, Button, } from '@mui/material' +import { Box } from '@mui/system' +import styled from '@emotion/styled' +import React, { useMemo } from 'react' +import { bg, enUS } from 'date-fns/locale' +import { useTranslation } from 'next-i18next' import StarIcon from '@mui/icons-material/Star' -import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers' -import { money } from 'common/util/money' import { format, isAfter, isBefore, parseISO } from 'date-fns' -import React, { useEffect, useMemo, useState } from 'react' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' -import { useTranslation } from 'next-i18next' -import { UserDonation } from 'gql/donations' -import theme from 'common/theme' -import { bg, enUS } from 'date-fns/locale' import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' -import styled from '@emotion/styled' -import { Box } from '@mui/system' +import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers' + +import theme from 'common/theme' +import { money } from 'common/util/money' +import { UserDonation } from 'gql/donations' export type DonationTableProps = { donations: UserDonation[] | undefined @@ -81,9 +82,7 @@ function DonationTable({ donations }: DonationTableProps) { {t('profile:donations.oneTime')} { - setOneTime(checked) - }} + onChange={(e, checked) => setOneTime(checked)} checked={oneTime} name="oneTime" /> @@ -91,9 +90,7 @@ function DonationTable({ donations }: DonationTableProps) { {t('profile:donations.monthly')} { - setMonthly(checked) - }} + onChange={(e, checked) => setMonthly(checked)} checked={monthly} name="monthly" /> diff --git a/src/components/common/FormFieldButton.tsx b/src/components/common/FormFieldButton.tsx new file mode 100644 index 000000000..2d2ffd607 --- /dev/null +++ b/src/components/common/FormFieldButton.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { Button, Typography, Box } from '@mui/material' +import makeStyles from '@mui/styles/makeStyles' + +type Props = { + error?: string + onClick?: () => void + placeholder?: string + value?: string + button?: { label: string } +} + +const useStyles = makeStyles({ + imitateInputBox: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + border: `1px solid rgba(0, 0, 0, 0.23)`, + borderRadius: '3px', + padding: '8.5px 14px', + cursor: 'pointer', + }, + errorInputBox: { + borderColor: '#d32f2f', + color: '#d32f2f', + }, + errorText: { + color: '#d32f2f', + fontWeight: 400, + fontSize: '0.75rem', + lineHeight: 1.66, + letterSpacing: '0.03333em', + textAlign: 'left', + marginTop: '4px', + marginRight: '14px', + marginBottom: 0, + marginLeft: '14px', + }, +}) + +function FormFieldButton({ error, onClick, value, placeholder, button }: Props) { + const classes = useStyles() + return ( + <> + + {value || placeholder} + {button ? ( + + ) : null} + + {error ?

{error}

: null} + + ) +} + +export default FormFieldButton diff --git a/src/components/common/form/FormSelectField.tsx b/src/components/common/form/FormSelectField.tsx new file mode 100644 index 000000000..b048dd58a --- /dev/null +++ b/src/components/common/form/FormSelectField.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { useField } from 'formik' +import { useTranslation } from 'next-i18next' +import { MenuItem, TextField, TextFieldProps } from '@mui/material' + +import { translateError } from 'common/form/useForm' +import { TranslatableField } from 'common/form/validation' + +export type FormSelectFieldOption = { + key: string + value: string | number + name: string +} +export type FormSelectFieldProps = { + label: string + name: string + options: FormSelectFieldOption[] +} & TextFieldProps + +export default function FormSelectField({ + label, + name, + options, + ...textFieldProps +}: FormSelectFieldProps) { + const { t } = useTranslation() + const [field, meta] = useField(name) + const helperText = meta.touched ? translateError(meta.error as TranslatableField, t) : '' + return ( + + {options.map((o) => { + return ( + + {o.name} + + ) + })} + + ) +} diff --git a/src/components/person/PersonAutocomplete.tsx b/src/components/person/PersonAutocomplete.tsx new file mode 100644 index 000000000..82233590c --- /dev/null +++ b/src/components/person/PersonAutocomplete.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { Autocomplete, AutocompleteProps, TextField } from '@mui/material' +import { PersonResponse } from 'gql/person' +import { usePersonList } from 'common/hooks/person' + +export type PersonAutocompleteProps = { + onSelect: (person: PersonResponse | null) => void + autocompleteProps?: Omit< + AutocompleteProps, + 'renderInput' | 'options' | 'getOptionLabel' | 'onChange' | 'loading' + > + showId?: boolean +} +export default function PersonAutocomplete({ + onSelect, + showId, + autocompleteProps, +}: PersonAutocompleteProps) { + const { t } = useTranslation('person') + const { + data: personList, + isLoading, + refetch, + } = usePersonList({ + enabled: false, + refetchOnWindowFocus: false, + }) + return ( + option.firstName === value.firstName} + options={personList || []} + getOptionLabel={(person) => + showId + ? `${person.firstName} ${person.lastName} (${person.id})` + : person.firstName + ' ' + person.lastName + } + onChange={(e, person) => { + onSelect(person) + }} + onOpen={() => { + refetch() + }} + loading={isLoading} + renderInput={(params) => ( + + )} + {...autocompleteProps} + /> + ) +} diff --git a/src/components/person/PersonInfo.tsx b/src/components/person/PersonInfo.tsx new file mode 100644 index 000000000..36fa1ebf6 --- /dev/null +++ b/src/components/person/PersonInfo.tsx @@ -0,0 +1,69 @@ +import { Box, Grid, Theme, Typography } from '@mui/material' +import createStyles from '@mui/styles/createStyles' +import makeStyles from '@mui/styles/makeStyles' +import theme from 'common/theme' +import { formatDateString } from 'common/util/date' +import { PersonResponse } from 'gql/person' +import { useTranslation } from 'next-i18next' +import React from 'react' + +type Props = { + person: PersonResponse +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + infoHeading: { + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + }, + infoWrapper: { + '&>*': { + marginBottom: theme.spacing(1), + }, + }, + }), +) + +function PersonInfo({ person }: Props) { + const classes = useStyles() + const { t } = useTranslation() + return ( + + + + {t('person:info.contact')} + + + + {t('person:info.email')}: {person.email} + + + {t('person:info.tel')}: {person.phone} + + + {t('person:info.address')}: {person.address} + + + + + + {t('person:info.general')} + + + + {t('person:info.createdAt')}: {formatDateString(person.createdAt)} + + + {t('person:info.company')}: {person.company} + + + {t('person:info.confirmedEmail')}: {person.emailConfirmed ? 'Yes' : 'No'} + + + + + ) +} + +export default PersonInfo diff --git a/src/components/person/PersonSelectDialog.tsx b/src/components/person/PersonSelectDialog.tsx new file mode 100644 index 000000000..98d418862 --- /dev/null +++ b/src/components/person/PersonSelectDialog.tsx @@ -0,0 +1,68 @@ +import { LoadingButton } from '@mui/lab' +import { Box, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material' +import { translateError } from 'common/form/validation' +import useConfirm from 'common/hooks/useConfirm' +import theme from 'common/theme' +import CloseModalButton from 'components/common/CloseModalButton' +import FormFieldButton from 'components/common/FormFieldButton' +import { PersonResponse } from 'gql/person' +import { useTranslation } from 'next-i18next' +import React, { useState } from 'react' +import PersonAutocomplete from './PersonAutocomplete' +import PersonInfo from './PersonInfo' + +type Props = { + onConfirm?: (person: PersonResponse | null) => void + onClose?: (person: PersonResponse | null) => void + error?: string +} + +function PersonSelectDialog({ onConfirm: confirmCallback, onClose: closeCallback, error }: Props) { + const [person, setPerson] = useState(null) + const { t } = useTranslation() + const { open, confirmHandler, closeHandler, openHandler, loading } = useConfirm({ + onConfirm: async () => { + confirmCallback ? confirmCallback(person) : null + }, + onClose: async () => { + closeCallback ? closeCallback(person) : null + }, + }) + return ( + <> + + + {t('person:selectDialog.personSelect')} + + + { + setPerson(person) + }} + showId + autocompleteProps={{ defaultValue: person }} + /> + + + + {person ? : t('person:selectDialog.notSelected')} + + + + + + {t('person:selectDialog.confirm')} + + + + + ) +} + +export default PersonSelectDialog diff --git a/src/components/recurring-donation/CreatePage.tsx b/src/components/recurring-donation/CreatePage.tsx new file mode 100644 index 000000000..b3cadbb5c --- /dev/null +++ b/src/components/recurring-donation/CreatePage.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from 'next-i18next' +import { Container } from '@mui/material' + +import AdminContainer from 'components/admin/navigation/AdminContainer' +import AdminLayout from 'components/admin/navigation/AdminLayout' + +import Form from './Form' + +export default function CreatePage() { + const { t } = useTranslation('recurring-donation') + + return ( + + + +
+ + + + ) +} diff --git a/src/components/recurring-donation/DeleteModal.tsx b/src/components/recurring-donation/DeleteModal.tsx new file mode 100644 index 000000000..cbc2ef678 --- /dev/null +++ b/src/components/recurring-donation/DeleteModal.tsx @@ -0,0 +1,41 @@ +import { useMutation } from 'react-query' +import { observer } from 'mobx-react' +import { AxiosError, AxiosResponse } from 'axios' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' + +import { RecurringDonationResponse } from 'gql/recurring-donation' +import { ApiErrors } from 'service/apiErrors' +import { useDeleteRecurringDonation } from 'service/recurringDonation' +import { ModalStore } from 'stores/dashboard/ModalStore' +import { AlertStore } from 'stores/AlertStore' +import { routes } from 'common/routes' +import DeleteDialog from 'components/admin/DeleteDialog' + +export default observer(function DeleteModal() { + const router = useRouter() + const { hideDelete, selectedRecord } = ModalStore + const { t } = useTranslation('recurring-donation') + + const mutationFn = useDeleteRecurringDonation(selectedRecord.id) + + const deleteMutation = useMutation< + AxiosResponse, + AxiosError, + string + >({ + mutationFn, + onError: () => AlertStore.show(t('alerts.error'), 'error'), + onSuccess: () => { + hideDelete() + AlertStore.show(t('alerts.delete'), 'success') + router.push(routes.admin.recurringDonation.index) + }, + }) + + function deleteHandler() { + deleteMutation.mutate(selectedRecord.id) + } + + return +}) diff --git a/src/components/recurring-donation/DetailsModal.tsx b/src/components/recurring-donation/DetailsModal.tsx new file mode 100644 index 000000000..c8ed97881 --- /dev/null +++ b/src/components/recurring-donation/DetailsModal.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { UseQueryResult } from 'react-query' +import { observer } from 'mobx-react' +import { useTranslation } from 'next-i18next' + +import { RecurringDonationResponse } from 'gql/recurring-donation' +import { useRecurringDonation } from 'common/hooks/recurringDonation' +import { ModalStore } from 'stores/dashboard/ModalStore' +import DetailsDialog from 'components/admin/DetailsDialog' + +export default observer(function DetailsModal() { + const { selectedRecord } = ModalStore + const { data }: UseQueryResult = useRecurringDonation( + selectedRecord.id, + ) + const { t } = useTranslation('recurring-donation') + + const dataConverted = [ + { name: 'ID', value: `${data?.id}` }, + { name: t('status'), value: `${data?.status}` }, + { name: t('currency'), value: `${data?.currency}` }, + { name: t('amount'), value: `${data?.amount}` }, + { name: t('extSubscriptionId'), value: `${data?.extSubscriptionId}` }, + { name: t('extCustomerId'), value: `${data?.extCustomerId}` }, + { name: t('vaultId'), value: `${data?.sourceVault}` }, + ] + + return +}) diff --git a/src/components/recurring-donation/EditPage.tsx b/src/components/recurring-donation/EditPage.tsx new file mode 100644 index 000000000..fc7df1cc6 --- /dev/null +++ b/src/components/recurring-donation/EditPage.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'next-i18next' +import { useRouter } from 'next/router' +import { Container } from '@mui/material' +import { UseQueryResult } from 'react-query' + +import AdminContainer from 'components/admin/navigation/AdminContainer' +import AdminLayout from 'components/admin/navigation/AdminLayout' +import NotFoundIllustration from 'components/errors/assets/NotFoundIllustration' +import { useRecurringDonation } from 'common/hooks/recurringDonation' +import { RecurringDonationResponse } from 'gql/recurring-donation' +import Form from './Form' + +export default function EditPage() { + const { t } = useTranslation('recurring-donation') + const { query } = useRouter() + const { data: donation }: UseQueryResult = useRecurringDonation( + String(query.id), + ) + + return ( + + + + {donation ? : } + + + + ) +} diff --git a/src/components/recurring-donation/Form.tsx b/src/components/recurring-donation/Form.tsx new file mode 100644 index 000000000..d6c483878 --- /dev/null +++ b/src/components/recurring-donation/Form.tsx @@ -0,0 +1,178 @@ +import React from 'react' +import { useMutation, useQueryClient, UseQueryResult } from 'react-query' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import Link from 'next/link' +import { AxiosError, AxiosResponse } from 'axios' +import * as yup from 'yup' +import { Box, Button, Grid, Typography } from '@mui/material' + +import { RecurringDonationInput, RecurringDonationResponse } from 'gql/recurring-donation' + +import { Currency } from 'gql/currency' +import { useRecurringDonation } from 'common/hooks/recurringDonation' +import { routes } from 'common/routes' +import { ApiErrors } from 'service/apiErrors' +import { useCreateRecurringDonation, useEditRecurringDonation } from 'service/recurringDonation' +import { endpoints } from 'service/apiEndpoints' +import { AlertStore } from 'stores/AlertStore' +import FormTextField from 'components/common/form/FormTextField' +import SubmitButton from 'components/common/form/SubmitButton' +import CurrencySelect from 'components/currency/CurrencySelect' +import RecurringDonationStatusSelect from './RecurringDonationStatusSelect' +import PersonSelectDialog from 'components/person/PersonSelectDialog' +import { Form, Formik } from 'formik' + +export enum RecurringDonationStatus { + trialing = 'trialing', + active = 'active', + canceled = 'canceled', + incomplete = 'incomplete', + incompleteExpired = 'incompleteExpired', + pastDue = 'pastDue', + unpaid = 'unpaid', +} + +const validCurrencies = Object.keys(Currency) +const validStatuses = Object.keys(RecurringDonationStatus) + +const validationSchema = yup + .object() + .defined() + .shape({ + status: yup.string().oneOf(validStatuses).required(), + personId: yup.string().trim().max(50).required(), + extSubscriptionId: yup.string().trim().max(50).required(), + extCustomerId: yup.string().trim().max(50).required(), + amount: yup.number().positive().integer().required(), + currency: yup.string().oneOf(validCurrencies).required(), + sourceVault: yup.string().trim().uuid().required(), + }) + +export default function EditForm() { + const router = useRouter() + const queryClient = useQueryClient() + const { t } = useTranslation() + let id = router.query.id + + let initialValues: RecurringDonationInput = { + status: '', + personId: '', + extSubscriptionId: '', + extCustomerId: '', + amount: 0, + currency: '', + sourceVault: '', + } + + if (id) { + id = String(id) + const { data }: UseQueryResult = useRecurringDonation(id) + + initialValues = { + status: data?.status, + personId: data?.personId, + extSubscriptionId: data?.extSubscriptionId, + extCustomerId: data?.extCustomerId, + amount: data?.amount, + currency: data?.currency, + sourceVault: data?.sourceVault, + } + } + + const mutationFn = id ? useEditRecurringDonation(id) : useCreateRecurringDonation() + + const mutation = useMutation< + AxiosResponse, + AxiosError, + RecurringDonationInput + >({ + mutationFn, + onError: () => AlertStore.show(t('recurring-donation:alerts:error'), 'error'), + onSuccess: () => { + if (id) + queryClient.invalidateQueries( + endpoints.recurringDonation.getRecurringDonation(String(id)).url, + ) + AlertStore.show( + id ? t('recurring-donation:alerts:edit') : t('recurring-donation:alerts:create'), + 'success', + ) + router.push(routes.admin.recurringDonation.index) + }, + }) + async function onSubmit(data: RecurringDonationInput) { + mutation.mutate(data) + } + + return ( + + {({ errors, handleSubmit, setFieldTouched, setFieldValue }) => ( + + + + {id + ? t('recurring-donation:edit-form-heading') + : t('recurring-donation:form-heading')} + + + + { + person ? setFieldValue('personId', person.id) : setFieldTouched('personId') + }} + onClose={(person) => { + person ? setFieldValue('personId', person.id) : setFieldTouched('personId') + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) +} diff --git a/src/components/recurring-donation/RecurringDonationPage.tsx b/src/components/recurring-donation/RecurringDonationPage.tsx new file mode 100644 index 000000000..5635877c2 --- /dev/null +++ b/src/components/recurring-donation/RecurringDonationPage.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'next-i18next' + +import AdminContainer from 'components/admin/navigation/AdminContainer' +import AdminLayout from 'components/admin/navigation/AdminLayout' +import Grid from './grid/Grid' +import GridAppbar from './grid/GridAppbar' + +export default function VaultsPage() { + const { t } = useTranslation('recurring-donation') + + return ( + + + + + + + ) +} diff --git a/src/components/recurring-donation/RecurringDonationStatusSelect.tsx b/src/components/recurring-donation/RecurringDonationStatusSelect.tsx new file mode 100644 index 000000000..ef288dcb1 --- /dev/null +++ b/src/components/recurring-donation/RecurringDonationStatusSelect.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next' +import { FormControl, MenuItem } from '@mui/material' +import { useField } from 'formik' + +import FormTextField from 'components/common/form/FormTextField' + +export default function RecurringDonationStatusSelect({ name = 'status' }) { + const { t } = useTranslation('recurring-donation') + + enum RecurringDonationStatus { + trialing = 'trialing', + active = 'active', + canceled = 'canceled', + incomplete = 'incomplete', + incompleteExpired = 'incompleteExpired', + pastDue = 'pastDue', + unpaid = 'unpaid', + } + + const values = Object.keys(RecurringDonationStatus) + const [field, meta] = useField(name) + + return ( + + + + {t(name)} + + {values?.map((value, index) => ( + + {value} + + ))} + + + ) +} diff --git a/src/components/recurring-donation/grid/Grid.tsx b/src/components/recurring-donation/grid/Grid.tsx new file mode 100644 index 000000000..0e9a601be --- /dev/null +++ b/src/components/recurring-donation/grid/Grid.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react' +import { UseQueryResult } from 'react-query' +import { useTranslation } from 'next-i18next' +import { Box } from '@mui/material' +import { DataGrid, GridColDef, GridColumns, GridRenderCellParams } from '@mui/x-data-grid' + +import { ModalStore } from 'stores/dashboard/ModalStore' +import { routes } from 'common/routes' +import { RecurringDonationResponse } from 'gql/recurring-donation' +import { useRecurringDonationList } from 'common/hooks/recurringDonation' +import GridActions from 'components/admin/GridActions' + +import DeleteModal from '../DeleteModal' +import DetailsModal from '../DetailsModal' + +export default function Grid() { + const { t } = useTranslation('recurring-donation') + const { data }: UseQueryResult = useRecurringDonationList() + const [pageSize, setPageSize] = useState(5) + + const commonProps: Partial = { + align: 'left', + width: 150, + headerAlign: 'left', + } + + const columns: GridColumns = [ + { + field: 'status', + headerName: t('recurring-donation:status'), + flex: 1.5, + ...commonProps, + }, + { + field: 'currency', + headerName: t('currency'), + flex: 1.5, + ...commonProps, + }, + { + field: 'amount', + headerName: t('amount'), + flex: 1.5, + ...commonProps, + }, + { + field: 'extSubscriptionId', + headerName: t('extSubscriptionId'), + ...commonProps, + width: 300, + }, + { + field: 'extCustomerId', + headerName: t('extCustomerId'), + ...commonProps, + width: 300, + }, + { + field: 'personId', + headerName: t('personId'), + ...commonProps, + width: 300, + }, + { + field: 'vaultId', + headerName: t('vaultId'), + ...commonProps, + width: 300, + }, + { + field: 'actions', + headerName: t('actions'), + width: 120, + type: 'actions', + headerAlign: 'center', + renderCell: (params: GridRenderCellParams): React.ReactNode => { + return ( + + ) + }, + }, + ] + + return ( + <> + + setPageSize(newPageSize)} + disableSelectionOnClick + /> + + + + + ) +} diff --git a/src/components/recurring-donation/grid/GridAppbar.tsx b/src/components/recurring-donation/grid/GridAppbar.tsx new file mode 100644 index 000000000..a897fbf90 --- /dev/null +++ b/src/components/recurring-donation/grid/GridAppbar.tsx @@ -0,0 +1,45 @@ +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { Box, Toolbar, Tooltip, Typography } from '@mui/material' +import { Add as AddIcon } from '@mui/icons-material' + +import { routes } from 'common/routes' + +const addIconStyles = { + background: '#4ac3ff', + borderRadius: '50%', + cursor: 'pointer', + padding: 1.2, + boxShadow: 3, +} + +export default function GridAppbar() { + const router = useRouter() + const { t } = useTranslation() + + return ( + + + {t('recurring-donation:recurring-donations')} + + + + + router.push(routes.admin.recurringDonation.create)} + /> + + + + + ) +} diff --git a/src/gql/person.d.ts b/src/gql/person.d.ts index 6aa8c7a49..46972448e 100644 --- a/src/gql/person.d.ts +++ b/src/gql/person.d.ts @@ -5,6 +5,13 @@ export type PersonResponse = { personId: string firstName: string lastName: string + email: string + phone: string + address: string + company: string + createdAt: string + newsletter: boolean + emailConfirmed: boolean } export type PersonFormData = { diff --git a/src/gql/recurring-donation-status.d.ts b/src/gql/recurring-donation-status.d.ts new file mode 100644 index 000000000..8dac5c718 --- /dev/null +++ b/src/gql/recurring-donation-status.d.ts @@ -0,0 +1,9 @@ +export enum RecurringDonationStatus { + trialing = 'trialing', + active = 'active', + canceled = 'canceled', + incomplete = 'incomplete', + incompleteExpired = 'incompleteExpired', + pastDue = 'pastDue', + unpaid = 'unpaid', +} diff --git a/src/gql/recurring-donation.d.ts b/src/gql/recurring-donation.d.ts new file mode 100644 index 000000000..733e9785f --- /dev/null +++ b/src/gql/recurring-donation.d.ts @@ -0,0 +1,25 @@ +import type { Currency } from './currency' +import { UUID } from './types' + +export type RecurringDonationResponse = { + id: UUID + status: RecurringDonationStatus + personId: UUID + extSubscriptionId: UUID + extCustomerId: UUID + amount: number + currency: Currency + sourceVault: UUID + createdAt: Date + updatedAt: Date | null +} + +export type RecurringDonationInput = { + status?: RecurringDonationStatus | string + personId?: UUID + extSubscriptionId?: UUID + extCustomerId?: UUID + amount?: number + currency?: Currency | string + sourceVault?: UUID +} diff --git a/src/pages/admin/recurring-donation/[id]/index.tsx b/src/pages/admin/recurring-donation/[id]/index.tsx new file mode 100644 index 000000000..7f0df9a5f --- /dev/null +++ b/src/pages/admin/recurring-donation/[id]/index.tsx @@ -0,0 +1,33 @@ +import { GetServerSideProps } from 'next' +import { dehydrate, QueryClient } from 'react-query' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +import { keycloakInstance } from 'middleware/auth/keycloak' +import { endpoints } from 'service/apiEndpoints' +import { authQueryFnFactory } from 'service/restRequests' +import EditPage from 'components/recurring-donation/EditPage' + +export const getServerSideProps: GetServerSideProps = async (params) => { + const client = new QueryClient() + const keycloak = keycloakInstance(params) + const { id } = params.query + + await client.prefetchQuery( + endpoints.recurringDonation.editRecurringDonation(`${id}`).url, + authQueryFnFactory(keycloak.token), + ) + + return { + props: { + ...(await serverSideTranslations(params.locale ?? 'bg', [ + 'common', + 'auth', + 'validation', + 'recurring-donation', + ])), + dehydratedState: dehydrate(client), + }, + } +} + +export default EditPage diff --git a/src/pages/admin/recurring-donation/create.tsx b/src/pages/admin/recurring-donation/create.tsx new file mode 100644 index 000000000..8285ad24d --- /dev/null +++ b/src/pages/admin/recurring-donation/create.tsx @@ -0,0 +1,24 @@ +import { GetServerSideProps } from 'next' +import { dehydrate, QueryClient } from 'react-query' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +import CreatePage from 'components/recurring-donation/CreatePage' + +export const getServerSideProps: GetServerSideProps = async ({ locale }) => { + const client = new QueryClient() + + return { + props: { + ...(await serverSideTranslations(locale ?? 'bg', [ + 'common', + 'auth', + 'recurring-donation', + 'validation', + 'person', + ])), + dehydratedState: dehydrate(client), + }, + } +} + +export default CreatePage diff --git a/src/pages/admin/recurring-donation/index.tsx b/src/pages/admin/recurring-donation/index.tsx new file mode 100644 index 000000000..052e9d4f8 --- /dev/null +++ b/src/pages/admin/recurring-donation/index.tsx @@ -0,0 +1,26 @@ +import { GetServerSideProps } from 'next' +import { dehydrate, QueryClient } from 'react-query' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +import { keycloakInstance } from 'middleware/auth/keycloak' +import RecurringDonationPage from 'components/recurring-donation/RecurringDonationPage' + +export const getServerSideProps: GetServerSideProps = async (params) => { + const client = new QueryClient() + const keycloak = keycloakInstance(params) + + return { + props: { + ...(await serverSideTranslations(params.locale ?? 'bg', [ + 'common', + 'auth', + 'recurring-donation', + 'admin', + 'validation', + ])), + dehydratedState: dehydrate(client), + }, + } +} + +export default RecurringDonationPage diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index 8f90fb31c..dbf8ff196 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -138,4 +138,15 @@ export const endpoints = { me: { url: '/account/me', method: 'GET' }, update: { url: '/account/me', method: 'PATCH' }, }, + recurringDonation: { + recurringDonation: { url: '/recurring-donation', method: 'GET' }, + getRecurringDonation: (id: string) => + { url: `/recurring-donation/${id}`, method: 'GET' }, + createRecurringDonation: { url: '/recurring-donation', method: 'POST' }, + editRecurringDonation: (id: string) => + { url: `/recurring-donation/${id}`, method: 'PUT' }, + deleteRecurringDonation: (id: string) => + { url: `/recurring-donation/${id}`, method: 'DELETE' }, + deleteRecurringDonations: { url: '/recurring-donation/deletemany', method: 'POST' }, + }, } diff --git a/src/service/recurringDonation.ts b/src/service/recurringDonation.ts new file mode 100644 index 000000000..2f55c9df2 --- /dev/null +++ b/src/service/recurringDonation.ts @@ -0,0 +1,38 @@ +import { KeycloakInstance } from 'keycloak-js' +import { useKeycloak } from '@react-keycloak/ssr' +import { AxiosResponse } from 'axios' + +import { apiClient } from 'service/apiClient' +import { authConfig } from 'service/restRequests' +import { endpoints } from 'service/apiEndpoints' +import { RecurringDonationInput, RecurringDonationResponse } from 'gql/recurring-donation' + +export function useCreateRecurringDonation() { + const { keycloak } = useKeycloak() + return async (data: RecurringDonationInput) => { + return await apiClient.post< + RecurringDonationResponse, + AxiosResponse + >(endpoints.recurringDonation.createRecurringDonation.url, data, authConfig(keycloak?.token)) + } +} + +export function useEditRecurringDonation(id: string) { + const { keycloak } = useKeycloak() + return async (data: RecurringDonationInput) => { + return await apiClient.patch< + RecurringDonationResponse, + AxiosResponse + >(endpoints.recurringDonation.editRecurringDonation(id).url, data, authConfig(keycloak?.token)) + } +} + +export function useDeleteRecurringDonation(id: string) { + const { keycloak } = useKeycloak() + return async () => { + return await apiClient.delete< + RecurringDonationResponse, + AxiosResponse + >(endpoints.recurringDonation.deleteRecurringDonation(id).url, authConfig(keycloak?.token)) + } +} diff --git a/src/stores/dashboard/ModalStore.ts b/src/stores/dashboard/ModalStore.ts index 35203468e..898d46ee8 100644 --- a/src/stores/dashboard/ModalStore.ts +++ b/src/stores/dashboard/ModalStore.ts @@ -48,3 +48,5 @@ export class ModalStoreImpl { this.selectedRecord = record } } + +export const ModalStore = new ModalStoreImpl()