diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 38babdba1aee..a98739996f32 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -13,13 +13,13 @@ ## Rules 1. **API Integration**: Use `npx ssg` CLI + check `../api` backend -2. **Imports**: Use `common/`, `components/`, `project/` (NO relative imports) +2. **Imports**: Use the `common/`, `components/`, `project/` aliases when the relative path would go up (`../`); use a relative path for same-folder or descendant imports. This is enforced by `@dword-design/import-alias/prefer-alias`, and `eslint --fix` will rewrite an alias back to relative inside its own alias root (e.g. `components/base/forms/X` becomes `./base/forms/X` in a file under `web/components/`). 3. **State**: Redux Toolkit + RTK Query, store in `common/store.ts` 4. **Feature Flags**: When user says "create a feature flag", you MUST: (1) Create it in Flagsmith using MCP tools (`mcp__flagsmith__create_feature`), (2) Implement code with `useFlags` hook. See `.claude/context/feature-flags/` for details 5. **Linting**: ALWAYS run `npx eslint --fix ` on any files you modify 6. **Type Enums**: Extract inline union types to named types (e.g., `type Status = 'A' | 'B'` instead of inline) 7. **NO FETCH**: NEVER use `fetch()` directly - ALWAYS use RTK Query mutations/queries (inject endpoints into services in `common/services/`), see api-integration context -8. **Component structure**: Each new component lives in its own folder with an `index.ts` barrel - `ComponentName/ComponentName.tsx`, co-located `ComponentName.scss`, any sub-components, and an `index.ts` that re-exports the default (and public types). Import via the folder (`components/.../ComponentName`), never the inner file. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt. +8. **Component structure**: A component with nothing to keep beside it is a single `ComponentName.tsx`. It gets a folder once it has a co-located `ComponentName.scss`, sub-components, tests or hooks: `ComponentName/ComponentName.tsx` plus an `index.ts` re-exporting the default (and public types). Import via `components/.../ComponentName` either way, never the inner file, so promoting a file to a folder changes no imports. Keep files focused (~100 lines as a target); split by concern, not to hit a number. Data tables/constant maps are exempt. ## Key Files - Store: `common/store.ts` diff --git a/frontend/documentation/components/UsageDashboard.stories.tsx b/frontend/documentation/components/UsageDashboard.stories.tsx index 44f2074816b5..e592539090e4 100644 --- a/frontend/documentation/components/UsageDashboard.stories.tsx +++ b/frontend/documentation/components/UsageDashboard.stories.tsx @@ -1,9 +1,14 @@ import { FC, useState } from 'react' import type { Meta, StoryObj } from 'storybook' -import { UsageDashboard } from 'components/pages/usage' +import UsagePageLayout from 'components/pages/usage/components/UsagePageLayout' +import OverLimitBanner from 'components/pages/usage/components/OverLimitBanner' +import SectionHeading from 'components/pages/usage/components/SectionHeading' import UsageBreakdown, { useUsageBreakdown, } from 'components/pages/usage/components/UsageBreakdown' +import UsageMeter from 'components/pages/usage/components/UsageMeter' +import UsageOverTime from 'components/pages/usage/components/UsageOverTime' +import { overLimitNote, overLimitOf } from 'components/pages/usage/overLimit' import { allowanceWindow, contributionNote, @@ -99,10 +104,20 @@ const UsagePage: FC = ({ scenarioFor(billingPeriod, !!empty, isFreePlan), share * scale, ) - const allowanceTotal = toUsageResponse( + const allowance = toUsageResponse( scenarioFor(allowanceWindow(basis), !!empty, isFreePlan), scale, - ).totals.total + ) + const allowanceTotal = allowance.totals.total + const exceeded = overLimitOf(allowanceTotal, limit, allowance) + + const contribution = showsContribution( + basis, + billingPeriod, + filtered ? 1 : undefined, + ) + ? contributionNote(project, scoped.totals.total, allowanceTotal) + : undefined // The note needs the organisation over the period on screen, not over the // allowance window, or a project can read as more than all of it. @@ -114,53 +129,68 @@ const UsagePage: FC = ({ )}` return ( - - } - data={scoped} - filters={ - -
- setProject(option.value)} - options={PROJECTS.map((name) => ({ label: name, value: name }))} - value={{ label: project, value: project }} - /> -
-
- } - hasBillingPeriod={isBillingPeriodSelected(billingPeriod)} + {}} - periodLabel={periodLabel(periods, billingPeriod)} - planCopy={planSectionCopy(basis, limit)} - showPlanCeiling={showsPlanCeiling( - billingPeriod, - filtered ? 1 : undefined, - )} - total={allowanceTotal} - /> + > + {exceeded && } + + + + + + +
+ + setProject(option.value) + } + options={PROJECTS.map((name) => ({ label: name, value: name }))} + value={{ label: project, value: project }} + /> +
+ + } + /> + + + + +
) } @@ -183,6 +213,7 @@ export const PaidApproachingTheLimit: Story = { args: { limit: 1400000, subscription: billed }, } +// Billed on a term, so the banner mentions charges. export const PaidOverTheLimit: Story = { args: { limit: 900000, subscription: billed }, } @@ -203,6 +234,17 @@ export const EnterpriseWithoutABillingPeriod: Story = { }, } +// Invoiced outside Chargebee, so no charge line. +export const EnterpriseOverTheLimit: Story = { + args: { + limit: 1000000, + subscription: subscriptionOf({ + payment_method: 'XERO', + plan: 'enterprise', + }), + }, +} + // On Chargebee, but no period has arrived. Reads differently from invoiced, // because this one may resolve itself. export const ChargebeeWithoutAPeriodYet: Story = { diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index e43a9bf4d433..4b47ac5a1f38 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -30,7 +30,16 @@ import Announcement from './Announcement' import { getBuildVersion } from 'common/services/useBuildVersion' import AccountProvider from 'common/providers/AccountProvider' import Nav from './navigation/Nav' +import { routes } from 'web/routes' import 'project/darkMode' + +// The usage page explains the block, so it stays reachable. Read inside the +// function: web/routes imports this file, so routes is empty at module level. +const isAllowedWhileBlocked = (pathname) => + [routes.organisations, routes['organisation-usage']].some((path) => + matchPath(pathname, { exact: true, path, strict: false }), + ) + const App = class extends Component { static propTypes = { children: propTypes.element.isRequired, @@ -271,9 +280,8 @@ const App = class extends Component { const environmentId = this.getEnvironmentId(this.props) if ( - AccountStore.getOrganisation() && - AccountStore.getOrganisation().block_access_to_admin && - pathname !== '/organisations' + AccountStore.getOrganisation()?.block_access_to_admin && + !isAllowedWhileBlocked(pathname) ) { return } diff --git a/frontend/web/components/pages/usage/UsageDashboard.tsx b/frontend/web/components/pages/usage/UsageDashboard.tsx deleted file mode 100644 index 6402cb0866fc..000000000000 --- a/frontend/web/components/pages/usage/UsageDashboard.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { FC, ReactNode } from 'react' -import { Res } from 'common/types/responses' -import { PlanLimit } from 'components/shared/UsageBar/utils' -import EmptyState from 'components/EmptyState' -import SectionHeading from './components/SectionHeading' -import UsageMeter from './components/UsageMeter' -import UsageOverTime from './components/UsageOverTime' - -export type UsageDashboardProps = { - data: Res['organisationUsage'] | undefined - total: number - limit: PlanLimit - planCopy: { title: string; hint: string } - periodLabel: string - meterNote?: ReactNode - showPlanCeiling?: boolean - hasBillingPeriod: boolean - isError?: boolean - isLoading?: boolean - isExploring?: boolean - onRetry?: () => void - filters?: ReactNode - breakdown?: ReactNode -} - -const UsageDashboard: FC = ({ - breakdown, - data, - filters, - hasBillingPeriod, - isError, - isExploring, - isLoading, - limit, - meterNote, - onRetry, - periodLabel, - planCopy, - showPlanCeiling, - total, -}) => { - let content - - if (isLoading) { - content = ( -
- -
- ) - } else if (isError) { - content = ( - - Try again - - ) - } - /> - ) - } else { - content = ( - <> - - - - - - - {isExploring ? ( -
- -
- ) : ( - <> - - - {breakdown} - - )} - - ) - } - - return ( -
-

Usage

- {content} -
- ) -} - -export default UsageDashboard diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.scss b/frontend/web/components/pages/usage/UsageDashboardPage.scss deleted file mode 100644 index 1d1cc3421b5f..000000000000 --- a/frontend/web/components/pages/usage/UsageDashboardPage.scss +++ /dev/null @@ -1,3 +0,0 @@ -.usage-dashboard__filter { - min-width: 210px; -} diff --git a/frontend/web/components/pages/usage/UsageDashboardPage.tsx b/frontend/web/components/pages/usage/UsageDashboardPage.tsx index 4f1d91b642bf..b9230b0e7547 100644 --- a/frontend/web/components/pages/usage/UsageDashboardPage.tsx +++ b/frontend/web/components/pages/usage/UsageDashboardPage.tsx @@ -3,11 +3,15 @@ import { skipToken } from '@reduxjs/toolkit/query' import Utils, { planNames } from 'common/utils/utils' import { useGetOrganisationQuery } from 'common/services/useOrganisation' import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' -import ProjectFilter from 'components/ProjectFilter' -import { PeriodOption } from 'common/types/requests' +import OverLimitBanner from './components/OverLimitBanner' +import SectionHeading from './components/SectionHeading' import UsageBreakdown, { useUsageBreakdown } from './components/UsageBreakdown' -import UsageDashboard from './UsageDashboard' +import UsageFilters from './components/UsageFilters' +import UsageMeter from './components/UsageMeter' +import UsageOverTime from './components/UsageOverTime' +import UsagePageLayout from './components/UsagePageLayout' import { useUsageData } from './useUsageData' +import { overLimitNote, overLimitOf } from './overLimit' import { isBilledOnAPeriod, isBillingPeriodSelected, @@ -21,7 +25,6 @@ import { usageBasisOf, resolvePeriod, } from './utils' -import './UsageDashboardPage.scss' type UsageDashboardPageProps = { organisationId: number | undefined @@ -68,6 +71,10 @@ const UsageDashboardPage: FC = ({ organisationId ? { id: organisationId } : skipToken, ) + const limit = subscriptionMeta?.max_api_calls + const allowanceTotal = usage.allowance?.totals?.total ?? 0 + const exceeded = overLimitOf(allowanceTotal, limit, usage.allowance) + const periods = periodsFor(planIsBilled) const { setDimension, ...breakdown } = useUsageBreakdown({ @@ -81,71 +88,90 @@ const UsageDashboardPage: FC = ({ .filter(Boolean) .join(' ยท ') + const contribution = + showsContribution(basis, billingPeriod, selectedProjectId) && projectName + ? contributionNote( + projectName, + usage.scoped?.totals?.total ?? 0, + allowanceTotal, + ) + : undefined + + // One line, so being over the limit outranks the project's share. + const meterNote = exceeded ? overLimitNote(exceeded) : contribution + if (!organisationId) { return null } return ( - - } + { refetchOrganisation() refetchLimit() usage.retry() }} - filters={ - -
- onChangePeriod(option.value)} + value={periods.find((option) => option.value === period)} + options={periods} + /> +
+
+ +
+
+) + +export default UsageFilters diff --git a/frontend/web/components/pages/usage/components/UsageFilters/index.ts b/frontend/web/components/pages/usage/components/UsageFilters/index.ts new file mode 100644 index 000000000000..a38e848308c3 --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsageFilters/index.ts @@ -0,0 +1,2 @@ +export { default } from './UsageFilters' +export type { UsageFiltersProps } from './UsageFilters' diff --git a/frontend/web/components/pages/usage/components/UsagePageLayout.tsx b/frontend/web/components/pages/usage/components/UsagePageLayout.tsx new file mode 100644 index 000000000000..e584e68082fe --- /dev/null +++ b/frontend/web/components/pages/usage/components/UsagePageLayout.tsx @@ -0,0 +1,50 @@ +import { FC, ReactNode } from 'react' +import EmptyState from 'components/EmptyState' + +export type UsagePageLayoutProps = { + isError?: boolean + isLoading?: boolean + onRetry?: () => void + children?: ReactNode +} + +const UsagePageLayout: FC = ({ + children, + isError, + isLoading, + onRetry, +}) => { + let content = children + + if (isLoading) { + content = ( +
+ +
+ ) + } else if (isError) { + content = ( + + Try again + + ) + } + /> + ) + } + + return ( +
+

Usage

+ {content} +
+ ) +} + +export default UsagePageLayout diff --git a/frontend/web/components/pages/usage/index.ts b/frontend/web/components/pages/usage/index.ts index a42d3b483e04..20695f388977 100644 --- a/frontend/web/components/pages/usage/index.ts +++ b/frontend/web/components/pages/usage/index.ts @@ -1,3 +1 @@ export { default } from './UsageDashboardPage' -export { default as UsageDashboard } from './UsageDashboard' -export type { UsageDashboardProps } from './UsageDashboard' diff --git a/frontend/web/components/pages/usage/overLimit.ts b/frontend/web/components/pages/usage/overLimit.ts new file mode 100644 index 000000000000..b21c7ac2fc9a --- /dev/null +++ b/frontend/web/components/pages/usage/overLimit.ts @@ -0,0 +1,58 @@ +import { Res } from 'common/types/responses' +import Format from 'common/utils/format' +import { PlanLimit } from 'components/shared/UsageBar/utils' +import { cumulativeTotals, dailyTotals } from './components/UsageOverTime/utils' +import { allowanceWindowLabel, isBilledOnAPeriod, UsageBasis } from './utils' + +export type OverLimit = { + limit: number + overBy: number + /** Undefined when the rows do not cover the crossing. */ + crossedOn: string | undefined +} + +// Same running total the chart draws, so the two cannot disagree. +export const limitCrossedOn = ( + data: Res['organisationUsage'] | undefined, + limit: PlanLimit, +): string | undefined => + limit + ? cumulativeTotals(dailyTotals(data)).find( + (point) => point.cumulative >= limit, + )?.day + : undefined + +export const overLimitOf = ( + total: number, + limit: PlanLimit, + data: Res['organisationUsage'] | undefined, +): OverLimit | undefined => + limit && total > limit + ? { crossedOn: limitCrossedOn(data, limit), limit, overBy: total - limit } + : undefined + +// Overages are only billed against a Chargebee term, so a rolling window +// cannot be charged for one. Charged or covered by grace is #8264. +const chargeWarning = (basis: UsageBasis): string => + isBilledOnAPeriod(basis) + ? ` Overage charges may apply over ${allowanceWindowLabel(basis)}.` + : '' + +export const overLimitBannerCopy = ( + over: OverLimit, + basis: UsageBasis, +): { title: string; body: string } => ({ + body: [ + `You reached 100% of your ${Format.shortenNumber(over.limit)} plan limit`, + over.crossedOn ? ` on ${over.crossedOn}` : '', + '.', + chargeWarning(basis), + ' Your usage stays visible below so you can see what happened.', + ].join(''), + title: 'Your organisation has exceeded its plan limit', +}) + +export const overLimitNote = (over: OverLimit): string => + `${Format.shortenNumber(over.overBy)} calls over your ${Format.shortenNumber( + over.limit, + )} limit.` diff --git a/frontend/web/components/pages/usage/useUsageData.ts b/frontend/web/components/pages/usage/useUsageData.ts index 453df37731ec..10974a1fd976 100644 --- a/frontend/web/components/pages/usage/useUsageData.ts +++ b/frontend/web/components/pages/usage/useUsageData.ts @@ -15,16 +15,15 @@ type UseUsageData = { export type UsageData = { /** The period and project on screen. Feeds the chart and the breakdown. */ scoped: Res['organisationUsage'] | undefined - /** The organisation over the window its allowance covers. Feeds the meter. */ - allowanceTotal: number + /** The organisation over the window its allowance covers. */ + allowance: Res['organisationUsage'] | undefined isLoadingPlan: boolean isLoadingScoped: boolean failed: boolean retry: () => void } -// usage-data is throttled at five requests a minute per user, so refetching -// every time the tab regains focus spends the budget the page needs. +// usage-data is throttled at five requests a minute per user. const OPTIONS = { refetchOnFocus: false } export const useUsageData = ({ @@ -51,7 +50,7 @@ export const useUsageData = ({ ) return { - allowanceTotal: allowance.data?.totals?.total ?? 0, + allowance: allowance.data, // Either query failing leaves a number missing, so both are fatal. failed: scoped.isError || allowance.isError,