diff --git a/package.json b/package.json index 34f12cc97..63929838c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@tryghost/content-api": "^1.11.4", "axios": "0.21.4", "axios-hooks": "2.7.0", + "chart.js": "^4.4.0", "date-fns": "2.24.0", "dompurify": "^3.0.3", "formik": "2.2.9", @@ -62,6 +63,7 @@ "quill-blot-formatter": "^1.0.5", "quill-html-edit-button": "^2.2.12", "react": "18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "18.2.0", "react-gtm-module": "2.0.11", "react-i18next": "^11.17.1", diff --git a/src/components/client/campaigns/CampaignDetails.tsx b/src/components/client/campaigns/CampaignDetails.tsx index 4d14b40a2..33be22a54 100644 --- a/src/components/client/campaigns/CampaignDetails.tsx +++ b/src/components/client/campaigns/CampaignDetails.tsx @@ -26,6 +26,7 @@ import { routes } from 'common/routes' import { useCanEditCampaign } from 'common/hooks/campaigns' import { moneyPublic } from 'common/util/money' import ReceiptLongIcon from '@mui/icons-material/ReceiptLong' +import CampaignPublicExpensesChart from './CampaignPublicExpensesChart' const ReactQuill = dynamic(() => import('react-quill'), { ssr: false }) const CampaignNewsSection = dynamic(() => import('./CampaignNewsSection'), { ssr: false }) @@ -143,6 +144,13 @@ export default function CampaignDetails({ campaign }: Props) { )} + + + {t('expenses:reported')}:{' '} diff --git a/src/components/client/campaigns/CampaignPublicExpensesChart.tsx b/src/components/client/campaigns/CampaignPublicExpensesChart.tsx new file mode 100644 index 000000000..1dad5a43b --- /dev/null +++ b/src/components/client/campaigns/CampaignPublicExpensesChart.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { observer } from 'mobx-react' +import { useTranslation } from 'next-i18next' +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Colors, + Tooltip, + Legend, + TooltipItem, +} from 'chart.js' +import { Bar } from 'react-chartjs-2' + +import { useCampaignApprovedExpensesList } from 'common/hooks/expenses' +import { fromMoney, moneyPublic, toMoney } from 'common/util/money' + +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Colors, Tooltip, Legend) + +type ExpenseDataset = { + type: string + total: number +} + +type Props = { + slug: string + reachedAmount: number + currency?: string +} + +export default observer(function CampaignPublicExpensesChart({ + slug, + reachedAmount, + currency, +}: Props) { + const { t } = useTranslation('') + const { data: campaignExpenses } = useCampaignApprovedExpensesList(slug) + + const expenses: ExpenseDataset[] = [] + + campaignExpenses?.forEach(({ type, amount }) => { + const exists = expenses.find((e) => e.type === type) + if (exists) exists.total += fromMoney(amount) + else expenses.push({ type, total: fromMoney(amount) }) + }) + + expenses.sort((a, b) => b.total - a.total) + + const options = { + indexAxis: 'y' as const, + scales: { + x: { + stacked: true, + min: 0, + max: fromMoney(reachedAmount), + }, + y: { + stacked: true, + display: false, + }, + }, + elements: { + bar: { + borderWidth: 1, + }, + }, + responsive: true, + plugins: { + legend: { + position: 'bottom' as const, + }, + colors: { + enabled: true, + }, + tooltip: { + callbacks: { + label: (context: TooltipItem<'bar'>) => + ` ${context.dataset.label + ':' || ''} ${moneyPublic( + toMoney(context.parsed.x), + currency, + )}`, + }, + }, + }, + } + + const data = { + labels: [''], + datasets: expenses.map((expense) => { + return { + label: t('expenses:field-types.' + expense.type), + data: [expense.total], + } + }), + } + + return +}) diff --git a/yarn.lock b/yarn.lock index 6c7c5de8f..961b43692 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1296,6 +1296,13 @@ __metadata: languageName: node linkType: hard +"@kurkle/color@npm:^0.3.0": + version: 0.3.2 + resolution: "@kurkle/color@npm:0.3.2" + checksum: 79e97b31f8f6efb28c69d373f94b0c7480226fe8ec95221f518ac998e156444a496727ce47de6d728eb5c3369288e794cba82cae34253deb0d472d3bfe080e49 + languageName: node + linkType: hard + "@lexical/clipboard@npm:0.11.3, @lexical/clipboard@npm:^0.11.1": version: 0.11.3 resolution: "@lexical/clipboard@npm:0.11.3" @@ -4960,6 +4967,15 @@ __metadata: languageName: node linkType: hard +"chart.js@npm:^4.4.0": + version: 4.4.0 + resolution: "chart.js@npm:4.4.0" + dependencies: + "@kurkle/color": ^0.3.0 + checksum: 5ee2d99b78608025525b5790af17178fdaa5adc3294e082deba2718029b0496109ba124f1b08dd1e4c8a04d6e842be7f384f2cfe9a11df8d1c6fe884acece52b + languageName: node + linkType: hard + "chokidar@npm:>=3.0.0 <4.0.0": version: 3.5.3 resolution: "chokidar@npm:3.5.3" @@ -11314,6 +11330,7 @@ __metadata: all-contributors-cli: ^6.20.0 axios: 0.21.4 axios-hooks: 2.7.0 + chart.js: ^4.4.0 date-fns: 2.24.0 depcheck: ^1.4.3 dompurify: ^3.0.3 @@ -11342,6 +11359,7 @@ __metadata: quill-blot-formatter: ^1.0.5 quill-html-edit-button: ^2.2.12 react: 18.2.0 + react-chartjs-2: ^5.2.0 react-dom: 18.2.0 react-gtm-module: 2.0.11 react-i18next: ^11.17.1 @@ -11785,6 +11803,16 @@ __metadata: languageName: node linkType: hard +"react-chartjs-2@npm:^5.2.0": + version: 5.2.0 + resolution: "react-chartjs-2@npm:5.2.0" + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: ace702185be1450e5888a8bcd8b5fc1995067e3b11d236764a67f5567a3d7c32ff16923b8d48d3d39bda6e45135da6c044c9b43fbe8e1978f95aca9d2c0ce348 + languageName: node + linkType: hard + "react-devtools-inline@npm:4.4.0": version: 4.4.0 resolution: "react-devtools-inline@npm:4.4.0"