> =
+ [
+ {
+ value: 'none',
+ label: 'None',
+ },
+ {
+ value: 'all',
+ label: 'Full',
+ },
+ ];
+ if (
+ getManualResultsMetadataQuery.data &&
+ getManualResultsMetadataQuery.data.length > 0
+ ) {
+ breakdownOptions.push({
+ value: 'manual',
+ label: 'Manual',
+ });
+ }
+
+ const hasMadeSelections = !isFilterEmpty(filter) || !isGroupByEmpty(groupBy);
+ return (
+
+
+
+ Back
+
+
+
+ Use the report builder to create custom reports for print or export.
+
+
+ -
+ Filters restrict the report to ballots
+ matching the criteria
+
+ -
+ Report By organizes the ballot counts into
+ multiple rows
+
+
+ Filters
+
+
+
+ Report By
+
+
+
+ Options
+
+
+ Ballot Count Breakdown:
+
+ setBreakdown(value as Tabulation.BallotCountBreakdown)
+ }
+ ariaLabel="Select Breakdown Type"
+ />
+
+
+
+ );
+}
diff --git a/apps/admin/frontend/src/screens/reports_screen.test.tsx b/apps/admin/frontend/src/screens/reports_screen.test.tsx
index 8eafec442b..1f6d1c43a0 100644
--- a/apps/admin/frontend/src/screens/reports_screen.test.tsx
+++ b/apps/admin/frontend/src/screens/reports_screen.test.tsx
@@ -128,7 +128,7 @@ test('exporting batch results', async () => {
usbDriveStatus: mockUsbDriveStatus('mounted'),
});
- apiMock.expectGetCardCounts(
+ apiMock.deprecatedExpectGetCardCounts(
mockBallotCountsTableGroupBy({ groupByBatch: true }),
[]
);
diff --git a/apps/admin/frontend/src/screens/reports_screen.tsx b/apps/admin/frontend/src/screens/reports_screen.tsx
index aedbfb0aba..3649638969 100644
--- a/apps/admin/frontend/src/screens/reports_screen.tsx
+++ b/apps/admin/frontend/src/screens/reports_screen.tsx
@@ -207,6 +207,9 @@ export function ReportsScreen(): JSX.Element {
Tally Report Builder
+ {' '}
+
+ Ballot Count Report Builder
{tallyResultsInfo}
diff --git a/apps/admin/frontend/src/screens/tally_report_builder.tsx b/apps/admin/frontend/src/screens/tally_report_builder.tsx
index 3389322983..5833abf410 100644
--- a/apps/admin/frontend/src/screens/tally_report_builder.tsx
+++ b/apps/admin/frontend/src/screens/tally_report_builder.tsx
@@ -69,11 +69,31 @@ export function TallyReportBuilder(): JSX.Element {
Filters
-
+
Report By
-
+
{
},
'Absentee Ballot Tally Report',
],
+ [
+ {
+ partyIds: ['0'],
+ },
+ 'Mammal Party Tally Report',
+ ],
[
{
batchIds: ['12345678-0000-0000-0000-000000000000'],
@@ -121,6 +127,27 @@ test('generateTitleForReport', () => {
},
'Ballot Style 1M Absentee Ballot Tally Report',
],
+ [
+ {
+ ballotStyleIds: ['1M'],
+ partyIds: ['0'],
+ },
+ 'Mammal Party Ballot Style 1M Tally Report',
+ ],
+ [
+ {
+ partyIds: ['0'],
+ votingMethods: ['absentee'],
+ },
+ 'Mammal Party Absentee Ballot Tally Report',
+ ],
+ [
+ {
+ partyIds: ['0'],
+ precinctIds: ['precinct-1'],
+ },
+ 'Mammal Party Precinct 1 Tally Report',
+ ],
[
{
scannerIds: ['VX-00-001'],
@@ -142,6 +169,16 @@ test('generateTitleForReport', () => {
generateTitleForReport({ filter, electionDefinition, scannerBatches })
).toEqual(ok(title));
}
+
+ // Ballot Count Report
+ expect(
+ generateTitleForReport({
+ filter: { precinctIds: ['precinct-1'] },
+ electionDefinition,
+ scannerBatches,
+ reportType: 'Ballot Count',
+ })
+ ).toEqual(ok('Precinct 1 Ballot Count Report'));
});
test('canonicalizeFilter', () => {
diff --git a/apps/admin/frontend/src/utils/reporting.ts b/apps/admin/frontend/src/utils/reporting.ts
index 307ac64153..1f26817124 100644
--- a/apps/admin/frontend/src/utils/reporting.ts
+++ b/apps/admin/frontend/src/utils/reporting.ts
@@ -1,6 +1,10 @@
import { Election, ElectionDefinition, Tabulation } from '@votingworks/types';
import { Optional, Result, err, find, ok } from '@votingworks/basics';
-import { getPrecinctById, sanitizeStringForFilename } from '@votingworks/utils';
+import {
+ getPartyById,
+ getPrecinctById,
+ sanitizeStringForFilename,
+} from '@votingworks/utils';
import moment from 'moment';
import type { ScannerBatch } from '@votingworks/admin-backend';
@@ -41,16 +45,18 @@ function getFilterRank(filter: Tabulation.Filter): number {
export const BATCH_ID_TRUNCATE_LENGTH = 8;
/**
- * Attempts to generate a title for an individual tally report based on its filter.
+ * Attempts to generate a title for an individual report based on its filter.
*/
export function generateTitleForReport({
filter,
electionDefinition,
scannerBatches,
+ reportType = 'Tally',
}: {
filter: Tabulation.Filter;
electionDefinition: ElectionDefinition;
scannerBatches: ScannerBatch[];
+ reportType?: 'Tally' | 'Ballot Count';
}): Result, 'title-not-supported'> {
if (isCompoundFilter(filter)) {
return err('title-not-supported');
@@ -61,6 +67,7 @@ export function generateTitleForReport({
const votingMethod = filter.votingMethods?.[0];
const batchId = filter.batchIds?.[0];
const scannerId = filter.scannerIds?.[0];
+ const partyId = filter.partyIds?.[0];
const reportRank = getFilterRank(filter);
@@ -72,16 +79,20 @@ export function generateTitleForReport({
if (reportRank === 1) {
if (precinctId) {
return ok(
- `${getPrecinctById(electionDefinition, precinctId).name} Tally Report`
+ `${
+ getPrecinctById(electionDefinition, precinctId).name
+ } ${reportType} Report`
);
}
if (ballotStyleId) {
- return ok(`Ballot Style ${ballotStyleId} Tally Report`);
+ return ok(`Ballot Style ${ballotStyleId} ${reportType} Report`);
}
if (votingMethod) {
- return ok(`${VOTING_METHOD_LABELS[votingMethod]} Ballot Tally Report`);
+ return ok(
+ `${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report`
+ );
}
if (batchId) {
@@ -90,52 +101,92 @@ export function generateTitleForReport({
`Scanner ${batch.scannerId} Batch ${batch.batchId.slice(
0,
BATCH_ID_TRUNCATE_LENGTH
- )} Tally Report`
+ )} ${reportType} Report`
);
}
if (scannerId) {
- return ok(`Scanner ${scannerId} Tally Report`);
+ return ok(`Scanner ${scannerId} ${reportType} Report`);
}
- }
- if (reportRank === 2) {
- if (precinctId && votingMethod) {
+ if (partyId) {
return ok(
- `${getPrecinctById(electionDefinition, precinctId).name} ${
- VOTING_METHOD_LABELS[votingMethod]
- } Ballot Tally Report`
+ `${
+ getPartyById(electionDefinition, partyId).fullName
+ } ${reportType} Report`
);
}
+ }
- if (ballotStyleId && votingMethod) {
- return ok(
- `Ballot Style ${ballotStyleId} ${VOTING_METHOD_LABELS[votingMethod]} Ballot Tally Report`
- );
+ if (reportRank === 2) {
+ // Party + Other
+ if (partyId) {
+ const partyFullName = getPartyById(electionDefinition, partyId).fullName;
+ if (precinctId) {
+ return ok(
+ `${partyFullName} ${
+ getPrecinctById(electionDefinition, precinctId).name
+ } ${reportType} Report`
+ );
+ }
+
+ if (votingMethod) {
+ return ok(
+ `${partyFullName} ${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report`
+ );
+ }
+
+ if (ballotStyleId) {
+ return ok(
+ `${partyFullName} Ballot Style ${ballotStyleId} ${reportType} Report`
+ );
+ }
}
- if (precinctId && ballotStyleId) {
- return ok(
- `Ballot Style ${ballotStyleId} ${
- getPrecinctById(electionDefinition, precinctId).name
- } Tally Report`
- );
+ // Ballot Style + Other
+ if (ballotStyleId) {
+ if (precinctId) {
+ return ok(
+ `Ballot Style ${ballotStyleId} ${
+ getPrecinctById(electionDefinition, precinctId).name
+ } ${reportType} Report`
+ );
+ }
+
+ if (votingMethod) {
+ return ok(
+ `Ballot Style ${ballotStyleId} ${VOTING_METHOD_LABELS[votingMethod]} Ballot ${reportType} Report`
+ );
+ }
}
- if (precinctId && scannerId) {
- return ok(
- `${
- getPrecinctById(electionDefinition, precinctId).name
- } Scanner ${scannerId} Tally Report`
- );
+ // Precinct + Other
+ if (precinctId) {
+ if (votingMethod) {
+ return ok(
+ `${getPrecinctById(electionDefinition, precinctId).name} ${
+ VOTING_METHOD_LABELS[votingMethod]
+ } Ballot ${reportType} Report`
+ );
+ }
+
+ if (scannerId) {
+ return ok(
+ `${
+ getPrecinctById(electionDefinition, precinctId).name
+ } Scanner ${scannerId} ${reportType} Report`
+ );
+ }
}
+ // Other Combinations
+
if (scannerId && batchId) {
return ok(
`Scanner ${scannerId} Batch ${batchId.slice(
0,
BATCH_ID_TRUNCATE_LENGTH
- )} Tally Report`
+ )} ${reportType} Report`
);
}
}
@@ -415,3 +466,51 @@ export function generateTallyReportCsvFilename({
time,
});
}
+
+export function generateBallotCountReportPdfFilename({
+ election,
+ filter,
+ groupBy,
+ isTestMode,
+ time = new Date(),
+}: {
+ election: Election;
+ filter: Tabulation.Filter;
+ groupBy: Tabulation.GroupBy;
+ isTestMode: boolean;
+ time?: Date;
+}): string {
+ return generateReportFilename({
+ election,
+ filter,
+ groupBy,
+ isTestMode,
+ extension: 'pdf',
+ type: 'ballot-count-report',
+ time,
+ });
+}
+
+export function generateBallotCountReportCsvFilename({
+ election,
+ filter,
+ groupBy,
+ isTestMode,
+ time = new Date(),
+}: {
+ election: Election;
+ filter: Tabulation.Filter;
+ groupBy: Tabulation.GroupBy;
+ isTestMode: boolean;
+ time?: Date;
+}): string {
+ return generateReportFilename({
+ election,
+ filter,
+ groupBy,
+ isTestMode,
+ extension: 'csv',
+ type: 'ballot-count-report',
+ time,
+ });
+}
diff --git a/apps/admin/frontend/test/helpers/api_expect_helpers.ts b/apps/admin/frontend/test/helpers/api_expect_helpers.ts
index fb9d6f9828..af681ea53c 100644
--- a/apps/admin/frontend/test/helpers/api_expect_helpers.ts
+++ b/apps/admin/frontend/test/helpers/api_expect_helpers.ts
@@ -35,23 +35,23 @@ export function expectReportsScreenCardCountQueries({
isPrimary: boolean;
overallCardCount?: Tabulation.CardCounts;
}): void {
- apiMock.expectGetCardCounts(
+ apiMock.deprecatedExpectGetCardCounts(
mockBallotCountsTableGroupBy({ groupByPrecinct: true }),
[]
);
- apiMock.expectGetCardCounts(
+ apiMock.deprecatedExpectGetCardCounts(
mockBallotCountsTableGroupBy({ groupByVotingMethod: true }),
[]
);
if (isPrimary) {
- apiMock.expectGetCardCounts(
+ apiMock.deprecatedExpectGetCardCounts(
mockBallotCountsTableGroupBy({ groupByParty: true }),
[]
);
}
- apiMock.expectGetCardCounts(
+ apiMock.deprecatedExpectGetCardCounts(
mockBallotCountsTableGroupBy({ groupByScanner: true }),
[]
);
- apiMock.expectGetCardCounts({}, [overallCardCount]);
+ apiMock.deprecatedExpectGetCardCounts({}, [overallCardCount]);
}
diff --git a/apps/admin/frontend/test/helpers/mock_api_client.ts b/apps/admin/frontend/test/helpers/mock_api_client.ts
index e5b0e2654e..2dcce64b7c 100644
--- a/apps/admin/frontend/test/helpers/mock_api_client.ts
+++ b/apps/admin/frontend/test/helpers/mock_api_client.ts
@@ -364,13 +364,53 @@ export function createApiMock(
.resolves(ok([]));
},
- expectGetCardCounts(
+ expectExportBallotCountReportCsv({
+ path,
+ filter,
+ groupBy,
+ ballotCountBreakdown,
+ }: {
+ path: string;
+ filter?: Tabulation.Filter;
+ groupBy?: Tabulation.GroupBy;
+ ballotCountBreakdown: Tabulation.BallotCountBreakdown;
+ }) {
+ apiClient.exportBallotCountReportCsv
+ .expectCallWith({ path, groupBy, filter, ballotCountBreakdown })
+ .resolves(ok([]));
+ },
+
+ deprecatedExpectGetCardCounts(
groupBy: Tabulation.GroupBy,
result: Array>
) {
apiClient.getCardCounts.expectCallWith({ groupBy }).resolves(result);
},
+ expectGetCardCounts(
+ input: {
+ filter?: Tabulation.Filter;
+ groupBy?: Tabulation.GroupBy;
+ },
+ results: Array>,
+ deferResult = false
+ ) {
+ const { promise, resolve } =
+ deferred>();
+
+ apiClient.getCardCounts.expectCallWith(input).returns(promise);
+
+ if (!deferResult) {
+ resolve(results);
+ }
+
+ return {
+ resolve: () => {
+ resolve(results);
+ },
+ };
+ },
+
expectGetScannerBatches(result: ScannerBatch[]) {
apiClient.getScannerBatches.expectCallWith().resolves(result);
},
diff --git a/apps/admin/frontend/test/helpers/mock_results.ts b/apps/admin/frontend/test/helpers/mock_results.ts
index 5eebe2049c..77951a0962 100644
--- a/apps/admin/frontend/test/helpers/mock_results.ts
+++ b/apps/admin/frontend/test/helpers/mock_results.ts
@@ -96,3 +96,15 @@ export function getSimpleMockTallyResults({
cardCounts: scannedResults.cardCounts,
};
}
+
+export function getMockCardCounts(
+ bmd: number,
+ manual?: number,
+ ...hmpb: number[]
+): Tabulation.CardCounts {
+ return {
+ bmd,
+ manual,
+ hmpb,
+ };
+}
diff --git a/libs/ui/src/reports/ballot_count_report.test.tsx b/libs/ui/src/reports/ballot_count_report.test.tsx
index b389b7ec3d..192701f0ee 100644
--- a/libs/ui/src/reports/ballot_count_report.test.tsx
+++ b/libs/ui/src/reports/ballot_count_report.test.tsx
@@ -155,7 +155,7 @@ test('can render all attribute columns', () => {
{
'ballot-style': '1M',
batch: 'batch-10',
- party: 'Ma',
+ party: 'Mammal',
precinct: 'Precinct 1',
scanner: 'scanner-1',
total: '5',
@@ -164,7 +164,7 @@ test('can render all attribute columns', () => {
{
'ballot-style': '2F',
batch: 'batch-20',
- party: 'F',
+ party: 'Fish',
precinct: 'Precinct 2',
scanner: 'scanner-2',
total: '5',
@@ -386,6 +386,7 @@ test('title, metadata, and custom filters', () => {
);
screen.getByText('Custom Filter Ballot Count Report');
+ screen.getByText('Example Primary Election');
expect(screen.getByTestId('custom-filter-summary').textContent).toEqual(
'Precinct: Precinct 1'
);
diff --git a/libs/ui/src/reports/ballot_count_report.tsx b/libs/ui/src/reports/ballot_count_report.tsx
index 4d97f2d2f5..d564ece089 100644
--- a/libs/ui/src/reports/ballot_count_report.tsx
+++ b/libs/ui/src/reports/ballot_count_report.tsx
@@ -319,7 +319,7 @@ function BallotCountTable({
content = getPartyById(
electionDefinition,
assertDefined(partyId)
- ).abbrev;
+ ).name;
break;
case 'voting-method':
content =
@@ -438,6 +438,7 @@ export function BallotCountReport({
{title}
+ {electionDefinition.election.title}
{customFilter && (