Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary This PR adds a comprehensive billing usage analytics feature that provides detailed breakdowns of credit consumption across execution types, users, resources, and time periods. The implementation includes a new ClickHouse-backed analytics service, GraphQL API endpoint, and a frontend dashboard component. ## Key Changes ### Backend - **New BillingAnalyticsService**: Queries ClickHouse for usage breakdowns by user, resource, execution type, and time series data - **BillingEventWriterService**: Writes billing events to ClickHouse for analytics while maintaining best-effort semantics (never blocks Stripe billing) - **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for storing detailed billing event data - **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates usage data for the current billing period, protected by feature flag and billing permissions - **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track per-user credit consumption - **AI Billing Integration**: Updated AI billing service to pass `userWorkspaceId` when recording usage events ### Frontend - **SettingsBillingAnalyticsSection**: New component displaying: - Usage breakdown by execution type with progress bars - Daily usage time series chart (28-day view) - Per-user credit consumption breakdown - Per-resource (agent/workflow) credit consumption breakdown - **SettingsUsage Page**: Dedicated page for viewing usage analytics - **GraphQL Query**: `GetBillingAnalytics` query with generated hooks - **Navigation**: Added Usage menu item in settings (feature-flagged) - **Mock Data**: Included screenshot mock data for preview/testing ### Feature Flag - Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility and access to analytics features ## Implementation Details - Analytics data is queried in parallel for performance - ClickHouse writes are non-blocking to ensure billing operations never fail - Progress bars use dynamic coloring from a predefined palette - Time series visualization normalizes bar heights relative to max value - Empty state handling when no analytics data is available - Responsive UI with proper text truncation for long names https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,11 @@ module.exports = {
|
||||
'./src/modules/views/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/ai/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/applications/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/application-variables/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/workspace/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/workspace-member/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/workspace-invitation/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/billing/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/settings/**/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/logic-functions/graphql/**/*.{ts,tsx}',
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
import { AIChatBanner } from '@/ai/components/AIChatBanner';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
agentChatUsageState,
|
||||
type AgentChatLastMessageUsage,
|
||||
} from '@/ai/states/agentChatUsageState';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { formatNumber } from '~/utils/format/formatNumber';
|
||||
|
||||
@@ -259,6 +259,18 @@ const SettingsBilling = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsUsage = lazy(() =>
|
||||
import('~/pages/settings/SettingsUsage').then((module) => ({
|
||||
default: module.SettingsUsage,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsUsageUserDetail = lazy(() =>
|
||||
import('~/pages/settings/SettingsUsageUserDetail').then((module) => ({
|
||||
default: module.SettingsUsageUserDetail,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsObjects = lazy(() =>
|
||||
import('~/pages/settings/data-model/SettingsObjects').then((module) => ({
|
||||
default: module.SettingsObjects,
|
||||
@@ -529,6 +541,19 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
element={<SettingsLogicFunctionDetail />}
|
||||
/>
|
||||
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
|
||||
<Route
|
||||
element={
|
||||
<SettingsProtectedRouteWrapper
|
||||
requiredFeatureFlag={FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
|
||||
<Route
|
||||
path={SettingsPath.UsageUserDetail}
|
||||
element={<SettingsUsageUserDetail />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={SettingsPath.Subdomain}
|
||||
element={<SettingsSubdomainPage />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessio
|
||||
import { returnToPathState } from '@/auth/states/returnToPathState';
|
||||
import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
||||
import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const billingCheckoutSessionState =
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
|
||||
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
|
||||
import { returnToPathState } from '@/auth/states/returnToPathState';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
|
||||
import { BILLING_CHECKOUT_SESSION_DEFAULT_VALUE } from '@/settings/billing/constants/BillingCheckoutSessionDefaultValue';
|
||||
import { useHandleCheckoutSession } from '@/settings/billing/hooks/useHandleCheckoutSession';
|
||||
import { InformationBanner } from '@/information-banner/components/InformationBanner';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
+1
@@ -168,6 +168,7 @@ export const useSaveLayoutCustomization = () => {
|
||||
saveFieldsWidgetGroups,
|
||||
exitLayoutCustomizationMode,
|
||||
enqueueErrorSnackBar,
|
||||
isRecordPageLayoutEditingEnabled,
|
||||
store,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type Opportunity = {
|
||||
__typename: 'Opportunity';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 365 KiB |
+6
-6
@@ -1,22 +1,22 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsBillingCreditsSection } from '@/billing/components/SettingsBillingCreditsSection';
|
||||
import { SettingsBillingSubscriptionInfo } from '@/billing/components/SettingsBillingSubscriptionInfo';
|
||||
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
|
||||
import { SettingsBillingCreditsSection } from '@/settings/billing/components/SettingsBillingCreditsSection';
|
||||
import { SettingsBillingSubscriptionInfo } from '@/settings/billing/components/SettingsBillingSubscriptionInfo';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconCircleX, IconCreditCard } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
BillingPortalSessionDocument,
|
||||
SubscriptionStatus,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const SettingsBillingContent = () => {
|
||||
const { t } = useLingui();
|
||||
+39
-11
@@ -1,21 +1,33 @@
|
||||
import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState';
|
||||
import { MeteredPriceSelector } from '@/billing/components/internal/MeteredPriceSelector';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import { useBillingWording } from '@/billing/hooks/useBillingWording';
|
||||
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { MeteredPriceSelector } from '@/settings/billing/components/internal/MeteredPriceSelector';
|
||||
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
|
||||
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { formatToShortNumber } from 'twenty-shared/utils';
|
||||
import { H2Title, HorizontalSeparator } from 'twenty-ui/display';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { formatToShortNumber, getSettingsPath } from 'twenty-shared/utils';
|
||||
import { H2Title, HorizontalSeparator, IconChartBar } from 'twenty-ui/display';
|
||||
import { ProgressBar } from 'twenty-ui/feedback';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { SubscriptionStatus } from '~/generated-metadata/graphql';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
SubscriptionStatus,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledCreditUsageFooterActions = styled.div`
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
export const SettingsBillingCreditsSection = ({
|
||||
currentBillingSubscription,
|
||||
@@ -32,6 +44,10 @@ export const SettingsBillingCreditsSection = ({
|
||||
|
||||
const { getCurrentMeteredPricesByInterval } = useCurrentMetered();
|
||||
|
||||
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
);
|
||||
|
||||
const { getIntervalLabel } = useBillingWording();
|
||||
|
||||
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
|
||||
@@ -127,6 +143,18 @@ export const SettingsBillingCreditsSection = ({
|
||||
</>
|
||||
)}
|
||||
</SubscriptionInfoContainer>
|
||||
|
||||
{isUsageAnalyticsEnabled && (
|
||||
<StyledCreditUsageFooterActions>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.Usage)}>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</StyledCreditUsageFooterActions>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<MeteredPriceSelector
|
||||
+14
-14
@@ -1,26 +1,26 @@
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import {
|
||||
SubscriptionInfoHeaderRow,
|
||||
SubscriptionInfoRowContainer,
|
||||
} from '@/billing/components/internal/SubscriptionInfoRowContainer';
|
||||
} from '@/settings/billing/components/internal/SubscriptionInfoRowContainer';
|
||||
|
||||
import {
|
||||
type CurrentWorkspace,
|
||||
currentWorkspaceState,
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
|
||||
import { PlansTags } from '@/billing/components/internal/PlansTags';
|
||||
import { useBillingWording } from '@/billing/hooks/useBillingWording';
|
||||
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
|
||||
import { useCurrentPlan } from '@/billing/hooks/useCurrentPlan';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useHasNextBillingPhase } from '@/billing/hooks/useHasNextBillingPhase';
|
||||
import { useNextBillingPhase } from '@/billing/hooks/useNextBillingPhase';
|
||||
import { useNextBillingSeats } from '@/billing/hooks/useNextBillingSeats';
|
||||
import { useNextPlan } from '@/billing/hooks/useNextPlan';
|
||||
import { useSplitPhaseItemsInPrices } from '@/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { PlansTags } from '@/settings/billing/components/internal/PlansTags';
|
||||
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
|
||||
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useHasNextBillingPhase } from '@/settings/billing/hooks/useHasNextBillingPhase';
|
||||
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
|
||||
import { useNextBillingSeats } from '@/settings/billing/hooks/useNextBillingSeats';
|
||||
import { useNextPlan } from '@/settings/billing/hooks/useNextPlan';
|
||||
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
+1
@@ -5,6 +5,7 @@ const StyledSubscriptionInfoContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useBillingWording } from '@/billing/hooks/useBillingWording';
|
||||
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import {
|
||||
type BillingPriceTiers,
|
||||
type MeteredBillingPrice,
|
||||
} from '@/billing/types/billing-price-tiers.type';
|
||||
} from '@/settings/billing/types/billing-price-tiers.type';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { BILLING_PRICE_METERED_FRAGMENT } from '@/billing/graphql/fragments/billingPriceMeteredFragment';
|
||||
import { BILLING_PRICE_LICENSED_FRAGMENT } from '@/billing/graphql/fragments/billingPriceLicensedFragment';
|
||||
import { BILLING_PRICE_METERED_FRAGMENT } from '@/settings/billing/graphql/fragments/billingPriceMeteredFragment';
|
||||
import { BILLING_PRICE_LICENSED_FRAGMENT } from '@/settings/billing/graphql/fragments/billingPriceLicensedFragment';
|
||||
|
||||
export const LIST_PLANS = gql`
|
||||
query listPlans {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useAllBillingPrices } from '@/billing/hooks/useAllBillingPrices';
|
||||
import { useAllBillingPrices } from '@/settings/billing/hooks/useAllBillingPrices';
|
||||
|
||||
describe('useAllBillingPrices', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
|
||||
describe('useBaseLicensedPriceByPlanKeyAndInterval', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useBaseProductByPlanKey } from '@/billing/hooks/useBaseProductByPlanKey';
|
||||
import { useBaseProductByPlanKey } from '@/settings/billing/hooks/useBaseProductByPlanKey';
|
||||
|
||||
describe('useBaseProductByPlanKey', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useBillingWording } from '@/billing/hooks/useBillingWording';
|
||||
import { useBillingWording } from '@/settings/billing/hooks/useBillingWording';
|
||||
|
||||
describe('useBillingWording', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
|
||||
|
||||
describe('useCurrentBillingFlags', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
|
||||
describe('useCurrentMetered', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useCurrentPlan } from '@/billing/hooks/useCurrentPlan';
|
||||
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
|
||||
|
||||
describe('useCurrentPlan', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
import { useEndSubscriptionTrialPeriod } from '@/settings/billing/hooks/useEndSubscriptionTrialPeriod';
|
||||
|
||||
describe('useEndSubscriptionTrialPeriod', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useFormatPrices } from '@/billing/hooks/useFormatPrices';
|
||||
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
|
||||
|
||||
describe('useFormatPrices', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/settings/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
|
||||
describe('useGetWorkflowNodeExecutionUsage', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
|
||||
import { useHandleCheckoutSession } from '@/settings/billing/hooks/useHandleCheckoutSession';
|
||||
|
||||
describe('useHandleCheckoutSession', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useHasNextBillingPhase } from '@/billing/hooks/useHasNextBillingPhase';
|
||||
import { useHasNextBillingPhase } from '@/settings/billing/hooks/useHasNextBillingPhase';
|
||||
|
||||
describe('useHasNextBillingPhase', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useListProducts } from '@/billing/hooks/useListProducts';
|
||||
import { useListProducts } from '@/settings/billing/hooks/useListProducts';
|
||||
|
||||
describe('useListProducts', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useNextBillingPhase } from '@/billing/hooks/useNextBillingPhase';
|
||||
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
|
||||
|
||||
describe('useNextBillingPhase', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useNextBillingSeats } from '@/billing/hooks/useNextBillingSeats';
|
||||
import { useNextBillingSeats } from '@/settings/billing/hooks/useNextBillingSeats';
|
||||
|
||||
describe('useNextBillingSeats', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useNextPlan } from '@/billing/hooks/useNextPlan';
|
||||
import { useNextPlan } from '@/settings/billing/hooks/useNextPlan';
|
||||
|
||||
describe('useNextPlan', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePlanByPlanKey } from '@/billing/hooks/usePlanByPlanKey';
|
||||
import { usePlanByPlanKey } from '@/settings/billing/hooks/usePlanByPlanKey';
|
||||
|
||||
describe('usePlanByPlanKey', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePlanByPriceId } from '@/billing/hooks/usePlanByPriceId';
|
||||
import { usePlanByPriceId } from '@/settings/billing/hooks/usePlanByPriceId';
|
||||
|
||||
describe('usePlanByPriceId', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePlans } from '@/billing/hooks/usePlans';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
|
||||
describe('usePlans', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePriceAndBillingUsageByPriceId } from '@/billing/hooks/usePriceAndBillingUsageByPriceId';
|
||||
import { usePriceAndBillingUsageByPriceId } from '@/settings/billing/hooks/usePriceAndBillingUsageByPriceId';
|
||||
|
||||
describe('usePriceAndBillingUsageByPriceId', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useSplitPhaseItemsInPrices } from '@/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
|
||||
describe('useSplitPhaseItemsInPrices', () => {
|
||||
it('should be a function', () => {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePlans } from '@/billing/hooks/usePlans';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
import {
|
||||
type BillingPriceLicensed,
|
||||
type BillingPriceMetered,
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
type BillingPlanKey,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
import { useBaseProductByPlanKey } from '@/billing/hooks/useBaseProductByPlanKey';
|
||||
import { useBaseProductByPlanKey } from '@/settings/billing/hooks/useBaseProductByPlanKey';
|
||||
|
||||
export const useBaseLicensedPriceByPlanKeyAndInterval = () => {
|
||||
const { getBaseProductByPlanKey } = useBaseProductByPlanKey();
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
type BillingPlanKey,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
import { usePlanByPlanKey } from '@/billing/hooks/usePlanByPlanKey';
|
||||
import { usePlanByPlanKey } from '@/settings/billing/hooks/usePlanByPlanKey';
|
||||
|
||||
export const useBaseProductByPlanKey = () => {
|
||||
const { getPlanByPlanKey } = usePlanByPlanKey();
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { useFormatPrices } from '@/billing/hooks/useFormatPrices';
|
||||
import { useFormatPrices } from '@/settings/billing/hooks/useFormatPrices';
|
||||
import {
|
||||
BillingPlanKey,
|
||||
SubscriptionInterval,
|
||||
@@ -9,9 +9,9 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { beautifyExactDate } from '~/utils/date-utils';
|
||||
import { useCurrentPlan } from '@/billing/hooks/useCurrentPlan';
|
||||
import { useCurrentMetered } from '@/billing/hooks/useCurrentMetered';
|
||||
import { useCurrentBillingFlags } from '@/billing/hooks/useCurrentBillingFlags';
|
||||
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
|
||||
import { useCurrentMetered } from '@/settings/billing/hooks/useCurrentMetered';
|
||||
import { useCurrentBillingFlags } from '@/settings/billing/hooks/useCurrentBillingFlags';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const useBillingWording = () => {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useCurrentPlan } from '@/billing/hooks/useCurrentPlan';
|
||||
import type { MeteredBillingPrice } from '@/billing/types/billing-price-tiers.type';
|
||||
import { useCurrentPlan } from '@/settings/billing/hooks/useCurrentPlan';
|
||||
import type { MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { assertIsDefinedOrThrow, findOrThrow } from 'twenty-shared/utils';
|
||||
import {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
BillingPlanKey,
|
||||
SubscriptionInterval,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
|
||||
export const useFormatPrices = () => {
|
||||
const { getBaseLicensedPriceByPlanKeyAndInterval } =
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { usePlans } from '@/billing/hooks/usePlans';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
|
||||
export const useListProducts = () => {
|
||||
const { listPlans } = usePlans();
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { findOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { useSplitPhaseItemsInPrices } from '@/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { useNextBillingPhase } from '@/billing/hooks/useNextBillingPhase';
|
||||
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
|
||||
|
||||
export const useNextBillingSeats = () => {
|
||||
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { useSplitPhaseItemsInPrices } from '@/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { usePlanByPriceId } from '@/billing/hooks/usePlanByPriceId';
|
||||
import { useSplitPhaseItemsInPrices } from '@/settings/billing/hooks/useSplitPhaseItemsInPrices';
|
||||
import { usePlanByPriceId } from '@/settings/billing/hooks/usePlanByPriceId';
|
||||
|
||||
export const useNextPlan = () => {
|
||||
const { splitedPhaseItemsInPrices } = useSplitPhaseItemsInPrices();
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { useNextBillingPhase } from '@/billing/hooks/useNextBillingPhase';
|
||||
import { usePriceAndBillingUsageByPriceId } from '@/billing/hooks/usePriceAndBillingUsageByPriceId';
|
||||
import { type MeteredBillingPrice } from '@/billing/types/billing-price-tiers.type';
|
||||
import { useNextBillingPhase } from '@/settings/billing/hooks/useNextBillingPhase';
|
||||
import { usePriceAndBillingUsageByPriceId } from '@/settings/billing/hooks/usePriceAndBillingUsageByPriceId';
|
||||
import { type MeteredBillingPrice } from '@/settings/billing/types/billing-price-tiers.type';
|
||||
import {
|
||||
BillingUsageType,
|
||||
type BillingPriceLicensed,
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
|
||||
import { SubscriptionBenefit } from '@/settings/billing/components/SubscriptionBenefit';
|
||||
import { ENTERPRISE_CHECKOUT_SESSION } from '@/settings/enterprise/graphql/queries/enterpriseCheckoutSession';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IconCalendarEvent,
|
||||
IconColorSwatch,
|
||||
type IconComponent,
|
||||
IconChartBar,
|
||||
IconCurrencyDollar,
|
||||
IconDoorEnter,
|
||||
IconHelpCircle,
|
||||
@@ -78,6 +79,9 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
const isApplicationEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_APPLICATION_ENABLED,
|
||||
);
|
||||
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
);
|
||||
const isSupportChatConfigured =
|
||||
supportChat?.supportDriver === 'FRONT' &&
|
||||
isNonEmptyString(supportChat.supportFrontChatId);
|
||||
@@ -161,6 +165,15 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
isHidden:
|
||||
!isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`Usage`,
|
||||
path: SettingsPath.Usage,
|
||||
Icon: IconChartBar,
|
||||
isHidden:
|
||||
!isUsageAnalyticsEnabled ||
|
||||
isBillingEnabled ||
|
||||
!permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`APIs & Webhooks`,
|
||||
path: SettingsPath.ApiWebhooks,
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graph-widget-line-chart/components/GraphWidgetLineChart';
|
||||
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { getOperationTypeLabel } from '@/settings/usage/utils/getOperationTypeLabel';
|
||||
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
|
||||
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
|
||||
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
|
||||
import { UsagePieChart } from '@/settings/usage/components/UsagePieChart';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { GetUsageAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledIconChevronRightContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const StyledLineChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
|
||||
|
||||
export const SettingsUsageAnalyticsSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
|
||||
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
|
||||
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
|
||||
const [userPeriod, setUserPeriod] = useState<PeriodPreset>('30d');
|
||||
const [userSearchTerm, setUserSearchTerm] = useState('');
|
||||
|
||||
const colorRegistry = createGraphColorRegistry(theme.color);
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const typeDates = getPeriodDates(typePeriod);
|
||||
const dailyDates = getPeriodDates(dailyPeriod);
|
||||
const userDates = getPeriodDates(userPeriod);
|
||||
|
||||
const { data: typeData, loading: typeLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: typeDates } },
|
||||
);
|
||||
|
||||
const { data: dailyData, loading: dailyLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: dailyDates } },
|
||||
);
|
||||
|
||||
const { data: userData, loading: userLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: userDates } },
|
||||
);
|
||||
|
||||
const typeAnalytics = typeData?.getUsageAnalytics;
|
||||
const dailyAnalytics = dailyData?.getUsageAnalytics;
|
||||
const userAnalytics = userData?.getUsageAnalytics;
|
||||
|
||||
const usageByOperationType = typeAnalytics?.usageByOperationType ?? [];
|
||||
const timeSeries = dailyAnalytics?.timeSeries ?? [];
|
||||
const usageByUser = userAnalytics?.usageByUser ?? [];
|
||||
|
||||
const anyLoading = typeLoading || dailyLoading || userLoading;
|
||||
|
||||
if (anyLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyData =
|
||||
usageByOperationType.length > 0 ||
|
||||
timeSeries.length > 0 ||
|
||||
usageByUser.length > 0;
|
||||
|
||||
const totalCredits = usageByOperationType.reduce(
|
||||
(sum, item) => sum + item.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const filteredUsageByUser = usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(userSearchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
});
|
||||
|
||||
const pieData = usageByOperationType.map((item, index) => ({
|
||||
id: getOperationTypeLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
color: getColorSchemeByIndex(colorRegistry, index).solid,
|
||||
}));
|
||||
|
||||
const lineData: LineChartSeriesWithColor[] = [
|
||||
{
|
||||
id: 'credits',
|
||||
label: t`Credits`,
|
||||
data: timeSeries.map((point) => ({
|
||||
x: formatDate(point.date, 'MMM d'),
|
||||
y: point.creditsUsed,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage Analytics`}
|
||||
description={t`Credit usage breakdown for your workspace.`}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`No usage data`}
|
||||
value={t`No credit consumption recorded yet.`}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{usageByOperationType.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by Type`}
|
||||
description={t`${formatNumber(totalCredits)} credits`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-type-period"
|
||||
value={typePeriod}
|
||||
options={periodOptions}
|
||||
onChange={setTypePeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<UsagePieChart data={pieData} />
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{timeSeries.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Daily Usage`}
|
||||
description={t`Credit consumption over time.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-daily-period"
|
||||
value={dailyPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setDailyPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledLineChartContainer>
|
||||
<GraphWidgetLineChart
|
||||
id="usage-daily-line-chart"
|
||||
data={lineData}
|
||||
colorMode="automaticPalette"
|
||||
showLegend={false}
|
||||
enableArea
|
||||
/>
|
||||
</StyledLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{usageByUser.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by User`}
|
||||
description={t`Click a user to see their daily breakdown.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-user-period"
|
||||
value={userPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setUserPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledSearchInputContainer>
|
||||
<SearchInput
|
||||
placeholder={t`Search for a user...`}
|
||||
value={userSearchTerm}
|
||||
onChange={setUserSearchTerm}
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
<Table>
|
||||
<TableRow
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader align="right">{t`Credits`}</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
{filteredUsageByUser.map((item) => (
|
||||
<TableRow
|
||||
key={item.key}
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
to={getSettingsPath(SettingsPath.UsageUserDetail, {
|
||||
userWorkspaceId: item.key,
|
||||
})}
|
||||
>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="md"
|
||||
placeholder={item.label ?? item.key}
|
||||
placeholderColorSeed={item.key}
|
||||
/>
|
||||
{item.label ?? item.key}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatNumber(item.creditsUsed)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
type UsagePieChartDatum = {
|
||||
id: string;
|
||||
value: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type UsagePieChartProps = {
|
||||
data: UsagePieChartDatum[];
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const UsagePieChart = ({ data }: UsagePieChartProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ResponsivePie
|
||||
data={data}
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
innerRadius={0.6}
|
||||
padAngle={0.5}
|
||||
cornerRadius={2}
|
||||
colors={data.map((item) => item.color)}
|
||||
enableArcLabels={false}
|
||||
enableArcLinkLabels={true}
|
||||
arcLinkLabelsSkipAngle={10}
|
||||
arcLinkLabelsTextColor={theme.font.color.secondary}
|
||||
arcLinkLabelsColor={{ from: 'color' }}
|
||||
arcLinkLabelsDiagonalLength={10}
|
||||
arcLinkLabelsStraightLength={10}
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<div>{`${String(datum.id)}: ${t`${formatNumber(datum.value)} credits`}`}</div>
|
||||
)}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_USAGE_ANALYTICS = gql`
|
||||
query GetUsageAnalytics($input: UsageAnalyticsInput) {
|
||||
getUsageAnalytics(input: $input) {
|
||||
usageByUser {
|
||||
key
|
||||
label
|
||||
creditsUsed
|
||||
}
|
||||
usageByOperationType {
|
||||
key
|
||||
creditsUsed
|
||||
}
|
||||
timeSeries {
|
||||
date
|
||||
creditsUsed
|
||||
}
|
||||
periodStart
|
||||
periodEnd
|
||||
userDailyUsage {
|
||||
userWorkspaceId
|
||||
dailyUsage {
|
||||
date
|
||||
creditsUsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export const getOperationTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'AI_TOKEN':
|
||||
return t`AI Chat`;
|
||||
case 'WORKFLOW_EXECUTION':
|
||||
return t`Workflow Execution`;
|
||||
case 'CODE_EXECUTION':
|
||||
return t`Code Execution`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
|
||||
|
||||
const PERIOD_DAYS: Record<PeriodPreset, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
export const getPeriodDates = (
|
||||
preset: PeriodPreset,
|
||||
): { periodStart: string; periodEnd: string } => {
|
||||
const now = new Date();
|
||||
|
||||
const end = new Date(now);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - PERIOD_DAYS[preset]);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
return {
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: end.toISOString(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
|
||||
|
||||
export const getPeriodOptions = (): {
|
||||
value: PeriodPreset;
|
||||
label: string;
|
||||
}[] => [
|
||||
{ value: '7d', label: t`Last 7 days` },
|
||||
{ value: '30d', label: t`Last 30 days` },
|
||||
{ value: '90d', label: t`Last 90 days` },
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export type PeriodPreset = '7d' | '30d' | '90d';
|
||||
@@ -18,7 +18,7 @@ type TableCellProps = {
|
||||
const StyledTableCell = styled.div<TableCellProps>`
|
||||
align-items: center;
|
||||
color: ${({ color }) => color || themeCssVariables.font.color.secondary};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'default')};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'inherit')};
|
||||
display: flex;
|
||||
gap: ${({ gap }) => gap ?? 'normal'};
|
||||
height: ${({ height }) => height ?? themeCssVariables.spacing[8]};
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_ITEM_FRAGMENT } from '@/billing/graphql/fragments/billingSubscriptionSchedulePhaseItemFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_FRAGMENT } from '@/billing/graphql/fragments/billingSubscriptionSchedulePhaseFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_ITEM_FRAGMENT } from '@/settings/billing/graphql/fragments/billingSubscriptionSchedulePhaseItemFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_FRAGMENT } from '@/settings/billing/graphql/fragments/billingSubscriptionSchedulePhaseFragment';
|
||||
|
||||
export const BILLING_SUBSCRIPTION_FRAGMENT = gql`
|
||||
fragment BillingSubscriptionFragment on BillingSubscription {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_FRAGMENT } from '@/billing/graphql/fragments/billingSubscriptionSchedulePhaseFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_ITEM_FRAGMENT } from '@/billing/graphql/fragments/billingSubscriptionSchedulePhaseItemFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_FRAGMENT } from '@/settings/billing/graphql/fragments/billingSubscriptionSchedulePhaseFragment';
|
||||
import { BILLING_SUBSCRIPTION_SCHEDULE_PHASE_ITEM_FRAGMENT } from '@/settings/billing/graphql/fragments/billingSubscriptionSchedulePhaseItemFragment';
|
||||
|
||||
export const CURRENT_BILLING_SUBSCRIPTION_FRAGMENT = gql`
|
||||
fragment CurrentBillingSubscriptionFragment on BillingSubscription {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { ChooseYourPlanContent } from '~/pages/onboarding/internal/ChooseYourPlanContent';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { usePlans } from '@/billing/hooks/usePlans';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
|
||||
const StyledChooseYourPlanPlaceholder = styled.div`
|
||||
height: 566px;
|
||||
|
||||
@@ -3,12 +3,12 @@ import { SubTitle } from '@/auth/components/SubTitle';
|
||||
import { Title } from '@/auth/components/Title';
|
||||
import { useAuth } from '@/auth/hooks/useAuth';
|
||||
import { billingCheckoutSessionState } from '@/auth/states/billingCheckoutSessionState';
|
||||
import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit';
|
||||
import { SubscriptionPrice } from '@/billing/components/SubscriptionPrice';
|
||||
import { TrialCard } from '@/billing/components/TrialCard';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
import { useBaseProductByPlanKey } from '@/billing/hooks/useBaseProductByPlanKey';
|
||||
import { useHandleCheckoutSession } from '@/billing/hooks/useHandleCheckoutSession';
|
||||
import { SubscriptionBenefit } from '@/settings/billing/components/SubscriptionBenefit';
|
||||
import { SubscriptionPrice } from '@/settings/billing/components/SubscriptionPrice';
|
||||
import { TrialCard } from '@/settings/billing/components/TrialCard';
|
||||
import { useBaseLicensedPriceByPlanKeyAndInterval } from '@/settings/billing/hooks/useBaseLicensedPriceByPlanKeyAndInterval';
|
||||
import { useBaseProductByPlanKey } from '@/settings/billing/hooks/useBaseProductByPlanKey';
|
||||
import { useHandleCheckoutSession } from '@/settings/billing/hooks/useHandleCheckoutSession';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { SettingsBillingContent } from '@/billing/components/SettingsBillingContent';
|
||||
import { SettingsBillingContent } from '@/settings/billing/components/SettingsBillingContent';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { usePlans } from '@/billing/hooks/usePlans';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
|
||||
export const SettingsBilling = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user