diff --git a/public/locales/bg/campaigns.json b/public/locales/bg/campaigns.json index 4eb9bc987..c7afe5d7e 100644 --- a/public/locales/bg/campaigns.json +++ b/public/locales/bg/campaigns.json @@ -97,6 +97,8 @@ "status": "Статус:", "messages": "Послания:", "gallery": "Галерия", + "report-irregularity": "Сигнализирай за злоупотреба", + "financial-report": "Финансови отчети", "report-campaign": "Докладвайте кампанията", "feedback": "Обратна връзка", "images": { diff --git a/public/locales/bg/expenses.json b/public/locales/bg/expenses.json index 8f145911d..dcda99f1f 100644 --- a/public/locales/bg/expenses.json +++ b/public/locales/bg/expenses.json @@ -2,6 +2,9 @@ "fields-error": { "amount-unavailable": "Недостатъчна наличност в трезора!" }, + "errors": { + "no-default-vault": "Не е избран трезор по подразбиране!" + }, "fields": { "type": "Тип", "status": "Статус", @@ -14,8 +17,30 @@ "documentId": "Документ Id", "approvedById": "Одобрен от (Id)", "approvedBy": "Одобрен от", + "approved": "Одобрен", "action": "Действие", - "empty": "Празно" + "empty": "Празно", + "date": "Дата", + "attached-files": "Прикачени файлове", + "files": "Файлове" + }, + "field-types": { + "none": "Няма", + "internal": "Вътрешен", + "operating": "Оперативен", + "administrative": "Административен", + "medical" : "Медицински", + "services" : "Услуги", + "groceries" : "Хранителни", + "transport" : "Транспорт", + "accommodation" : "Жилищни", + "shipping" : "Доставки", + "utility" : "Комунални", + "rental" : "Наеми", + "legal" : "Юридически", + "bank" : "Банкови", + "advertising" : "Рекламни", + "other" : "Други" }, "alerts": { "new-row": { @@ -35,7 +60,9 @@ "question": "Желаете ли да изтриете избраните разходи", "error": "Възникна грешка при опит за изтриване на разходи.", "success": "Разходите са изтрити успешно!" - } + }, + "delete": "Изтрит разход", + "no-files-uploaded": "Няма прикачени файлове!" }, "btns": { "add": "Добави", @@ -52,10 +79,15 @@ "info": "Информация за разход" }, "description": "Всички разходи", + "reported": "Общо отчетени", + "uploaded-documents": "Прикачени документи", + "uploaded-files": "Прикачени файлове", + "deleteTitle": "Сигурни ли сте, че искате да изтриете файла?", "tooltips": { "add": "Добави", "view": "Преглед", "edit": "Редактирай", - "delete": "Изтрий" + "delete": "Изтрий", + "download": "Свали" } } diff --git a/public/locales/en/campaigns.json b/public/locales/en/campaigns.json index ee017aa29..591e065b0 100644 --- a/public/locales/en/campaigns.json +++ b/public/locales/en/campaigns.json @@ -92,6 +92,8 @@ "profile": "Profile:", "status": "Status:", "gallery": "Gallery", + "report-irregularity": "Report irregularity", + "financial-report": "Financial report", "report-campaign": "Report the campaign", "feedback": "Feedback", "images": { diff --git a/public/locales/en/expenses.json b/public/locales/en/expenses.json index 7451c6501..a9670cfb5 100644 --- a/public/locales/en/expenses.json +++ b/public/locales/en/expenses.json @@ -2,6 +2,9 @@ "fields-error": { "amount-unavailable": "Insufficient stock in the vault!" }, + "errors": { + "no-default-vault": "No default vault found!" + }, "fields": { "type": "Type", "status": "Status", @@ -13,7 +16,10 @@ "approvedById": "Approved by (Id)", "approvedBy": "Approved by", "action": "Action", - "empty": "Empty" + "empty": "Empty", + "date": "Date", + "attached-files": "Attached files", + "files": "Files" }, "alerts": { "new-row": { @@ -33,7 +39,9 @@ "question": "Are you sure you want to delete multiple expenses", "error": "Error occurred trying to delete multiple expenses.", "success": "Expenses were deleted successfully!!" - } + }, + "delete": "Deleted expense", + "no-files-uploaded": "No files uploaded" }, "btns": { "add": "Add", @@ -50,10 +58,15 @@ "info": "Expense information" }, "description": "All expenses", + "reported": "Total reported", + "uploaded-documents": "Uploaded documents", + "uploaded-files": "Uploaded files", + "deleteTitle": "Delete expense file?", "tooltips": { "add": "Add", "view": "View", "edit": "Edit", - "delete": "Delete" + "delete": "Delete", + "download": "Download" } } diff --git a/src/common/hooks/campaigns.ts b/src/common/hooks/campaigns.ts index f855e438f..6ac33e2ad 100644 --- a/src/common/hooks/campaigns.ts +++ b/src/common/hooks/campaigns.ts @@ -12,6 +12,7 @@ import { } from 'gql/campaigns' import { DonationStatus } from 'gql/donations.enums' import { apiClient } from 'service/apiClient' +import { useCurrentPerson } from 'common/util/useCurrentPerson' // NOTE: shuffling the campaigns so that each gets its fair chance to be on top row export const campaignsOrderQueryFunction: QueryFunction = async ({ @@ -110,3 +111,24 @@ export function useCampaignDonationHistory( endpoints.donation.getDonations(campaignId, DonationStatus.succeeded, pageindex, pagesize).url, ]) } + +export function useCanEditCampaign(slug: string) { + const { data: session } = useSession() + + const { data: userData } = useCurrentPerson() + const { data: campaignData } = useViewCampaign(slug) + + if (!session || !session.user) { + return false + } + + if (!userData || !campaignData || !userData.user) { + return false + } + + const canEdit = + userData.user.id === campaignData.campaign.organizer?.person.id || + session?.user?.realm_access?.roles?.includes('podkrepi-admin') + + return canEdit +} diff --git a/src/common/hooks/donation.ts b/src/common/hooks/donation.ts index ce8a35fe5..8c0d39535 100644 --- a/src/common/hooks/donation.ts +++ b/src/common/hooks/donation.ts @@ -35,7 +35,7 @@ export function useDonationSession() { mutationFn: createCheckoutSession, onError: () => AlertStore.show(t('common:alerts.error'), 'error'), onSuccess: () => AlertStore.show(t('common:alerts.message-sent'), 'success'), - retry(failureCount, error) { + retry(failureCount) { if (failureCount < 4) { return true } diff --git a/src/common/hooks/expenses.ts b/src/common/hooks/expenses.ts index 34c588b91..ea8032e96 100644 --- a/src/common/hooks/expenses.ts +++ b/src/common/hooks/expenses.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useSession } from 'next-auth/react' import { endpoints } from 'service/apiEndpoints' -import { ExpenseResponse } from 'gql/expenses' +import { ExpenseFile, ExpenseResponse } from 'gql/expenses' import { authQueryFnFactory } from 'service/restRequests' export function useExpensesList() { @@ -19,3 +19,39 @@ export function useViewExpense(id: string) { queryFn: authQueryFnFactory(session?.accessToken), }) } + +export function useCampaignExpensesList(slug: string) { + const { data: session } = useSession() + return useQuery([endpoints.campaign.listCampaignExpenses(slug).url], { + queryFn: authQueryFnFactory(session?.accessToken), + }) +} + +export function useCampaignApprovedExpensesList(slug: string) { + const { data: session } = useSession() + return useQuery([endpoints.campaign.listCampaignApprovedExpenses(slug).url], { + queryFn: authQueryFnFactory(session?.accessToken), + }) +} + +export function useCampaignExpenseFiles(id: string) { + const { data: session } = useSession() + + return useQuery([endpoints.expenses.listExpenseFiles(id).url], { + queryFn: authQueryFnFactory(session?.accessToken), + }) +} + +export function useDeleteCampaignExpense(id: string) { + const { data: session } = useSession() + return useQuery([endpoints.expenses.deleteExpense(id).url], { + queryFn: authQueryFnFactory(session?.accessToken), + }) +} + +export function useDeleteExpenseFile(id: string) { + const { data: session } = useSession() + return useQuery([endpoints.expenses.deleteExpenseFile(id).url], { + queryFn: authQueryFnFactory(session?.accessToken), + }) +} diff --git a/src/common/hooks/person.ts b/src/common/hooks/person.ts index 257617646..ba505228c 100644 --- a/src/common/hooks/person.ts +++ b/src/common/hooks/person.ts @@ -21,3 +21,12 @@ export function usePerson(id: string) { authQueryFnFactory(session?.accessToken), ) } + +export function useViewPersonByKeylockId(id: string) { + const { data: session } = useSession() + + return useQuery( + [endpoints.person.viewPersonByKeylockId(id).url], + authQueryFnFactory(session?.accessToken), + ) +} diff --git a/src/common/routes.ts b/src/common/routes.ts index a3b77ed21..56d0ff143 100644 --- a/src/common/routes.ts +++ b/src/common/routes.ts @@ -79,7 +79,13 @@ export const routes = { index: '/campaigns', create: '/campaigns/create', viewCampaignBySlug: (slug: string) => `/campaigns/${slug}`, + viewExpenses: (slug: string) => `/campaigns/${slug}/expenses`, oneTimeDonation: (slug: string) => `/campaigns/donation/${slug}`, + expenses: { + create: (slug: string) => `/campaigns/${slug}/expenses/create`, + edit: (slug: string, id: string) => `/campaigns/${slug}/expenses/${id}`, + downloadFile: (id: string) => `/expenses/download-files/${id}`, + }, }, donation: { viewCertificate: (donationId: string) => `/api/pdf/certificate/${donationId}`, diff --git a/src/common/util/roles.ts b/src/common/util/roles.ts index b6fb6aa72..0de876eb4 100644 --- a/src/common/util/roles.ts +++ b/src/common/util/roles.ts @@ -53,7 +53,7 @@ export const canViewSupporters = (sessionRoles: SessionRoles): boolean => { } export const isAdmin = (session: Session | JWT | null): boolean => { - if (session) { + if (session && session.user && session.user.resource_access && session.user.realm_access) { const sessionRoles: SessionRoles = { realmRoles: session.user?.realm_access.roles ?? [], resourceRoles: session.user?.resource_access?.account.roles ?? [], diff --git a/src/components/admin/GridActions.tsx b/src/components/admin/GridActions.tsx index 8b478ca72..9874406b3 100644 --- a/src/components/admin/GridActions.tsx +++ b/src/components/admin/GridActions.tsx @@ -13,9 +13,10 @@ type Props = { id: string name: string editLink?: string + disableView?: boolean } -export default function GridActions({ modalStore, id, name, editLink }: Props) { +export default function GridActions({ modalStore, id, name, editLink, disableView }: Props) { const { t } = useTranslation('admin') const { showDetails, showDelete, setSelectedRecord } = modalStore @@ -31,11 +32,15 @@ export default function GridActions({ modalStore, id, name, editLink }: Props) { return ( <> - - - - - + {!disableView ? ( + + + + + + ) : ( + '' + )} {editLink ? ( diff --git a/src/components/admin/expenses/ExpenseTypeSelect.tsx b/src/components/admin/expenses/ExpenseTypeSelect.tsx index ef7f0cc03..03d7ea109 100644 --- a/src/components/admin/expenses/ExpenseTypeSelect.tsx +++ b/src/components/admin/expenses/ExpenseTypeSelect.tsx @@ -24,12 +24,9 @@ export default function ExpenseTypeSelect({ name = 'type' }) { defaultValue="" label={t('fields.' + name)} {...field}> - - {t('fields.' + name)} - {values?.map((value, index) => ( - {value} + {t('expenses:field-types.' + value)} ))} diff --git a/src/components/admin/expenses/Form.tsx b/src/components/admin/expenses/Form.tsx index aac2c8c8d..939ba0849 100644 --- a/src/components/admin/expenses/Form.tsx +++ b/src/components/admin/expenses/Form.tsx @@ -84,11 +84,13 @@ export default function Form() { status: data?.status || ExpenseStatus.pending, currency: data?.currency || Currency.BGN, amount: data?.amount || 0, + money: 0, vaultId: data?.vaultId || '', deleted: data?.deleted || false, description: data?.description || '', documentId: data?.documentId || '', approvedById: data?.approvedById || '', + spentAt: '', } const mutationFn = id ? useEditExpense(id) : useCreateExpense() diff --git a/src/components/admin/expenses/grid/GridAppbar.tsx b/src/components/admin/expenses/grid/GridAppbar.tsx index 1fed94b7b..7e4bf3b61 100644 --- a/src/components/admin/expenses/grid/GridAppbar.tsx +++ b/src/components/admin/expenses/grid/GridAppbar.tsx @@ -12,7 +12,7 @@ const addIconStyles = { boxShadow: 3, } -export default function GridAppbar() { +export default function ExpensesGridAppbar() { const router = useRouter() return ( diff --git a/src/components/client/campaign-expenses/CampaignExpenseCreate.tsx b/src/components/client/campaign-expenses/CampaignExpenseCreate.tsx new file mode 100644 index 000000000..6877dd425 --- /dev/null +++ b/src/components/client/campaign-expenses/CampaignExpenseCreate.tsx @@ -0,0 +1,10 @@ +import Form from 'components/client/campaign-expenses/Form' +import Layout from 'components/client/layout/Layout' + +export default function CreateCampaignExpensePage() { + return ( + +
+ + ) +} diff --git a/src/components/client/campaign-expenses/CampaignExpenseEdit.tsx b/src/components/client/campaign-expenses/CampaignExpenseEdit.tsx new file mode 100644 index 000000000..db9a3ada0 --- /dev/null +++ b/src/components/client/campaign-expenses/CampaignExpenseEdit.tsx @@ -0,0 +1,11 @@ +import Layout from 'components/client/layout/Layout' + +import Form from './Form' + +export default function ExpensesEditPage() { + return ( + + + + ) +} diff --git a/src/components/client/campaign-expenses/Form.tsx b/src/components/client/campaign-expenses/Form.tsx new file mode 100644 index 000000000..db827bc10 --- /dev/null +++ b/src/components/client/campaign-expenses/Form.tsx @@ -0,0 +1,284 @@ +import React, { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { AxiosError, AxiosResponse } from 'axios' +import * as yup from 'yup' +import { FormikHelpers } from 'formik' + +import { Box, Button, Grid, Tooltip, Typography } from '@mui/material' + +import { routes } from 'common/routes' +import { Currency } from 'gql/currency' +import { AlertStore } from 'stores/AlertStore' +import { endpoints } from 'service/apiEndpoints' +import LinkButton from 'components/common/LinkButton' +import { useViewExpense } from 'common/hooks/expenses' +import GenericForm from 'components/common/form/GenericForm' +import SubmitButton from 'components/common/form/SubmitButton' +import CurrencySelect from 'components/common/currency/CurrencySelect' +import FormTextField from 'components/common/form/FormTextField' +import { Checkbox } from '@mui/material' +import { useCreateExpense, useEditExpense } from 'service/expense' + +import { ApiErrors, isAxiosError, matchValidator } from 'service/apiErrors' +import { ExpenseInput, ExpenseResponse, ExpenseStatus, ExpenseType } from 'gql/expenses' +import FileUpload from 'components/common/file-upload/FileUpload' + +import ExpenseTypeSelect from 'components/admin/expenses/ExpenseTypeSelect' +import { useViewCampaign } from 'common/hooks/campaigns' +import { UploadExpenseFile, ExpenseFile } from 'gql/expenses' +import { useUploadExpenseFiles } from 'service/expense' +import FileList from 'components/client/campaign-expenses/grid/FileList' +import FormDatePicker from 'components/common/form/FormDatePicker' +import { toMoney, fromMoney } from 'common/util/money' +import { useCampaignExpenseFiles } from 'common/hooks/expenses' +import { downloadCampaignExpenseFile, deleteExpenseFile } from 'service/expense' +import { useSession } from 'next-auth/react' +import DeleteForeverIcon from '@mui/icons-material/DeleteForever' +import { useViewPersonByKeylockId } from 'common/hooks/person' + +const validTypes = Object.keys(ExpenseType) +const validStatuses = Object.keys(ExpenseStatus) +const validCurrencies = Object.keys(Currency) + +export default function Form() { + const queryClient = useQueryClient() + const router = useRouter() + const slug = router.query.slug as string + const { t } = useTranslation('expenses') + const { data: campaignResponse } = useViewCampaign(slug) + let id = router.query.id as string + const [files, setFiles] = useState([]) + const { data: expenseFiles } = useCampaignExpenseFiles(id) + const { data: session } = useSession() + + const canApprove = !!session?.user?.realm_access?.roles?.includes('podkrepi-admin') + + const { data: person } = useViewPersonByKeylockId(session?.user?.sub as string) + + const fileUploadMutation = useMutation< + AxiosResponse, + AxiosError, + UploadExpenseFile + >({ + mutationFn: useUploadExpenseFiles(), + onError: () => AlertStore.show(t('common:alerts.error'), 'error'), + }) + + let data: ExpenseResponse | undefined + const validationSchema = yup + .object() + .defined() + .shape({ + type: yup.string().trim().oneOf(validTypes).required(), + status: yup.string().trim().oneOf(validStatuses).required(), + currency: yup.string().trim().oneOf(validCurrencies).required(), + money: yup.number().required(), + description: yup.string().trim().notRequired(), + }) + if (id) { + id = String(id) + data = useViewExpense(id).data + } + + const initialValues: ExpenseInput = { + type: data?.type || ExpenseType.none, + status: data?.status || ExpenseStatus.pending, + currency: data?.currency || Currency.BGN, + money: fromMoney(data?.amount as number), + amount: data?.amount || 0, + vaultId: data?.vaultId || campaignResponse?.campaign.defaultVault || '', + deleted: data?.deleted || false, + description: data?.description || '', + documentId: data?.documentId || '', + approvedById: data?.approvedById || '', + approved: !!data?.approvedById, + spentAt: data?.spentAt || '', + } + + const [approvedBy, setApprovedBy] = useState(data?.approvedById) + + const mutationFn = id ? useEditExpense(id) : useCreateExpense() + + const mutation = useMutation, AxiosError, ExpenseInput>( + { + mutationFn, + onError: () => + AlertStore.show(id ? t('alerts.edit-row.error') : t('alerts.new-row.error'), 'error'), + onSuccess: () => { + queryClient.invalidateQueries([endpoints.expenses.listExpenses.url]) + router.push(routes.campaigns.viewExpenses(slug)) + AlertStore.show(id ? t('alerts.edit-row.success') : t('alerts.new-row.success'), 'success') + }, + }, + ) + + const downloadExpensesFileHandler = async (file: ExpenseFile) => { + downloadCampaignExpenseFile(file.id, session) + .then((response) => { + const url = window.URL.createObjectURL(new Blob([response.data])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', `${file.filename}`) + link.click() + }) + .catch((error) => { + AlertStore.show(t('common:alerts.error'), 'error') + console.error(error) + }) + } + + const deleteFileHandler = async (file: ExpenseFile) => { + if (confirm(t('deleteTitle'))) { + deleteExpenseFile(file.id, session) + } + } + + async function onSubmit(data: ExpenseInput, { setFieldError }: FormikHelpers) { + if (files.length == 0) { + AlertStore.show(t('expenses:alerts.no-files-uploaded'), 'error') + return false + } + + try { + if (data.documentId == '') { + data.documentId = null + } + + data.approvedById = approvedBy + + if (data.spentAt.length == 10) { + data.spentAt = data.spentAt + 'T00:00:00.000Z' + } + + data.amount = toMoney(data.money) + + const response = await mutation.mutateAsync(data) + + if (files.length > 0) { + await fileUploadMutation.mutateAsync({ + files, + expenseId: response.data.id, + }) + } + } catch (error) { + if (isAxiosError(error)) { + const { response } = error as AxiosError + response?.data.message.map(({ property, constraints }) => { + setFieldError(property, t(matchValidator(constraints))) + }) + } + } + } + + if (!campaignResponse?.campaign.defaultVault) { + //return an error if there is no default vault for the campaign + return
{t('expenses:errors.no-default-vault')}
+ } + + return ( + + + + {id ? t('headings.edit') : t('headings.add')} + + + + + + + + + + + + + + + + {t('expenses:fields.approved')}: + { + setApprovedBy(val && person ? person.id : null) + }} + /> + + + { + setFiles((prevFiles) => [...prevFiles, ...newFiles]) + }} + /> + + setFiles((prevFiles) => prevFiles.filter((file) => file.name !== deletedFile.name)) + } + onSetFileRole={() => { + return undefined + }} + /> + + + + + + {expenseFiles && expenseFiles.length > 0 ? ( + + {t('expenses:uploaded-files')}: + + ) : ( + + {t('expenses:alerts.no-files-uploaded')} + + )} + {expenseFiles?.map((file, key) => ( + + + + + + + + + ))} + + + + + + + {t('btns.cancel')} + + + + + + ) +} diff --git a/src/components/client/campaign-expenses/grid/CampaignExpensesGrid.tsx b/src/components/client/campaign-expenses/grid/CampaignExpensesGrid.tsx new file mode 100644 index 000000000..0761f6c23 --- /dev/null +++ b/src/components/client/campaign-expenses/grid/CampaignExpensesGrid.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { styled } from '@mui/material/styles' +import { observer } from 'mobx-react' +import { DataGrid, GridColumns, GridRenderCellParams } from '@mui/x-data-grid' +import { useTranslation } from 'next-i18next' + +import { usePersonList } from 'common/hooks/person' + +import { routes } from 'common/routes' +import GridActions from 'components/admin/GridActions' + +import DeleteModal from './DeleteModal' +//import { statusRenderCell } from './GridHelper' +import { useCampaignExpensesList } from 'common/hooks/expenses' +import { moneyPublic } from 'common/util/money' +import { ModalStoreImpl } from 'stores/dashboard/ModalStore' + +const PREFIX = 'Grid' + +type Props = { slug: string } + +const classes = { + grid: `${PREFIX}-grid`, + gridColumn: `${PREFIX}-gridColumn`, +} + +export const ModalStore = new ModalStoreImpl() + +// TODO jss-to-styled codemod: The Fragment root was replaced by div. Change the tag if needed. +const Root = styled('div')({ + [`& .${classes.grid}`]: { + marginBottom: 15, + border: 'none', + '& .MuiDataGrid-virtualScroller': { + overflow: 'hidden', + }, + '& .MuiDataGrid-footerContainer': { + marginTop: '30px', + marginRight: '40px', + }, + fontSize: '12px', + }, + [`& .${classes.gridColumn}`]: { + '& .MuiDataGrid-columnHeaderTitle': { + fontSize: '14px', + fontWeight: '700', + }, + }, +}) + +export default observer(function CampaignExpensesGrid({ slug }: Props) { + const { t } = useTranslation('') + const { data: expensesList } = useCampaignExpensesList(slug) + + const [pageSize, setPageSize] = React.useState(10) + const { data: personList } = usePersonList() + + const columns: GridColumns = [ + { field: 'id', headerName: 'ID', hide: true }, + { + field: 'type', + headerName: t('expenses:fields.type'), + headerClassName: classes.gridColumn, + width: 120, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + return t('expenses:field-types.' + params.row.type) + }, + }, + { + field: 'amount', + headerName: t('expenses:fields.amount'), + headerClassName: classes.gridColumn, + align: 'right', + width: 90, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + if (!params.row.amount) { + return '0' + } + + return moneyPublic(params.row.amount, params.row.currency) + }, + }, + { + field: 'description', + headerName: t('expenses:fields.description'), + headerClassName: classes.gridColumn, + flex: 1, + }, + { + field: 'approvedById', + headerName: t('expenses:fields.approvedBy'), + headerClassName: classes.gridColumn, + valueGetter: (p) => { + if (personList && p.value) { + const found = personList.find((person) => person.id == p.value) + return `${found?.firstName} ${found?.lastName}` + } + if (!personList && p.value) { + return 'Administrator' + } + return '' + }, + flex: 1, + }, + { + field: 'spentAt', + headerName: t('expenses:fields.date'), + headerClassName: classes.gridColumn, + flex: 1, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + if (!params.row.spentAt) { + return '' + } + + return params.row.spentAt.split('T')[0] + }, + }, + { + field: 'no_of_files', + headerName: t('expenses:fields.files'), + headerClassName: classes.gridColumn, + width: 100, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + if (!params.row.expenseFiles || !params.row.expenseFiles.length) { + return '' + } + + return params.row.expenseFiles.length + }, + }, + { + field: 'actions', + headerName: t('expenses:fields.action'), + headerAlign: 'left', + width: 120, + type: 'actions', + headerClassName: classes.gridColumn, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + return ( + + ) + }, + }, + ] + + return ( + + setPageSize(newPageSize)} + rowsPerPageOptions={[100]} + pagination + autoHeight + disableSelectionOnClick + /> + + + ) +}) diff --git a/src/components/client/campaign-expenses/grid/CampaignGridAppbar.tsx b/src/components/client/campaign-expenses/grid/CampaignGridAppbar.tsx new file mode 100644 index 000000000..47a5f8073 --- /dev/null +++ b/src/components/client/campaign-expenses/grid/CampaignGridAppbar.tsx @@ -0,0 +1,75 @@ +import { useRouter } from 'next/router' +import { Box, Toolbar, Tooltip, Typography } from '@mui/material' +import { Add as AddIcon } from '@mui/icons-material' +import { routes } from 'common/routes' +import { useViewCampaign } from 'common/hooks/campaigns' +import { useCampaignExpensesList } from 'common/hooks/expenses' +import { useTranslation } from 'next-i18next' +import { moneyPublic } from 'common/util/money' + +const addIconStyles = { + background: '#4ac3ff', + borderRadius: '50%', + cursor: 'pointer', + padding: 1.2, + boxShadow: 3, +} +type Props = { slug: string } + +export default function GridAppbar({ slug }: Props) { + const router = useRouter() + const { data: campaignResponse } = useViewCampaign(slug) + const { data: expensesList } = useCampaignExpensesList(slug) + const { t } = useTranslation('') + + const totalExpenses = expensesList?.reduce((acc, expense) => acc + expense.amount, 0) + + return ( + + + + {t('expenses:reported')}:{' '} + {moneyPublic(totalExpenses || 0, campaignResponse?.campaign.currency)} + + + + + {t('campaigns:campaign.amount')}:{' '} + {moneyPublic( + campaignResponse?.campaign.targetAmount || 0, + campaignResponse?.campaign.currency, + )} + + + + + {t('campaigns:donationsAmount')}:{' '} + {moneyPublic( + campaignResponse?.campaign.summary.reachedAmount || 0, + campaignResponse?.campaign.currency, + )} + + + + + + + router.push(routes.campaigns.expenses.create(router.query?.slug as string)) + } + /> + + + + + ) +} diff --git a/src/components/client/campaign-expenses/grid/DeleteModal.tsx b/src/components/client/campaign-expenses/grid/DeleteModal.tsx new file mode 100644 index 000000000..333a79000 --- /dev/null +++ b/src/components/client/campaign-expenses/grid/DeleteModal.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { useMutation } from '@tanstack/react-query' +import { observer } from 'mobx-react' +import { AxiosError, AxiosResponse } from 'axios' +import { useTranslation } from 'next-i18next' + +import { ApiErrors } from 'service/apiErrors' +import { useDeleteExpense } from 'service/expense' +import { AlertStore } from 'stores/AlertStore' +import DeleteDialog from 'components/admin/DeleteDialog' + +import { ModalStore } from './CampaignExpensesGrid' +import { ExpenseResponse } from 'gql/expenses' + +export default observer(function DeleteModal() { + const { hideDelete, selectedRecord, setSelectedRecord } = ModalStore + const { t } = useTranslation('common') + + const mutationFn = useDeleteExpense() + + const deleteMutation = useMutation, AxiosError, string>( + { + mutationFn, + onError: () => AlertStore.show(t('alerts.error'), 'error'), + onSuccess: () => { + setSelectedRecord({ id: '', name: '' }) + // queryClient.invalidateQueries([endpoints.bankAccounts.bankAccountList.url]) + hideDelete() + AlertStore.show(t('alerts.delete'), 'success') + }, + }, + ) + + function deleteHandler() { + deleteMutation.mutate(selectedRecord.id) + } + + return +}) diff --git a/src/components/client/campaign-expenses/grid/FileList.tsx b/src/components/client/campaign-expenses/grid/FileList.tsx new file mode 100644 index 000000000..9d4f053a4 --- /dev/null +++ b/src/components/client/campaign-expenses/grid/FileList.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { Delete, UploadFile } from '@mui/icons-material' +import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemText } from '@mui/material' + +import { CampaignFileRole, FileRole } from 'components/common/campaign-file/roles' + +type Props = { + files: File[] + onDelete?: (file: File) => void + onSetFileRole: (file: File, role: CampaignFileRole) => void + filesRole: FileRole[] +} + +function FileList({ files, onDelete }: Props) { + return ( + + {files.map((file, key) => ( + onDelete && onDelete(file)}> + + + }> + + + + + + + + ))} + + ) +} + +export default FileList diff --git a/src/components/client/campaigns/CampaignDetails.tsx b/src/components/client/campaigns/CampaignDetails.tsx index 529efb0d2..1e34e33c1 100644 --- a/src/components/client/campaigns/CampaignDetails.tsx +++ b/src/components/client/campaigns/CampaignDetails.tsx @@ -7,7 +7,7 @@ import { CampaignResponse } from 'gql/campaigns' import 'react-quill/dist/quill.bubble.css' -import { Divider, Grid, Typography } from '@mui/material' +import { Divider, Grid, Tooltip, Typography } from '@mui/material' import SecurityIcon from '@mui/icons-material/Security' import { styled } from '@mui/material/styles' @@ -18,6 +18,12 @@ import CampaignInfoGraphics from './CampaignInfoGraphics' import CampaignInfoOperator from './CampaignInfoOperator' import LinkButton from 'components/common/LinkButton' import { campaignSliderUrls } from 'common/util/campaignImageUrls' +import CampaignPublicExpensesGrid from './CampaignPublicExpensesGrid' +import EditIcon from '@mui/icons-material/Edit' +import { useCampaignApprovedExpensesList } from 'common/hooks/expenses' +import { Assessment } from '@mui/icons-material' +import { routes } from 'common/routes' +import { useCanEditCampaign } from 'common/hooks/campaigns' const ReactQuill = dynamic(() => import('react-quill'), { ssr: false }) @@ -86,6 +92,8 @@ type Props = { export default function CampaignDetails({ campaign }: Props) { const { t } = useTranslation() const sliderImages = campaignSliderUrls(campaign) + const canEditExpenses = useCanEditCampaign(campaign.slug) + const { data: expensesList } = useCampaignApprovedExpensesList(campaign.slug) return ( @@ -107,6 +115,32 @@ export default function CampaignDetails({ campaign }: Props) { + {expensesList?.length || canEditExpenses ? ( + + + + {t('campaigns:campaign.financial-report')} + {canEditExpenses ? ( + + } + /> + + ) : ( + '' + )} + + + + + + + ) : ( + '' + )} + diff --git a/src/components/client/campaigns/CampaignPublicExpensesGrid.tsx b/src/components/client/campaigns/CampaignPublicExpensesGrid.tsx new file mode 100644 index 000000000..d49f2653c --- /dev/null +++ b/src/components/client/campaigns/CampaignPublicExpensesGrid.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { styled } from '@mui/material/styles' +import { observer } from 'mobx-react' +import { DataGrid, GridColumns, GridRenderCellParams } from '@mui/x-data-grid' +import { useTranslation } from 'next-i18next' + +import { useCampaignApprovedExpensesList } from 'common/hooks/expenses' +import { moneyPublic } from 'common/util/money' +import { ModalStoreImpl } from 'stores/dashboard/ModalStore' +import { ExpenseFile } from 'gql/expenses' +import { Button, Tooltip } from '@mui/material' +import { downloadCampaignExpenseFile } from 'service/expense' +import { useSession } from 'next-auth/react' +import FilePresentIcon from '@mui/icons-material/FilePresent' + +const PREFIX = 'Grid' + +type Props = { slug: string } + +const classes = { + grid: `${PREFIX}-grid`, + gridColumn: `${PREFIX}-gridColumn`, +} + +export const ModalStore = new ModalStoreImpl() + +// TODO jss-to-styled codemod: The Fragment root was replaced by div. Change the tag if needed. +const Root = styled('div')({ + [`& .${classes.grid}`]: { + marginBottom: 15, + border: 'none', + '& .MuiDataGrid-virtualScroller': { + overflow: 'hidden', + }, + '& .MuiDataGrid-footerContainer': { + marginTop: '30px', + marginRight: '40px', + }, + fontSize: '12px', + }, + [`& .${classes.gridColumn}`]: { + '& .MuiDataGrid-columnHeaderTitle': { + fontSize: '14px', + fontWeight: '700', + }, + }, +}) + +export default observer(function CampaignPublicExpensesGrid({ slug }: Props) { + const { t } = useTranslation('') + const { data: expensesList } = useCampaignApprovedExpensesList(slug) + + const [pageSize, setPageSize] = React.useState(10) + const { data: session } = useSession() + + const downloadExpenseFileHandler = async (file: ExpenseFile) => { + downloadCampaignExpenseFile(file.id, session) + .then((response) => { + const url = window.URL.createObjectURL(new Blob([response.data])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', `${file.filename}`) + link.click() + }) + .catch((error) => console.error(error)) + } + + const columns: GridColumns = [ + { field: 'id', headerName: 'ID', hide: true }, + { + field: 'type', + headerName: t('expenses:fields.type'), + headerClassName: classes.gridColumn, + width: 120, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + return t('expenses:field-types.' + params.row.type) + }, + }, + { + field: 'amount', + headerName: t('expenses:fields.amount'), + headerClassName: classes.gridColumn, + align: 'right', + width: 120, + renderCell: (params: GridRenderCellParams): React.ReactNode => { + if (!params.row.amount) { + return '0' + } + + return moneyPublic(params.row.amount, params.row.currency) + }, + }, + { + field: 'description', + headerName: t('expenses:fields.description'), + headerClassName: classes.gridColumn, + flex: 1, + }, + { + field: 'files', + headerName: t('expenses:fields.attached-files'), + headerClassName: classes.gridColumn, + flex: 1, + renderCell: (params: GridRenderCellParams) => { + const rows = params.row.expenseFiles.map((file: ExpenseFile) => { + return ( + + + + ) + }) + return
{rows}
+ }, + }, + ] + + return ( + + setPageSize(newPageSize)} + rowsPerPageOptions={[10, 20, 30]} + // pagination + autoHeight + disableSelectionOnClick + /> + + ) +}) diff --git a/src/components/client/campaigns/ExpensesPage.tsx b/src/components/client/campaigns/ExpensesPage.tsx new file mode 100644 index 000000000..5888af821 --- /dev/null +++ b/src/components/client/campaigns/ExpensesPage.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +import { Container } from '@mui/material' + +import Layout from 'components/client/layout/Layout' + +import CampaignExpensesGrid from '../campaign-expenses/grid/CampaignExpensesGrid' +import ExpensesGridAppbar from '../campaign-expenses/grid/CampaignGridAppbar' +import { useCanEditCampaign, useViewCampaign } from 'common/hooks/campaigns' + +type Props = { slug: string } + +export default function ExpensesPage({ slug }: Props) { + const canEdit = useCanEditCampaign(slug) + const { data: campaignResponse } = useViewCampaign(slug) + + if (canEdit == false) { + return + } + + const campaignTitle = campaignResponse?.campaign.title + + return ( + + +

{campaignTitle}

+ + +
+
+ ) +} diff --git a/src/components/client/campaigns/InlineDonation.tsx b/src/components/client/campaigns/InlineDonation.tsx index b35e3f13f..51f5ce794 100644 --- a/src/components/client/campaigns/InlineDonation.tsx +++ b/src/components/client/campaigns/InlineDonation.tsx @@ -18,6 +18,7 @@ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import { baseUrl, routes } from 'common/routes' import { moneyPublic } from 'common/util/money' import { useCampaignDonationHistory } from 'common/hooks/campaigns' + import theme from 'common/theme' import { useCopyToClipboard } from 'common/util/useCopyToClipboard' import useMobile from 'common/hooks/useMobile' diff --git a/src/components/common/person/PersonSelect.tsx b/src/components/common/person/PersonSelect.tsx index 849e3e35e..ba0db7177 100644 --- a/src/components/common/person/PersonSelect.tsx +++ b/src/components/common/person/PersonSelect.tsx @@ -4,7 +4,12 @@ import { useField } from 'formik' import { usePersonList } from 'common/hooks/person' import FormTextField from 'components/common/form/FormTextField' -export default function PersonSelect({ name = 'personId', label = '', ...textFieldProps }) { +export default function PersonSelect({ + name = 'personId', + label = '', + selectedId = '', + ...textFieldProps +}) { const [field, meta] = useField(name) const { data: personList } = usePersonList() if (!personList) { @@ -21,7 +26,7 @@ export default function PersonSelect({ name = 'personId', label = '', ...textFie select type="text" fullWidth - defaultValue="" + defaultValue={selectedId} label={label} {...field} {...textFieldProps}> diff --git a/src/gql/expenses.ts b/src/gql/expenses.ts index f0eefc11b..8f4be02b5 100644 --- a/src/gql/expenses.ts +++ b/src/gql/expenses.ts @@ -6,11 +6,14 @@ export type ExpenseInput = { status: ExpenseStatus | string currency: Currency | string amount: number + money: number vaultId: UUID deleted: boolean description?: string documentId?: UUID | null approvedById?: UUID | null + approved?: boolean + spentAt: string } export type ExpenseResponse = { @@ -24,6 +27,8 @@ export type ExpenseResponse = { description?: string documentId?: UUID | null approvedById?: UUID | null + spentAt: string + expenseFiles: ExpenseFile[] } export enum ExpenseType { @@ -50,3 +55,13 @@ export enum ExpenseStatus { approved = 'approved', canceled = 'canceled', } + +export type ExpenseFile = { + id: UUID + filename: string +} + +export type UploadExpenseFile = { + files: File[] + expenseId: UUID +} diff --git a/src/pages/campaigns/[slug]/expenses.tsx b/src/pages/campaigns/[slug]/expenses.tsx new file mode 100644 index 000000000..47ca9302e --- /dev/null +++ b/src/pages/campaigns/[slug]/expenses.tsx @@ -0,0 +1,34 @@ +import { dehydrate, QueryClient } from '@tanstack/react-query' +import { GetServerSideProps } from 'next' +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' + +import ExpensesPage from 'components/client/campaigns/ExpensesPage' +import { endpoints } from 'service/apiEndpoints' +import { queryFnFactory } from 'service/restRequests' +import { CampaignResponse } from 'gql/campaigns' + +export const getServerSideProps: GetServerSideProps = async ({ query, locale }) => { + const { slug } = query + const client = new QueryClient() + await client.prefetchQuery( + [endpoints.campaign.viewCampaign(slug as string)], + queryFnFactory(), + ) + return { + props: { + slug, + ...(await serverSideTranslations(locale ?? 'bg', [ + 'common', + 'auth', + 'validation', + 'campaigns', + 'irregularity', + 'expenses', + 'admin', + ])), + dehydratedState: dehydrate(client), + }, + } +} + +export default ExpensesPage diff --git a/src/pages/campaigns/[slug]/expenses/[id].tsx b/src/pages/campaigns/[slug]/expenses/[id].tsx new file mode 100644 index 000000000..0fbf9b738 --- /dev/null +++ b/src/pages/campaigns/[slug]/expenses/[id].tsx @@ -0,0 +1,10 @@ +import { securedAdminProps } from 'middleware/auth/securedProps' +import { endpoints } from 'service/apiEndpoints' +import ExpensesEditPage from 'components/client/campaign-expenses/CampaignExpenseEdit' + +export const getServerSideProps = securedAdminProps( + ['common', 'auth', 'validation', 'expenses'], + (ctx) => endpoints.expenses.editExpense(ctx.query.id as string).url, +) + +export default ExpensesEditPage diff --git a/src/pages/campaigns/[slug]/expenses/create.tsx b/src/pages/campaigns/[slug]/expenses/create.tsx new file mode 100644 index 000000000..6e8543b6f --- /dev/null +++ b/src/pages/campaigns/[slug]/expenses/create.tsx @@ -0,0 +1,13 @@ +import { GetServerSideProps } from 'next' +import CreateCampaignExpensePage from 'components/client/campaign-expenses/CampaignExpenseCreate' +import { securedPropsWithTranslation } from 'middleware/auth/securedProps' +//import { routes } from 'common/routes' + +export const getServerSideProps: GetServerSideProps = securedPropsWithTranslation([ + 'common', + 'auth', + 'validation', + 'expenses', +]) + +export default CreateCampaignExpensePage diff --git a/src/pages/campaigns/[slug]/index.tsx b/src/pages/campaigns/[slug]/index.tsx index 5db849517..703872aad 100644 --- a/src/pages/campaigns/[slug]/index.tsx +++ b/src/pages/campaigns/[slug]/index.tsx @@ -23,6 +23,7 @@ export const getServerSideProps: GetServerSideProps = async ({ query, locale }) 'validation', 'campaigns', 'irregularity', + 'expenses', ])), dehydratedState: dehydrate(client), }, diff --git a/src/service/apiEndpoints.ts b/src/service/apiEndpoints.ts index b36ee8331..bf6007232 100644 --- a/src/service/apiEndpoints.ts +++ b/src/service/apiEndpoints.ts @@ -31,6 +31,10 @@ export const endpoints = { downloadFile: (fileId: string) => { url: `/campaign-file/${fileId}`, method: 'GET' }, deleteFile: (fileId: string) => { url: `/campaign-file/${fileId}`, method: 'DELETE' }, getDonations: (id: string) => { url: `/campaign/donations/${id}`, method: 'GET' }, + listCampaignExpenses: (slug: string) => + { url: `/campaign/${slug}/expenses`, method: 'GET' }, + listCampaignApprovedExpenses: (slug: string) => + { url: `/campaign/${slug}/expenses/approved`, method: 'GET' }, }, campaignType: { listCampaignTypes: { url: '/campaign-type/list', method: 'GET' }, @@ -133,6 +137,13 @@ export const endpoints = { viewExpense: (id: string) => { url: `/expenses/${id}`, method: 'GET' }, editExpense: (id: string) => { url: `/expenses/${id}`, method: 'PATCH' }, deleteExpense: (id: string) => { url: `/expenses/${id}`, method: 'DELETE' }, + uploadFile: (expenseId: string) => + { url: `/expenses/${expenseId}/files/`, method: 'POST' }, + downloadFile: (fileId: string) => + { url: `/expenses/download-file/${fileId}`, method: 'GET' }, + listExpenseFiles: (id: string) => { url: `/expenses/${id}/files`, method: 'GET' }, + deleteExpenseFile: (fileId: string) => + { url: `/expenses/file/${fileId}`, method: 'DELETE' }, }, benefactor: { benefactorList: { url: '/benefactor', method: 'GET' }, @@ -167,6 +178,8 @@ export const endpoints = { list: { url: '/person', method: 'GET' }, createBeneficiary: { url: '/beneficiary/create-beneficiary', method: 'POST' }, viewPerson: (slug: string) => { url: `/person/${slug}`, method: 'GET' }, + viewPersonByKeylockId: (sub: string) => + { url: `/person/by-keylock-id/${sub}`, method: 'GET' }, editPerson: (id: string) => { url: `/person/${id}`, method: 'PUT' }, createPerson: { url: '/person', method: 'POST' }, deletePerson: (id: string) => { url: `/person/${id}`, method: 'DELETE' }, diff --git a/src/service/expense.ts b/src/service/expense.ts index e81aa4c95..e9c057a09 100644 --- a/src/service/expense.ts +++ b/src/service/expense.ts @@ -4,7 +4,8 @@ import { AxiosResponse } from 'axios' import { apiClient } from 'service/apiClient' import { authConfig } from 'service/restRequests' import { endpoints } from 'service/apiEndpoints' -import { ExpenseInput, ExpenseResponse } from 'gql/expenses' +import { ExpenseInput, ExpenseResponse, UploadExpenseFile, ExpenseFile } from 'gql/expenses' +import { Session } from 'next-auth' export function useCreateExpense() { const { data: session } = useSession() @@ -37,3 +38,37 @@ export function useDeleteExpense() { ) } } + +export const useUploadExpenseFiles = () => { + const { data: session } = useSession() + return async ({ files, expenseId }: UploadExpenseFile) => { + const formData = new FormData() + files.forEach((file: File) => { + formData.append('file', file) + }) + return await apiClient.post>( + endpoints.expenses.uploadFile(expenseId).url, + formData, + { + headers: { + ...authConfig(session?.accessToken).headers, + 'Content-Type': 'multipart/form-data', + }, + }, + ) + } +} + +export const downloadCampaignExpenseFile = (id: string, session: Session | null) => { + return apiClient(endpoints.expenses.downloadFile(id).url, { + ...authConfig(session?.accessToken), + responseType: 'blob', + }) +} + +export const deleteExpenseFile = (id: string, session: Session | null) => { + return apiClient.delete( + endpoints.expenses.deleteExpenseFile(id).url, + authConfig(session?.accessToken), + ) +} diff --git a/src/service/person.ts b/src/service/person.ts index dd31237ec..ea437f1f5 100644 --- a/src/service/person.ts +++ b/src/service/person.ts @@ -7,15 +7,6 @@ import { useQuery } from '@tanstack/react-query' import { apiClient } from './apiClient' import { AxiosResponse } from 'axios' -export const usePeopleList = () => { - const { data: session } = useSession() - - return useQuery( - [endpoints.person.list.url], - authQueryFnFactory(session?.accessToken), - ) -} - export const useViewPerson = (id: string) => { const { data: session } = useSession()