Files
twenty/packages/twenty-front/src/modules/settings/admin-panel/components/SettingsAdminWorkspaceBillingContent.tsx
T
Thomas des Francs 454758471f Fix side panel command menu header controls (#21747)
## Summary

Tested the 3 behaviors of the issue locally. Animation is not perfect on
the closing but I think this is a great v1

- Move the side-panel close action to the right side of the top bar
while keeping back navigation on the left.
- Keep the nav side-panel button as the command-menu entry point for
direct side-panel pages and hide it while command-menu pages/history are
active.
- Reset command-menu search/filter state when opening the root command
menu from the nav button.

Fixes twentyhq/core-team-issues#2504

## Videos

### Before


https://github.com/user-attachments/assets/08c1b6b3-5fbd-4154-a85d-5072a3b7690e

### After


https://github.com/user-attachments/assets/11682dea-f21c-47b5-91a8-869f30b09d96


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21747?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-18 13:30:05 +00:00

427 lines
12 KiB
TypeScript

import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/data-display';
import {
IconBox,
IconCalendarEvent,
IconCalendarRepeat,
IconChartBar,
IconCircleX,
IconCoins,
IconCreditCard,
IconExternalLink,
IconId,
IconStatusChange,
IconTag,
IconUsers,
} from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Section } from 'twenty-ui/layout';
import { type ThemeColor } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { GET_WORKSPACE_BILLING_ADMIN_PANEL } from '@/settings/admin-panel/graphql/queries/getWorkspaceBillingAdminPanel';
import { SettingsTableCard } from '@/settings/components/SettingsTableCard';
import { PlansTags } from '@/settings/billing/components/internal/PlansTags';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { beautifyExactDate } from '~/utils/date-utils';
import { BillingPlanKey } from '~/generated-metadata/graphql';
import {
SubscriptionInterval,
SubscriptionStatus,
type WorkspaceBillingAdminPanelQuery,
} from '~/generated-admin/graphql';
const STRIPE_DASHBOARD_BASE_URL = 'https://dashboard.stripe.com';
const BASE_PRODUCT_KEY = 'BASE_PRODUCT';
const RESOURCE_CREDIT_KEY = 'RESOURCE_CREDIT';
const EM_DASH = '\u2014';
type SettingsAdminWorkspaceBillingContentProps = {
workspaceId: string;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
margin-top: ${themeCssVariables.spacing[6]};
`;
const StyledExternalLink = styled.a`
align-items: center;
color: inherit;
display: inline-flex;
gap: ${themeCssVariables.spacing[1]};
text-decoration: none;
&:hover {
color: ${themeCssVariables.font.color.primary};
}
`;
const StyledMono = styled.span`
font-family: ${themeCssVariables.code.font.family};
`;
const StyledItemValue = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const STATUS_COLORS: Record<SubscriptionStatus, ThemeColor> = {
[SubscriptionStatus.Active]: 'green',
[SubscriptionStatus.Trialing]: 'blue',
[SubscriptionStatus.PastDue]: 'orange',
[SubscriptionStatus.Canceled]: 'red',
[SubscriptionStatus.Unpaid]: 'red',
[SubscriptionStatus.Paused]: 'gray',
[SubscriptionStatus.Incomplete]: 'gray',
[SubscriptionStatus.IncompleteExpired]: 'gray',
};
const STATUS_LABELS: Record<SubscriptionStatus, string> = {
[SubscriptionStatus.Active]: 'Active',
[SubscriptionStatus.Trialing]: 'Trialing',
[SubscriptionStatus.PastDue]: 'Past Due',
[SubscriptionStatus.Canceled]: 'Canceled',
[SubscriptionStatus.Unpaid]: 'Unpaid',
[SubscriptionStatus.Paused]: 'Paused',
[SubscriptionStatus.Incomplete]: 'Incomplete',
[SubscriptionStatus.IncompleteExpired]: 'Incomplete Expired',
};
const formatCurrency = (amountMinor: number, currency: string): string => {
const normalizedCurrency = currency.toUpperCase();
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: normalizedCurrency,
}).format(amountMinor / 100);
} catch {
return `${(amountMinor / 100).toFixed(2)} ${normalizedCurrency}`;
}
};
const toBillingPlanKey = (planKey: string): BillingPlanKey | null =>
planKey === BillingPlanKey.PRO
? BillingPlanKey.PRO
: planKey === BillingPlanKey.ENTERPRISE
? BillingPlanKey.ENTERPRISE
: null;
const StripeLink = ({
path,
id,
}: {
path: 'customers' | 'subscriptions';
id: string;
}) => (
<StyledExternalLink
href={`${STRIPE_DASHBOARD_BASE_URL}/${path}/${id}`}
target="_blank"
rel="noopener noreferrer"
>
<StyledMono>{id}</StyledMono>
<IconExternalLink size={12} />
</StyledExternalLink>
);
export const SettingsAdminWorkspaceBillingContent = ({
workspaceId,
}: SettingsAdminWorkspaceBillingContentProps) => {
const { t } = useLingui();
const { formatNumber } = useNumberFormat();
const apolloAdminClient = useApolloAdminClient();
const { data, loading } = useQuery<WorkspaceBillingAdminPanelQuery>(
GET_WORKSPACE_BILLING_ADMIN_PANEL,
{
client: apolloAdminClient,
variables: { workspaceId },
skip: !workspaceId,
},
);
if (loading) {
return (
<StyledContainer>
<SettingsSectionSkeletonLoader rowCount={6} />
</StyledContainer>
);
}
const billing = data?.workspaceBillingAdminPanel ?? null;
if (!billing) {
return (
<StyledContainer>
<Section>
<H2Title
title={t`Billing`}
description={t`No billing data is available for this workspace.`}
/>
</Section>
</StyledContainer>
);
}
const { stripeCustomerId, creditBalance, subscription, usage } = billing;
const formatCredits = (credits: number): string =>
formatNumber(credits, { abbreviate: true, decimals: 2 });
const customerItems = [
{
Icon: IconId,
label: t`Stripe customer`,
value: isDefined(stripeCustomerId) ? (
<StripeLink path="customers" id={stripeCustomerId} />
) : (
EM_DASH
),
},
{
Icon: IconCoins,
label: t`Credit balance`,
value: isDefined(creditBalance)
? `${formatNumber(creditBalance, { abbreviate: true, decimals: 2 })} ${t`credits`}`
: EM_DASH,
},
];
const intervalLabel =
subscription?.interval === SubscriptionInterval.Month
? t`Monthly`
: subscription?.interval === SubscriptionInterval.Year
? t`Yearly`
: null;
const formatPeriod = (start: string, end: string): string =>
`${beautifyExactDate(start)}${beautifyExactDate(end)}`;
const planKey = isDefined(subscription?.planKey)
? toBillingPlanKey(subscription.planKey)
: null;
const isTrialing = subscription?.status === SubscriptionStatus.Trialing;
const usageItems = isDefined(usage)
? [
{
Icon: IconChartBar,
label: t`Credits used`,
value: `${formatCredits(usage.usedCredits)} / ${formatCredits(usage.totalGrantedCredits)}`,
},
...(!isTrialing
? [
{
Icon: IconCoins,
label: t`Base credits`,
value: formatCredits(usage.grantedCredits),
},
]
: []),
...(usage.rolloverCredits > 0
? [
{
Icon: IconCoins,
label: t`Rollover credits`,
value: formatCredits(usage.rolloverCredits),
},
]
: []),
{
Icon: IconCalendarRepeat,
label: t`Usage period`,
value: formatPeriod(usage.periodStart, usage.periodEnd),
},
]
: [];
const formatItemValue = (
item: NonNullable<typeof subscription>['items'][number],
): string => {
const parts: string[] = [];
if (isDefined(item.quantity)) {
parts.push(`${formatNumber(item.quantity)} ${t`seats`}`);
}
if (isDefined(item.includedCredits)) {
parts.push(
`${formatNumber(item.includedCredits, { abbreviate: true, decimals: 2 })} ${t`credits/period`}`,
);
}
if (isDefined(item.unitAmount) && isDefined(subscription)) {
parts.push(formatCurrency(item.unitAmount, subscription.currency));
}
return parts.length > 0 ? parts.join(' · ') : EM_DASH;
};
const subscriptionItems = subscription
? [
{
Icon: IconCreditCard,
label: t`Stripe subscription`,
value: (
<StripeLink
path="subscriptions"
id={subscription.stripeSubscriptionId}
/>
),
},
{
Icon: IconStatusChange,
label: t`Status`,
value: (
<Tag
color={STATUS_COLORS[subscription.status]}
text={STATUS_LABELS[subscription.status]}
/>
),
},
...(isDefined(planKey)
? [
{
Icon: IconTag,
label: t`Plan`,
value: <PlansTags plan={planKey} isTrialPeriod={isTrialing} />,
},
]
: []),
...(isDefined(intervalLabel)
? [
{
Icon: IconCalendarEvent,
label: t`Billing interval`,
value: intervalLabel,
},
]
: []),
{
Icon: IconCalendarRepeat,
label: t`Current period`,
value: formatPeriod(
subscription.currentPeriodStart,
subscription.currentPeriodEnd,
),
},
...(isDefined(subscription.trialStart) &&
isDefined(subscription.trialEnd)
? [
{
Icon: IconCalendarRepeat,
label: t`Trial period`,
value: formatPeriod(
subscription.trialStart,
subscription.trialEnd,
),
},
]
: []),
...(subscription.cancelAtPeriodEnd
? [
{
Icon: IconCircleX,
label: t`Cancels at period end`,
value: t`Yes`,
},
]
: []),
...(isDefined(subscription.cancelAt)
? [
{
Icon: IconCircleX,
label: t`Cancels at`,
value: beautifyExactDate(subscription.cancelAt),
},
]
: []),
...(isDefined(subscription.canceledAt)
? [
{
Icon: IconCircleX,
label: t`Canceled at`,
value: beautifyExactDate(subscription.canceledAt),
},
]
: []),
...subscription.items.map((item) => ({
Icon:
item.productKey === BASE_PRODUCT_KEY
? IconUsers
: item.productKey === RESOURCE_CREDIT_KEY
? IconCoins
: IconBox,
label: item.productName || t`Unnamed product`,
value: (
<StyledItemValue>
<span>{formatItemValue(item)}</span>
{isDefined(item.productKey) && (
<Tag color="gray" text={item.productKey} />
)}
</StyledItemValue>
),
})),
]
: [];
return (
<StyledContainer>
<Section>
<H2Title
title={t`Customer`}
description={t`Stripe customer linked to this workspace`}
/>
<SettingsTableCard
rounded
items={customerItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`Usage`}
description={
isDefined(usage)
? t`Credit consumption for the current period`
: t`No usage data is available for this workspace.`
}
/>
{isDefined(usage) && (
<SettingsTableCard
rounded
items={usageItems}
gridAutoColumns="3fr 8fr"
/>
)}
</Section>
<Section>
<H2Title
title={t`Subscription`}
description={
subscription
? t`Current subscription state and line items`
: t`No active subscription.`
}
/>
{subscription && (
<SettingsTableCard
rounded
items={subscriptionItems}
gridAutoColumns="3fr 8fr"
/>
)}
</Section>
</StyledContainer>
);
};