Skip to content

Commit 975ebf2

Browse files
authored
Merge pull request #39 from github/asizikov/add-cost-management-view
Add cost management budgets view
2 parents 15dccbc + 0f1fc3e commit 975ebf2

9 files changed

Lines changed: 1218 additions & 56 deletions

src/App.tsx

Lines changed: 165 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useRef, useState } from 'react'
22
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent } from 'react'
3-
import { MarkGithubIcon, GraphIcon, PeopleIcon, CopilotIcon, TableIcon, OrganizationIcon, DatabaseIcon, InfoIcon, QuestionIcon } from '@primer/octicons-react'
3+
import { MarkGithubIcon, GraphIcon, PeopleIcon, CopilotIcon, TableIcon, OrganizationIcon, DatabaseIcon, InfoIcon, QuestionIcon, CreditCardIcon } from '@primer/octicons-react'
44

55
import { UploadPage } from './components'
66
import { UsersView } from './views/UsersView'
@@ -13,6 +13,7 @@ import { ReportGuideView } from './views/ReportGuideView'
1313
import { FaqView } from './views/FaqView'
1414
import { ProductsView } from './views/ProductsView'
1515
import { OverviewView } from './views/OverviewView'
16+
import { CostManagementView, type BudgetField, type BudgetValues } from './views/CostManagementView'
1617
import { QuickStatsAggregator, type QuickStatsResult } from './pipeline/aggregators/quickStatsAggregator'
1718
import { ReportContextAggregator, type ReportContextResult } from './pipeline/aggregators/reportContextAggregator'
1819
import { DailyUsageAggregator, type DailyUsageData } from './pipeline/aggregators/dailyUsageAggregator'
@@ -22,10 +23,21 @@ import { CostCenterAggregator, type CostCenterResult } from './pipeline/aggregat
2223
import { OrganizationAggregator, type OrganizationResult } from './pipeline/aggregators/organizationAggregator'
2324
import { UserUsageAggregator, type UserUsageResult } from './pipeline/aggregators/userUsageAggregator'
2425
import { calculateLicenseSummary, inferReportPlanScope, type AicEntitlementOverrides } from './pipeline/aicEntitlements'
26+
import { PRODUCT_BUDGET_COPILOT, PRODUCT_BUDGET_COPILOT_CODE_REVIEW, PRODUCT_BUDGET_SPARK } from './pipeline/productClassification'
2527
import { runPipeline } from './pipeline/runPipeline'
28+
import { runBudgetSimulation, type BudgetSimulationResult } from './utils/enterpriseBudgetSimulation'
2629
import { calculateCostOptimizationOpportunity } from './utils/costOptimization'
2730

2831
type Status = 'idle' | 'processing' | 'done'
32+
type ActiveView = 'overview' | 'users' | 'userDetails' | 'costCenters' | 'orgs' | 'models' | 'products' | 'costManagement' | 'guide' | 'faq'
33+
34+
const EMPTY_BUDGET_VALUES: BudgetValues = {
35+
user: '',
36+
enterprise: '',
37+
productCodeReview: '',
38+
productSpark: '',
39+
productCopilot: '',
40+
}
2941

3042
function App() {
3143
const [status, setStatus] = useState<Status>('idle')
@@ -35,7 +47,7 @@ function App() {
3547
const [fileName, setFileName] = useState<string | null>(null)
3648
const [dragActive, setDragActive] = useState(false)
3749
const [dailyUsageData, setDailyUsageData] = useState<DailyUsageData[]>([])
38-
const [activeView, setActiveView] = useState<'overview' | 'users' | 'userDetails' | 'costCenters' | 'orgs' | 'models' | 'products' | 'guide' | 'faq'>('overview')
50+
const [activeView, setActiveView] = useState<ActiveView>('overview')
3951
const [userUsage, setUserUsage] = useState<UserUsageResult | null>(null)
4052
const [modelUsage, setModelUsage] = useState<ModelUsageResult | null>(null)
4153
const [productUsage, setProductUsage] = useState<ProductUsageResult | null>(null)
@@ -45,9 +57,14 @@ function App() {
4557
const [progress, setProgress] = useState(0)
4658
const [rowsProcessed, setRowsProcessed] = useState(0)
4759
const [seatOverrides, setSeatOverrides] = useState<SeatOverrides>({})
60+
const [budgetValues, setBudgetValues] = useState<BudgetValues>(EMPTY_BUDGET_VALUES)
61+
const [budgetSimulation, setBudgetSimulation] = useState<BudgetSimulationResult | null>(null)
62+
const [budgetSimulationError, setBudgetSimulationError] = useState<string | null>(null)
63+
const [isApplyingBudgetSimulation, setIsApplyingBudgetSimulation] = useState(false)
4864
const fileInputRef = useRef<HTMLInputElement | null>(null)
4965
const currentFileRef = useRef<File | null>(null)
5066
const latestRunIdRef = useRef(0)
67+
const latestSimulationIdRef = useRef(0)
5168

5269
const applyProcessedData = useCallback(({
5370
quickStats,
@@ -144,6 +161,7 @@ function App() {
144161
const handleProcess = useCallback(async (file: File) => {
145162
currentFileRef.current = file
146163
const runId = ++latestRunIdRef.current
164+
latestSimulationIdRef.current += 1
147165
setStatus('processing')
148166
setError(null)
149167
setQuickStats(null)
@@ -160,6 +178,10 @@ function App() {
160178
setProgress(0)
161179
setRowsProcessed(0)
162180
setSeatOverrides({})
181+
setBudgetValues(EMPTY_BUDGET_VALUES)
182+
setBudgetSimulation(null)
183+
setBudgetSimulationError(null)
184+
setIsApplyingBudgetSimulation(false)
163185

164186
try {
165187
const nextData = await buildReportData(file, {}, (progressInfo) => {
@@ -218,13 +240,28 @@ function App() {
218240
return compactOverrides
219241
}, [getDefaultSeatCounts])
220242

243+
const handleBudgetValueChange = useCallback((field: BudgetField, value: string) => {
244+
latestSimulationIdRef.current += 1
245+
setBudgetValues((current) => ({
246+
...current,
247+
[field]: value,
248+
}))
249+
setBudgetSimulation(null)
250+
setBudgetSimulationError(null)
251+
setIsApplyingBudgetSimulation(false)
252+
}, [])
253+
221254
const handleSeatOverridesChange = useCallback(async (overrides: SeatOverrides) => {
222255
const file = currentFileRef.current
223256
if (!file) return
224257

225258
const runId = ++latestRunIdRef.current
259+
latestSimulationIdRef.current += 1
226260
const resolvedOverrides = resolveEntitlementOverrides(overrides)
227261
setError(null)
262+
setBudgetSimulation(null)
263+
setBudgetSimulationError(null)
264+
setIsApplyingBudgetSimulation(false)
228265

229266
try {
230267
const nextData = await buildReportData(file, resolvedOverrides)
@@ -238,6 +275,80 @@ function App() {
238275
}
239276
}, [applyProcessedData, buildReportData, compactSeatOverrides, resolveEntitlementOverrides])
240277

278+
const handleApplyBudgetSimulation = useCallback(async () => {
279+
const file = currentFileRef.current
280+
if (!file) return
281+
282+
const parsedEnterpriseBudget = budgetValues.enterprise.trim() === '' ? undefined : Number(budgetValues.enterprise)
283+
const parsedUserBudget = budgetValues.user.trim() === '' ? undefined : Number(budgetValues.user)
284+
const parsedProductCodeReviewBudget = budgetValues.productCodeReview.trim() === '' ? undefined : Number(budgetValues.productCodeReview)
285+
const parsedProductSparkBudget = budgetValues.productSpark.trim() === '' ? undefined : Number(budgetValues.productSpark)
286+
const parsedProductCopilotBudget = budgetValues.productCopilot.trim() === '' ? undefined : Number(budgetValues.productCopilot)
287+
288+
if (
289+
parsedEnterpriseBudget === undefined
290+
&& parsedUserBudget === undefined
291+
&& parsedProductCodeReviewBudget === undefined
292+
&& parsedProductSparkBudget === undefined
293+
&& parsedProductCopilotBudget === undefined
294+
) {
295+
setBudgetSimulation(null)
296+
setBudgetSimulationError('Enter a user-level, account-level, or product-level budget in USD before running the simulation.')
297+
return
298+
}
299+
300+
if (
301+
(parsedEnterpriseBudget !== undefined && !Number.isFinite(parsedEnterpriseBudget))
302+
|| (parsedUserBudget !== undefined && !Number.isFinite(parsedUserBudget))
303+
|| (parsedProductCodeReviewBudget !== undefined && !Number.isFinite(parsedProductCodeReviewBudget))
304+
|| (parsedProductSparkBudget !== undefined && !Number.isFinite(parsedProductSparkBudget))
305+
|| (parsedProductCopilotBudget !== undefined && !Number.isFinite(parsedProductCopilotBudget))
306+
) {
307+
setBudgetSimulation(null)
308+
setBudgetSimulationError('Enter valid USD budget values before running the simulation.')
309+
return
310+
}
311+
312+
const simulationId = ++latestSimulationIdRef.current
313+
setBudgetSimulationError(null)
314+
setIsApplyingBudgetSimulation(true)
315+
316+
try {
317+
const result = await runBudgetSimulation(
318+
file,
319+
{
320+
enterpriseBudgetUsd: parsedEnterpriseBudget,
321+
userBudgetUsd: parsedUserBudget,
322+
productBudgetsUsd: {
323+
[PRODUCT_BUDGET_COPILOT_CODE_REVIEW]: parsedProductCodeReviewBudget,
324+
[PRODUCT_BUDGET_SPARK]: parsedProductSparkBudget,
325+
[PRODUCT_BUDGET_COPILOT]: parsedProductCopilotBudget,
326+
},
327+
},
328+
resolveEntitlementOverrides(seatOverrides),
329+
)
330+
331+
if (simulationId !== latestSimulationIdRef.current) return
332+
setBudgetSimulation(result)
333+
} catch (err) {
334+
if (simulationId !== latestSimulationIdRef.current) return
335+
setBudgetSimulation(null)
336+
setBudgetSimulationError(err instanceof Error ? err.message : 'Failed to run the budget simulation.')
337+
} finally {
338+
if (simulationId === latestSimulationIdRef.current) {
339+
setIsApplyingBudgetSimulation(false)
340+
}
341+
}
342+
}, [
343+
budgetValues.enterprise,
344+
budgetValues.productCodeReview,
345+
budgetValues.productCopilot,
346+
budgetValues.productSpark,
347+
budgetValues.user,
348+
resolveEntitlementOverrides,
349+
seatOverrides,
350+
])
351+
241352
const preventDefault = (event: DragEvent<HTMLDivElement>) => {
242353
event.preventDefault()
243354
event.stopPropagation()
@@ -306,7 +417,22 @@ function App() {
306417
setActiveView('users')
307418
}
308419

309-
const overviewAicNetAmount = dailyUsageData.reduce((sum, day) => sum + day.aicNetAmount, 0)
420+
const overviewTotals = dailyUsageData.reduce(
421+
(totals, day) => {
422+
totals.requests += day.requests
423+
totals.grossAmount += day.grossAmount
424+
totals.discountAmount += day.discountAmount
425+
totals.netAmount += day.netAmount
426+
totals.aicQuantity += day.aicQuantity
427+
totals.aicGrossAmount += day.aicGrossAmount
428+
totals.aicNetAmount += day.aicNetAmount
429+
return totals
430+
},
431+
{ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0, aicQuantity: 0, aicGrossAmount: 0, aicNetAmount: 0 },
432+
)
433+
const overviewPruNetAmount = overviewTotals.netAmount
434+
const overviewAicNetAmount = overviewTotals.aicNetAmount
435+
const overviewAicDiscountAmount = Math.max(overviewTotals.aicGrossAmount - overviewAicNetAmount, 0)
310436
const resolvedSeatOverrides = resolveEntitlementOverrides(seatOverrides)
311437
const costOptimizationOpportunity = calculateCostOptimizationOpportunity({
312438
aicNetAmount: overviewAicNetAmount,
@@ -434,6 +560,15 @@ function App() {
434560
</button>
435561
)}
436562

563+
<button
564+
type="button"
565+
className={`${sidebarItemBase} ${activeView === 'costManagement' ? sidebarActive : sidebarInactive}`}
566+
onClick={() => setActiveView('costManagement')}
567+
>
568+
<CreditCardIcon size={18} className="shrink-0" aria-hidden />
569+
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Cost Management</span>
570+
</button>
571+
437572
<hr className="border-0 border-t border-border-default my-[6px]" />
438573

439574
<button
@@ -506,13 +641,33 @@ function App() {
506641
<div className={viewContentClasses}>
507642
<CostCentersView data={costCenters ?? { costCenters: [] }} rangeStart={rangeStart} />
508643
</div>
509-
) : activeView === 'products' ? (
510-
<div className={viewContentClasses}>
511-
<ProductsView data={productUsage ?? { products: [] }} />
512-
</div>
513-
) : activeView === 'guide' ? (
514-
<div className={viewContentClasses}>
515-
<ReportGuideView />
644+
) : activeView === 'products' ? (
645+
<div className={viewContentClasses}>
646+
<ProductsView data={productUsage ?? { products: [] }} />
647+
</div>
648+
) : activeView === 'costManagement' ? (
649+
<div className={viewContentClasses}>
650+
<CostManagementView
651+
budgetValues={budgetValues}
652+
currentPruBill={overviewPruNetAmount}
653+
currentPruGrossAmount={overviewTotals.grossAmount}
654+
currentPruDiscountAmount={overviewTotals.discountAmount}
655+
currentPruQuantity={overviewTotals.requests}
656+
currentAicBill={overviewAicNetAmount}
657+
currentAicGrossAmount={overviewTotals.aicGrossAmount}
658+
currentAicDiscountAmount={overviewAicDiscountAmount}
659+
currentAicQuantity={overviewTotals.aicQuantity}
660+
dailyUsageData={dailyUsageData}
661+
budgetSimulation={budgetSimulation}
662+
budgetSimulationError={budgetSimulationError}
663+
isApplyingBudgetSimulation={isApplyingBudgetSimulation}
664+
onBudgetValueChange={handleBudgetValueChange}
665+
onApplyBudgetSimulation={handleApplyBudgetSimulation}
666+
/>
667+
</div>
668+
) : activeView === 'guide' ? (
669+
<div className={viewContentClasses}>
670+
<ReportGuideView />
516671
</div>
517672
) : activeView === 'faq' ? (
518673
<div className={viewContentClasses}>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { formatAic, formatUsd } from '../../utils/format'
2+
3+
export type BillingTotalsCardsProps = {
4+
pruNetAmount: number
5+
pruGrossAmount: number
6+
pruDiscountAmount: number
7+
pruQuantity: number
8+
aicNetAmount: number
9+
aicGrossAmount: number
10+
aicDiscountAmount: number
11+
aicQuantity: number
12+
className?: string
13+
}
14+
15+
export function BillingTotalsCards({
16+
pruNetAmount,
17+
pruGrossAmount,
18+
pruDiscountAmount,
19+
pruQuantity,
20+
aicNetAmount,
21+
aicGrossAmount,
22+
aicDiscountAmount,
23+
aicQuantity,
24+
className = '',
25+
}: BillingTotalsCardsProps) {
26+
return (
27+
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-4 ${className}`.trim()}>
28+
<div className="bg-bg-default border border-border-default rounded-md px-5 py-[28px] text-center">
29+
<div className="text-[13px] font-medium text-fg-muted uppercase tracking-[0.5px] mb-3">Current billing (PRUs)</div>
30+
<div className="text-4xl font-bold leading-[1.2] text-fg-default">{formatUsd(pruNetAmount)}</div>
31+
<div className="text-sm text-fg-default mt-[6px]">{pruQuantity.toLocaleString()} PRUs</div>
32+
<div className="text-xs text-fg-muted mt-1">1 PRU = $0.04</div>
33+
<div className="mt-4 pt-3 border-t border-border-default w-full flex flex-col gap-[6px] text-left">
34+
<div className="flex justify-between items-center text-[13px] text-fg-default tabular-nums">
35+
<span>Gross cost</span>
36+
<span>{formatUsd(pruGrossAmount)}</span>
37+
</div>
38+
<div className="flex justify-between items-center text-[13px] text-fg-muted tabular-nums">
39+
<span>Discounts</span>
40+
<span>{formatUsd(pruDiscountAmount)}</span>
41+
</div>
42+
<div className="flex justify-between items-center text-[13px] text-fg-default tabular-nums pt-[6px] border-t border-border-default font-semibold">
43+
<span>Net cost</span>
44+
<span>{formatUsd(pruNetAmount)}</span>
45+
</div>
46+
</div>
47+
</div>
48+
<div className="bg-bg-default border border-border-default rounded-md px-5 py-[28px] text-center">
49+
<div className="text-[13px] font-medium text-fg-muted uppercase tracking-[0.5px] mb-3">AI Credits-based billing (AICs)</div>
50+
<div className="text-4xl font-bold leading-[1.2] text-app-savings-fg">{formatUsd(aicNetAmount)}</div>
51+
<div className="text-sm text-fg-default mt-[6px]">{formatAic(aicQuantity)} AICs</div>
52+
<div className="text-xs text-fg-muted mt-1">1 AIC = $0.01</div>
53+
<div className="mt-4 pt-3 border-t border-border-default w-full flex flex-col gap-[6px] text-left">
54+
<div className="flex justify-between items-center text-[13px] text-fg-default tabular-nums">
55+
<span>Gross cost</span>
56+
<span>{formatUsd(aicGrossAmount)}</span>
57+
</div>
58+
<div className="flex justify-between items-center text-[13px] text-fg-muted tabular-nums">
59+
<span>Included credits discount</span>
60+
<span>{formatUsd(aicDiscountAmount)}</span>
61+
</div>
62+
<div className="flex justify-between items-center text-[13px] text-fg-default tabular-nums pt-[6px] border-t border-border-default font-semibold">
63+
<span>Net cost</span>
64+
<span>{formatUsd(aicNetAmount)}</span>
65+
</div>
66+
</div>
67+
</div>
68+
</div>
69+
)
70+
}

src/components/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export { CostBreakdownCard } from './CostBreakdownCard'
44
export type { CostBreakdownCardProps, CostBreakdownItem } from './CostBreakdownCard'
55
export { BillingComparisonCard } from './BillingComparisonCard'
66
export type { BillingComparisonCardProps } from './BillingComparisonCard'
7+
export { BillingTotalsCards } from './BillingTotalsCards'
8+
export type { BillingTotalsCardsProps } from './BillingTotalsCards'

src/pipeline/aicEntitlements.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ type ReportScopeUser = {
4242
costCenters?: string[]
4343
}
4444

45-
type AicEntitlementContext = {
45+
export type AicEntitlementContext = {
4646
reportPlanScope: ReportPlanScope
4747
organizationEntitlementPool: number
4848
individualWeeklyEntitlements: number
@@ -166,7 +166,7 @@ export function calculateLicenseSummary(
166166
}
167167
}
168168

169-
async function calculateAicEntitlementContext(
169+
export async function calculateAicEntitlementContext(
170170
file: File,
171171
overrides: AicEntitlementOverrides = {},
172172
): Promise<AicEntitlementContext> {
@@ -319,7 +319,7 @@ export async function createAicEntitlementAllocator(
319319
return new PooledAicEntitlementAllocator(entitlementContext.organizationEntitlementPool)
320320
}
321321

322-
function getIsoWeekStart(value: string): string | null {
322+
export function getIsoWeekStart(value: string): string | null {
323323
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
324324
if (!match) return null
325325

0 commit comments

Comments
 (0)