Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 104 additions & 52 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ import { CostCenterAggregator, type CostCenterResult } from './pipeline/aggregat
import { OrganizationAggregator, type OrganizationResult } from './pipeline/aggregators/organizationAggregator'
import { UserUsageAggregator, type UserUsageResult } from './pipeline/aggregators/userUsageAggregator'
import {
BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS,
ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS,
calculateLicenseSummary,
inferReportPlanScope,
type AicIncludedCreditsOverrides,
} from './pipeline/aicIncludedCredits'
import { resolveIncludedCreditsPolicy } from './pipeline/includedCreditsPolicy'
import { PRODUCT_BUDGET_COPILOT, PRODUCT_BUDGET_COPILOT_CLOUD_AGENT, PRODUCT_BUDGET_SPARK } from './pipeline/productClassification'
import { runPipeline } from './pipeline/runPipeline'
import type { ReportFormatMetadata } from './pipeline/reportAdapters'
import { runBudgetSimulation, type BudgetSimulationResult } from './utils/budgetSimulation'
import { EMPTY_BUDGET_VALUES, getDefaultBudgetValues, getUserSpendSegmentsByUsername, type BudgetField, type BudgetValues } from './utils/costManagementBudgets'
import { calculateIndividualPlanUpgradeRecommendation, getIndividualLicenseMonthlyCost } from './utils/individualPlanUpgrade'
import { normalizeSeatCount } from './utils/seatCounts'
import { getReportMode, isNativeAiCreditsMode } from './utils/reportMode'
import { useAppVersionCheck } from './hooks/useAppVersionCheck'

type Status = 'idle' | 'processing' | 'done'
Expand Down Expand Up @@ -116,25 +116,37 @@ function App() {
includedCreditsOverrides: AicIncludedCreditsOverrides = {},
onProgress?: (progressInfo: { rowsProcessed: number; progressPercent: number }) => void,
) => {
const statsAggregator = new QuickStatsAggregator()
const contextAggregator = new ReportContextAggregator()
const dailyAggregator = new DailyUsageAggregator()
const modelAggregator = new ModelUsageAggregator()
const productAggregator = new ProductUsageAggregator()
const costCenterAggregator = new CostCenterAggregator()
const orgAggregator = new OrganizationAggregator()
const userAggregator = new UserUsageAggregator()

const pipelineResult = await runPipeline(file, [
statsAggregator,
contextAggregator,
dailyAggregator,
modelAggregator,
productAggregator,
costCenterAggregator,
orgAggregator,
userAggregator,
], {
let statsAggregator!: QuickStatsAggregator
let contextAggregator!: ReportContextAggregator
let dailyAggregator!: DailyUsageAggregator
let modelAggregator!: ModelUsageAggregator
let productAggregator!: ProductUsageAggregator
let costCenterAggregator!: CostCenterAggregator
let orgAggregator!: OrganizationAggregator
let userAggregator!: UserUsageAggregator

const pipelineResult = await runPipeline(file, (reportMetadata) => {
statsAggregator = new QuickStatsAggregator()
contextAggregator = new ReportContextAggregator()
dailyAggregator = new DailyUsageAggregator(reportMetadata)
modelAggregator = new ModelUsageAggregator(reportMetadata)
productAggregator = new ProductUsageAggregator(reportMetadata)
costCenterAggregator = new CostCenterAggregator(reportMetadata)
orgAggregator = new OrganizationAggregator(reportMetadata)
userAggregator = new UserUsageAggregator(reportMetadata)

return [
statsAggregator,
contextAggregator,
dailyAggregator,
modelAggregator,
productAggregator,
costCenterAggregator,
orgAggregator,
userAggregator,
]
}, {
enableNativeAiCreditsProcessing: true,
includedCreditsOverrides,
progressResolution: 500,
onProgress,
Expand All @@ -156,8 +168,16 @@ function App() {
}
}, [])

const reportMode = getReportMode(reportMetadata)
const isNativeAiCreditsReport = isNativeAiCreditsMode(reportMode)
const rangeStart = reportContext?.startDate ?? null
const rangeEnd = reportContext?.endDate ?? null
const includedCreditsPolicy = resolveIncludedCreditsPolicy(reportMode, { startDate: rangeStart, endDate: rangeEnd })
const showOrganizationPromotionalDataDisclaimer = reportMode === 'transition-period-billing-preview'
|| includedCreditsPolicy.id === 'native-ai-credits-summer-promo'

const getDefaultSeatCounts = useCallback(() => {
const summary = calculateLicenseSummary(userUsage?.users ?? [])
const summary = calculateLicenseSummary(userUsage?.users ?? [], includedCreditsPolicy)
return {
business: normalizeSeatCount(
summary.rows.find((row) => row.label === 'Copilot Business')?.users ?? 0,
Expand All @@ -168,7 +188,7 @@ function App() {
0,
),
}
}, [userUsage])
}, [includedCreditsPolicy, userUsage])

const resetReportState = useCallback(({ status, fileName }: { status: Status; fileName: string | null }) => {
setStatus(status)
Expand Down Expand Up @@ -339,6 +359,11 @@ function App() {
const handleApplyBudgetSimulation = useCallback(async () => {
const file = currentFileRef.current
if (!file) return
if (isNativeAiCreditsReport) {
setBudgetSimulation(null)
setBudgetSimulationError('Budget simulation is not available for native AI Credits reports yet.')
return
}

const budgetReportUsers = userUsage?.users ?? []
const hasBudgetOrganizationContext = budgetReportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
Expand Down Expand Up @@ -424,6 +449,7 @@ function App() {
budgetValues.productCopilot,
budgetValues.productSpark,
budgetValues.user,
isNativeAiCreditsReport,
resolveIncludedCreditOverrides,
seatOverrides,
userUsage,
Expand Down Expand Up @@ -485,8 +511,6 @@ function App() {

const hasReport = status === 'done' && fileName !== null && reportMetadata !== null
const showSeatConfirmation = hasReport && seatConfirmationPending
const rangeStart = reportContext?.startDate ?? null
const rangeEnd = reportContext?.endDate ?? null
const reportUsers = userUsage?.users ?? []
const hasOrganizationContext = reportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
const reportPlanScope = inferReportPlanScope(reportUsers.length, hasOrganizationContext)
Expand All @@ -505,15 +529,18 @@ function App() {
? { business: effectiveBusinessSeats, enterprise: effectiveEnterpriseSeats }
: undefined
const includedAicPoolSize = reportPlanScope === 'organization'
? (effectiveBusinessSeats * BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS) + (effectiveEnterpriseSeats * ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS)
: calculateLicenseSummary(reportUsers).totalIncludedAic
? (effectiveBusinessSeats * includedCreditsPolicy.organizationPlans.business.monthlyIncludedCredits) + (effectiveEnterpriseSeats * includedCreditsPolicy.organizationPlans.enterprise.monthlyIncludedCredits)
: calculateLicenseSummary(reportUsers, includedCreditsPolicy).totalIncludedAic

const selectedUser = individualUser
?? (selectedUsername && userUsage
? userUsage.users.find((user) => user.username === selectedUsername) ?? null
: null)
const canShowSpendInsights = Boolean(userUsage) && !isIndividualReport && reportUsers.length > 1
const visibleActiveView = activeView === 'spendInsights' && !canShowSpendInsights ? 'overview' : activeView
const visibleActiveView = (activeView === 'spendInsights' && !canShowSpendInsights)
|| (isNativeAiCreditsReport && (activeView === 'guide' || activeView === 'faq'))
? 'overview'
: activeView
const userNavActive = isIndividualReport
? visibleActiveView === 'userDetails'
: visibleActiveView === 'users' || visibleActiveView === 'userDetails'
Expand Down Expand Up @@ -704,25 +731,29 @@ function App() {
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Cost Management</span>
</button>

<hr className="border-0 border-t border-border-default my-[6px]" />

<button
type="button"
className={`${sidebarItemBase} ${visibleActiveView === 'guide' ? sidebarActive : sidebarInactive}`}
onClick={() => setActiveView('guide')}
>
<InfoIcon size={18} className="shrink-0" aria-hidden />
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Report Format</span>
</button>

<button
type="button"
className={`${sidebarItemBase} ${visibleActiveView === 'faq' ? sidebarActive : sidebarInactive}`}
onClick={() => setActiveView('faq')}
>
<QuestionIcon size={18} className="shrink-0" aria-hidden />
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">FAQ</span>
</button>
{!isNativeAiCreditsReport && (
<>
<hr className="border-0 border-t border-border-default my-[6px]" />

<button
type="button"
className={`${sidebarItemBase} ${visibleActiveView === 'guide' ? sidebarActive : sidebarInactive}`}
onClick={() => setActiveView('guide')}
>
<InfoIcon size={18} className="shrink-0" aria-hidden />
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Report Format</span>
</button>

<button
type="button"
className={`${sidebarItemBase} ${visibleActiveView === 'faq' ? sidebarActive : sidebarInactive}`}
onClick={() => setActiveView('faq')}
>
<QuestionIcon size={18} className="shrink-0" aria-hidden />
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">FAQ</span>
</button>
</>
)}
</nav>
</aside>

Expand All @@ -740,6 +771,8 @@ function App() {
reportPlanScope={reportPlanScope}
upgradeRecommendation={individualUpgradeRecommendation}
onAdjustSeatCounts={reportPlanScope === 'organization' && !isIndividualReport ? () => setActiveView('users') : undefined}
reportMode={reportMode}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
/>
) : visibleActiveView === 'models' ? (
modelUsage && modelUsage.models.length > 0 ? (
Expand All @@ -749,6 +782,8 @@ function App() {
isIndividualReport={isIndividualReport}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
reportMode={reportMode}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
/>
</div>
) : null
Expand All @@ -764,6 +799,8 @@ function App() {
setSelectedUsername(username)
setActiveView('userDetails')
}}
reportMode={reportMode}
includedCreditsPolicy={includedCreditsPolicy}
/>
</div>
) : visibleActiveView === 'userDetails' || (visibleActiveView === 'users' && isIndividualReport) ? (
Expand All @@ -774,16 +811,24 @@ function App() {
showUsersBreadcrumb={!isIndividualReport}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
reportMode={reportMode}
includedCreditsPolicy={includedCreditsPolicy}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
onBackToUsers={isIndividualReport ? undefined : () => setActiveView('users')}
/>
</div>
) : visibleActiveView === 'costCenters' ? (
<div className={viewContentClasses}>
<CostCentersView data={costCenters ?? { costCenters: [] }} rangeStart={rangeStart} />
<CostCentersView
data={costCenters ?? { costCenters: [] }}
rangeStart={rangeStart}
reportMode={reportMode}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
/>
</div>
) : visibleActiveView === 'products' ? (
<div className={viewContentClasses}>
<ProductsView data={productUsage ?? { products: [] }} />
<ProductsView data={productUsage ?? { products: [] }} reportMode={reportMode} />
</div>
) : visibleActiveView === 'spendInsights' ? (
<div className={viewContentClasses}>
Expand Down Expand Up @@ -818,19 +863,26 @@ function App() {
isApplyingBudgetSimulation={isApplyingBudgetSimulation}
onBudgetValueChange={handleBudgetValueChange}
onApplyBudgetSimulation={handleApplyBudgetSimulation}
reportMode={reportMode}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
/>
</div>
) : visibleActiveView === 'guide' ? (
) : !isNativeAiCreditsReport && visibleActiveView === 'guide' ? (
<div className={viewContentClasses}>
<ReportGuideView />
</div>
) : visibleActiveView === 'faq' ? (
) : !isNativeAiCreditsReport && visibleActiveView === 'faq' ? (
<div className={viewContentClasses}>
<FaqView />
</div>
) : (
<div className={viewContentClasses}>
<OrganizationsView data={orgs ?? { organizations: [] }} rangeStart={rangeStart} />
<OrganizationsView
data={orgs ?? { organizations: [] }}
rangeStart={rangeStart}
reportMode={reportMode}
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
/>
</div>
)}
</main>
Expand Down
49 changes: 49 additions & 0 deletions src/components/ProductUsageTable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'

import { ProductUsageTable, type ProductUsageTableProduct } from './ProductUsageTable'

const PRODUCTS: ProductUsageTableProduct[] = [
{
product: 'Copilot',
totals: {
requests: 10,
aicQuantity: 25,
netAmount: 0.4,
aicNetAmount: 0.25,
},
models: {
'GPT-5.2': {
requests: 10,
aicQuantity: 25,
netAmount: 0.4,
aicNetAmount: 0.25,
},
},
},
]

describe('ProductUsageTable', () => {
it('keeps transition-period PRU comparison columns by default', () => {
const html = renderToStaticMarkup(createElement(ProductUsageTable, { products: PRODUCTS }))

expect(html).toContain('PRUs')
expect(html).toContain('PRU net cost')
expect(html).toContain('Difference')
expect(html).toContain('AIC net cost')
})

it('renders native AI Credits columns without PRU comparison labels', () => {
const html = renderToStaticMarkup(createElement(ProductUsageTable, {
products: PRODUCTS,
reportMode: 'native-ai-credits',
}))

expect(html).not.toContain('PRUs')
expect(html).not.toContain('PRU net cost')
expect(html).not.toContain('Difference')
expect(html).toContain('AICs')
expect(html).toContain('AIC net cost')
})
})
Loading