diff --git a/components/crowdfunding-redesign/Accounts.tsx b/components/crowdfunding-redesign/Accounts.tsx new file mode 100644 index 00000000000..9b3bf6e17d3 --- /dev/null +++ b/components/crowdfunding-redesign/Accounts.tsx @@ -0,0 +1,448 @@ +import React from 'react'; +import { gql, useQuery } from '@apollo/client'; +import { useRouter } from 'next/router'; +import { FormattedMessage } from 'react-intl'; +import { z } from 'zod'; + +import { API_V2_CONTEXT } from '../../lib/graphql/helpers'; +import useQueryFilter from '../../lib/hooks/useQueryFilter'; +import { getDashboardRoute } from '../../lib/url-helpers'; + +import { DashboardContext } from '../dashboard/DashboardContext'; +import DashboardHeader from '../dashboard/DashboardHeader'; +import { childAccountFilter } from '../dashboard/filters/ChildAccountFilter'; +import { Filterbar } from '../dashboard/filters/Filterbar'; +import { periodCompareFilter } from '../dashboard/filters/PeriodCompareFilter'; +import { Accounts } from '../dashboard/sections/overview/Accounts'; +import AccountTable from '../dashboard/sections/overview/AccountTable'; +import type { MetricProps } from '../dashboard/sections/overview/Metric'; +import { Metric } from '../dashboard/sections/overview/Metric'; +import { overviewMetricsQuery } from '../dashboard/sections/overview/queries'; +import MessageBoxGraphqlError from '../MessageBoxGraphqlError'; +import { Account, TimeUnit } from '../../lib/graphql/types/v2/graphql'; +import { accountHoverCardFields } from '../AccountHoverCard'; +import AccountsList from './AccountsList'; +import { ProfileMetric } from './ProfileMetric'; +import clsx from 'clsx'; +import ComparisonChart from '../dashboard/sections/overview/ComparisonChart'; +import { PeriodFilterCompare, PeriodFilterType } from '../dashboard/filters/PeriodCompareFilter/schema'; +import { ArrowLeft, ChevronRight } from 'lucide-react'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; +import { TransactionGroups } from './TransactionGroups'; +import Link from '../Link'; + +const profileAccountsQuery = gql` + query MetricsPerAccount( + $slug: String! + $dateFrom: DateTime + $dateTo: DateTime + $compareFrom: DateTime + $compareTo: DateTime + $includeComparison: Boolean! + $includeBalance: Boolean! + $includeSpent: Boolean! + $includeReceived: Boolean! + $includeBalanceTimeseries: Boolean! + $includeReceivedTimeseries: Boolean! + $timeUnit: TimeUnit + $includeChildren: Boolean! + ) { + account(slug: $slug) { + id + name + type + + totalBalance: stats { + id + current: balance(includeChildren: $includeChildren, dateTo: $dateTo) { + currency + valueInCents + } + comparison: balance(includeChildren: $includeChildren, dateTo: $compareTo) @include(if: $includeComparison) { + currency + valueInCents + } + } + + balanceTimeseries: stats @include(if: $includeBalanceTimeseries) { + id + current: balanceTimeSeries( + includeChildren: $includeChildren + dateFrom: $dateFrom + dateTo: $dateTo + timeUnit: $timeUnit + ) { + dateTo + dateFrom + timeUnit + nodes { + date + amount { + currency + value + } + } + } + comparison: balanceTimeSeries( + includeChildren: $includeChildren + dateFrom: $compareFrom + dateTo: $compareTo + timeUnit: $timeUnit + ) @include(if: $includeComparison) { + dateTo + dateFrom + timeUnit + nodes { + date + amount { + currency + value + } + } + } + } + + totalSpent: stats { + id + current: totalAmountSpent(includeChildren: $includeChildren, dateFrom: $dateFrom, dateTo: $dateTo, net: true) { + currency + valueInCents + } + comparison: totalAmountSpent( + includeChildren: $includeChildren + dateFrom: $compareFrom + dateTo: $compareTo + net: true + ) @include(if: $includeComparison) { + currency + valueInCents + } + } + totalReceived: stats { + id + current: totalAmountReceived( + includeChildren: $includeChildren + dateFrom: $dateFrom + dateTo: $dateTo + net: true + ) { + currency + valueInCents + } + comparison: totalAmountReceived( + includeChildren: $includeChildren + dateFrom: $compareFrom + dateTo: $compareTo + net: true + ) @include(if: $includeComparison) { + currency + valueInCents + } + } + + receivedTimeseries: stats @include(if: $includeReceivedTimeseries) { + id + current: totalAmountReceivedTimeSeries( + includeChildren: $includeChildren + dateFrom: $dateFrom + dateTo: $dateTo + timeUnit: $timeUnit + net: true + ) { + dateTo + dateFrom + timeUnit + nodes { + date + amount { + currency + value + } + } + } + comparison: totalAmountReceivedTimeSeries( + includeChildren: $includeChildren + dateFrom: $compareFrom + dateTo: $compareTo + timeUnit: $timeUnit + net: true + ) @include(if: $includeComparison) { + dateTo + dateFrom + timeUnit + nodes { + date + amount { + currency + value + } + } + } + } + + ...AccountMetrics + + childrenAccounts { + totalCount + nodes { + id + ...AccountMetrics + } + } + } + } + fragment AccountMetrics on Account { + ...AccountHoverCardFields + balance: stats @include(if: $includeBalance) { + id + current: balance(dateTo: $dateTo) { + currency + valueInCents + } + # comparison: balance(dateTo: $compareTo) @include(if: $includeComparison) { + # currency + # valueInCents + # } + } + spent: stats @include(if: $includeSpent) { + id + current: totalAmountSpent(dateFrom: $dateFrom, dateTo: $dateTo, net: true) { + currency + valueInCents + } + # comparison: totalAmountSpent(dateFrom: $compareFrom, dateTo: $compareTo, net: true) + # @include(if: $includeComparison) { + # currency + # valueInCents + # } + } + received: stats @include(if: $includeReceived) { + id + current: totalAmountReceived(dateFrom: $dateFrom, dateTo: $dateTo, net: true) { + currency + valueInCents + } + # comparison: totalAmountReceived(dateFrom: $compareFrom, dateTo: $compareTo, net: true) + # @include(if: $includeComparison) { + # currency + # valueInCents + # } + } + } + ${accountHoverCardFields} +`; + +export const schema = z.object({ + period: periodCompareFilter.schema.default({ + type: PeriodFilterType.ALL_TIME, + compare: PeriodFilterCompare.NO_COMPARISON, + timeUnit: TimeUnit.MONTH, + }), + as: z.string().optional(), + account: childAccountFilter.schema, + metric: z.coerce.string().nullable().default('balance'), +}); + +export function ProfileAccounts() { + const router = useRouter(); + const accountSlug = router.query.accountSlug ?? router.query.collectiveSlug; + const queryFilter = useQueryFilter({ + schema, + skipRouter: true, + toVariables: { + period: periodCompareFilter.toVariables, + account: childAccountFilter.toVariables, + as: slug => ({ slug }), + metric: subpath => { + const include = { + includeReceived: false, + includeReceivedTimeseries: false, + includeBalance: false, + includeBalanceTimeseries: false, + includeSpent: false, + includeContributionsCount: false, + }; + switch (subpath) { + case 'received': + return { + ...include, + includeReceived: true, + includeReceivedTimeseries: true, + }; + + case 'spent': + return { + ...include, + includeSpent: true, + includeReceivedTimeseries: true, + }; + case 'balance': + default: + return { + ...include, + includeBalance: true, + includeBalanceTimeseries: true, + }; + } + }, + }, + filters: { + period: periodCompareFilter.filter, + }, + meta: { + accountSlug, + }, + }); + + const { data, loading, error } = useQuery(profileAccountsQuery, { + variables: { + slug: accountSlug, + ...queryFilter.variables, + includeChildren: !router.query.accountSlug, + }, + fetchPolicy: 'cache-and-network', + context: API_V2_CONTEXT, + }); + + if (error) { + return ; + } + + const metrics: MetricProps[] = [ + { + id: 'balance', + // className: 'col-span-1 row-span-2', + label: , + helpLabel: ( + + ), + timeseries: { ...data?.account.balanceTimeseries, currency: data?.account.totalBalance?.current?.currency }, + amount: data?.account.totalBalance, + // showCurrencyCode: , + isSnapshot: true, + showTimeSeries: true, + }, + { + id: 'received', + label: , + helpLabel: , + amount: data?.account.totalReceived, + timeseries: { ...data?.account.receivedTimeseries, currency: data?.account.totalReceived?.current?.currency }, + }, + { + id: 'spent', + label: , + helpLabel: , + amount: data?.account.totalSpent, + timeseries: { ...data?.account.receivedTimeseries, currency: data?.account.totalReceived?.current?.currency }, + }, + ]; + + const metric = metrics.find(m => m.id === queryFilter.values.metric) ?? metrics[0]; + + // if (queryFilter.values.subpath) { + // const metric = metrics.find(m => m.id === queryFilter.values.subpath); + // if (metric) { + // return ( + //
+ // } + // subpathTitle={metric.label} + // titleRoute={getDashboardRoute(account, 'overview')} + // /> + + // + + // + // + // + //
+ // ); + // } + // } + + return ( +
+
+ {router.query.accountSlug && ( +
+ {/*
+ + + +
*/} + {/* + Back to overview + */} +
+
+

{data?.account?.name}

+ + {data?.account?.type === 'COLLECTIVE' + ? 'Main account' + : data?.account?.type === 'EVENT' + ? 'Event' + : 'Project'} + +
+
+ + +
+
+

{data?.account.description}

+
+ )} + + +
+
+
+ {metrics + .filter(metric => !metric.hide) + .map((metric, i) => ( + queryFilter.setFilter('metric', metric.id)} + /> + ))} +
+
+ +
+ {metric.timeseries.current && } +
+
+
+ {router.query.accountSlug ? ( + + ) : ( + + )} +
+ ); +} diff --git a/components/crowdfunding-redesign/AccountsList.tsx b/components/crowdfunding-redesign/AccountsList.tsx new file mode 100644 index 00000000000..51e3a92dcea --- /dev/null +++ b/components/crowdfunding-redesign/AccountsList.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import type { Column, ColumnDef, Row, TableMeta } from '@tanstack/react-table'; +import clsx from 'clsx'; +import { isNil, omit } from 'lodash'; +import { ArrowDown10, ArrowDownZA, ArrowUp10, ArrowUpZA } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import type { + AccountMetricsFragment, + Currency, + OverviewMetricsQueryVariables, +} from '../../lib/graphql/types/v2/graphql'; +import type { useQueryFilterReturnType } from '../../lib/hooks/useQueryFilter'; +import { getCollectivePageRoute } from '../../lib/url-helpers'; + +import { AccountHoverCard } from '../AccountHoverCard'; +import Avatar from '../Avatar'; +import type { schema } from '../dashboard/sections/overview/CollectiveOverview'; +import type { MetricProps } from '../dashboard/sections/overview/Metric'; +import { ChangeBadge, getPercentageDifference } from '../dashboard/sections/overview/Metric'; +import FormattedMoneyAmount from '../FormattedMoneyAmount'; +import Link from '../Link'; +import { DataTable } from '../table/DataTable'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; +import { Checkbox } from '../ui/Checkbox'; +import { AccountsSublist } from './AccountsSublist'; + +export default function AccountsList({ data, queryFilter, loading, metric }) { + const currency = data?.account?.[metric.id]?.current?.currency; + + const meta = { + queryFilter, + currency: currency, + isAmount: !!metric.amount, + metric, + }; + + // if (error) { + // return ; + // } + + return ( +
+ + + + +
+ ); +} diff --git a/components/crowdfunding-redesign/AccountsSublist.tsx b/components/crowdfunding-redesign/AccountsSublist.tsx new file mode 100644 index 00000000000..c4673aa84af --- /dev/null +++ b/components/crowdfunding-redesign/AccountsSublist.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import type { Column, ColumnDef, Row, TableMeta } from '@tanstack/react-table'; +import clsx from 'clsx'; +import { isNil, omit } from 'lodash'; +import { ArrowDown10, ArrowDownZA, ArrowUp10, ArrowUpZA, ChevronRight } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import type { + AccountMetricsFragment, + Currency, + OverviewMetricsQueryVariables, +} from '../../lib/graphql/types/v2/graphql'; +import type { useQueryFilterReturnType } from '../../lib/hooks/useQueryFilter'; +import { getCollectivePageRoute } from '../../lib/url-helpers'; + +import { AccountHoverCard } from '../AccountHoverCard'; +import Avatar from '../Avatar'; +import type { schema } from '../dashboard/sections/overview/CollectiveOverview'; +import type { MetricProps } from '../dashboard/sections/overview/Metric'; +import { ChangeBadge, getPercentageDifference } from '../dashboard/sections/overview/Metric'; +import FormattedMoneyAmount from '../FormattedMoneyAmount'; +import Link from '../Link'; +import { DataTable } from '../table/DataTable'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; +import { Checkbox } from '../ui/Checkbox'; +import { useRouter } from 'next/router'; +type AccountMetricsRow = AccountMetricsFragment & { + current: number; + comparison?: number; + percentageDifference?: number; +}; + +interface AccountMetricsMeta extends TableMeta { + currency: Currency; + isAmount: boolean; + queryFilter: useQueryFilterReturnType; + metric: MetricProps; +} + +const SortableHeader = ({ + column, + label, + type, + align, +}: { + column: Column; + label: React.ReactNode; + type?: 'alphabetic' | 'numerical'; + align?: 'left' | 'right'; +}) => { + const isSorted = column.getIsSorted(); + const isSortedDesc = isSorted === 'desc'; + const UpIcon = type === 'alphabetic' ? ArrowUpZA : ArrowUp10; + const DownIcon = type === 'alphabetic' ? ArrowDownZA : ArrowDown10; + const SortIcon = isSortedDesc || !isSorted ? UpIcon : DownIcon; + return ( +
+ +
+ ); +}; +const columns: ColumnDef[] = [ + { + id: 'name', + accessorKey: 'name', + header: ({ column }) => ( + } + align="left" + /> + ), + meta: { className: 'min-w-0 max-w-[300px]' }, + + cell: ({ row, table }) => { + const account = row.original; + // const { queryFilter } = table.options.meta as AccountMetricsMeta; + // const selectedAccountSlug = queryFilter.values.account; + return ( +
+
+ + + {account.name} + +
+ } + /> + + {account.isArchived && ( + + Archived {account.type.toLowerCase()} + + )} +
+ + ); + }, + }, + { + id: 'current', + accessorKey: 'current', + meta: { className: 'text-right' }, + header: ({ column, table }) => { + const meta = table.options.meta as AccountMetricsMeta; + return ; + }, + sortingFn: (rowA: Row, rowB: Row): number => { + const a = rowA.original.current; + const b = rowB.original.current; + + const diff = a - b; + + // sort by comparison value if current is the same + if (diff === 0) { + const rowAPrevious = rowA.original.comparison; + const rowBPrevious = rowB.original.comparison; + return rowAPrevious - rowBPrevious; + } + return a - b; + }, + cell: ({ cell, table }) => { + const current = cell.getValue() as number; + const meta = table.options.meta as AccountMetricsMeta; + + return ( +
+ + {meta.isAmount ? ( + + ) : ( + current.toLocaleString() + )} + + +
+ ); + }, + }, +]; + +export function AccountsSublist({ label, type, data, metric, meta }) { + const router = useRouter(); + const columnData: AccountMetricsRow[] = React.useMemo(() => { + const nodes = data + ? [omit(data?.account, 'childrenAccounts'), ...(data?.account.childrenAccounts.nodes ?? [])] + : []; + const filteredNodes = nodes.filter(node => node.type === type); + + return filteredNodes.map(node => { + const current = node[metric.id].current.valueInCents ?? node[metric.id].current; + const comparison = node[metric.id].comparison?.valueInCents ?? node[metric.id].comparison; + return { + ...node, + current: Math.abs(current), + comparison: !isNil(comparison) ? Math.abs(comparison) : undefined, + percentageDifference: getPercentageDifference(current, comparison), + }; + }); + }, [metric.id, data]); + return ( +
+

{label}

+
+ {columnData + .sort((a, b) => b.current - a.current) + .map(account => ( + +
{account.name}
+
+
+ +
+ +
+ + ))} +
+ + {/* */} +
+ ); +} diff --git a/components/crowdfunding-redesign/Breadcrumb.tsx b/components/crowdfunding-redesign/Breadcrumb.tsx new file mode 100644 index 00000000000..e5419e767b6 --- /dev/null +++ b/components/crowdfunding-redesign/Breadcrumb.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'next/router'; +import React from 'react'; +import Link from '../Link'; +import { Slash } from 'lucide-react'; + +const getPathdata = (router, collective, account) => { + switch (router.pathname) { + case '/preview/[collectiveSlug]/finances/[accountSlug]': + return [{ href: `/preview/${router.query.collectiveSlug}/finances`, label: 'Finances' }]; + case '/preview/[collectiveSlug]/transactions/[groupId]': + return [ + { href: `/preview/${router.query.collectiveSlug}/finances`, label: 'Finances' }, + { + href: `/preview/${router.query.collectiveSlug}/finances/${router.query.collectiveSlug}`, + label: collective?.name, + }, + ]; + case '/preview/[collectiveSlug]/[accountSlug]/transactions/[groupId]': + return [ + { href: `/preview/${router.query.collectiveSlug}/finances`, label: 'Finances' }, + { + href: `/preview/${router.query.collectiveSlug}/finances/${router.query.accountSlug}`, + label: account?.name, + }, + ]; + case '/preview/[collectiveSlug]/projects/[accountSlug]': + return [{ href: `/preview/${router.query.collectiveSlug}/projects`, label: 'Projects' }]; + case '/preview/[collectiveSlug]/events/[accountSlug]': + return [{ href: `/preview/${router.query.collectiveSlug}/events`, label: 'Events' }]; + default: + return [{ href: '', label: '' }]; + } +}; + +export function Breadcrumb({ breadcrumbs }) { + return ( +
+ + + {breadcrumbs?.map(({ href, label }, i, a) => { + if (i === a.length - 1) { + return ( + + {label} + + ); + } + return ( + + + {label} + + + + ); + })} +
+ ); +} diff --git a/components/crowdfunding-redesign/CollectiveHeader.tsx b/components/crowdfunding-redesign/CollectiveHeader.tsx new file mode 100644 index 00000000000..65a8f96e17b --- /dev/null +++ b/components/crowdfunding-redesign/CollectiveHeader.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { gql, useQuery } from '@apollo/client'; +import { useRouter } from 'next/router'; +import { API_V2_CONTEXT } from '../../lib/graphql/helpers'; +import Link from '../Link'; +import Avatar from '../Avatar'; + +const collectiveHeaderQuery = gql` + query CollectiveHeader($slug: String!) { + account(slug: $slug) { + id + slug + name + type + ... on AccountWithParent { + parent { + id + slug + name + } + } + } + } +`; +export function CollectiveHeader() { + const router = useRouter(); + const { data, loading } = useQuery(collectiveHeaderQuery, { + variables: { slug: router.query.accountSlug }, + context: API_V2_CONTEXT, + }); + return ( +
+
+ + {data?.account.name} + +
+
+ ); +} diff --git a/components/crowdfunding-redesign/ContentOverview.tsx b/components/crowdfunding-redesign/ContentOverview.tsx new file mode 100644 index 00000000000..9c08d3f3c9b --- /dev/null +++ b/components/crowdfunding-redesign/ContentOverview.tsx @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from 'react'; +import { cva } from 'class-variance-authority'; +// eslint-disable-next-line no-restricted-imports +import Link from 'next/link'; +import { triggerPrototypeToast } from './helpers'; + +export const ContentOverview = ({ content }) => { + const [headings, setHeadings] = useState([]); + + useEffect(() => { + const parser = new DOMParser(); + const doc = parser.parseFromString(content, 'text/html'); + const headingElements = doc.querySelectorAll('h3'); + const headingTexts = Array.from(headingElements).map(h3 => h3.textContent?.trim() || ''); + setHeadings(headingTexts); + }, [content]); + + const linkClasses = cva('px-2 font-semibold block hover:text-primary text-sm border-l-[3px]', { + variants: { + active: { + true: 'border-primary/70', + false: 'border-transparent', + }, + }, + defaultVariants: { + active: false, + }, + }); + + return ( +
+ {headings.map(heading => ( + + {heading} + + ))} +
+ ); +}; diff --git a/components/crowdfunding-redesign/DumbTabs.tsx b/components/crowdfunding-redesign/DumbTabs.tsx new file mode 100644 index 00000000000..15c17cfb037 --- /dev/null +++ b/components/crowdfunding-redesign/DumbTabs.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { cn } from '../../lib/utils'; +import Link from 'next/link'; + +const TabsList = ({ className, centered, ...props }) => ( +
+); + +const TabsTrigger = ({ className, children, href, value, activeTab, count, ...props }) => ( + + {children} {count > 0 &&
{count}
} + +); + +export { TabsList, TabsTrigger }; diff --git a/components/crowdfunding-redesign/Footer.tsx b/components/crowdfunding-redesign/Footer.tsx index 1e40c5db1c3..25a8d835791 100644 --- a/components/crowdfunding-redesign/Footer.tsx +++ b/components/crowdfunding-redesign/Footer.tsx @@ -5,21 +5,21 @@ import Link from '../Link'; import { Separator } from '../ui/Separator'; export const Footer = ({ account }) => { - const mainAccount = account.parent ?? account; + const mainAccount = account?.parent ?? account; return (