diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue
index 2bdb783d..66043d96 100644
--- a/app/components/BillingCreditsViewer.vue
+++ b/app/components/BillingCreditsViewer.vue
@@ -280,9 +280,24 @@
Mixed sources: User list + Tokens (CLI) come from the Copilot Metrics API for {{ dateRangeDescription || 'the last 28 days' }}.
Credits, Gross $, Net $, and Models come from the Billing API for {{ rangeLabel }}.
+
+
+
());
const loadedLogins = reactive(new Set());
const perUserLoading = ref(false);
+ const perUserSearch = ref('');
// When the user switches month or toggles month view, drop cached
// per-user roll-ups so the visible page re-fetches against the new window.
@@ -576,10 +592,10 @@ export default defineComponent({
}
// Called by v-data-table @update:options on initial mount, page change,
- // and sort change. We use page + itemsPerPage + the currently-sorted
- // `perUserRows` view to know which logins are visible.
+ // and sort/search change. We use page + itemsPerPage + the currently
+ // searchable `perUserRows` view to know which logins are visible.
function onTableOptions(opts: { page: number; itemsPerPage: number }): void {
- const allRows = perUserRows.value;
+ const allRows = filteredPerUserRowsForLazyLoad.value;
if (allRows.length === 0) return;
const start = (opts.page - 1) * opts.itemsPerPage;
const visible = allRows.slice(start, start + opts.itemsPerPage).map(r => r.user);
@@ -645,6 +661,17 @@ export default defineComponent({
};
});
});
+ const filteredPerUserRowsForLazyLoad = computed(() => {
+ const query = perUserSearch.value.trim().toLocaleLowerCase();
+ if (!query) return perUserRows.value;
+ return perUserRows.value.filter(row => row.user.toLocaleLowerCase().includes(query));
+ });
+ watch(perUserSearch, () => {
+ const matchedLogins = filteredPerUserRowsForLazyLoad.value
+ .slice(0, 50)
+ .map(row => row.user);
+ void loadBillingForLogins(matchedLogins);
+ });
const loadedLoginsCount = computed(() => loadedLogins.size);
// True when we've loaded at least one page of users AND the aggregate
@@ -830,7 +857,7 @@ export default defineComponent({
monthView, rangeLabel,
totalGrossQty, totalGrossAmount, totalNetAmount,
errorReason, headers,
- perUserRows, perUserHeaders,
+ perUserRows, perUserHeaders, perUserSearch,
perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution,
topSpendersChartData, topSpendersChartOptions,
topTokensChartData, topTokensChartOptions,
diff --git a/tests/billing-per-user-search.spec.ts b/tests/billing-per-user-search.spec.ts
new file mode 100644
index 00000000..12110a36
--- /dev/null
+++ b/tests/billing-per-user-search.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const componentSource = readFileSync(resolve(process.cwd(), 'app/components/BillingCreditsViewer.vue'), 'utf8');
+
+describe('BillingCreditsViewer per-user search', () => {
+ test('renders a searchable per-user table input wired to Vuetify data-table search', () => {
+ expect(componentSource).toContain('data-testid="billing-per-user-search"');
+ expect(componentSource).toContain('v-model="perUserSearch"');
+ expect(componentSource).toContain(':search="perUserSearch"');
+ expect(componentSource).toContain(':filter-keys="[\'user\']"');
+ });
+
+ test('loads billing for users matched by search outside the current page', () => {
+ expect(componentSource).toContain('const perUserSearch = ref');
+ expect(componentSource).toContain('const filteredPerUserRowsForLazyLoad = computed');
+ expect(componentSource).toContain('watch(perUserSearch');
+ expect(componentSource).toContain('loadBillingForLogins(matchedLogins');
+ });
+});