Skip to content

Commit ec6e49b

Browse files
xrvkCopilot
authored andcommitted
feat: review licensed seats before estimates
Co-authored-by: Anton Sizikov <asizikov@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2e2b037 commit ec6e49b

4 files changed

Lines changed: 229 additions & 9 deletions

File tree

src/App.tsx

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent } from 'react'
33
import { MarkGithubIcon, GraphIcon, PeopleIcon, CopilotIcon, TableIcon, OrganizationIcon, DatabaseIcon, InfoIcon, QuestionIcon, CreditCardIcon } from '@primer/octicons-react'
44

55
import { NewVersionBanner, UploadPage } from './components'
6+
import { SeatCountConfirmation } from './components/SeatCountConfirmation'
67
import { UsersView } from './views/UsersView'
78
import type { SeatOverrides } from './views/UsersView'
89
import { UserDetailsView } from './views/UserDetailsView'
@@ -67,6 +68,9 @@ function App() {
6768
const [budgetSimulation, setBudgetSimulation] = useState<BudgetSimulationResult | null>(null)
6869
const [budgetSimulationError, setBudgetSimulationError] = useState<string | null>(null)
6970
const [isApplyingBudgetSimulation, setIsApplyingBudgetSimulation] = useState(false)
71+
const [seatConfirmationPending, setSeatConfirmationPending] = useState(false)
72+
const [seatConfirmationError, setSeatConfirmationError] = useState<string | null>(null)
73+
const [isApplyingSeatConfirmation, setIsApplyingSeatConfirmation] = useState(false)
7074
const fileInputRef = useRef<HTMLInputElement | null>(null)
7175
const currentFileRef = useRef<File | null>(null)
7276
const latestRunIdRef = useRef(0)
@@ -146,7 +150,6 @@ function App() {
146150
}
147151
}, [])
148152

149-
150153
const getDefaultSeatCounts = useCallback(() => {
151154
const summary = calculateLicenseSummary(userUsage?.users ?? [])
152155
return {
@@ -179,6 +182,9 @@ function App() {
179182
setProgress(0)
180183
setRowsProcessed(0)
181184
setSeatOverrides({})
185+
setSeatConfirmationPending(false)
186+
setSeatConfirmationError(null)
187+
setIsApplyingSeatConfirmation(false)
182188
setBudgetValues(EMPTY_BUDGET_VALUES)
183189
setBudgetSimulation(null)
184190
setBudgetSimulationError(null)
@@ -203,6 +209,11 @@ function App() {
203209
setProgress(100)
204210
applyProcessedData(nextData)
205211
setBudgetValues(getDefaultBudgetValues(nextData.userUsage.users))
212+
setSeatConfirmationError(null)
213+
const processedUsers = nextData.userUsage.users
214+
const hasOrgContext = processedUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
215+
const processedPlanScope = inferReportPlanScope(processedUsers.length, hasOrgContext)
216+
setSeatConfirmationPending(processedPlanScope === 'organization')
206217
setStatus('done')
207218
} catch (err) {
208219
if (runId !== latestRunIdRef.current) return
@@ -260,30 +271,64 @@ function App() {
260271
setIsApplyingBudgetSimulation(false)
261272
}, [])
262273

263-
const handleSeatOverridesChange = useCallback(async (overrides: SeatOverrides) => {
274+
const handleSeatOverridesChange = useCallback(async (
275+
overrides: SeatOverrides,
276+
onError?: (message: string) => void,
277+
): Promise<boolean> => {
264278
const file = currentFileRef.current
265-
if (!file) return
279+
if (!file) return false
266280

267281
const runId = ++latestRunIdRef.current
268282
latestSimulationIdRef.current += 1
269283
const resolvedOverrides = resolveIncludedCreditOverrides(overrides)
270-
setError(null)
271284
setBudgetSimulation(null)
272285
setBudgetSimulationError(null)
273286
setIsApplyingBudgetSimulation(false)
274287

275288
try {
276289
const nextData = await buildReportData(file, resolvedOverrides)
277-
if (runId !== latestRunIdRef.current) return
290+
if (runId !== latestRunIdRef.current) return false
278291

279292
applyProcessedData(nextData)
280293
setSeatOverrides(compactSeatOverrides(resolvedOverrides))
294+
if (!onError) {
295+
setError(null)
296+
}
297+
return true
281298
} catch (err) {
282-
if (runId !== latestRunIdRef.current) return
283-
setError(err instanceof Error ? err.message : 'Failed to recalculate usage-based billing.')
299+
if (runId !== latestRunIdRef.current) return false
300+
const message = err instanceof Error ? err.message : 'Failed to recalculate usage-based billing.'
301+
if (onError) {
302+
onError(message)
303+
} else {
304+
setError(message)
305+
}
306+
return false
284307
}
285308
}, [applyProcessedData, buildReportData, compactSeatOverrides, resolveIncludedCreditOverrides])
286309

310+
const handleSeatConfirmationApply = useCallback(async (counts: { business: number; enterprise: number }) => {
311+
setIsApplyingSeatConfirmation(true)
312+
setSeatConfirmationError(null)
313+
try {
314+
const { business: defaultBusiness, enterprise: defaultEnterprise } = getDefaultSeatCounts()
315+
if (counts.business === defaultBusiness && counts.enterprise === defaultEnterprise) {
316+
setSeatConfirmationPending(false)
317+
return
318+
}
319+
320+
const success = await handleSeatOverridesChange(
321+
{ business: counts.business, enterprise: counts.enterprise },
322+
setSeatConfirmationError,
323+
)
324+
if (success) {
325+
setSeatConfirmationPending(false)
326+
}
327+
} finally {
328+
setIsApplyingSeatConfirmation(false)
329+
}
330+
}, [getDefaultSeatCounts, handleSeatOverridesChange])
331+
287332
const handleApplyBudgetSimulation = useCallback(async () => {
288333
const file = currentFileRef.current
289334
if (!file) return
@@ -432,6 +477,7 @@ function App() {
432477
}
433478

434479
const hasReport = status === 'done' && fileName !== null
480+
const showSeatConfirmation = hasReport && seatConfirmationPending
435481
const rangeStart = reportContext?.startDate ?? null
436482
const rangeEnd = reportContext?.endDate ?? null
437483
const reportUsers = userUsage?.users ?? []
@@ -533,6 +579,16 @@ function App() {
533579
</header>
534580

535581
{hasReport ? (
582+
showSeatConfirmation ? (
583+
<SeatCountConfirmation
584+
fileName={fileName}
585+
defaultBusinessSeats={defaultBusinessSeats}
586+
defaultEnterpriseSeats={defaultEnterpriseSeats}
587+
error={seatConfirmationError}
588+
isApplying={isApplyingSeatConfirmation}
589+
onConfirm={(counts) => { void handleSeatConfirmationApply(counts) }}
590+
/>
591+
) : (
536592
<>
537593
<nav className="bg-bg-default border-b border-border-default px-6 py-3 flex justify-between items-center gap-4 flex-wrap max-sm:px-4 max-sm:flex-col max-sm:items-start max-sm:gap-3">
538594
<div className="flex items-center gap-2 flex-wrap text-sm text-fg-default max-sm:flex-col max-sm:items-start max-sm:gap-1">
@@ -675,6 +731,7 @@ function App() {
675731
licenseSeatCounts={licenseSeatCounts}
676732
reportPlanScope={reportPlanScope}
677733
upgradeRecommendation={individualUpgradeRecommendation}
734+
onAdjustSeatCounts={reportPlanScope === 'organization' && !isIndividualReport ? () => setActiveView('users') : undefined}
678735
/>
679736
) : visibleActiveView === 'models' ? (
680737
modelUsage && modelUsage.models.length > 0 ? (
@@ -771,6 +828,7 @@ function App() {
771828
</main>
772829
</div>
773830
</>
831+
)
774832
) : (
775833
<UploadPage
776834
dragActive={dragActive}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { useMemo, useState } from 'react'
2+
import type { ChangeEvent } from 'react'
3+
import { PeopleIcon, ArrowRightIcon } from '@primer/octicons-react'
4+
import { ValidationPopover } from './InfoTip'
5+
import { getSeatReductionError, parseSeatCountInput } from '../utils/seatCounts'
6+
7+
export type SeatCountConfirmationProps = {
8+
fileName: string | null
9+
defaultBusinessSeats: number
10+
defaultEnterpriseSeats: number
11+
error: string | null
12+
isApplying: boolean
13+
onConfirm: (counts: { business: number; enterprise: number }) => void
14+
}
15+
16+
export function SeatCountConfirmation({
17+
fileName,
18+
defaultBusinessSeats,
19+
defaultEnterpriseSeats,
20+
error,
21+
isApplying,
22+
onConfirm,
23+
}: SeatCountConfirmationProps) {
24+
const [businessDraft, setBusinessDraft] = useState<string>(String(defaultBusinessSeats))
25+
const [enterpriseDraft, setEnterpriseDraft] = useState<string>(String(defaultEnterpriseSeats))
26+
27+
const businessError = useMemo(() => getSeatReductionError(businessDraft, defaultBusinessSeats), [businessDraft, defaultBusinessSeats])
28+
const enterpriseError = useMemo(() => getSeatReductionError(enterpriseDraft, defaultEnterpriseSeats), [enterpriseDraft, defaultEnterpriseSeats])
29+
const hasError = Boolean(businessError || enterpriseError)
30+
const normalizedBusinessSeats = parseSeatCountInput(businessDraft, defaultBusinessSeats)
31+
const normalizedEnterpriseSeats = parseSeatCountInput(enterpriseDraft, defaultEnterpriseSeats)
32+
const hasAddedSeats = normalizedBusinessSeats > defaultBusinessSeats || normalizedEnterpriseSeats > defaultEnterpriseSeats
33+
const canApply = !hasError && !isApplying
34+
35+
const onBusinessChange = (event: ChangeEvent<HTMLInputElement>) => setBusinessDraft(event.target.value)
36+
const onEnterpriseChange = (event: ChangeEvent<HTMLInputElement>) => setEnterpriseDraft(event.target.value)
37+
38+
const handleApply = () => {
39+
if (!canApply) return
40+
onConfirm({
41+
business: normalizedBusinessSeats,
42+
enterprise: normalizedEnterpriseSeats,
43+
})
44+
}
45+
46+
const inputBase = 'no-spin-number w-full px-3 py-2 text-[15px] tabular-nums text-right border rounded-md bg-bg-default focus:outline-none'
47+
const inputOk = `${inputBase} border-border-default focus:border-fg-accent focus:shadow-[0_0_0_3px_rgba(9,105,218,0.3)]`
48+
const inputBad = `${inputBase} border-border-danger text-fg-danger focus:border-border-danger focus:shadow-[0_0_0_3px_rgba(207,34,46,0.3)]`
49+
50+
return (
51+
<main className="flex-1 flex flex-col items-center justify-center gap-6 py-6 px-4 sm:pt-12 sm:px-6 sm:pb-8 bg-bg-muted">
52+
<div className="max-w-[680px] w-full bg-bg-default border border-border-default rounded-[16px] shadow-[0_8px_24px_rgba(31,35,40,0.08)] py-8 px-5 sm:py-10 sm:px-10">
53+
<div className="flex flex-col items-center text-center mb-6">
54+
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-bg-accent-muted border border-border-accent/25 mb-4" aria-hidden="true">
55+
<PeopleIcon size={28} className="text-fg-accent" />
56+
</div>
57+
<h1 className="m-0 mb-2 text-[22px] sm:text-[26px] leading-[1.25] text-fg-default font-bold">Review licensed seat counts</h1>
58+
<p className="m-0 max-w-[520px] text-fg-muted text-[14px] leading-[1.6]">
59+
Licensed users without billable activity during the report period may be missing from the
60+
uploaded CSV. Add any missing Copilot Business and Enterprise seats so included AI Credits
61+
are pooled across all licensed users and your estimate is more accurate.
62+
</p>
63+
<p className="m-0 mt-3 max-w-[520px] text-fg-muted text-[13px] leading-[1.6]">
64+
Current licensed seat totals are available in your enterprise account under
65+
<br />
66+
<strong className="text-fg-default">Billing and licensing &rarr; Licensing</strong>.
67+
</p>
68+
{fileName && (
69+
<p className="m-0 mt-2 text-[12px] text-fg-muted">
70+
Report: <span className="font-semibold text-fg-default">{fileName}</span>
71+
</p>
72+
)}
73+
{error && (
74+
<div className="mt-4 py-3 px-4 rounded-md bg-bg-danger-muted text-fg-danger border border-border-danger text-sm" role="alert">
75+
{error}
76+
</div>
77+
)}
78+
</div>
79+
80+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
81+
<div className="flex flex-col gap-1">
82+
<label htmlFor="seat-confirm-business" className="text-[13px] font-semibold text-fg-default">
83+
Total Copilot Business seats
84+
</label>
85+
<ValidationPopover id="seat-confirm-business-error" text={businessError}>
86+
<input
87+
id="seat-confirm-business"
88+
type="number"
89+
inputMode="numeric"
90+
min={defaultBusinessSeats}
91+
step="1"
92+
value={businessDraft}
93+
onChange={onBusinessChange}
94+
className={businessError ? inputBad : inputOk}
95+
aria-invalid={businessError ? 'true' : undefined}
96+
aria-describedby={businessError ? 'seat-confirm-business-error' : undefined}
97+
disabled={isApplying}
98+
/>
99+
</ValidationPopover>
100+
</div>
101+
102+
<div className="flex flex-col gap-1">
103+
<label htmlFor="seat-confirm-enterprise" className="text-[13px] font-semibold text-fg-default">
104+
Total Copilot Enterprise seats
105+
</label>
106+
<ValidationPopover id="seat-confirm-enterprise-error" text={enterpriseError}>
107+
<input
108+
id="seat-confirm-enterprise"
109+
type="number"
110+
inputMode="numeric"
111+
min={defaultEnterpriseSeats}
112+
step="1"
113+
value={enterpriseDraft}
114+
onChange={onEnterpriseChange}
115+
className={enterpriseError ? inputBad : inputOk}
116+
aria-invalid={enterpriseError ? 'true' : undefined}
117+
aria-describedby={enterpriseError ? 'seat-confirm-enterprise-error' : undefined}
118+
disabled={isApplying}
119+
/>
120+
</ValidationPopover>
121+
</div>
122+
</div>
123+
124+
<div className="flex justify-end">
125+
<button
126+
type="button"
127+
onClick={handleApply}
128+
disabled={!canApply}
129+
className="inline-flex items-center justify-center gap-2 text-[14px] font-semibold text-fg-on-emphasis bg-bg-success-emphasis hover:bg-app-savings-fg disabled:opacity-50 disabled:cursor-not-allowed rounded-md py-2 px-5 cursor-pointer border-0 focus-visible:outline-2 focus-visible:outline-border-accent focus-visible:outline-offset-2"
130+
>
131+
{isApplying ? 'Applying…' : (
132+
<>
133+
{hasAddedSeats ? 'Apply and view estimate' : 'Confirm and view estimate'}
134+
<ArrowRightIcon size={16} aria-hidden />
135+
</>
136+
)}
137+
</button>
138+
</div>
139+
140+
<p className="m-0 mt-5 text-[12px] text-fg-muted leading-[1.6] text-center">
141+
You can revise these counts anytime in the <strong className="text-fg-default">Users</strong> section.
142+
</p>
143+
</div>
144+
</main>
145+
)
146+
}

src/components/ui/BillingTotalsCards.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type BillingTotalsCardsProps = {
2121
showExistingDiscountDisclaimer?: boolean
2222
showPromotionalDataDisclaimer?: boolean
2323
upgradeRecommendation?: IndividualPlanUpgradeRecommendation | null
24+
onAdjustSeatCounts?: () => void
2425
className?: string
2526
}
2627

@@ -38,6 +39,7 @@ export function BillingTotalsCards({
3839
showExistingDiscountDisclaimer = false,
3940
showPromotionalDataDisclaimer = false,
4041
upgradeRecommendation = null,
42+
onAdjustSeatCounts,
4143
className = '',
4244
}: BillingTotalsCardsProps) {
4345
const pruTotalAmount = pruNetAmount + (licenseAmount ?? 0)
@@ -151,8 +153,19 @@ export function BillingTotalsCards({
151153
</div>
152154
{licenseSeatCounts && (
153155
<p className="m-0 text-[13px] text-fg-muted leading-normal text-center">
154-
This report contains activity for <strong className="text-fg-default">{licenseSeatCounts.business.toLocaleString()}</strong> Copilot Business and{' '}
155-
<strong className="text-fg-default">{licenseSeatCounts.enterprise.toLocaleString()}</strong> Copilot Enterprise users. If you had more users with these licenses during the billing period covered by the uploaded report, you can adjust counters in the <strong className="text-fg-default">Users</strong> section of this app.
156+
This estimate uses <strong className="text-fg-default">{licenseSeatCounts.business.toLocaleString()}</strong> Copilot Business and{' '}
157+
<strong className="text-fg-default">{licenseSeatCounts.enterprise.toLocaleString()}</strong> Copilot Enterprise seats for the included AI Credits pool. If these totals do not match your licensed seat counts,{' '}
158+
{onAdjustSeatCounts ? (
159+
<button
160+
type="button"
161+
onClick={onAdjustSeatCounts}
162+
className="inline bg-transparent border-0 p-0 text-fg-accent font-medium cursor-pointer hover:underline focus-visible:outline-2 focus-visible:outline-border-accent focus-visible:outline-offset-2 rounded-sm"
163+
>
164+
adjust seat counts &rarr;
165+
</button>
166+
) : (
167+
<>adjust seat counts in the <strong className="text-fg-default">Users</strong> section of this app.</>
168+
)}
156169
</p>
157170
)}
158171
</div>

src/views/OverviewView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type OverviewViewProps = {
2020
}
2121
reportPlanScope?: ReportPlanScope
2222
upgradeRecommendation?: IndividualPlanUpgradeRecommendation | null
23+
onAdjustSeatCounts?: () => void
2324
}
2425

2526
function createEmptyDailyUsage(date: string): DailyUsageData {
@@ -45,6 +46,7 @@ export function OverviewView({
4546
licenseSeatCounts,
4647
reportPlanScope = 'organization',
4748
upgradeRecommendation = null,
49+
onAdjustSeatCounts,
4850
}: OverviewViewProps) {
4951
const filledDailyUsageData = fillDataForRange(dailyUsageData, rangeStart, rangeEnd, createEmptyDailyUsage)
5052

@@ -138,6 +140,7 @@ export function OverviewView({
138140
showExistingDiscountDisclaimer={reportPlanScope !== 'individual'}
139141
showPromotionalDataDisclaimer={reportPlanScope === 'individual'}
140142
upgradeRecommendation={upgradeRecommendation}
143+
onAdjustSeatCounts={onAdjustSeatCounts}
141144
className="mb-3"
142145
/>
143146
<BillingProjectionDisclaimer className="mb-6" />

0 commit comments

Comments
 (0)