diff --git a/packages/collectives-next/pages/secretary/statistics/index.jsx b/packages/collectives-next/pages/secretary/statistics/index.jsx new file mode 100644 index 0000000000..724defcef2 --- /dev/null +++ b/packages/collectives-next/pages/secretary/statistics/index.jsx @@ -0,0 +1,31 @@ +import ListLayout from "next-common/components/layout/ListLayout"; +import SecretaryCollectivesStatistics from "next-common/components/secretary/statistics/collectives"; +import CollectivesProvider from "next-common/context/collectives/collectives"; +import { withCommonProps } from "next-common/lib"; +import { backendApi } from "next-common/services/nextApi"; +import { secretaryMembersApiUri } from "next-common/services/url"; + +export default function SecretaryStatisticsPage() { + const category = "Secretary Statistics"; + const seoInfo = { title: category, desc: category }; + + return ( + + + + + + ); +} + +export const getServerSideProps = withCommonProps(async () => { + const { result: secretaryMembers } = await backendApi.fetch( + secretaryMembersApiUri, + ); + + return { + props: { + secretaryMembers: secretaryMembers ?? null, + }, + }; +}); diff --git a/packages/next-common/components/fellowship/statistics/expenditure/claimants/index.jsx b/packages/next-common/components/fellowship/statistics/expenditure/claimants/index.jsx index 05c9604464..ba77d7456f 100644 --- a/packages/next-common/components/fellowship/statistics/expenditure/claimants/index.jsx +++ b/packages/next-common/components/fellowship/statistics/expenditure/claimants/index.jsx @@ -32,16 +32,19 @@ function paginateData(data, page, pageSize) { return data.slice(start, end); } -function StatisticsClaimantsTable({ members = [] }) { +function StatisticsClaimantsTable({ + members = [], + membersApi = fellowshipStatisticsMembersApi, +}) { const [total, setTotal] = useState(0); const [processedData, setProcessedData] = useState([]); const [rowData, setRowData] = useState([]); const [tableLoading, setTableLoading] = useState(false); + const pageSize = defaultPageSize; const { page, component: pageComponent } = usePaginationComponent( total, - defaultPageSize, + pageSize, ); - const membersApi = fellowshipStatisticsMembersApi; const columns = [ useStatisticsClaimantsRankColumn(), @@ -64,7 +67,7 @@ function StatisticsClaimantsTable({ members = [] }) { setTableLoading(false); return []; } - }, []); + }, [membersApi]); useEffect(() => { if (originalMembers && members) { @@ -99,12 +102,15 @@ function StatisticsClaimantsTable({ members = [] }) { ); } -export default function StatisticsClaimants({ members = [] }) { +export default function StatisticsClaimants({ + members = [], + membersApi = fellowshipStatisticsMembersApi, +}) { return (
Top Claimants - +
); diff --git a/packages/next-common/components/fellowship/statistics/expenditure/rank/doughnutChart/index.jsx b/packages/next-common/components/fellowship/statistics/expenditure/rank/doughnutChart/index.jsx index c1290f421b..0ce28c67ea 100644 --- a/packages/next-common/components/fellowship/statistics/expenditure/rank/doughnutChart/index.jsx +++ b/packages/next-common/components/fellowship/statistics/expenditure/rank/doughnutChart/index.jsx @@ -33,9 +33,9 @@ function handleLabelDataArr(members, ranksData) { const totalSalary = getTotalSalary(ranksData); const ranksDataObj = transformRanksDataToObject(ranksData); const dataArr = rankArr.map((rank, index) => { - const count = ranksDataObj[index] || 0; - const percent = ranksDataObj[index] - ? new BigNumber(ranksDataObj[index]).div(totalSalary) + const count = ranksDataObj[rank] || 0; + const percent = ranksDataObj[rank] + ? new BigNumber(ranksDataObj[rank]).div(totalSalary) : ""; return { label: `Rank ${rank}`, @@ -71,11 +71,11 @@ function RankChart({ labelDataArr, data }) { } export default function RankDoughnutChart({ members = [] }) { + const ranksApi = fellowshipStatisticsRanksApi; + const [labelDataArr, setLabelDataArr] = useState([]); const [contentLoading, setContentLoading] = useState(false); - const ranksApi = fellowshipStatisticsRanksApi; - const { value: ranksData } = useAsync(async () => { setContentLoading(true); if (!ranksApi) { diff --git a/packages/next-common/components/secretary/statistics/assetBreakdown.jsx b/packages/next-common/components/secretary/statistics/assetBreakdown.jsx new file mode 100644 index 0000000000..3d061f8108 --- /dev/null +++ b/packages/next-common/components/secretary/statistics/assetBreakdown.jsx @@ -0,0 +1,34 @@ +import BigNumber from "bignumber.js"; +import { cn } from "next-common/utils"; +import TokenSymbolAsset from "next-common/components/summary/polkadotTreasurySummary/common/tokenSymbolAsset"; + +export default function AssetBreakdown({ + usdTotal, + rows = [], + align = "left", +}) { + if (!rows || rows.length === 0) { + return null; + } + + return ( +
+
${usdTotal}
+
+ {rows.map((row, i) => ( + 6 ? 4 : 2)} + symbol={row.symbol} + /> + ))} +
+
+ ); +} diff --git a/packages/next-common/components/secretary/statistics/breakdown.js b/packages/next-common/components/secretary/statistics/breakdown.js new file mode 100644 index 0000000000..6aab4ad59f --- /dev/null +++ b/packages/next-common/components/secretary/statistics/breakdown.js @@ -0,0 +1,46 @@ +import BigNumber from "bignumber.js"; + +export function getCyclesTotal(cycles) { + if (!cycles || cycles.length === 0) { + return new BigNumber(0); + } + return cycles.reduce((total, item) => { + const registeredPaid = new BigNumber(item.registeredPaid || 0); + const unRegisteredPaid = new BigNumber(item.unRegisteredPaid || 0); + return total.plus(registeredPaid).plus(unRegisteredPaid); + }, new BigNumber(0)); +} + +export function getReferendaTotal(paymentReferenda) { + if (!paymentReferenda || paymentReferenda.length === 0) { + return new BigNumber(0); + } + return paymentReferenda.reduce((total, item) => { + return total.plus(new BigNumber(item.value || 0)); + }, new BigNumber(0)); +} + +export function getReferendaUsd(paymentReferenda) { + if (!paymentReferenda || paymentReferenda.length === 0) { + return new BigNumber(0); + } + return paymentReferenda.reduce((total, ref) => { + const value = new BigNumber(ref.value || 0); + const amount = value.div(Math.pow(10, ref.decimals || 10)); + return total.plus(amount.times(ref.price || 0)); + }, new BigNumber(0)); +} + +export function getReferendaTotalByAddress(paymentReferenda, address) { + const refs = (paymentReferenda || []).filter( + (r) => r.beneficiary === address, + ); + return getReferendaTotal(refs); +} + +export function getReferendaUsdByAddress(paymentReferenda, address) { + const refs = (paymentReferenda || []).filter( + (r) => r.beneficiary === address, + ); + return getReferendaUsd(refs); +} diff --git a/packages/next-common/components/secretary/statistics/claimants/index.jsx b/packages/next-common/components/secretary/statistics/claimants/index.jsx new file mode 100644 index 0000000000..8dc0a530b4 --- /dev/null +++ b/packages/next-common/components/secretary/statistics/claimants/index.jsx @@ -0,0 +1,221 @@ +import { useMemo } from "react"; +import DataList from "next-common/components/dataList"; +import { backendApi } from "next-common/services/nextApi"; +import { fellowshipStatisticsMembersApi } from "next-common/services/url"; +import { defaultPageSize } from "next-common/utils/constants"; +import { useAsync } from "react-use"; +import { useStatisticsClaimantColumn } from "../../../fellowship/statistics/expenditure/claimants/columns/claimant"; +import { useStatisticsClaimantsCyclesColumn } from "../../../fellowship/statistics/expenditure/claimants/columns/cycles"; +import { useStatisticsClaimantsRankColumn } from "../../../fellowship/statistics/expenditure/claimants/columns/rank"; +import { useState, useEffect } from "react"; +import usePaginationComponent from "next-common/components/pagination/usePaginationComponent"; +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import { StatisticsTitle } from "next-common/components/statistics/styled.js"; +import { isNil } from "lodash-es"; +import BigNumber from "bignumber.js"; +import PaymentReferendaTooltip from "next-common/components/secretary/statistics/paymentReferendaTooltip"; +import AssetBreakdown from "next-common/components/secretary/statistics/assetBreakdown"; +import { + getReferendaTotalByAddress, + getReferendaUsdByAddress, +} from "next-common/components/secretary/statistics/breakdown"; +import ValueDisplay from "next-common/components/valueDisplay"; +import { getSalaryAsset } from "next-common/utils/consts/getSalaryAsset"; +import { formatNum, toPrecision } from "next-common/utils"; + +function handleClaimantsData(originalMembers, members) { + const membersRank = members.reduce((acc, member) => { + acc[member.address] = member.rank; + return acc; + }, {}); + + return originalMembers.map((item) => ({ + ...item, + rank: !isNil(membersRank[item.who]) ? membersRank[item.who] : null, + })); +} + +function paginateData(data, page, pageSize) { + const start = (page - 1) * pageSize; + const end = page * pageSize; + return data.slice(start, end); +} + +function ReferendaCell({ paymentReferenda = [] }) { + if (!paymentReferenda.length) { + return -; + } + + return ( + + + {paymentReferenda.length} + + + ); +} + +function useSecretaryClaimantsReferendaColumn(paymentReferenda) { + const referendaByAddress = useMemo(() => { + const map = {}; + for (const ref of paymentReferenda || []) { + if (!map[ref.beneficiary]) { + map[ref.beneficiary] = []; + } + map[ref.beneficiary].push(ref); + } + return map; + }, [paymentReferenda]); + + return { + name: "Referenda", + width: 160, + cellRender(data) { + const refs = referendaByAddress[data.who] || []; + return ; + }, + }; +} + +function useSecretaryClaimantsPaidColumn(paymentReferenda) { + const { decimals, symbol } = getSalaryAsset(); + + return { + name: "Total Paid", + className: "text-right", + cellRender(data, idx) { + const salary = BigInt(data.salary || 0); + const address = data.who; + const referendaTotal = getReferendaTotalByAddress( + paymentReferenda, + address, + ); + + if (referendaTotal.isZero()) { + return ( + + ); + } + + const referendaUsd = getReferendaUsdByAddress(paymentReferenda, address); + const cyclesUsd = new BigNumber(salary.toString()).div( + Math.pow(10, decimals), + ); + const usdTotal = cyclesUsd.plus(referendaUsd).toFixed(2); + + const rows = [ + { value: salary.toString(), decimals, symbol }, + { + value: referendaTotal.toString(), + decimals: 10, + symbol: "DOT", + }, + ]; + + return ( + + ); + }, + }; +} + +function SecretaryClaimantsTable({ + members = [], + membersApi = fellowshipStatisticsMembersApi, + paymentReferenda = [], +}) { + const [total, setTotal] = useState(0); + const [processedData, setProcessedData] = useState([]); + const [rowData, setRowData] = useState([]); + const [tableLoading, setTableLoading] = useState(false); + const pageSize = defaultPageSize; + const { page, component: pageComponent } = usePaginationComponent( + total, + pageSize, + ); + + const columns = [ + useStatisticsClaimantsRankColumn(), + useStatisticsClaimantColumn(), + useStatisticsClaimantsCyclesColumn(), + useSecretaryClaimantsReferendaColumn(paymentReferenda), + useSecretaryClaimantsPaidColumn(paymentReferenda), + ]; + + const { value: originalMembers } = useAsync(async () => { + setTableLoading(true); + if (!membersApi) { + return []; + } + try { + const resp = await backendApi.fetch(membersApi, { + page, + pageSize: defaultPageSize, + }); + return resp?.result || []; + } catch { + setTableLoading(false); + return []; + } + }, [membersApi]); + + useEffect(() => { + if (originalMembers && members) { + const processed = handleClaimantsData(originalMembers, members); + setProcessedData(processed); + setTotal(processed.length); + setTableLoading(false); + } + }, [originalMembers, members]); + + useEffect(() => { + if (processedData.length > 0) { + const paginatedData = paginateData(processedData, page, defaultPageSize); + const rows = paginatedData.map((item, idx) => + columns.map((col) => col.cellRender(item, idx)), + ); + setRowData(rows); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [processedData, page]); + + return ( +
+ + {pageComponent} +
+ ); +} + +export default function SecretaryStatisticsClaimants({ + members = [], + membersApi = fellowshipStatisticsMembersApi, + paymentReferenda = [], +}) { + return ( + +
+ Top Beneficiary + +
+
+ ); +} diff --git a/packages/next-common/components/secretary/statistics/collectives.jsx b/packages/next-common/components/secretary/statistics/collectives.jsx new file mode 100644 index 0000000000..143243e628 --- /dev/null +++ b/packages/next-common/components/secretary/statistics/collectives.jsx @@ -0,0 +1,40 @@ +import { useAsync } from "react-use"; +import { backendApi } from "next-common/services/nextApi"; +import { + secretaryStatisticsCyclesApi, + secretaryStatisticsMembersApi, +} from "next-common/services/url"; +import { TitleContainer } from "next-common/components/styled/containers/titleContainer"; +import SecretaryStatisticsSummary from "next-common/components/secretary/statistics/summary"; +import StatisticsCycles from "next-common/components/fellowship/statistics/expenditure/cycles"; +import SecretaryStatisticsClaimants from "next-common/components/secretary/statistics/claimants"; +import { useFellowshipCollectiveMembers } from "next-common/hooks/fellowship/core/useFellowshipCollectiveMembers"; + +export default function SecretaryCollectivesStatistics() { + const { members } = useFellowshipCollectiveMembers(); + + const { value: data, loading } = useAsync(async () => { + const resp = await backendApi.fetch(secretaryStatisticsCyclesApi); + return resp?.result; + }, []); + + const cycles = data?.cycles || []; + const paymentReferenda = data?.paymentReferenda || []; + + return ( +
+ Salary + + + +
+ ); +} diff --git a/packages/next-common/components/secretary/statistics/paymentReferendaTooltip.jsx b/packages/next-common/components/secretary/statistics/paymentReferendaTooltip.jsx new file mode 100644 index 0000000000..88648ebaee --- /dev/null +++ b/packages/next-common/components/secretary/statistics/paymentReferendaTooltip.jsx @@ -0,0 +1,33 @@ +import Tooltip from "next-common/components/tooltip"; +import Link from "next-common/components/link"; +import { toPrecision } from "next-common/utils"; + +export default function PaymentReferendaTooltip({ + paymentReferenda = [], + children, +}) { + if (!paymentReferenda || paymentReferenda.length === 0) { + return children; + } + + const content = ( +
+ {paymentReferenda.map((ref) => ( +
+ + #{ref.referendumIndex} + {" · "} + {ref.title} + + {" · "} + {toPrecision(ref.value, ref.decimals)} {ref.symbol} +
+ ))} +
+ ); + + return {children}; +} diff --git a/packages/next-common/components/secretary/statistics/summary/index.jsx b/packages/next-common/components/secretary/statistics/summary/index.jsx new file mode 100644 index 0000000000..9fa49dd077 --- /dev/null +++ b/packages/next-common/components/secretary/statistics/summary/index.jsx @@ -0,0 +1,75 @@ +import { SecondaryCard } from "next-common/components/styled/containers/secondaryCard"; +import SummaryItem from "next-common/components/summary/layout/item"; +import SummaryLayout from "next-common/components/summary/layout/layout"; +import { formatNum } from "next-common/utils"; +import { getSalaryAsset } from "next-common/utils/consts/getSalaryAsset"; +import { LoadingContent } from "next-common/components/fellowship/statistics/common"; +import PaymentReferendaTooltip from "next-common/components/secretary/statistics/paymentReferendaTooltip"; +import AssetBreakdown from "next-common/components/secretary/statistics/assetBreakdown"; +import { + getCyclesTotal, + getReferendaTotal, + getReferendaUsd, +} from "next-common/components/secretary/statistics/breakdown"; + +function TotalSpent({ cycles, paymentReferenda }) { + const { decimals: salaryDecimals } = getSalaryAsset(); + const cyclesTotal = getCyclesTotal(cycles); + const referendaTotal = getReferendaTotal(paymentReferenda); + const referendaUsd = getReferendaUsd(paymentReferenda); + + const cyclesUsd = cyclesTotal.div(Math.pow(10, salaryDecimals)); + const usdTotal = formatNum(cyclesUsd.plus(referendaUsd).toFixed(2)); + + const rows = [ + { value: cyclesTotal.toString(), decimals: salaryDecimals, symbol: "USDT" }, + ]; + + if (referendaTotal.gt(0)) { + rows.push({ + value: referendaTotal.toString(), + decimals: 10, + symbol: "DOT", + }); + } + + return ( + + + + ); +} + +function SpentCycles({ count }) { + return {count}; +} + +export default function SecretaryStatisticsSummary({ + cycles = [], + paymentReferenda = [], + loading, +}) { + if (loading) { + return ( + + + + ); + } + + return ( + + + + + + + + {paymentReferenda?.length || 0} + + + + + + ); +} diff --git a/packages/next-common/components/summary/polkadotTreasurySummary/common/tokenSymbolAsset.jsx b/packages/next-common/components/summary/polkadotTreasurySummary/common/tokenSymbolAsset.jsx index c8ab88aa41..e1d75a6839 100644 --- a/packages/next-common/components/summary/polkadotTreasurySummary/common/tokenSymbolAsset.jsx +++ b/packages/next-common/components/summary/polkadotTreasurySummary/common/tokenSymbolAsset.jsx @@ -3,6 +3,7 @@ import ValueDisplay from "next-common/components/valueDisplay"; import { cn } from "next-common/utils"; import { useNativeTokenIcon } from "next-common/components/assethubMigrationAssets/known"; import { useMemo } from "react"; +import { useChainSettings } from "next-common/context/chain"; export default function TokenSymbolAsset({ amount, @@ -10,12 +11,14 @@ export default function TokenSymbolAsset({ valueClassName, type = "", }) { - + const { symbol: nativeSymbol } = useChainSettings(); const NativeAssetIcon = useNativeTokenIcon(); const TokenIcon = useMemo(() => { - return type === "native" ? NativeAssetIcon : AssetIcon; - }, [type, NativeAssetIcon]); + return type === "native" || symbol === nativeSymbol + ? NativeAssetIcon + : AssetIcon; + }, [type, symbol, nativeSymbol, NativeAssetIcon]); return (
diff --git a/packages/next-common/services/url.js b/packages/next-common/services/url.js index 1ae6328bb0..4050f1a03d 100644 --- a/packages/next-common/services/url.js +++ b/packages/next-common/services/url.js @@ -118,6 +118,11 @@ export const secretarySalaryCycleUnregisteredPaymentsApi = (index) => export const secretarySalaryCycleFeedsApi = (index) => `secretary/salary/cycles/${index}/feeds`; +export const secretaryStatisticsCyclesApi = + "secretary/statistics/salary/cycles"; +export const secretaryStatisticsMembersApi = + "secretary/statistics/salary/members"; + // ambassador export const ambassadorParamsApi = "ambassador/params"; export const ambassadorMembersApiUri = "ambassador/members"; diff --git a/packages/next-common/utils/consts/menu/secretary.js b/packages/next-common/utils/consts/menu/secretary.js index af5f433c4d..9f10181362 100644 --- a/packages/next-common/utils/consts/menu/secretary.js +++ b/packages/next-common/utils/consts/menu/secretary.js @@ -38,6 +38,19 @@ function getSecretarySalaryMenu() { }; } +function getSecretaryStatisticsMenu() { + const chainSettings = getChainSettings(process.env.NEXT_PUBLIC_CHAIN); + if (!chainSettings.modules.secretary) { + return null; + } + + return { + value: "secretary-statistics", + name: Names.statistics, + pathname: "/secretary/statistics", + }; +} + export function getSecretaryMenu() { const chainSettings = getChainSettings(process.env.NEXT_PUBLIC_CHAIN); if (!chainSettings.modules.secretary) { @@ -48,8 +61,10 @@ export function getSecretaryMenu() { name: Names.secretary, icon: , pathname: "/secretary", - items: [getSecretaryMembersMenu(), getSecretarySalaryMenu()].filter( - Boolean, - ), + items: [ + getSecretaryMembersMenu(), + getSecretarySalaryMenu(), + getSecretaryStatisticsMenu(), + ].filter(Boolean), }; }