diff --git a/imports/_test/fixtures.ts b/imports/_test/fixtures.ts
index 3288efa24..a5def8a6f 100644
--- a/imports/_test/fixtures.ts
+++ b/imports/_test/fixtures.ts
@@ -142,7 +142,6 @@ export const client = (title, fn) => {
 		const prepare = async () => {
 			// @ts-expect-error TODO
 			await import('./mocha.css');
-			await import('../../client/polyfill');
 
 			original = await forgetHistory();
 			await cleanup();
@@ -169,8 +168,6 @@ export const server = (title, fn) => {
 		};
 
 		const prepare = async () => {
-			await import('../../server/polyfill');
-
 			if (isAppTest()) {
 				await appIsReady();
 			}
diff --git a/imports/api/_dev/populate/consultations.ts b/imports/api/_dev/populate/consultations.ts
index ea95c138e..94ed64a1b 100644
--- a/imports/api/_dev/populate/consultations.ts
+++ b/imports/api/_dev/populate/consultations.ts
@@ -17,6 +17,9 @@ export const newConsultationFormData = makeTemplate({
 	currency: () => 'EUR',
 	price: () => faker.number.int(150),
 	paid: () => 0,
+	payment_method: () =>
+		faker.helpers.arrayElement(['cash', 'wire', 'third-party']),
+
 	book: () => `${faker.number.int(100)}`,
 });
 
diff --git a/imports/api/_dev/populate/uploads.ts b/imports/api/_dev/populate/uploads.ts
index 4f8b43e4f..97c86e943 100644
--- a/imports/api/_dev/populate/uploads.ts
+++ b/imports/api/_dev/populate/uploads.ts
@@ -4,8 +4,8 @@ import {randomPNGBuffer, randomPNGDataURI} from '../../../_test/png';
 import {type MetadataType, Uploads} from '../../uploads';
 
 export const newUpload = async (
-	invocation,
-	options?,
+	invocation?: {userId?: string},
+	options?: {name?: string},
 ): Promise<FileRef<MetadataType>> => {
 	const type = 'image/png';
 	const fileName = options?.name ?? 'pic.png';
@@ -17,7 +17,7 @@ export const newUpload = async (
 				{
 					fileName,
 					type,
-					userId: invocation.userId,
+					userId: invocation?.userId,
 				},
 				(writeError, fileRef) => {
 					if (writeError) {
diff --git a/imports/api/issues.ts b/imports/api/issues.ts
index 5a8e7018f..2bc759321 100644
--- a/imports/api/issues.ts
+++ b/imports/api/issues.ts
@@ -1,36 +1,72 @@
-import {patientDocument, Patients} from './collection/patients';
-import {consultationDocument, Consultations} from './collection/consultations';
-import {documentDocument, Documents} from './collection/documents';
+import {
+	type PatientDocument,
+	patientDocument,
+	Patients,
+} from './collection/patients';
+import {
+	type ConsultationDocument,
+	consultationDocument,
+	Consultations,
+} from './collection/consultations';
+import {
+	type DocumentDocument,
+	documentDocument,
+	Documents,
+} from './collection/documents';
+import {
+	type AttachmentDocument,
+	attachmentDocument,
+	Attachments,
+} from './collection/attachments';
+
 import makeFilteredCollection from './makeFilteredCollection';
+import type Query from './query/Query';
+import {
+	type DoctorDocument,
+	doctorDocument,
+	Doctors,
+} from './collection/doctors';
 
 export const usePatientsMissingABirthdate = makeFilteredCollection(
 	Patients,
 	patientDocument,
-	{
-		$or: [{birthdate: null!}, {birthdate: ''}],
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<PatientDocument> => ({
+		filter: {
+			$or: [{birthdate: null}, {birthdate: ''}],
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.PatientsMissingABirthdate',
 );
 
 export const usePatientsMissingAGender = makeFilteredCollection(
 	Patients,
 	patientDocument,
-	{
-		$or: [{sex: null!}, {sex: ''}],
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<PatientDocument> => ({
+		filter: {
+			$or: [{sex: null}, {sex: ''}],
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.PatientsMissingAGender',
 );
 
 export const useConsultationsMissingABook = makeFilteredCollection(
 	Consultations,
 	consultationDocument,
-	{
-		isDone: true,
-		$or: [{book: null!}, {book: ''}],
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<ConsultationDocument> => ({
+		filter: {
+			isDone: true,
+			$or: [{book: null}, {book: ''}],
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.ConsultationsMissingABook',
 );
 
@@ -38,81 +74,135 @@ export const useConsultationsWithPriceZeroNotInBookZero =
 	makeFilteredCollection(
 		Consultations,
 		consultationDocument,
-		{
-			isDone: true,
-			price: 0,
-			book: {$ne: '0'},
-		},
-		undefined,
+		(
+			ctx,
+			{filter: userFilter, ...userOptions},
+		): Query<ConsultationDocument> => ({
+			filter: {
+				isDone: true,
+				price: 0,
+				book: {$ne: '0'},
+				...userFilter,
+				owner: ctx.userId,
+			},
+			...userOptions,
+		}),
 		'issues.ConsultationsWithPriceZeroNotInBookZero',
 	);
 
 export const useConsultationsMissingPaymentData = makeFilteredCollection(
 	Consultations,
 	consultationDocument,
-	{
-		isDone: true,
-		datetime: {$gte: new Date(2020, 0, 1)},
-		$or: [
-			/**
-			 * We filter a superset of the following query:
-			 * {
-			 *   $or: [{price: {$not: {$type: 1}}}, {price: Number.NaN}]
-			 * }.
-			 *
-			 * Executing this query through meteor raises the following error:
-			 * "The Mongo server and the Meteor query disagree on how many documents match your query."
-			 * See https://forums.meteor.com/t/the-mongo-server-and-the-meteor-query-disagree-not-type/55086.
-			 */
-			{price: {$not: {$gt: 1}}},
-			{paid: {$not: {$gt: 1}}},
-			/**
-			 * The original query must then be run on the superset loaded in minimongo.
-			 *
-			 * Remark:
-			 * --
-			 * Note that:
-			 *   - true >= 1
-			 *   - '' >= 0
-			 *
-			 * So we cannot use price: {$not: {$gte: 0}} which would correspond to
-			 * a supserset with negative prices included (and excluding prices in
-			 * [0, 1)).
-			 */
-			{currency: {$not: {$type: 2}}},
-			{payment_method: {$not: {$type: 2}}},
-		],
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<ConsultationDocument> => ({
+		filter: {
+			isDone: true,
+			datetime: {$gte: new Date(2020, 0, 1)},
+			$or: [
+				/**
+				 * We filter a superset of the following query:
+				 * {
+				 *   $or: [{price: {$not: {$type: 1}}}, {price: Number.NaN}]
+				 * }.
+				 *
+				 * Executing this query through meteor raises the following error:
+				 * "The Mongo server and the Meteor query disagree on how many documents match your query."
+				 * See https://forums.meteor.com/t/the-mongo-server-and-the-meteor-query-disagree-not-type/55086.
+				 */
+				{price: {$not: {$gt: 1}}},
+				{paid: {$not: {$gt: 1}}},
+				/**
+				 * The original query must then be run on the superset loaded in minimongo.
+				 *
+				 * Remark:
+				 * --
+				 * Note that:
+				 *   - true >= 1
+				 *   - '' >= 0
+				 *
+				 * So we cannot use price: {$not: {$gte: 0}} which would correspond to
+				 * a supserset with negative prices included (and excluding prices in
+				 * [0, 1)).
+				 */
+				{currency: {$not: {$type: 2}}},
+				{payment_method: {$not: {$type: 2}}},
+			],
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.ConsultationsMissingPaymentData',
 );
 
 export const useUnlinkedDocuments = makeFilteredCollection(
 	Documents,
 	documentDocument,
-	{
-		patientId: null!,
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({
+		filter: {
+			patientId: null,
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.UnlinkedDocuments',
 );
 
 export const useMangledDocuments = makeFilteredCollection(
 	Documents,
 	documentDocument,
-	{
-		encoding: null!,
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({
+		filter: {
+			encoding: null,
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.MangledDocuments',
 );
 
 export const useUnparsedDocuments = makeFilteredCollection(
 	Documents,
 	documentDocument,
-	{
-		parsed: false,
-	},
-	undefined,
+	(ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({
+		filter: {
+			parsed: false,
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
 	'issues.UnparsedDocuments',
 );
+
+export const useUnattachedUploads = makeFilteredCollection(
+	Attachments,
+	attachmentDocument,
+	(ctx, {filter: userFilter, ...userOptions}): Query<AttachmentDocument> => ({
+		filter: {
+			$and: [
+				{'meta.attachedToPatients': {$not: {$gt: ''}}},
+				{'meta.attachedToConsultations': {$not: {$gt: ''}}},
+			],
+			...userFilter,
+			userId: ctx.userId,
+		},
+		...userOptions,
+	}),
+	'issues.UnattachedUploads',
+);
+
+export const useDoctorsWithNonAlphabeticalSymbols = makeFilteredCollection(
+	Doctors,
+	doctorDocument,
+	(ctx, {filter: userFilter, ...userOptions}): Query<DoctorDocument> => ({
+		filter: {
+			containsNonAlphabetical: true,
+			...userFilter,
+			owner: ctx.userId,
+		},
+		...userOptions,
+	}),
+	'issues.DoctorsWithNonAlphabeticalSymbols',
+);
diff --git a/imports/api/makeDebouncedResultsQuery.ts b/imports/api/makeDebouncedResultsQuery.ts
index ece754075..6013418de 100644
--- a/imports/api/makeDebouncedResultsQuery.ts
+++ b/imports/api/makeDebouncedResultsQuery.ts
@@ -14,17 +14,18 @@ const makeDebouncedResultsQuery =
 		collection: Collection<T, U>,
 		publication: PublicationEndpoint<[UserQuery<T>]>,
 	) =>
-	(query: UserQuery<T>, deps: DependencyList) => {
+	(query: UserQuery<T> | null, deps: DependencyList) => {
 		const lastValue = useRef<U[]>(init);
 
 		const {loading, results: currentValue} = useQuery(
 			publication,
 			[query],
 			() => {
-				const [selector, options] = queryToSelectorOptionsPair(query);
+				const [selector, options] = queryToSelectorOptionsPair(query!);
 				return collection.find(selector, options);
 			},
 			deps,
+			query !== null,
 		);
 
 		useEffect(() => {
diff --git a/imports/api/makeFilteredCollection.ts b/imports/api/makeFilteredCollection.ts
index 5c8fc2f52..8ed30d695 100644
--- a/imports/api/makeFilteredCollection.ts
+++ b/imports/api/makeFilteredCollection.ts
@@ -3,20 +3,53 @@ import {type DependencyList} from 'react';
 import schema from '../util/schema';
 
 import type Collection from './Collection';
-import type Selector from './query/Selector';
-import {options} from './query/Options';
-import type Options from './query/Options';
+import type Document from './Document';
 
 import defineCollection from './collection/define';
 import define from './publication/define';
 import useCursor from './publication/useCursor';
 import useSubscription from './publication/useSubscription';
 import {AuthenticationLoggedIn} from './Authentication';
-import {userFilter} from './query/UserFilter';
-import type UserFilter from './query/UserFilter';
 import observeSetChanges from './query/observeSetChanges';
-import type Filter from './query/Filter';
 import {publishCursorObserver} from './publication/publishCursors';
+import type UserQuery from './query/UserQuery';
+import queryToSelectorOptionsPair from './query/queryToSelectorOptionsPair';
+import {type AuthenticatedContext} from './publication/Context';
+import type Query from './query/Query';
+import {userQuery} from './query/UserQuery';
+
+type QueryArg<T> = (
+	ctx: AuthenticatedContext,
+	publicationQuery: UserQuery<T>,
+) => Query<T>;
+
+const makeFilteredCollectionPublication = <T extends Document, U>(
+	collection: Collection<T, U>,
+	query: QueryArg<T>,
+	name: string,
+) => {
+	return async function (publicationQuery: UserQuery<T>) {
+		const {filter, ...rest} = query(this, publicationQuery);
+
+		const options = {
+			...rest,
+			skip: 0,
+			limit: 0,
+		};
+
+		const handle = await observeSetChanges(
+			collection,
+			filter,
+			options,
+			publishCursorObserver(this, name),
+		);
+
+		this.onStop(async (error?: Error) => {
+			await handle.emit('stop', error);
+		});
+		this.ready();
+	};
+};
 
 const makeFilteredCollection = <
 	S extends schema.ZodTypeAny,
@@ -24,59 +57,32 @@ const makeFilteredCollection = <
 >(
 	collection: Collection<schema.infer<S>, U>,
 	tSchema: S,
-	filterSelector: Selector<schema.infer<S>> | undefined,
-	filterOptions: Options<schema.infer<S>> | undefined,
+	query: QueryArg<schema.infer<S>>,
 	name: string,
 ) => {
 	const publication = define({
 		name,
 		authentication: AuthenticationLoggedIn,
-		schema: schema.tuple([
-			userFilter(tSchema).nullable(),
-			options(tSchema).nullable(),
-		]),
-		async handle(
-			publicationFilter: UserFilter<schema.infer<S>> | null,
-			publicationOptions: Options<schema.infer<S>> | null,
-		) {
-			const scopedFilter = {
-				...filterSelector,
-				...publicationFilter,
-				owner: this.userId,
-			} as Filter<schema.infer<S>>;
-
-			const options = {
-				...filterOptions,
-				...publicationOptions,
-				skip: 0,
-				limit: 0,
-			};
-
-			const handle = await observeSetChanges(
-				collection,
-				scopedFilter,
-				options,
-				publishCursorObserver(this, name),
-			);
-
-			this.onStop(async (error?: Error) => {
-				await handle.emit('stop', error);
-			});
-			this.ready();
-		},
+		schema: schema.tuple([userQuery(tSchema)]),
+		handle: makeFilteredCollectionPublication(collection, query, name),
 	});
 
 	const Filtered = defineCollection<schema.infer<S>, U>(name);
 
-	return (
-		hookSelector: Selector<schema.infer<S>> = {},
-		options: Options<schema.infer<S>> | undefined = undefined,
-		deps: DependencyList = [],
-	) => {
-		const isLoading = useSubscription(publication, [null, options ?? null]);
+	return (query: UserQuery<schema.infer<S>> | null, deps: DependencyList) => {
+		const isLoading = useSubscription(
+			publication,
+			[{...query, filter: {}}],
+			query !== null,
+		);
 		const loadingSubscription = isLoading();
 		const {loading: loadingResults, results} = useCursor(
-			() => Filtered.find(hookSelector, options),
+			query === null
+				? () => null
+				: () => {
+						const [hookSelector, options] = queryToSelectorOptionsPair(query);
+						return Filtered.find(hookSelector, options);
+				  },
 			deps,
 		);
 		const loading = loadingSubscription || loadingResults;
diff --git a/imports/api/publication/patient/noShows.ts b/imports/api/publication/patient/noShows.ts
index 8b53b190f..292c0974b 100644
--- a/imports/api/publication/patient/noShows.ts
+++ b/imports/api/publication/patient/noShows.ts
@@ -21,7 +21,7 @@ export default define({
 			owner: this.userId,
 			patientId,
 			isDone: false,
-			isCancelled: {$in: [false, null!]},
+			isCancelled: {$in: [false, null]},
 			scheduledDatetime: {$lt: startOfToday()}, // TODO make reactive?
 		};
 		const options = {fields: {_id: 1}};
diff --git a/imports/api/query/Filter.tests.ts b/imports/api/query/Filter.tests.ts
new file mode 100644
index 000000000..6ace3a8fa
--- /dev/null
+++ b/imports/api/query/Filter.tests.ts
@@ -0,0 +1,46 @@
+import {assert} from 'chai';
+
+import schema from '../../util/schema';
+import {isomorphic} from '../../_test/fixtures';
+
+import type ServerFilter from './Filter';
+import {filter as serverFilter} from './Filter';
+
+const _asserInvalidInput = <T>(result: schema.SafeParseReturnType<T, T>) => {
+	assert.isFalse(result.success);
+	assert.lengthOf(result.error.issues, 1);
+	assert.strictEqual(result.error.issues[0]!.message, 'Invalid input');
+};
+
+isomorphic(__filename, () => {
+	it('should work with $where (string)', () => {
+		const filter: ServerFilter<{name: string}> = {
+			$where: 'function() { return this.name !== undefined; }',
+		};
+		const tSchema: schema.ZodType<typeof filter> = serverFilter(
+			schema.object({name: schema.string()}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work with $where (function)', () => {
+		const filter: ServerFilter<{name: string}> = {
+			$where() {
+				return this.name !== '';
+			},
+		};
+		const tSchema: schema.ZodType<typeof filter> = serverFilter(
+			schema.object({name: schema.string()}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should throw an error with $where', () => {
+		// @ts-expect-error -- NOTE: This is on purpose for testing.
+		const filter: ServerFilter<{name: string}> = {$where: true};
+		const tSchema: schema.ZodType<typeof filter> = serverFilter(
+			schema.object({name: schema.string()}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+});
diff --git a/imports/api/query/Filter.ts b/imports/api/query/Filter.ts
index ae667bf87..71d53600e 100644
--- a/imports/api/query/Filter.ts
+++ b/imports/api/query/Filter.ts
@@ -1,6 +1,16 @@
-import {type Condition} from './UserFilter';
+import {chain} from '@iterable-iterator/chain';
+import {map} from '@iterable-iterator/map';
+
+import schema from '../../util/schema';
+
+import {$expr, $text, condition, type Condition} from './UserFilter';
 import type WithId from './WithId';
-import {type FieldSpecifiers, type PropertyType} from './dotNotation';
+import {
+	fieldSpecifiers,
+	type FieldSpecifiers,
+	propertyType,
+	type PropertyType,
+} from './dotNotation';
 
 type RootFilterOperators<TSchema> = {
 	$expr?: Record<string, any>;
@@ -13,9 +23,11 @@ type RootFilterOperators<TSchema> = {
 		$caseSensitive?: boolean;
 		$diacriticSensitive?: boolean;
 	};
-	$where?: string | ((this: TSchema) => boolean);
+	$where?: string | WhereFunction<TSchema>;
 };
 
+type WhereFunction<TSchema> = (this: TSchema) => boolean;
+
 type StrictFilter<TSchema> = {
 	[P in FieldSpecifiers<WithId<TSchema>>]?: Condition<
 		PropertyType<WithId<TSchema>, P>
@@ -23,3 +35,40 @@ type StrictFilter<TSchema> = {
 } & RootFilterOperators<WithId<TSchema>>;
 
 export default StrictFilter;
+
+export const $where = schema.union([
+	schema.string(),
+	schema.instanceof(Function),
+]);
+
+export const filter = <S extends schema.ZodTypeAny>(
+	tSchema: S,
+): schema.ZodType<StrictFilter<schema.infer<S>>> => {
+	const as = schema.lazy(() => schema.array(s));
+	const s = schema
+		.object(
+			Object.fromEntries(
+				chain(
+					map(
+						(key: string) => [
+							key,
+							condition(propertyType(tSchema, key) as any),
+						],
+						fieldSpecifiers(tSchema),
+					),
+					[
+						['$and', as],
+						['$nor', as],
+						['$or', as],
+						['$expr', $expr(tSchema)],
+						['$text', $text],
+						['$where', $where],
+					],
+				),
+			),
+		)
+		.strict()
+		.partial();
+
+	return s;
+};
diff --git a/imports/api/query/Options.ts b/imports/api/query/Options.ts
index 837085eba..076fe01d2 100644
--- a/imports/api/query/Options.ts
+++ b/imports/api/query/Options.ts
@@ -11,7 +11,7 @@ import sort from './sort';
 type Options<T> = Pick<Mongo.Options<T>, 'fields' | 'sort' | 'skip' | 'limit'>;
 
 /**
- * @deprecated Use userFilter instead.
+ * @deprecated Use userQuery instead.
  */
 export const options = <S extends schema.ZodTypeAny>(
 	tSchema: S,
diff --git a/imports/api/query/Query.ts b/imports/api/query/Query.ts
index 2b371eceb..def2fef86 100644
--- a/imports/api/query/Query.ts
+++ b/imports/api/query/Query.ts
@@ -1,9 +1,30 @@
-import type Options from './Options';
-import type Selector from './Selector';
+import schema from '../../util/schema';
+
+import sort, {type Sort} from './sort';
+import {projection} from './Projection';
+import type Projection from './Projection';
+import type Filter from './Filter';
+import {filter} from './Filter';
 
 type Query<T> = {
-	selector: Selector<T>;
-	options: Options<T> | null;
+	filter: Filter<T>;
+	projection?: Projection<T>;
+	sort?: Sort<T>;
+	skip?: number;
+	limit?: number;
 };
 
+export const query = <S extends schema.ZodTypeAny>(
+	tSchema: S,
+): schema.ZodType<Query<schema.infer<S>>> =>
+	schema
+		.object({
+			filter: filter(tSchema),
+			projection: projection(tSchema).optional(),
+			sort: sort(tSchema).optional(),
+			skip: schema.number().int().optional(),
+			limit: schema.number().int().optional(),
+		})
+		.strict() as schema.ZodType<Query<schema.infer<S>>>;
+
 export default Query;
diff --git a/imports/api/query/UserFilter.tests.ts b/imports/api/query/UserFilter.tests.ts
new file mode 100644
index 000000000..3bb90c750
--- /dev/null
+++ b/imports/api/query/UserFilter.tests.ts
@@ -0,0 +1,124 @@
+import {assert} from 'chai';
+
+import schema from '../../util/schema';
+import {isomorphic} from '../../_test/fixtures';
+
+import type UserFilter from './UserFilter';
+import {userFilter} from './UserFilter';
+
+const _asserInvalidInput = <T>(result: schema.SafeParseReturnType<T, T>) => {
+	assert.isFalse(result.success);
+	assert.lengthOf(result.error.issues, 1);
+	assert.strictEqual(result.error.issues[0]!.message, 'Invalid input');
+};
+
+isomorphic(__filename, () => {
+	it('should work on a simple schema', () => {
+		const filter: UserFilter<{name: string}> = {name: 'abcd'};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.string()}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a simple schema (NaN)', () => {
+		const filter: UserFilter<{count: number}> = {count: Number.NaN};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({count: schema.number()}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a schema with an optional field', () => {
+		const filter: UserFilter<{name?: string}> = {name: null};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.string().optional()}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a partial object schema', () => {
+		const filter: UserFilter<Partial<{name: string}>> = {name: null};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.string()}).partial(),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a schema containing an array (single value filter)', () => {
+		const filter: UserFilter<{name: string[]}> = {name: 'abcd'};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.array(schema.string())}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a schema containing an array (array filter)', () => {
+		const filter: UserFilter<{name: string[]}> = {name: ['abcd']};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.array(schema.string())}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should work on a schema containing an array (array `$elemMatch` filter)', () => {
+		const filter: UserFilter<{relations: Array<{name: string}>}> = {
+			relations: {$elemMatch: {name: 'abcd'}},
+		};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({
+				relations: schema.array(schema.object({name: schema.string()})),
+			}),
+		);
+		assert.deepEqual(tSchema.parse(filter), filter);
+	});
+
+	it('should throw error on a simple schema', () => {
+		// @ts-expect-error -- NOTE: This is on purpose for testing.
+		const filter: UserFilter<{name: string}> = {name: 1};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.string()}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+
+	it('should throw error on a simple schema (null)', () => {
+		// @ts-expect-error -- NOTE: This is on purpose for testing.
+		const filter: UserFilter<{name: string}> = {name: null};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.string()}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+
+	it('should throw an error on a schema containing an array (single value filter)', () => {
+		// @ts-expect-error -- NOTE: This is on purpose for testing.
+		const filter: UserFilter<{name: string[]}> = {name: 1};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.array(schema.string())}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+
+	it('should throw an error on a schema containing an array (array filter)', () => {
+		// @ts-expect-error -- NOTE: This is on purpose for testing.
+		const filter: UserFilter<{name: string[]}> = {name: [1]};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({name: schema.array(schema.string())}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+
+	it('should throw an error on a schema containing an array (array `$elemMatch` filter)', () => {
+		const filter: UserFilter<{relations: Array<{name: string}>}> = {
+			// @ts-expect-error -- NOTE: This is on purpose for testing.
+			relations: {$elemMatch: {name: 1}},
+		};
+		const tSchema: schema.ZodType<typeof filter> = userFilter(
+			schema.object({
+				relations: schema.array(schema.object({name: schema.string()})),
+			}),
+		);
+		_asserInvalidInput(tSchema.safeParse(filter));
+	});
+});
diff --git a/imports/api/query/UserFilter.ts b/imports/api/query/UserFilter.ts
index ed23acefb..e11113cc2 100644
--- a/imports/api/query/UserFilter.ts
+++ b/imports/api/query/UserFilter.ts
@@ -37,14 +37,14 @@ type UserFilter<TSchema> = {
 	>;
 } & RootFilterOperators<WithId<TSchema>>;
 
-const $text = schema.object({
+export const $text = schema.object({
 	$search: schema.string(),
 	$language: schema.string().optional(),
 	$caseSensitive: schema.boolean().optional(),
 	$diacriticSensitive: schema.boolean().optional(),
 });
 
-const $expr = <S extends schema.ZodTypeAny>(
+export const $expr = <S extends schema.ZodTypeAny>(
 	// @ts-expect-error TODO
 	tSchema: S /* TODO */,
 ) => schema.record(schema.string(), schema.any());
@@ -86,11 +86,21 @@ export type Condition<T> =
  * string types can be searched using a regex in mongo
  * array types can be searched using their element type
  */
-type AlternativeType<T> = T extends ReadonlyArray<infer U>
-	? T | Condition<U>
-	: RegExpOrString<T>;
+type AlternativeType<T> = T extends undefined
+	? null | T
+	: T extends Array<infer U>
+	? ArrayFilter<U, T>
+	: T extends string
+	? StringFilter<T>
+	: T extends number
+	? NumberFilter<T>
+	: T;
 
-type RegExpOrString<T> = T extends string ? BSONRegExp | RegExp | T : T;
+type ArrayFilter<U, T extends readonly U[] = readonly U[]> = T | Condition<U>;
+
+type StringFilter<T extends string> = BSONRegExp | RegExp | T;
+
+type NumberFilter<T extends number> = T;
 
 export const condition = <S extends schema.ZodTypeAny>(
 	tSchema: S,
@@ -102,27 +112,43 @@ export const condition = <S extends schema.ZodTypeAny>(
 const alternativeType = <S extends schema.ZodTypeAny>(
 	tSchema: S,
 ): schema.ZodType<AlternativeType<schema.infer<S>>> => {
-	tSchema = unwrap(tSchema);
+	const [_tSchema, wrap] = unwrap(tSchema);
 
-	if (tSchema instanceof schema.ZodArray) {
-		return schema.union([tSchema, condition(tSchema.element)]);
+	if (_tSchema instanceof schema.ZodArray) {
+		return wrap(arrayFilter(_tSchema));
+	}
+
+	if (_tSchema instanceof schema.ZodString) {
+		return wrap(stringFilter(_tSchema));
+	}
+
+	if (_tSchema instanceof schema.ZodNumber) {
+		return wrap(numberFilter(_tSchema));
 	}
 
-	return regExpOrString(tSchema) as any;
+	return wrap(_tSchema);
 };
 
-const regExpOrString = <S extends schema.ZodTypeAny>(
+const arrayFilter = <U extends schema.ZodTypeAny, S extends schema.ZodArray<U>>(
 	tSchema: S,
-): schema.ZodType<RegExpOrString<schema.infer<S>>> => {
-	if (unwrap(tSchema) instanceof schema.ZodString) {
-		return schema.union([
-			tSchema,
-			schema.instanceof(RegExp),
-			// schema.instanceof(BSONRegExp), // TODO This requires an explicit dependency on bson.
-		]) as any;
-	}
+): schema.ZodType<ArrayFilter<schema.infer<U>, schema.infer<S>>> => {
+	return schema.union([tSchema, condition(tSchema.element)]);
+};
 
-	return tSchema;
+const stringFilter = <S extends schema.ZodString>(
+	tSchema: S,
+): schema.ZodType<StringFilter<schema.infer<S>>> => {
+	return schema.union([
+		tSchema,
+		schema.instanceof(RegExp),
+		// schema.instanceof(BSONRegExp), // TODO This requires an explicit dependency on bson.
+	]);
+};
+
+const numberFilter = <S extends schema.ZodNumber>(
+	tSchema: S,
+): schema.ZodType<NumberFilter<schema.infer<S>>> => {
+	return schema.union([tSchema, schema.nan()]);
 };
 
 export type FilterOperators<TValue> = {
@@ -187,7 +213,7 @@ export const filterOperators = <S extends schema.ZodTypeAny>(
 			$nin: schema.array(tSchema.nullable()),
 			// Logical
 			$not: schema.lazy(() =>
-				unwrap(tSchema) instanceof schema.ZodString
+				unwrap(tSchema)[0] instanceof schema.ZodString
 					? schema.union(s, schema.instanceof(RegExp))
 					: s,
 			),
@@ -226,27 +252,69 @@ export const filterOperators = <S extends schema.ZodTypeAny>(
 	return s;
 };
 
-const unwrap = <S extends schema.ZodTypeAny>(tSchema: S) => {
-	if (
-		tSchema instanceof schema.ZodOptional ||
-		tSchema instanceof schema.ZodBranded
-	) {
-		return unwrap(tSchema.unwrap());
+const unwrap = <S extends schema.ZodTypeAny>(tSchema: S) =>
+	_unwrap(tSchema, (x: S) => x);
+
+const _unwrap = <W extends schema.ZodTypeAny, S extends schema.ZodTypeAny = W>(
+	tSchema: S,
+	wrap: (tSchema: S) => W,
+) => {
+	if (tSchema instanceof schema.ZodOptional) {
+		const [_tSchema, _wrap] = _unwrapOptional(tSchema);
+		return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const;
+	}
+
+	if (tSchema instanceof schema.ZodNullable) {
+		const [_tSchema, _wrap] = _unwrapNullable(tSchema);
+		return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const;
 	}
 
-	return tSchema;
+	if (tSchema instanceof schema.ZodBranded) {
+		const [_tSchema, _wrap] = _unwrapBranded(tSchema);
+		return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const;
+	}
+
+	return [tSchema, wrap] as const;
+};
+
+const _unwrapOptional = <
+	S extends schema.ZodOptional<T>,
+	T extends schema.ZodTypeAny,
+>(
+	tSchema: S,
+) => {
+	return _unwrap(tSchema.unwrap(), (x) => x.nullable());
+};
+
+const _unwrapNullable = <
+	S extends schema.ZodNullable<T>,
+	T extends schema.ZodTypeAny,
+>(
+	tSchema: S,
+) => {
+	return _unwrap(tSchema.unwrap(), (x) => x.nullable());
+};
+
+const _unwrapBranded = <
+	S extends schema.ZodBranded<T, B>,
+	T extends schema.ZodTypeAny,
+	B extends string,
+>(
+	tSchema: S,
+) => {
+	return _unwrap(tSchema.unwrap(), (x) => x.brand<B>());
 };
 
 const operator =
 	<O extends (s: schema.ZodTypeAny) => schema.ZodTypeAny>(op: O) =>
 	<S extends schema.ZodTypeAny>(tSchema: S) => {
-		tSchema = unwrap(tSchema);
+		const [_tSchema, wrap] = unwrap(tSchema);
 
-		if (tSchema instanceof schema.ZodUnion) {
-			return schema.union(tSchema.options.map(op));
+		if (_tSchema instanceof schema.ZodUnion) {
+			return wrap(schema.union(_tSchema.options.map(op)));
 		}
 
-		return op(tSchema);
+		return wrap(op(_tSchema));
 	};
 
 const $all = operator(<S extends schema.ZodTypeAny>(tSchema: S) => {
diff --git a/imports/ui/App.tests.tsx b/imports/ui/App.tests.tsx
index 18b728632..b88b99a52 100644
--- a/imports/ui/App.tests.tsx
+++ b/imports/ui/App.tests.tsx
@@ -1,5 +1,7 @@
 import React from 'react';
 
+import {type BoundFunctions, type queries} from '@testing-library/dom';
+
 import {render, waitForElementToBeRemoved} from '../_test/react';
 
 import {client, randomPassword, randomUserId} from '../_test/fixtures';
@@ -10,7 +12,7 @@ const setup = async (jsx: JSX.Element) => {
 	const {default: userEvent} = await import('@testing-library/user-event');
 	return {
 		user: userEvent.setup(),
-		...render(jsx),
+		...(render(jsx) as BoundFunctions<typeof queries>),
 	};
 };
 
@@ -32,23 +34,17 @@ client(__filename, () => {
 	});
 
 	it('should allow to register a new user', async () => {
+		const {createUserWithPasswordAndLogin} = await import(
+			'../../test/app/client/fixtures'
+		);
 		const username = randomUserId();
 		const password = randomPassword();
-		const {getByRole, findByRole, getByLabelText, user} = await setupApp();
-		await user.click(await findByRole('button', {name: 'Sign in'}));
-		await user.click(await findByRole('button', {name: 'Create account?'}));
-		await findByRole('button', {name: 'Register'});
-		await user.type(getByLabelText('Username'), username);
-		await user.type(getByLabelText('Password'), password);
-		await user.click(getByRole('button', {name: 'Register'}));
-		await waitForElementToBeRemoved(
-			() => {
-				return getByRole('button', {name: 'Register'});
-			},
-			{timeout: 5000},
-		);
-		await user.click(
-			await findByRole('button', {name: `Logged in as ${username}`}),
+		const app = await setupApp();
+		const button = await createUserWithPasswordAndLogin(
+			app,
+			username,
+			password,
 		);
+		await app.user.click(button);
 	});
 });
diff --git a/imports/ui/Routing.tsx b/imports/ui/Routing.tsx
index 33bb89f59..185326a56 100644
--- a/imports/ui/Routing.tsx
+++ b/imports/ui/Routing.tsx
@@ -157,7 +157,7 @@ export default function Routing() {
 				<Route element={<Stats />} path="stats" />
 				<Route element={<SEPAPaymentDetails />} path="sepa" />
 
-				<Route element={<Issues />} path="issues" />
+				<Route element={<Issues />} path="issues/*" />
 				<Route element={<MergePatientsForm />} path="merge" />
 
 				<Route element={<DoctorsListRoutes />} path="doctors/*" />
diff --git a/imports/ui/appointments/AppointmentsForPatient.tsx b/imports/ui/appointments/AppointmentsForPatient.tsx
index ef9f723ba..2fe8751e2 100644
--- a/imports/ui/appointments/AppointmentsForPatient.tsx
+++ b/imports/ui/appointments/AppointmentsForPatient.tsx
@@ -30,7 +30,7 @@ const _filter = (patientId: string, {showCancelled, showNoShow}) => {
 		$or: removeUndefinedValuesFromArray([
 			removeUndefinedValuesFromObject({
 				isCancelled: {
-					$in: [false, null!],
+					$in: [false, null],
 				},
 				scheduledDatetime: showNoShow
 					? undefined
diff --git a/imports/ui/attachments/AttachmentCard.tsx b/imports/ui/attachments/AttachmentCard.tsx
index 1c1c0b083..8e3feb8bf 100644
--- a/imports/ui/attachments/AttachmentCard.tsx
+++ b/imports/ui/attachments/AttachmentCard.tsx
@@ -49,32 +49,43 @@ const classes = {
 	thumbnail: `${PREFIX}-thumbnail`,
 };
 
-const StyledCard = styled(Card)(({theme}) => ({
-	[`&.${classes.card}`]: {
-		display: 'block',
-		margin: theme.spacing(1),
-	},
-
-	[`& .${classes.headerContent}`]: {
-		overflow: 'hidden',
-	},
-
-	[`& .${classes.headerContentTitle}`]: {
-		overflow: 'hidden',
-		textOverflow: 'ellipsis',
-		whiteSpace: 'nowrap',
-	},
-
-	[`& .${classes.headerContentSubheader}`]: {
-		overflow: 'hidden',
-		textOverflow: 'ellipsis',
-		whiteSpace: 'nowrap',
-	},
-
-	[`& .${classes.thumbnail}`]: {
-		height: 300,
-	},
-}));
+type AdditionalProps = {
+	loading: boolean;
+};
+
+const additionalProps = new Set<number | string | Symbol>(['loading']);
+const shouldForwardProp = (prop: string | number | Symbol) =>
+	!additionalProps.has(prop);
+const StyledCard = styled(Card, {shouldForwardProp})<AdditionalProps>(
+	({theme, loading}) => ({
+		[`&.${classes.card}`]: {
+			display: 'block',
+			margin: theme.spacing(1),
+			transition: 'opacity 500ms ease-out',
+			opacity: loading ? 0.7 : 1,
+		},
+
+		[`& .${classes.headerContent}`]: {
+			overflow: 'hidden',
+		},
+
+		[`& .${classes.headerContentTitle}`]: {
+			overflow: 'hidden',
+			textOverflow: 'ellipsis',
+			whiteSpace: 'nowrap',
+		},
+
+		[`& .${classes.headerContentSubheader}`]: {
+			overflow: 'hidden',
+			textOverflow: 'ellipsis',
+			whiteSpace: 'nowrap',
+		},
+
+		[`& .${classes.thumbnail}`]: {
+			height: 300,
+		},
+	}),
+);
 
 const initialState = {
 	menu: null,
@@ -144,11 +155,12 @@ export type AttachmentInfo = {
 };
 
 type Props = {
+	readonly loading?: boolean;
 	readonly attachment: AttachmentDocument;
 	readonly info?: AttachmentInfo;
 };
 
-const AttachmentCard = ({attachment, info}: Props) => {
+const AttachmentCard = ({loading = false, attachment, info}: Props) => {
 	const [state, dispatch] = useReducer(reducer, initialState);
 
 	const {menu, editing, linking, deleting, superDeleting} = state;
@@ -190,7 +202,7 @@ const AttachmentCard = ({attachment, info}: Props) => {
 	const open = Boolean(menu);
 
 	return (
-		<StyledCard className={classes.card}>
+		<StyledCard className={classes.card} loading={loading}>
 			<CardHeader
 				classes={headerClasses}
 				avatar={
@@ -319,6 +331,7 @@ const AttachmentCard = ({attachment, info}: Props) => {
 					width={450}
 					height={300}
 					attachmentId={attachment._id}
+					title={attachment.name}
 				/>
 			</CardActionArea>
 		</StyledCard>
diff --git a/imports/ui/attachments/AttachmentsForPatientStatic.tsx b/imports/ui/attachments/AttachmentsForPatientStatic.tsx
index d204c312b..8a9ebd52d 100644
--- a/imports/ui/attachments/AttachmentsForPatientStatic.tsx
+++ b/imports/ui/attachments/AttachmentsForPatientStatic.tsx
@@ -13,7 +13,6 @@ import AttachmentsForPatientPager from './AttachmentsForPatientPager';
 
 type Props = {
 	readonly patientId: string;
-	// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
 } & PropsOf<typeof AttachmentsForPatientPager>;
 
 const AttachmentsForPatientStatic = ({patientId, ...rest}: Props) => {
diff --git a/imports/ui/attachments/AttachmentsGrid.tsx b/imports/ui/attachments/AttachmentsGrid.tsx
index 3ad365e8a..8dbfdbf3d 100644
--- a/imports/ui/attachments/AttachmentsGrid.tsx
+++ b/imports/ui/attachments/AttachmentsGrid.tsx
@@ -9,11 +9,13 @@ import {type AttachmentDocument} from '../../api/collection/attachments';
 import AttachmentCard, {type AttachmentInfo} from './AttachmentCard';
 
 type AttachmentsGridProps = {
+	readonly loading?: boolean;
 	readonly attachments: AttachmentDocument[];
 	readonly attachmentsInfo?: Map<string, AttachmentInfo>;
 } & PropsOf<typeof Grid>;
 
 const AttachmentsGrid = ({
+	loading,
 	attachments,
 	attachmentsInfo,
 	...rest
@@ -22,6 +24,7 @@ const AttachmentsGrid = ({
 		{attachments.map((attachment) => (
 			<Grid key={attachment._id} item sm={12} md={4} xl={3}>
 				<AttachmentCard
+					loading={loading}
 					attachment={attachment}
 					info={attachmentsInfo?.get(attachment._id)}
 				/>
diff --git a/imports/ui/attachments/PagedAttachmentsList.tsx b/imports/ui/attachments/PagedAttachmentsList.tsx
new file mode 100644
index 000000000..6b7601e97
--- /dev/null
+++ b/imports/ui/attachments/PagedAttachmentsList.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import type PropsOf from '../../util/types/PropsOf';
+
+import Loading from '../navigation/Loading';
+import Paginator from '../navigation/Paginator';
+import Refresh from '../navigation/Refresh';
+import NoContent from '../navigation/NoContent';
+
+import {type AttachmentDocument} from '../../api/collection/attachments';
+
+import AttachmentsGrid from './AttachmentsGrid';
+
+type PagedAttachmentsListProps = {
+	readonly page: number;
+	readonly perpage: number;
+	readonly items: AttachmentDocument[];
+	readonly loading?: boolean;
+	readonly dirty?: boolean;
+	readonly refresh?: () => void;
+	readonly LoadingIndicator?: React.ElementType<{}>;
+	readonly EmptyPage?: React.ElementType<{page: number}>;
+} & Omit<PropsOf<typeof AttachmentsGrid>, 'attachments'>;
+
+const DefaultLoadingIndicator = Loading;
+const DefaultEmptyPage = ({page}: {readonly page: number}) => (
+	<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+);
+
+const PagedAttachmentsList = ({
+	loading = false,
+	page,
+	perpage,
+	items,
+	refresh = undefined,
+	dirty = false,
+	LoadingIndicator = DefaultLoadingIndicator,
+	EmptyPage = DefaultEmptyPage,
+	...rest
+}: PagedAttachmentsListProps) => (
+	<div>
+		{loading && items.length === 0 ? (
+			<LoadingIndicator />
+		) : items.length > 0 ? (
+			<AttachmentsGrid loading={loading} attachments={items} {...rest} />
+		) : (
+			<EmptyPage page={page} />
+		)}
+		<Paginator loading={loading} end={items.length < perpage} />
+		{refresh && dirty && <Refresh onClick={refresh} />}
+	</div>
+);
+
+export default PagedAttachmentsList;
diff --git a/imports/ui/attachments/makeAttachmentsPage.tsx b/imports/ui/attachments/makeAttachmentsPage.tsx
new file mode 100644
index 000000000..e7e9f1090
--- /dev/null
+++ b/imports/ui/attachments/makeAttachmentsPage.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+
+import type PropsOf from '../../util/types/PropsOf';
+
+import {type Sort} from '../../api/query/sort';
+import type UserFilter from '../../api/query/UserFilter';
+import type GenericQueryHook from '../../api/GenericQueryHook';
+
+import {type AttachmentDocument} from '../../api/collection/attachments';
+
+import PagedAttachmentsList from './PagedAttachmentsList';
+
+type Props = {
+	readonly url?: string;
+	readonly page?: number;
+	readonly perpage?: number;
+
+	readonly filter?: UserFilter<AttachmentDocument>;
+	readonly sort: Sort<AttachmentDocument>;
+} & Omit<PropsOf<typeof PagedAttachmentsList>, 'page' | 'perpage' | 'items'>;
+
+const makeAttachmentsPage =
+	(useUploads: GenericQueryHook<AttachmentDocument>) =>
+	({filter = {}, sort, page = 1, perpage = 10, url, ...rest}: Props) => {
+		const query = {
+			filter,
+			sort,
+			skip: (page - 1) * perpage,
+			limit: perpage,
+		};
+
+		const deps = [JSON.stringify(query)];
+
+		const {loading, results: items} = useUploads(query, deps);
+
+		return (
+			<PagedAttachmentsList
+				loading={loading}
+				page={page}
+				perpage={perpage}
+				items={items}
+				{...rest}
+			/>
+		);
+	};
+
+export default makeAttachmentsPage;
diff --git a/imports/ui/cards/AnimatedCardMedia.tsx b/imports/ui/cards/AnimatedCardMedia.tsx
index 47034f916..c091446b1 100644
--- a/imports/ui/cards/AnimatedCardMedia.tsx
+++ b/imports/ui/cards/AnimatedCardMedia.tsx
@@ -33,6 +33,7 @@ const AnimatedCardMedia = ({
 	loading,
 	image,
 	placeholder,
+	title,
 	...rest
 }: AnimatedCardMediaProps) => {
 	const transition = useTransition(image, {
@@ -51,6 +52,7 @@ const AnimatedCardMedia = ({
 							component={animated.div}
 							className={classes.item}
 							image={item}
+							title={style.opacity.goal === 1 ? title : undefined}
 							style={style as unknown as React.CSSProperties}
 							{...rest}
 						/>
diff --git a/imports/ui/consultations/ConsultationForm.tsx b/imports/ui/consultations/ConsultationForm.tsx
index b17033ab3..9e18db52a 100644
--- a/imports/ui/consultations/ConsultationForm.tsx
+++ b/imports/ui/consultations/ConsultationForm.tsx
@@ -390,7 +390,7 @@ const _priceProps = ({
 		case PriceStatus.SHOULD_NOT_BE_ZERO: {
 			return {
 				color: 'warning',
-				helperText: 'Price is zero but book is not special',
+				helperText: 'Price is zero but book is not `0`',
 				focused: true,
 			};
 		}
diff --git a/imports/ui/consultations/ConsultationsForPatient.tsx b/imports/ui/consultations/ConsultationsForPatient.tsx
index b40d7c3f1..0a97b7acf 100644
--- a/imports/ui/consultations/ConsultationsForPatient.tsx
+++ b/imports/ui/consultations/ConsultationsForPatient.tsx
@@ -36,7 +36,7 @@ const ConsultationsForPatient = ({patientId}: Props) => {
 		isDone: true,
 	};
 
-	const sort = {datetime: -1};
+	const sort = {datetime: -1} as const;
 
 	return (
 		<>
diff --git a/imports/ui/consultations/ConsultationsPage.ts b/imports/ui/consultations/ConsultationsPage.ts
new file mode 100644
index 000000000..e1af12281
--- /dev/null
+++ b/imports/ui/consultations/ConsultationsPage.ts
@@ -0,0 +1,9 @@
+import makeConsultationsPage from './makeConsultationsPage';
+
+import useConsultationsAndAppointments from './useConsultationsAndAppointments';
+
+const ConsultationsPage = makeConsultationsPage(
+	useConsultationsAndAppointments,
+);
+
+export default ConsultationsPage;
diff --git a/imports/ui/consultations/ConsultationsPage.tsx b/imports/ui/consultations/ConsultationsPage.tsx
deleted file mode 100644
index 69169ae1e..000000000
--- a/imports/ui/consultations/ConsultationsPage.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-
-import {type ConsultationDocument} from '../../api/collection/consultations';
-import type UserFilter from '../../api/query/UserFilter';
-import type PropsOf from '../../util/types/PropsOf';
-
-import PagedConsultationsList from './PagedConsultationsList';
-import useConsultationsAndAppointments from './useConsultationsAndAppointments';
-
-type Props = {
-	readonly url?: string;
-	readonly page?: number;
-	readonly perpage?: number;
-
-	readonly filter?: UserFilter<ConsultationDocument>;
-	readonly sort: object;
-	readonly defaultExpandedFirst?: boolean;
-} & Omit<PropsOf<typeof PagedConsultationsList>, 'page' | 'perpage' | 'items'>;
-
-const ConsultationsPage = ({
-	filter = {},
-	sort,
-	page = 1,
-	perpage = 10,
-	url,
-	defaultExpandedFirst = false,
-	...rest
-}: Props) => {
-	const query = {
-		filter,
-		sort,
-		skip: (page - 1) * perpage,
-		limit: perpage,
-	};
-
-	const deps = [JSON.stringify(query)];
-
-	const {loading, results: items} = useConsultationsAndAppointments(
-		query,
-		deps,
-	);
-
-	return (
-		<PagedConsultationsList
-			loading={loading}
-			page={page}
-			perpage={perpage}
-			items={items}
-			defaultExpandedFirst={page === 1 && defaultExpandedFirst}
-			{...rest}
-		/>
-	);
-};
-
-export default ConsultationsPage;
diff --git a/imports/ui/consultations/PagedConsultationsList.tsx b/imports/ui/consultations/PagedConsultationsList.tsx
index 7b71c9b28..7f1b6a837 100644
--- a/imports/ui/consultations/PagedConsultationsList.tsx
+++ b/imports/ui/consultations/PagedConsultationsList.tsx
@@ -16,8 +16,15 @@ type PagedConsultationsListProps = {
 	readonly loading?: boolean;
 	readonly dirty?: boolean;
 	readonly refresh?: () => void;
+	readonly LoadingIndicator?: React.ElementType<{}>;
+	readonly EmptyPage?: React.ElementType<{page: number}>;
 } & PropsOf<typeof ConsultationsList>;
 
+const DefaultLoadingIndicator = Loading;
+const DefaultEmptyPage = ({page}: {readonly page: number}) => (
+	<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+);
+
 const PagedConsultationsList = ({
 	loading = false,
 	page,
@@ -25,15 +32,17 @@ const PagedConsultationsList = ({
 	items,
 	refresh = undefined,
 	dirty = false,
+	LoadingIndicator = DefaultLoadingIndicator,
+	EmptyPage = DefaultEmptyPage,
 	...rest
 }: PagedConsultationsListProps) => (
 	<div>
 		{loading && items.length === 0 ? (
-			<Loading />
+			<LoadingIndicator />
 		) : items.length > 0 ? (
 			<ConsultationsList loading={loading} items={items} {...rest} />
 		) : (
-			<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+			<EmptyPage page={page} />
 		)}
 		<Paginator loading={loading} end={items.length < perpage} />
 		{refresh && dirty && <Refresh onClick={refresh} />}
diff --git a/imports/ui/consultations/PaidConsultationsList.tsx b/imports/ui/consultations/PaidConsultationsList.tsx
index 1f7c3a38a..6a11dac40 100644
--- a/imports/ui/consultations/PaidConsultationsList.tsx
+++ b/imports/ui/consultations/PaidConsultationsList.tsx
@@ -41,14 +41,14 @@ const PaidConsultationsList = ({year, payment_method = undefined}: Props) => {
 	if (payment_method) filter.payment_method = payment_method;
 	if (!showBookZero) filter.book = {$ne: '0'};
 
-	const sort = {datetime: -1};
+	const sort = {datetime: -1} as const;
 
-	const genericToURL = (method) =>
-		method
-			? (x) => `/paid/year/${x}/payment_method/${method}`
-			: (x) => `/paid/year/${x}`;
+	const genericToURL = (method: PaymentMethod | undefined) =>
+		method === undefined
+			? (x: number) => `/paid/year/${x}`
+			: (x: number) => `/paid/year/${x}/payment_method/${method}`;
 	const toURL = genericToURL(payment_method);
-	const toggle = (method) =>
+	const toggle = (method: PaymentMethod) =>
 		payment_method === method
 			? genericToURL(undefined)(year)
 			: genericToURL(method)(year);
diff --git a/imports/ui/consultations/StaticConsultationCardChips.tsx b/imports/ui/consultations/StaticConsultationCardChips.tsx
index 0b55188cc..3243c5b37 100644
--- a/imports/ui/consultations/StaticConsultationCardChips.tsx
+++ b/imports/ui/consultations/StaticConsultationCardChips.tsx
@@ -18,7 +18,10 @@ import UnstyledLinkChip from '../chips/LinkChip';
 import {useDateFormat} from '../../i18n/datetime';
 import {useCurrencyFormat} from '../../i18n/currency';
 import {type PatientDocument} from '../../api/collection/patients';
-import {type ConsultationDocument} from '../../api/collection/consultations';
+import {
+	type PaymentMethod,
+	type ConsultationDocument,
+} from '../../api/collection/consultations';
 import {msToString, msToStringShort} from '../../api/duration';
 
 const Chips = styled('div')(({theme}) => ({
@@ -35,7 +38,8 @@ const additionalProps = new Set<number | string | Symbol>([
 	'isDebt',
 	'bold',
 ]);
-const shouldForwardProp = (prop) => !additionalProps.has(prop);
+const shouldForwardProp = (prop: string | number | Symbol) =>
+	!additionalProps.has(prop);
 const styles: any = ({
 	theme,
 	didNotOrWillNotHappen,
@@ -74,7 +78,7 @@ const LinkChip = styled(UnstyledLinkChip, {
 	shouldForwardProp,
 })<AdditionalChipProps>(styles);
 
-function paymentMethodIcon(payment_method) {
+function paymentMethodIcon(payment_method: PaymentMethod | undefined) {
 	switch (payment_method) {
 		case 'wire': {
 			return <PaymentIcon />;
diff --git a/imports/ui/consultations/UnpaidConsultationsList.tsx b/imports/ui/consultations/UnpaidConsultationsList.tsx
index 84723ad22..429686294 100644
--- a/imports/ui/consultations/UnpaidConsultationsList.tsx
+++ b/imports/ui/consultations/UnpaidConsultationsList.tsx
@@ -47,9 +47,9 @@ const UnpaidConsultationsList = ({year}: Props) => {
 		filter.payment_method = {$in: displayedPaymentMethods};
 	}
 
-	const sort = {datetime: 1};
+	const sort = {datetime: 1} as const;
 
-	const toURL = (x) => `/unpaid/year/${x}`;
+	const toURL = (x: number) => `/unpaid/year/${x}`;
 
 	return (
 		<div>
diff --git a/imports/ui/consultations/makeConsultationsPage.tsx b/imports/ui/consultations/makeConsultationsPage.tsx
new file mode 100644
index 000000000..0005a9115
--- /dev/null
+++ b/imports/ui/consultations/makeConsultationsPage.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+
+import type PropsOf from '../../util/types/PropsOf';
+
+import type UserFilter from '../../api/query/UserFilter';
+import {type Sort} from '../../api/query/sort';
+
+import {type ConsultationDocument} from '../../api/collection/consultations';
+
+import type GenericQueryHook from '../../api/GenericQueryHook';
+
+import PagedConsultationsList from './PagedConsultationsList';
+
+type Props = {
+	readonly url?: string;
+	readonly page?: number;
+	readonly perpage?: number;
+
+	readonly filter?: UserFilter<ConsultationDocument>;
+	readonly sort: Sort<ConsultationDocument>;
+	readonly defaultExpandedFirst?: boolean;
+} & Omit<PropsOf<typeof PagedConsultationsList>, 'page' | 'perpage' | 'items'>;
+
+const makeConsultationsPage =
+	(useConsultations: GenericQueryHook<ConsultationDocument>) =>
+	({
+		filter = {},
+		sort,
+		page = 1,
+		perpage = 10,
+		url,
+		defaultExpandedFirst = false,
+		...rest
+	}: Props) => {
+		const query = {
+			filter,
+			sort,
+			skip: (page - 1) * perpage,
+			limit: perpage,
+		};
+
+		const deps = [JSON.stringify(query)];
+
+		const {loading, results: items} = useConsultations(query, deps);
+
+		return (
+			<PagedConsultationsList
+				loading={loading}
+				page={page}
+				perpage={perpage}
+				items={items}
+				defaultExpandedFirst={page === 1 && defaultExpandedFirst}
+				{...rest}
+			/>
+		);
+	};
+
+export default makeConsultationsPage;
diff --git a/imports/ui/consultations/useConsultationEditorState.ts b/imports/ui/consultations/useConsultationEditorState.ts
index 3401e3aba..93de2fe35 100644
--- a/imports/ui/consultations/useConsultationEditorState.ts
+++ b/imports/ui/consultations/useConsultationEditorState.ts
@@ -206,8 +206,7 @@ const _priceStatus = ({
 	if (priceString === '') return PriceStatus.SHOULD_NOT_BE_EMPTY;
 	if (!isValidAmount(priceString)) return PriceStatus.SHOULD_NOT_BE_INVALID;
 	const price = parseAmount(priceString);
-	if (price === 0 && isRealBookNumber(book))
-		return PriceStatus.SHOULD_NOT_BE_ZERO;
+	if (price === 0 && book !== '0') return PriceStatus.SHOULD_NOT_BE_ZERO;
 	return PriceStatus.OK;
 };
 
diff --git a/imports/ui/documents/DocumentListItem.tsx b/imports/ui/documents/DocumentListItem.tsx
index 1d6967de1..45c7a3d4b 100644
--- a/imports/ui/documents/DocumentListItem.tsx
+++ b/imports/ui/documents/DocumentListItem.tsx
@@ -63,7 +63,7 @@ const DocumentListItem = ({loading = false, document, ...rest}: Props) => {
 					rel="noreferrer"
 					target="_blank"
 					to={`/document/${_id}`}
-					aria-label="Open in New Tab"
+					aria-label={`Open document #${_id} in New Tab`}
 				>
 					<OpenInNewIcon />
 				</IconButton>
diff --git a/imports/ui/documents/DocumentsList.ts b/imports/ui/documents/DocumentsList.ts
new file mode 100644
index 000000000..f62e9a593
--- /dev/null
+++ b/imports/ui/documents/DocumentsList.ts
@@ -0,0 +1,6 @@
+import useDocuments from './useDocuments';
+import makeDocumentsList from './makeDocumentsList';
+
+const DocumentsList = makeDocumentsList(useDocuments);
+
+export default DocumentsList;
diff --git a/imports/ui/documents/DocumentsList.tsx b/imports/ui/documents/DocumentsList.tsx
deleted file mode 100644
index 12c70a70d..000000000
--- a/imports/ui/documents/DocumentsList.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-
-import FixedFab from '../button/FixedFab';
-
-import useDocuments from './useDocuments';
-import StaticDocumentList from './StaticDocumentList';
-import CustomDocumentImportButton from './CustomDocumentImportButton';
-
-type Props = {
-	readonly page?: number;
-	readonly perpage?: number;
-};
-
-const DocumentsList = ({page = 1, perpage = 10}: Props) => {
-	const query = {
-		filter: {},
-		sort: {createdAt: -1} as const,
-		projection: StaticDocumentList.projection,
-		skip: (page - 1) * perpage,
-		limit: perpage,
-	};
-
-	const deps = [page, perpage];
-
-	const {loading, results: documents} = useDocuments(query, deps);
-
-	return (
-		<>
-			<StaticDocumentList
-				page={page}
-				perpage={perpage}
-				loading={loading}
-				documents={documents}
-			/>
-			<CustomDocumentImportButton
-				Button={FixedFab}
-				col={4}
-				tooltip="Import documents"
-			/>
-		</>
-	);
-};
-
-export default DocumentsList;
diff --git a/imports/ui/documents/DocumentsListRoutes.ts b/imports/ui/documents/DocumentsListRoutes.ts
deleted file mode 100644
index 3ca268636..000000000
--- a/imports/ui/documents/DocumentsListRoutes.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import paged from '../routes/paged';
-
-import DocumentsList from './DocumentsList';
-
-const DocumentsListRoutes = paged(DocumentsList);
-
-export default DocumentsListRoutes;
diff --git a/imports/ui/documents/DocumentsListRoutes.tsx b/imports/ui/documents/DocumentsListRoutes.tsx
new file mode 100644
index 000000000..81f7151eb
--- /dev/null
+++ b/imports/ui/documents/DocumentsListRoutes.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import paged from '../routes/paged';
+
+import FixedFab from '../button/FixedFab';
+
+import DocumentsList from './DocumentsList';
+
+import CustomDocumentImportButton from './CustomDocumentImportButton';
+
+const DocumentsPager = paged(DocumentsList);
+
+const DocumentsListRoutes = () => (
+	<>
+		<DocumentsPager sort={{createdAt: -1}} />
+		<CustomDocumentImportButton
+			Button={FixedFab}
+			col={4}
+			tooltip="Import documents"
+		/>
+	</>
+);
+
+export default DocumentsListRoutes;
diff --git a/imports/ui/documents/StaticDocumentList.tsx b/imports/ui/documents/StaticDocumentList.tsx
index bd852972e..99ffc9242 100644
--- a/imports/ui/documents/StaticDocumentList.tsx
+++ b/imports/ui/documents/StaticDocumentList.tsx
@@ -13,22 +13,31 @@ type Props = {
 	readonly perpage: number;
 	readonly loading?: boolean;
 	readonly documents: DocumentDocument[];
+	readonly LoadingIndicator?: React.ElementType<{}>;
+	readonly EmptyPage?: React.ElementType<{page: number}>;
 };
 
+const DefaultLoadingIndicator = Loading;
+const DefaultEmptyPage = ({page}: {readonly page: number}) => (
+	<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+);
+
 const StaticDocumentList = ({
 	page,
 	perpage,
 	loading = false,
 	documents,
+	LoadingIndicator = DefaultLoadingIndicator,
+	EmptyPage = DefaultEmptyPage,
 }: Props) => (
 	<>
 		<div>
 			{loading && documents.length === 0 ? (
-				<Loading />
+				<LoadingIndicator />
 			) : documents.length > 0 ? (
 				<DocumentsPage loading={loading} documents={documents} />
 			) : (
-				<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+				<EmptyPage page={page} />
 			)}
 		</div>
 		<Paginator loading={loading} end={documents.length < perpage} />
diff --git a/imports/ui/documents/makeDocumentsList.tsx b/imports/ui/documents/makeDocumentsList.tsx
new file mode 100644
index 000000000..bc85e585a
--- /dev/null
+++ b/imports/ui/documents/makeDocumentsList.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+
+import type PropsOf from '../../util/types/PropsOf';
+
+import {type DocumentDocument} from '../../api/collection/documents';
+import type UserFilter from '../../api/query/UserFilter';
+import {type Sort} from '../../api/query/sort';
+import type GenericQueryHook from '../../api/GenericQueryHook';
+
+import StaticDocumentList from './StaticDocumentList';
+
+type Props = {
+	readonly filter?: UserFilter<DocumentDocument>;
+	readonly sort: Sort<DocumentDocument>;
+
+	readonly page?: number;
+	readonly perpage?: number;
+} & Omit<PropsOf<typeof StaticDocumentList>, 'page' | 'perpage' | 'documents'>;
+
+const makeDocumentsList =
+	(useDocuments: GenericQueryHook<DocumentDocument>) =>
+	({filter = {}, sort, page = 1, perpage = 10, ...rest}: Props) => {
+		const query = {
+			filter,
+			sort,
+			projection: StaticDocumentList.projection,
+			skip: (page - 1) * perpage,
+			limit: perpage,
+		};
+
+		const deps = [page, perpage];
+
+		const {loading, results: documents} = useDocuments(query, deps);
+
+		return (
+			<StaticDocumentList
+				page={page}
+				perpage={perpage}
+				loading={loading}
+				documents={documents}
+				{...rest}
+			/>
+		);
+	};
+
+export default makeDocumentsList;
diff --git a/imports/ui/issues/ConsultationsMissingABook.tests.tsx b/imports/ui/issues/ConsultationsMissingABook.tests.tsx
new file mode 100644
index 000000000..8951a02f9
--- /dev/null
+++ b/imports/ui/issues/ConsultationsMissingABook.tests.tsx
@@ -0,0 +1,184 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {type PatientDocument} from '../../api/collection/patients';
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+import {newConsultationFormData} from '../../api/_dev/populate/consultations';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+import consultationsInsert from '../../api/endpoint/consultations/insert';
+import consultationsUpdate from '../../api/endpoint/consultations/update';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import ConsultationsMissingABook from './ConsultationsMissingABook';
+
+const textNoIssues = 'All consultations have a book :)';
+
+const patientLinkName = ({
+	firstname,
+	lastname,
+}: Pick<PatientDocument, 'firstname' | 'lastname'>) => {
+	return `${lastname} ${firstname}`;
+};
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<ConsultationsMissingABook />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<ConsultationsMissingABook />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(<ConsultationsMissingABook />);
+
+		await findByText(textNoIssues);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			patientId,
+			book: '',
+		});
+		await call(consultationsInsert, consultation);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			patientId,
+			book: '',
+		});
+		const {insertedId: consultationId} = await call(
+			consultationsInsert,
+			consultation,
+		);
+
+		const {findByRole, findByText} = render(<ConsultationsMissingABook />);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+
+		await call(consultationsUpdate, consultationId, {book: 'book-name'});
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								patientId,
+								book: '',
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, getAllByRole} = render(<ConsultationsMissingABook />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: patientLinkName(patient),
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								patientId,
+								book: '',
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, findByText} = render(<ConsultationsMissingABook />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/ConsultationsMissingABook.tsx b/imports/ui/issues/ConsultationsMissingABook.tsx
index 770257e96..5aea86633 100644
--- a/imports/ui/issues/ConsultationsMissingABook.tsx
+++ b/imports/ui/issues/ConsultationsMissingABook.tsx
@@ -2,29 +2,39 @@ import React from 'react';
 
 import {useConsultationsMissingABook} from '../../api/issues';
 
-import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard';
-import ReactivePatientChip from '../patients/ReactivePatientChip';
+import paged from '../routes/paged';
+
+import makeConsultationsPage from '../consultations/makeConsultationsPage';
 
-const ConsultationsMissingABook = (props) => {
-	const {loading, results: consultations} = useConsultationsMissingABook();
+import ReactivePatientChip from '../patients/ReactivePatientChip';
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
+const ConsultationsPage = makeConsultationsPage(useConsultationsMissingABook);
+const ConsultationsPager = paged(ConsultationsPage);
 
-	if (consultations.length === 0) {
-		return <div {...props}>All consultations have a book :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const ConsultationsMissingABook = (props: Props) => {
 	return (
 		<div {...props}>
-			{consultations.map((consultation) => (
-				<ReactiveConsultationCard
-					key={consultation._id}
-					consultation={consultation}
-					PatientChip={ReactivePatientChip}
-				/>
-			))}
+			<ConsultationsPager
+				sort={{
+					datetime: -1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All consultations have a book :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+				itemProps={{
+					PatientChip: ReactivePatientChip,
+				}}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx b/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx
new file mode 100644
index 000000000..00a3801cc
--- /dev/null
+++ b/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx
@@ -0,0 +1,209 @@
+import {assert} from 'chai';
+import {faker} from '@faker-js/faker';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {type PatientDocument} from '../../api/collection/patients';
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+import {newConsultationFormData} from '../../api/_dev/populate/consultations';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+import consultationsInsert from '../../api/endpoint/consultations/insert';
+import consultationsUpdate from '../../api/endpoint/consultations/update';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import ConsultationsMissingPaymentData from './ConsultationsMissingPaymentData';
+
+const textNoIssues = 'All consultations have payment data :)';
+
+const patientLinkName = ({
+	firstname,
+	lastname,
+}: Pick<PatientDocument, 'firstname' | 'lastname'>) => {
+	return `${lastname} ${firstname}`;
+};
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<ConsultationsMissingPaymentData />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<ConsultationsMissingPaymentData />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsMissingPaymentData />,
+		);
+
+		await findByText(textNoIssues);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			datetime: faker.date.between({
+				from: new Date(2020, 0, 1),
+				to: new Date(),
+			}),
+			patientId,
+			price: undefined,
+		});
+		await call(consultationsInsert, consultation);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			datetime: faker.date.between({
+				from: new Date(2020, 0, 1),
+				to: new Date(),
+			}),
+			patientId,
+			paid: undefined,
+		});
+		const {insertedId: consultationId} = await call(
+			consultationsInsert,
+			consultation,
+		);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsMissingPaymentData />,
+		);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+
+		await call(consultationsUpdate, consultationId, {price: 10, paid: 10});
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								datetime: faker.date.between({
+									from: new Date(2020, 0, 1),
+									to: new Date(),
+								}),
+								patientId,
+								currency: undefined,
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, getAllByRole} = render(
+			<ConsultationsMissingPaymentData />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: patientLinkName(patient),
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								datetime: faker.date.between({
+									from: new Date(2020, 0, 1),
+									to: new Date(),
+								}),
+								patientId,
+								payment_method: undefined,
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsMissingPaymentData />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/ConsultationsMissingPaymentData.tsx b/imports/ui/issues/ConsultationsMissingPaymentData.tsx
index 7e35f7f6c..13c892538 100644
--- a/imports/ui/issues/ConsultationsMissingPaymentData.tsx
+++ b/imports/ui/issues/ConsultationsMissingPaymentData.tsx
@@ -2,47 +2,53 @@ import React from 'react';
 
 import {useConsultationsMissingPaymentData} from '../../api/issues';
 
-import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard';
-import ReactivePatientChip from '../patients/ReactivePatientChip';
+import paged from '../routes/paged';
 
-const ConsultationsMissingPaymentData = (props) => {
-	const {loading, results: consultations} = useConsultationsMissingPaymentData(
-		{
-			isDone: true,
-			datetime: {$gte: new Date(2020, 0, 1)},
-			$or: [
-				{price: {$not: {$type: 1}}},
-				{price: Number.NaN},
-				{paid: {$not: {$type: 1}}},
-				{paid: Number.NaN},
-				{currency: {$not: {$type: 2}}},
-				{payment_method: {$not: {$type: 2}}},
-			],
-		},
-		{
-			sort: {
-				datetime: -1,
-			},
-		},
-	);
+import makeConsultationsPage from '../consultations/makeConsultationsPage';
+
+import ReactivePatientChip from '../patients/ReactivePatientChip';
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
+const ConsultationsPage = makeConsultationsPage(
+	useConsultationsMissingPaymentData,
+);
+const ConsultationsPager = paged(ConsultationsPage);
 
-	if (consultations.length === 0) {
-		return <div {...props}>All consultations have payment data :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const ConsultationsMissingPaymentData = (props: Props) => {
 	return (
 		<div {...props}>
-			{consultations.map((consultation) => (
-				<ReactiveConsultationCard
-					key={consultation._id}
-					consultation={consultation}
-					PatientChip={ReactivePatientChip}
-				/>
-			))}
+			<ConsultationsPager
+				filter={{
+					isDone: true,
+					datetime: {$gte: new Date(2020, 0, 1)},
+					$or: [
+						{price: {$not: {$type: 1}}},
+						{price: Number.NaN},
+						{paid: {$not: {$type: 1}}},
+						{paid: Number.NaN},
+						{currency: {$not: {$type: 2}}},
+						{payment_method: {$not: {$type: 2}}},
+					],
+				}}
+				sort={{
+					datetime: -1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All consultations have payment data :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+				itemProps={{
+					PatientChip: ReactivePatientChip,
+				}}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx
new file mode 100644
index 000000000..b7383b528
--- /dev/null
+++ b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx
@@ -0,0 +1,196 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {type PatientDocument} from '../../api/collection/patients';
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+import {newConsultationFormData} from '../../api/_dev/populate/consultations';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+import consultationsInsert from '../../api/endpoint/consultations/insert';
+import consultationsUpdate from '../../api/endpoint/consultations/update';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import ConsultationsWithPriceZeroNotInBookZero from './ConsultationsWithPriceZeroNotInBookZero';
+
+const textNoIssues = 'All price 0 consultations are in book 0 :)';
+
+const patientLinkName = ({
+	firstname,
+	lastname,
+}: Pick<PatientDocument, 'firstname' | 'lastname'>) => {
+	return `${lastname} ${firstname}`;
+};
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<ConsultationsWithPriceZeroNotInBookZero />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<ConsultationsWithPriceZeroNotInBookZero />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsWithPriceZeroNotInBookZero />,
+		);
+
+		await findByText(textNoIssues);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			patientId,
+			price: 0,
+			book: '1',
+		});
+		await call(consultationsInsert, consultation);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+		const consultation = newConsultationFormData({
+			patientId,
+			price: 0,
+			book: 'abcd',
+		});
+		const {insertedId: consultationId} = await call(
+			consultationsInsert,
+			consultation,
+		);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsWithPriceZeroNotInBookZero />,
+		);
+
+		await findByRole('link', {name: patientLinkName(patient)});
+
+		await call(consultationsUpdate, consultationId, {book: '0'});
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								patientId,
+								price: 0,
+								book: '',
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, getAllByRole} = render(
+			<ConsultationsWithPriceZeroNotInBookZero />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: patientLinkName(patient),
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (_: number) =>
+						call(
+							consultationsInsert,
+							newConsultationFormData({
+								patientId,
+								price: 0,
+								book: undefined,
+							}),
+						),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, findByText} = render(
+			<ConsultationsWithPriceZeroNotInBookZero />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx
index 2c0968e1b..94ff8bba9 100644
--- a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx
+++ b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx
@@ -2,30 +2,41 @@ import React from 'react';
 
 import {useConsultationsWithPriceZeroNotInBookZero} from '../../api/issues';
 
-import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard';
-import ReactivePatientChip from '../patients/ReactivePatientChip';
+import paged from '../routes/paged';
+
+import makeConsultationsPage from '../consultations/makeConsultationsPage';
 
-const ConsultationsWithPriceZeroNotInBookZero = (props) => {
-	const {loading, results: consultations} =
-		useConsultationsWithPriceZeroNotInBookZero();
+import ReactivePatientChip from '../patients/ReactivePatientChip';
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
+const ConsultationsPage = makeConsultationsPage(
+	useConsultationsWithPriceZeroNotInBookZero,
+);
+const ConsultationsPager = paged(ConsultationsPage);
 
-	if (consultations.length === 0) {
-		return <div {...props}>All price 0 consultations are in book 0 :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const ConsultationsWithPriceZeroNotInBookZero = (props: Props) => {
 	return (
 		<div {...props}>
-			{consultations.map((consultation) => (
-				<ReactiveConsultationCard
-					key={consultation._id}
-					consultation={consultation}
-					PatientChip={ReactivePatientChip}
-				/>
-			))}
+			<ConsultationsPager
+				sort={{
+					datetime: -1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All price 0 consultations are in book 0 :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+				itemProps={{
+					PatientChip: ReactivePatientChip,
+				}}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx
new file mode 100644
index 000000000..59f5325dc
--- /dev/null
+++ b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx
@@ -0,0 +1,287 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {faker} from '@faker-js/faker';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, renderHook, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import {
+	type FormattedLine,
+	formattedLine,
+	normalizedLine,
+} from '../../api/string';
+import {type PatientDocument} from '../../api/collection/patients';
+import {deleteDoctor, renameDoctor, useDoctor} from '../../api/doctors';
+
+import DoctorsWithNonAlphabeticalSymbols from './DoctorsWithNonAlphabeticalSymbols';
+
+const textNoIssues = 'All doctors are made of alphabetical symbols only :)';
+const patientLinkName = ({
+	firstname,
+	lastname,
+}: Pick<PatientDocument, 'firstname' | 'lastname'>) => {
+	return `${lastname} ${firstname}`;
+};
+
+const doctorLinkName = (displayName: FormattedLine, patientCount: number) =>
+	`Dr ${displayName} soigne ${patientCount} patients`;
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<DoctorsWithNonAlphabeticalSymbols />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<DoctorsWithNonAlphabeticalSymbols />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		await findByText(textNoIssues);
+
+		const displayName = '@#$!';
+
+		const doctor = {
+			displayName: formattedLine(displayName),
+			name: normalizedLine(displayName),
+		};
+
+		const patient = newPatientFormData({
+			doctors: [doctor],
+		});
+
+		await call(patientsInsert, patient);
+
+		await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)});
+		await findByRole('link', {name: patientLinkName(patient)});
+	});
+
+	it('rerenders when a doctor is renamed to a name without issue', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const displayName = '@#$!';
+
+		const doctor = {
+			displayName: formattedLine(displayName),
+			name: normalizedLine(displayName),
+		};
+
+		const patient = newPatientFormData({
+			doctors: [doctor],
+		});
+
+		await call(patientsInsert, patient);
+
+		const {result} = renderHook(() => useDoctor(doctor.name, []));
+
+		const {findByRole, findByText} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)});
+		await findByRole('link', {name: patientLinkName(patient)});
+
+		await waitFor(() => {
+			assert(!result.current.loading);
+		});
+
+		await call(
+			renameDoctor,
+			result.current.item!._id,
+			`${faker.person.lastName()} ${faker.person.firstName()}`,
+		);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a doctor is renamed to a name with issue', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const displayName = `${faker.person.lastName()} ${faker.person.firstName()}`;
+
+		const doctor = {
+			displayName: formattedLine(displayName),
+			name: normalizedLine(displayName),
+		};
+
+		const patient = newPatientFormData({
+			doctors: [doctor],
+		});
+
+		await call(patientsInsert, patient);
+
+		const {result} = renderHook(() => useDoctor(doctor.name, []));
+
+		const {findByRole, findByText} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		await findByText(textNoIssues);
+
+		await waitFor(() => {
+			assert(!result.current.loading);
+		});
+
+		const nameWithIssue = '@#$!';
+
+		await call(renameDoctor, result.current.item!._id, nameWithIssue);
+
+		await findByRole('link', {
+			name: doctorLinkName(formattedLine(nameWithIssue), 1),
+		});
+		await findByRole('link', {name: patientLinkName(patient)});
+	});
+
+	it('rerenders when a doctor is deleted', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const displayName = '@#$!';
+
+		const doctor = {
+			displayName: formattedLine(displayName),
+			name: normalizedLine(displayName),
+		};
+
+		const patient = newPatientFormData({
+			doctors: [doctor],
+		});
+
+		await call(patientsInsert, patient);
+
+		const {result} = renderHook(() => useDoctor(doctor.name, []));
+
+		const {findByRole, findByText} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)});
+		await findByRole('link', {name: patientLinkName(patient)});
+
+		await waitFor(() => {
+			assert(!result.current.loading);
+		});
+
+		await call(deleteDoctor, result.current.item!._id);
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		await call(
+			patientsInsert,
+			newPatientFormData({
+				doctors: list(
+					map(
+						(i: number) => ({
+							displayName: formattedLine(`#${i}`),
+							name: normalizedLine(`#${i}`),
+						}),
+						range(n),
+					),
+				),
+			}),
+		);
+
+		const {findByRole, getAllByRole} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: /^Dr #\d+ soigne \d+ patients$/,
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		await call(
+			patientsInsert,
+			newPatientFormData({
+				doctors: list(
+					map(
+						(i: number) => ({
+							displayName: formattedLine(`#${i}`),
+							name: normalizedLine(`#${i}`),
+						}),
+						range(n),
+					),
+				),
+			}),
+		);
+
+		const {findByRole, findByText} = render(
+			<DoctorsWithNonAlphabeticalSymbols />,
+		);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx
index 970eeeec5..86d1ebd90 100644
--- a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx
+++ b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx
@@ -1,26 +1,36 @@
 import React from 'react';
 
-import {useDoctors} from '../../api/doctors';
-import TagGrid from '../tags/TagGrid';
+import {useDoctorsWithNonAlphabeticalSymbols} from '../../api/issues';
+
 import StaticDoctorCard from '../doctors/StaticDoctorCard';
+import TagListPage from '../tags/TagListPage';
 
-const DoctorsWithNonAlphabeticalSymbols = (props) => {
-	const query = {
-		filter: {containsNonAlphabetical: true},
-	};
-	const {loading, results: tags} = useDoctors(query, []);
+import paged from '../routes/paged';
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
+const TagList = paged(TagListPage);
 
-	if (tags.length === 0) {
-		return (
-			<div {...props}>All doctors are made of alphabetical symbols only :)</div>
-		);
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
-	return <TagGrid {...props} Card={StaticDoctorCard} tags={tags} />;
+const DoctorsWithNonAlphabeticalSymbols = (props: Props) => {
+	return (
+		<div {...props}>
+			<TagList
+				Card={StaticDoctorCard}
+				useTags={useDoctorsWithNonAlphabeticalSymbols}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All doctors are made of alphabetical symbols only :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+			/>
+		</div>
+	);
 };
 
 export default DoctorsWithNonAlphabeticalSymbols;
diff --git a/imports/ui/issues/Issues.tsx b/imports/ui/issues/Issues.tsx
index f0b3dbe70..d48ed2627 100644
--- a/imports/ui/issues/Issues.tsx
+++ b/imports/ui/issues/Issues.tsx
@@ -1,8 +1,16 @@
 import React from 'react';
+
+import {Route, Routes, useParams} from 'react-router-dom';
+
 import {styled} from '@mui/material/styles';
 
 import Typography from '@mui/material/Typography';
 
+import {myEncodeURIComponent} from '../../util/uri';
+
+import NoContent from '../navigation/NoContent';
+import TabJumper from '../navigation/TabJumper';
+
 import ConsultationsMissingPaymentData from './ConsultationsMissingPaymentData';
 import ConsultationsMissingABook from './ConsultationsMissingABook';
 import ConsultationsWithPriceZeroNotInBookZero from './ConsultationsWithPriceZeroNotInBookZero';
@@ -24,31 +32,129 @@ const Root = styled('div')(({theme}) => ({
 	},
 }));
 
+const tabs = [
+	'uploads-not-attached',
+	'documents-not-decoded',
+	'documents-not-parsed',
+	'documents-not-linked',
+	'consultations-no-payment',
+	'consultations-no-book',
+	'consultations-price-is-zero',
+	'doctors-non-alphabetical',
+];
+
+const IssuesTabs = () => {
+	const params = useParams<{tab?: string}>();
+	return (
+		<TabJumper
+			tabs={tabs}
+			current={params.tab}
+			toURL={(x) => `${params.tab ? '../' : ''}${myEncodeURIComponent(x)}`}
+		/>
+	);
+};
+
 const Issues = () => {
 	return (
 		<Root>
-			<Typography variant="h3">Uploads that are not attached</Typography>
-			<UnattachedUploads className={classes.container} />
-			<Typography variant="h3">Documents that are not parsed</Typography>
-			<UnparsedDocuments className={classes.container} />
-			<Typography variant="h3">
-				Documents that are not properly decoded
-			</Typography>
-			<MangledDocuments className={classes.container} />
-			<Typography variant="h3">Documents that are not linked</Typography>
-			<UnlinkedDocuments className={classes.container} />
-			<Typography variant="h3">Consultations missing payment data</Typography>
-			<ConsultationsMissingPaymentData className={classes.container} />
-			<Typography variant="h3">Consultations missing a book</Typography>
-			<ConsultationsMissingABook className={classes.container} />
-			<Typography variant="h3">
-				Consultations with price 0 not in book 0
-			</Typography>
-			<ConsultationsWithPriceZeroNotInBookZero className={classes.container} />
-			<Typography variant="h3">
-				Doctors with non alphabetical symbols
-			</Typography>
-			<DoctorsWithNonAlphabeticalSymbols className={classes.container} />
+			<Routes>
+				<Route index element={<IssuesTabs />} />
+				<Route path=":tab/*" element={<IssuesTabs />} />
+			</Routes>
+
+			<Routes>
+				<Route path="/" element={<NoContent>Select an issue type</NoContent>} />
+				<Route
+					path="uploads-not-attached/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Uploads that are not attached
+							</Typography>
+							<UnattachedUploads className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="documents-not-parsed/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Documents that are not parsed
+							</Typography>
+							<UnparsedDocuments className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="documents-not-decoded/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Documents that are not properly decoded
+							</Typography>
+							<MangledDocuments className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="documents-not-linked/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Documents that are not linked
+							</Typography>
+							<UnlinkedDocuments className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="consultations-no-payment/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Consultations missing payment data
+							</Typography>
+							<ConsultationsMissingPaymentData className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="consultations-no-book/*"
+					element={
+						<>
+							<Typography variant="h3">Consultations missing a book</Typography>
+							<ConsultationsMissingABook className={classes.container} />
+						</>
+					}
+				/>
+				<Route
+					path="consultations-price-is-zero/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Consultations with price 0 not in book 0
+							</Typography>
+							<ConsultationsWithPriceZeroNotInBookZero
+								className={classes.container}
+							/>
+						</>
+					}
+				/>
+				<Route
+					path="doctors-non-alphabetical/*"
+					element={
+						<>
+							<Typography variant="h3">
+								Doctors with non alphabetical symbols
+							</Typography>
+							<DoctorsWithNonAlphabeticalSymbols
+								className={classes.container}
+							/>
+						</>
+					}
+				/>
+			</Routes>
 		</Root>
 	);
 };
diff --git a/imports/ui/issues/MangledDocuments.tests.tsx b/imports/ui/issues/MangledDocuments.tests.tsx
new file mode 100644
index 000000000..d7aeb8f8d
--- /dev/null
+++ b/imports/ui/issues/MangledDocuments.tests.tsx
@@ -0,0 +1,174 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import call from '../../api/endpoint/call';
+import documentsInsert from '../../api/endpoint/documents/insert';
+import documentsSuperdelete from '../../api/endpoint/documents/superdelete';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import MangledDocuments from './MangledDocuments';
+
+const textNoIssues = 'All documents have been decoded :)';
+
+const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`;
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+const iso2022kr = [0x1b, 0x24, 0x29, 0x43];
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<MangledDocuments />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<MangledDocuments />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(<MangledDocuments />);
+
+		await findByText(textNoIssues);
+
+		const documentIds = await call(documentsInsert, {
+			array: Uint8Array.from(iso2022kr),
+		});
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const documentIds = await call(documentsInsert, {
+			array: Uint8Array.from(iso2022kr),
+		});
+
+		const {findByRole, findByText} = render(<MangledDocuments />);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) => call(documentsSuperdelete, documentId),
+					documentIds,
+				),
+			),
+		);
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		await Promise.all(
+			list(
+				map(
+					async (i: number) =>
+						call(documentsInsert, {
+							array: Uint8Array.from([...iso2022kr, i, i]),
+						}),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, getAllByRole} = render(<MangledDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: /^open document #[^ ]+ in new tab$/i,
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		await Promise.all(
+			list(
+				map(
+					async (i: number) =>
+						call(documentsInsert, {
+							array: Uint8Array.from([...iso2022kr, i, i]),
+						}),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, findByText} = render(<MangledDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/MangledDocuments.tsx b/imports/ui/issues/MangledDocuments.tsx
index 6850e77f8..ff88ab723 100644
--- a/imports/ui/issues/MangledDocuments.tsx
+++ b/imports/ui/issues/MangledDocuments.tsx
@@ -2,33 +2,34 @@ import React from 'react';
 
 import {useMangledDocuments} from '../../api/issues';
 
-import DocumentsPage from '../documents/DocumentsPage';
+import paged from '../routes/paged';
 
-const MangledDocuments = (props) => {
-	const options = {
-		sort: {
-			createdAt: 1,
-		},
-		fields: {
-			...DocumentsPage.projection,
-			// encoding: 1,
-			// createdAt: 1
-		},
-	};
+import makeDocumentsList from '../documents/makeDocumentsList';
 
-	const {loading, results: documents} = useMangledDocuments({}, options, []);
+const DocumentsPage = makeDocumentsList(useMangledDocuments);
+const DocumentsPager = paged(DocumentsPage);
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
-
-	if (documents.length === 0) {
-		return <div {...props}>All documents have been decoded :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const MangledDocuments = (props: Props) => {
 	return (
 		<div {...props}>
-			<DocumentsPage documents={documents} />
+			<DocumentsPager
+				sort={{
+					createdAt: 1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All documents have been decoded :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/PatientsMissingABirthdate.tsx b/imports/ui/issues/PatientsMissingABirthdate.tsx
index 0b0b3b7e6..28235cde5 100644
--- a/imports/ui/issues/PatientsMissingABirthdate.tsx
+++ b/imports/ui/issues/PatientsMissingABirthdate.tsx
@@ -6,9 +6,19 @@ import PatientGridItem from '../patients/PatientGridItem';
 import StaticPatientCard from '../patients/StaticPatientCard';
 
 import {usePatientsMissingABirthdate} from '../../api/issues';
-
-const PatientsMissingABirthdate = (props) => {
-	const {loading, results: patients} = usePatientsMissingABirthdate();
+import type PropsOf from '../../util/types/PropsOf';
+
+type DivProps = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
+type GridProps = PropsOf<typeof Grid>;
+type Props = DivProps & GridProps;
+
+const PatientsMissingABirthdate = (props: Props) => {
+	const {loading, results: patients} = usePatientsMissingABirthdate(
+		{filter: {}},
+		[],
+	);
 
 	if (loading) {
 		return <div {...props}>Loading...</div>;
diff --git a/imports/ui/issues/PatientsMissingAGender.tsx b/imports/ui/issues/PatientsMissingAGender.tsx
index 72a31b460..9667eaf1c 100644
--- a/imports/ui/issues/PatientsMissingAGender.tsx
+++ b/imports/ui/issues/PatientsMissingAGender.tsx
@@ -6,9 +6,19 @@ import PatientGridItem from '../patients/PatientGridItem';
 import StaticPatientCard from '../patients/StaticPatientCard';
 
 import {usePatientsMissingAGender} from '../../api/issues';
-
-const PatientsMissingAGender = (props) => {
-	const {loading, results: patients} = usePatientsMissingAGender();
+import type PropsOf from '../../util/types/PropsOf';
+
+type DivProps = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
+type GridProps = PropsOf<typeof Grid>;
+type Props = DivProps & GridProps;
+
+const PatientsMissingAGender = (props: Props) => {
+	const {loading, results: patients} = usePatientsMissingAGender(
+		{filter: {}},
+		[],
+	);
 
 	if (loading) {
 		return <div {...props}>Loading...</div>;
diff --git a/imports/ui/issues/UnattachedUploads.tests.tsx b/imports/ui/issues/UnattachedUploads.tests.tsx
new file mode 100644
index 000000000..d5b5a26e6
--- /dev/null
+++ b/imports/ui/issues/UnattachedUploads.tests.tsx
@@ -0,0 +1,214 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {faker} from '@faker-js/faker';
+
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {newUpload} from '../../api/_dev/populate/uploads';
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+import {newConsultationFormData} from '../../api/_dev/populate/consultations';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+import attachToPatient from '../../api/endpoint/patients/attach';
+import detachFromPatient from '../../api/endpoint/patients/detach';
+import consultationsInsert from '../../api/endpoint/consultations/insert';
+import attachToConsultation from '../../api/endpoint/consultations/attach';
+import detachFromConsultation from '../../api/endpoint/consultations/detach';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import UnattachedUploads from './UnattachedUploads';
+
+const textNoIssues = 'All uploads are attached to something :)';
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<UnattachedUploads />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<UnattachedUploads />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		await findByText(textNoIssues);
+
+		const name = faker.string.uuid();
+
+		await newUpload(undefined, {name});
+
+		await findByRole('img', {name});
+	});
+
+	it('rerenders when an upload is attached to a patient', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const name = faker.string.uuid();
+
+		const patientId = await call(patientsInsert, newPatientFormData());
+
+		const {_id: uploadId} = await newUpload(undefined, {name});
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		await findByRole('img', {name});
+
+		await call(attachToPatient, patientId, uploadId);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when an upload is attached to a consultation', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const name = faker.string.uuid();
+
+		const patientId = await call(patientsInsert, newPatientFormData());
+		const {insertedId: consultationId} = await call(
+			consultationsInsert,
+			newConsultationFormData({patientId}),
+		);
+
+		const {_id: uploadId} = await newUpload(undefined, {name});
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		await findByRole('img', {name});
+
+		await call(attachToConsultation, consultationId, uploadId);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when an upload is detached from a patient', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const name = faker.string.uuid();
+
+		const patientId = await call(patientsInsert, newPatientFormData());
+		const {_id: uploadId} = await newUpload(undefined, {name});
+		await call(attachToPatient, patientId, uploadId);
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		await findByText(textNoIssues);
+
+		await call(detachFromPatient, patientId, uploadId);
+
+		await findByRole('img', {name});
+	});
+
+	it('rerenders when an upload is detached from a consultation', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const name = faker.string.uuid();
+
+		const patientId = await call(patientsInsert, newPatientFormData());
+		const {insertedId: consultationId} = await call(
+			consultationsInsert,
+			newConsultationFormData({patientId}),
+		);
+		const {_id: uploadId} = await newUpload(undefined, {name});
+		await call(attachToConsultation, consultationId, uploadId);
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		await findByText(textNoIssues);
+
+		await call(detachFromConsultation, consultationId, uploadId);
+
+		await findByRole('img', {name});
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		await Promise.all(
+			map(async (i: number) => {
+				const name = `img-${i}.png`;
+				return newUpload(undefined, {name});
+			}, range(n)),
+		);
+
+		const {findByRole, getAllByRole} = render(<UnattachedUploads />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('img', {name: /^img-\d+\.png$/});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		await Promise.all(
+			map(async (i: number) => {
+				const name = `img-${i}.png`;
+				return newUpload(undefined, {name});
+			}, range(n)),
+		);
+
+		const {findByRole, findByText} = render(<UnattachedUploads />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/UnattachedUploads.tsx b/imports/ui/issues/UnattachedUploads.tsx
index 1c27919a4..0eaeda7dd 100644
--- a/imports/ui/issues/UnattachedUploads.tsx
+++ b/imports/ui/issues/UnattachedUploads.tsx
@@ -1,31 +1,35 @@
 import React from 'react';
 
-import useAttachments from '../attachments/useAttachments';
-import AttachmentsGrid from '../attachments/AttachmentsGrid';
+import {useUnattachedUploads} from '../../api/issues';
 
-const UnattachedUploads = (props) => {
-	const query = {
-		filter: {
-			$and: [
-				{'meta.attachedToPatients': {$not: {$gt: ''}}},
-				{'meta.attachedToConsultations': {$not: {$gt: ''}}},
-			],
-		},
-	};
+import paged from '../routes/paged';
 
-	const {loading, results: attachments} = useAttachments(query, []);
+import makeAttachmentsPage from '../attachments/makeAttachmentsPage';
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
+const UploadsPage = makeAttachmentsPage(useUnattachedUploads);
+const UploadsPager = paged(UploadsPage);
 
-	if (attachments.length === 0) {
-		return <div {...props}>All uploads are attached to something :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const UnattachedUploads = (props: Props) => {
 	return (
 		<div {...props}>
-			<AttachmentsGrid spacing={2} attachments={attachments} />
+			<UploadsPager
+				sort={{
+					updatedAt: -1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All uploads are attached to something :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/UnlinkedDocuments.tests.tsx b/imports/ui/issues/UnlinkedDocuments.tests.tsx
new file mode 100644
index 000000000..f35216fbc
--- /dev/null
+++ b/imports/ui/issues/UnlinkedDocuments.tests.tsx
@@ -0,0 +1,187 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {sum} from '@iterable-iterator/reduce';
+import {len} from '@functional-abstraction/operator';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import {newPatientFormData} from '../../api/_dev/populate/patients';
+import {
+	exampleHealthoneLab,
+	exampleMedidocReport,
+	exampleHealthoneReport,
+} from '../../api/_dev/populate/documents';
+
+import call from '../../api/endpoint/call';
+import patientsInsert from '../../api/endpoint/patients/insert';
+import documentsInsert from '../../api/endpoint/documents/insert';
+import documentsLink from '../../api/endpoint/documents/link';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import UnlinkedDocuments from './UnlinkedDocuments';
+
+const textNoIssues = 'All documents have an assigned patient :)';
+
+const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`;
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<UnlinkedDocuments />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<UnlinkedDocuments />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(<UnlinkedDocuments />);
+
+		await findByText(textNoIssues);
+
+		const documentIds = await call(documentsInsert, {
+			array: new TextEncoder().encode(exampleMedidocReport.contents),
+		});
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const documentIds = await call(documentsInsert, {
+			array: new TextEncoder().encode(exampleMedidocReport.contents),
+		});
+
+		const {findByRole, findByText} = render(<UnlinkedDocuments />);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+
+		const patient = newPatientFormData();
+		const patientId = await call(patientsInsert, patient);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						call(documentsLink, documentId, patientId),
+					documentIds,
+				),
+			),
+		);
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const inserted = await Promise.all(
+			list(
+				map(
+					async ({contents}) =>
+						call(documentsInsert, {
+							array: new TextEncoder().encode(contents),
+						}),
+					[exampleHealthoneReport, exampleHealthoneLab, exampleMedidocReport],
+				),
+			),
+		);
+
+		const n = sum(map(len, inserted));
+		assert.strictEqual(n, 11);
+
+		const {findByRole, getAllByRole} = render(<UnlinkedDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: /^open document #[^ ]+ in new tab$/i,
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const inserted = await Promise.all(
+			list(
+				map(
+					async ({contents}) =>
+						call(documentsInsert, {
+							array: new TextEncoder().encode(contents),
+						}),
+					[exampleHealthoneReport, exampleHealthoneLab],
+				),
+			),
+		);
+
+		const n = sum(map(len, inserted));
+		assert.strictEqual(n, 10);
+
+		const {findByRole, findByText} = render(<UnlinkedDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/UnlinkedDocuments.tsx b/imports/ui/issues/UnlinkedDocuments.tsx
index 7afe71d33..908b6cb3c 100644
--- a/imports/ui/issues/UnlinkedDocuments.tsx
+++ b/imports/ui/issues/UnlinkedDocuments.tsx
@@ -2,38 +2,37 @@ import React from 'react';
 
 import {useUnlinkedDocuments} from '../../api/issues';
 
-import DocumentsPage from '../documents/DocumentsPage';
+import paged from '../routes/paged';
 
-const UnlinkedDocuments = (props) => {
-	const options = {
-		sort: {
-			'patient.lastname': 1,
-			'patient.firstname': 1,
-			datetime: 1,
-			createdAt: 1,
-		},
-		fields: {
-			...DocumentsPage.projection,
-			// patientId: 1,
-			// patient: 1,
-			// datetime: 1,
-			// createdAt: 1
-		},
-	};
+import makeDocumentsList from '../documents/makeDocumentsList';
 
-	const {loading, results: documents} = useUnlinkedDocuments({}, options, []);
+const DocumentsPage = makeDocumentsList(useUnlinkedDocuments);
+const DocumentsPager = paged(DocumentsPage);
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
-
-	if (documents.length === 0) {
-		return <div {...props}>All documents have an assigned patient :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const UnlinkedDocuments = (props: Props) => {
 	return (
 		<div {...props}>
-			<DocumentsPage documents={documents} />
+			<DocumentsPager
+				sort={{
+					'patient.lastname': 1,
+					'patient.firstname': 1,
+					datetime: 1,
+					createdAt: 1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All documents have an assigned patient :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/issues/UnparsedDocuments.tests.tsx b/imports/ui/issues/UnparsedDocuments.tests.tsx
new file mode 100644
index 000000000..b245f7024
--- /dev/null
+++ b/imports/ui/issues/UnparsedDocuments.tests.tsx
@@ -0,0 +1,172 @@
+import {assert} from 'chai';
+
+import React from 'react';
+
+import {BrowserRouter} from 'react-router-dom';
+
+import {list} from '@iterable-iterator/list';
+import {map} from '@iterable-iterator/map';
+import {range} from '@iterable-iterator/range';
+
+import {client, randomPassword, randomUserId} from '../../_test/fixtures';
+import {render as _render, waitFor} from '../../_test/react';
+
+import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin';
+
+import call from '../../api/endpoint/call';
+import documentsInsert from '../../api/endpoint/documents/insert';
+import documentsSuperdelete from '../../api/endpoint/documents/superdelete';
+
+import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider';
+
+import UnparsedDocuments from './UnparsedDocuments';
+
+const textNoIssues = 'All documents have been parsed :)';
+
+const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`;
+
+const render = (children: React.ReactNode) =>
+	_render(children, {
+		wrapper: ({children}: {children: React.ReactNode}) => (
+			<DateTimeLocalizationProvider>
+				<BrowserRouter>{children}</BrowserRouter>
+			</DateTimeLocalizationProvider>
+		),
+	});
+
+client(__filename, () => {
+	it('renders when not logged in', async () => {
+		const {findByText} = render(<UnparsedDocuments />);
+		await findByText(textNoIssues);
+	});
+
+	it('renders when there are no issues', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByText} = render(<UnparsedDocuments />);
+
+		await findByText(textNoIssues);
+	});
+
+	it('rerenders when a new issue is created', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const {findByRole, findByText} = render(<UnparsedDocuments />);
+
+		await findByText(textNoIssues);
+
+		const documentIds = await call(documentsInsert, {
+			array: new TextEncoder().encode('ABRACADABRA'),
+		});
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+	});
+
+	it('rerenders when an issue is fixed', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const documentIds = await call(documentsInsert, {
+			array: new TextEncoder().encode('ABRACADABRA'),
+		});
+
+		const {findByRole, findByText} = render(<UnparsedDocuments />);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) =>
+						findByRole('link', {name: documentLinkName(documentId)}),
+					documentIds,
+				),
+			),
+		);
+
+		await Promise.all(
+			list(
+				map(
+					async (documentId: string) => call(documentsSuperdelete, documentId),
+					documentIds,
+				),
+			),
+		);
+
+		await findByText(textNoIssues);
+	});
+
+	it('paginates beyond 10 issues', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 13;
+
+		await Promise.all(
+			list(
+				map(
+					async (i: number) =>
+						call(documentsInsert, {
+							array: new TextEncoder().encode(`ABRACADABRA-${i}`),
+						}),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, getAllByRole} = render(<UnparsedDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await waitFor(() => {
+			const listed = getAllByRole('link', {
+				name: /^open document #[^ ]+ in new tab$/i,
+			});
+			assert.lengthOf(listed, n - 10);
+		});
+	});
+
+	it('paginates beyond 10 issues (empty)', async () => {
+		const {setupUser} = await import('../../../test/app/client/fixtures');
+		const username = randomUserId();
+		const password = randomPassword();
+		await createUserWithPasswordAndLogin(username, password);
+
+		const n = 10;
+
+		await Promise.all(
+			list(
+				map(
+					async (i: number) =>
+						call(documentsInsert, {
+							array: new TextEncoder().encode(`ABRACADABRA-${i}`),
+						}),
+					range(n),
+				),
+			),
+		);
+
+		const {findByRole, findByText} = render(<UnparsedDocuments />);
+
+		const {user} = setupUser();
+
+		await user.click(await findByRole('link', {name: 'Page 2'}));
+
+		await findByText('Nothing to see on page 2.');
+	});
+});
diff --git a/imports/ui/issues/UnparsedDocuments.tsx b/imports/ui/issues/UnparsedDocuments.tsx
index 4052fe847..8953e0c41 100644
--- a/imports/ui/issues/UnparsedDocuments.tsx
+++ b/imports/ui/issues/UnparsedDocuments.tsx
@@ -2,33 +2,34 @@ import React from 'react';
 
 import {useUnparsedDocuments} from '../../api/issues';
 
-import DocumentsPage from '../documents/DocumentsPage';
+import paged from '../routes/paged';
 
-const UnparsedDocuments = (props) => {
-	const options = {
-		sort: {
-			createdAt: 1,
-		},
-		fields: {
-			...DocumentsPage.projection,
-			// parsed: 1,
-			// createdAt: 1
-		},
-	};
+import makeDocumentsList from '../documents/makeDocumentsList';
 
-	const {loading, results: documents} = useUnparsedDocuments({}, options, []);
+const DocumentsPage = makeDocumentsList(useUnparsedDocuments);
+const DocumentsPager = paged(DocumentsPage);
 
-	if (loading) {
-		return <div {...props}>Loading...</div>;
-	}
-
-	if (documents.length === 0) {
-		return <div {...props}>All documents have been parsed :)</div>;
-	}
+type Props = React.JSX.IntrinsicAttributes &
+	React.ClassAttributes<HTMLDivElement> &
+	React.HTMLAttributes<HTMLDivElement>;
 
+const UnparsedDocuments = (props: Props) => {
 	return (
 		<div {...props}>
-			<DocumentsPage documents={documents} />
+			<DocumentsPager
+				sort={{
+					createdAt: 1,
+				}}
+				LoadingIndicator={(_: {}) => <>Loading...</>}
+				EmptyPage={({page}: {readonly page: number}) =>
+					page === 1 ? (
+						<>All documents have been parsed :)</>
+					) : (
+						// eslint-disable-next-line react/jsx-no-useless-fragment
+						<>{`Nothing to see on page ${page}.`}</>
+					)
+				}
+			/>
 		</div>
 	);
 };
diff --git a/imports/ui/navigation/Paginator.tsx b/imports/ui/navigation/Paginator.tsx
index 95508c201..0f33dd0fa 100644
--- a/imports/ui/navigation/Paginator.tsx
+++ b/imports/ui/navigation/Paginator.tsx
@@ -34,17 +34,24 @@ const PaginatorBase = ({
 		}
 	}, [loading, isOnlyPage]);
 
+	const prevPage = page - 1;
+	const nextPage = page + 1;
+	const prevDisabled = disabled || isFirstPage;
+	const nextDisabled = disabled || (!loading && end);
+
 	return (
 		<>
 			<Prev
 				visible={!hide}
-				to={`${root}${page - 1}`}
-				disabled={disabled || isFirstPage}
+				to={`${root}${prevPage}`}
+				disabled={prevDisabled}
+				aria-label={prevDisabled ? undefined : `Page ${prevPage}`}
 			/>
 			<Next
 				visible={!hide}
-				to={`${root}${page + 1}`}
-				disabled={disabled || (!loading && end)}
+				to={`${root}${nextPage}`}
+				disabled={nextDisabled}
+				aria-label={nextDisabled ? undefined : `Page ${nextPage}`}
 			/>
 		</>
 	);
diff --git a/imports/ui/routes/paged.tsx b/imports/ui/routes/paged.tsx
index a128ee144..0d6f84833 100644
--- a/imports/ui/routes/paged.tsx
+++ b/imports/ui/routes/paged.tsx
@@ -3,6 +3,7 @@ import React from 'react';
 import {useParams} from 'react-router-dom';
 
 import {parseNonNegativeIntegerStrictOrUndefined} from '../../api/string';
+import type PropsOf from '../../util/types/PropsOf';
 
 import branch, {type BranchProps} from './branch';
 
@@ -22,6 +23,10 @@ const PagedRoute = <C extends React.ElementType>({
 	return <Component page={page} {...props} />;
 };
 
-const paged = branch(['*', 'page/:page/*'], PagedRoute);
+const paged = branch(['*', 'page/:page/*'], PagedRoute) as <
+	C extends React.ElementType,
+>(
+	component: C,
+) => (props: PropsOf<C>) => React.ReactElement;
 
 export default paged;
diff --git a/imports/ui/search/FullTextSearchResultsRoutes.tsx b/imports/ui/search/FullTextSearchResultsRoutes.tsx
index ff0376f19..369b86141 100644
--- a/imports/ui/search/FullTextSearchResultsRoutes.tsx
+++ b/imports/ui/search/FullTextSearchResultsRoutes.tsx
@@ -40,7 +40,7 @@ const FullTextSearchResults = () => {
 	const [key, refresh] = useRandom();
 	const {query: rawQuery} = useParams<Params>();
 	const query = myDecodeURIComponent(rawQuery);
-	const deferredQuery = useDeferredValue(query);
+	const deferredQuery = useDeferredValue(query ?? '');
 	return (
 		<Root className={classes.root}>
 			<Typography className={classes.heading} variant="h3">
diff --git a/imports/ui/tags/TagListPage.tsx b/imports/ui/tags/TagListPage.tsx
index 70be5566e..48b9eab3a 100644
--- a/imports/ui/tags/TagListPage.tsx
+++ b/imports/ui/tags/TagListPage.tsx
@@ -23,8 +23,16 @@ export type TagListPageProps<T extends TagNameFields & TagMetadata> = {
 	readonly sort?: Sort<T>;
 
 	readonly useTags: GenericQueryHook<T>;
+
+	readonly LoadingIndicator?: React.ElementType<{}>;
+	readonly EmptyPage?: React.ElementType<{page: number}>;
 };
 
+const DefaultLoadingIndicator = Loading;
+const DefaultEmptyPage = ({page}: {readonly page: number}) => (
+	<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+);
+
 const TagListPage = <T extends TagNameFields & TagMetadata>({
 	useTags,
 	filter = {},
@@ -32,6 +40,8 @@ const TagListPage = <T extends TagNameFields & TagMetadata>({
 	page = 1,
 	perpage = 10,
 	Card,
+	LoadingIndicator = DefaultLoadingIndicator,
+	EmptyPage = DefaultEmptyPage,
 }: TagListPageProps<T>) => {
 	const [key, refresh] = useRandom();
 	const query = {
@@ -53,9 +63,9 @@ const TagListPage = <T extends TagNameFields & TagMetadata>({
 			<div style={style}>
 				{tags.length === 0 ? (
 					loading ? (
-						<Loading />
+						<LoadingIndicator />
 					) : (
-						<NoContent>{`Nothing to see on page ${page}.`}</NoContent>
+						<EmptyPage page={page} />
 					)
 				) : (
 					<TagGrid Card={Card} tags={tags} />
diff --git a/imports/util/uri.ts b/imports/util/uri.ts
index 87785c7d4..5bd77a444 100644
--- a/imports/util/uri.ts
+++ b/imports/util/uri.ts
@@ -2,8 +2,9 @@ export const myEncodeURIComponent = (component: string): string => {
 	return encodeURIComponent(component);
 };
 
-export const myDecodeURIComponent = (
-	component: string | undefined,
-): string | undefined => {
+export function myDecodeURIComponent<T extends string | undefined>(
+	component: T,
+): T;
+export function myDecodeURIComponent(component: string | undefined) {
 	return component === undefined ? undefined : decodeURIComponent(component);
-};
+}
diff --git a/lib/test/fixtures/_regenerator-runtime.tests.ts b/lib/test/fixtures/client/polyfill.app-tests.ts
similarity index 57%
rename from lib/test/fixtures/_regenerator-runtime.tests.ts
rename to lib/test/fixtures/client/polyfill.app-tests.ts
index 1a15fb5c6..ba17280c0 100644
--- a/lib/test/fixtures/_regenerator-runtime.tests.ts
+++ b/lib/test/fixtures/client/polyfill.app-tests.ts
@@ -1,2 +1,2 @@
 // eslint-disable-next-line import/no-unassigned-import
-import 'regenerator-runtime/runtime.js';
+import '../../../../client/polyfill';
diff --git a/lib/test/fixtures/_regenerator-runtime.app-tests.ts b/lib/test/fixtures/client/polyfill.tests.ts
similarity index 57%
rename from lib/test/fixtures/_regenerator-runtime.app-tests.ts
rename to lib/test/fixtures/client/polyfill.tests.ts
index 1a15fb5c6..ba17280c0 100644
--- a/lib/test/fixtures/_regenerator-runtime.app-tests.ts
+++ b/lib/test/fixtures/client/polyfill.tests.ts
@@ -1,2 +1,2 @@
 // eslint-disable-next-line import/no-unassigned-import
-import 'regenerator-runtime/runtime.js';
+import '../../../../client/polyfill';
diff --git a/lib/test/fixtures/server/polyfill.app-tests.ts b/lib/test/fixtures/server/polyfill.app-tests.ts
new file mode 100644
index 000000000..eaa26008c
--- /dev/null
+++ b/lib/test/fixtures/server/polyfill.app-tests.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/no-unassigned-import
+import '../../../../server/polyfill';
diff --git a/lib/test/fixtures/server/polyfill.tests.ts b/lib/test/fixtures/server/polyfill.tests.ts
new file mode 100644
index 000000000..eaa26008c
--- /dev/null
+++ b/lib/test/fixtures/server/polyfill.tests.ts
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/no-unassigned-import
+import '../../../../server/polyfill';
diff --git a/test/README.md b/test/README.md
index 76460e707..ac1843fcc 100644
--- a/test/README.md
+++ b/test/README.md
@@ -172,15 +172,15 @@
     - [ ] Delete
       - [ ] Deleting insurance removes it from all patients
 
-  - [ ] Issues
-    - [ ] It lists uploads that are not attached
-    - [ ] It lists documents that are not parsed
-    - [ ] It lists documents that are not decoded
-    - [ ] It lists documents that are not linked
-    - [ ] It lists consultations missing payment data
-    - [ ] It lists consultations missing a book
-    - [ ] It lists consultations with price 0 not in book 0
-    - [ ] It lists doctors with non-alphabetical symbols
+  - [x] Issues
+    - [x] It lists uploads that are not attached (~`integration`~)
+    - [x] It lists documents that are not parsed (~`integration`~)
+    - [x] It lists documents that are not decoded (~`integration`~)
+    - [x] It lists documents that are not linked (~`integration`~)
+    - [x] It lists consultations missing payment data (~`integration`~)
+    - [x] It lists consultations missing a book (~`integration`~)
+    - [x] It lists consultations with price 0 not in book 0 (~`integration`~)
+    - [x] It lists doctors with non-alphabetical symbols (~`integration`~)
 
   - [ ] Settings
     - [ ] UI
@@ -189,13 +189,14 @@
         - [ ] By clicking hamburger menu
     - [ ] Theme
       - [ ] Toggle theme light/dark
-      - [ ] Set theme primary color
-      - [ ] Reset theme primary color
-      - [ ] Set theme secondary color
-      - [ ] Reset theme secondary color
+      - [x] Set theme primary color (~`integration`~)
+      - [x] Reset theme primary color (~`integration`~)
+      - [x] Set theme secondary color (~`integration`~)
+      - [x] Reset theme secondary color (~`integration`~)
     - [ ] Payment
-      - [ ] Set account holder
-      - [ ] Set IBAN, check that it validates
+      - [x] Set account holder (~`integration`~)
+      - [x] Set IBAN (~`integration`~)
+      - [ ] Check IBAN validation
       - [ ] Check only EUR is supported
     - [ ] Locale
       - [ ] Changing language updates first day of the week and first week
diff --git a/test/app/client/fixtures.ts b/test/app/client/fixtures.ts
index 7abfc734a..c13678543 100644
--- a/test/app/client/fixtures.ts
+++ b/test/app/client/fixtures.ts
@@ -69,7 +69,16 @@ const flakyCloseModals = async ({user}: {user: UserEvent}) => {
 };
 
 export const createUserWithPasswordAndLogin = async (
-	{queryByRole, getByRole, findByRole, getByLabelText, user}: App,
+	{
+		queryByRole,
+		getByRole,
+		findByRole,
+		getByLabelText,
+		user,
+	}: Pick<
+		App,
+		'queryByRole' | 'getByRole' | 'findByRole' | 'getByLabelText' | 'user'
+	>,
 	username: string,
 	password: string,
 ) => {
@@ -95,12 +104,13 @@ export const createUserWithPasswordAndLogin = async (
 	}
 
 	console.debug('Waiting for "Logged in as ..." button to appear');
-	await findByRole(
+	const loggedInButton = await findByRole(
 		'button',
 		{name: `Logged in as ${username}`},
 		{timeout: 5000},
 	);
 	console.debug('User successfully created and logged in');
+	return loggedInButton;
 };
 
 export const logout = async ({findByRole, user}: App) => {
diff --git a/test/app/client/navigate.app-tests.ts b/test/app/client/navigate.app-tests.ts
index 397e9d644..39597ef7e 100644
--- a/test/app/client/navigate.app-tests.ts
+++ b/test/app/client/navigate.app-tests.ts
@@ -37,6 +37,55 @@ client(__filename, () => {
 		await navigateTo(app, 'Month', /^\/calendar\/month\//);
 	});
 
+	it('should allow to reach /issues/* tabs', async () => {
+		const username = randomUserId();
+		const password = randomPassword();
+		const app = setupApp();
+		await createUserWithPasswordAndLogin(username, password);
+
+		await navigateTo(app, 'Issues', '/issues');
+		await navigateTo(
+			app,
+			'uploads-not-attached',
+			'/issues/uploads-not-attached',
+		);
+		await navigateTo(
+			app,
+			'documents-not-decoded',
+			'/issues/documents-not-decoded',
+		);
+		await navigateTo(
+			app,
+			'documents-not-parsed',
+			'/issues/documents-not-parsed',
+		);
+		await navigateTo(
+			app,
+			'documents-not-linked',
+			'/issues/documents-not-linked',
+		);
+		await navigateTo(
+			app,
+			'consultations-no-payment',
+			'/issues/consultations-no-payment',
+		);
+		await navigateTo(
+			app,
+			'consultations-no-book',
+			'/issues/consultations-no-book',
+		);
+		await navigateTo(
+			app,
+			'consultations-price-is-zero',
+			'/issues/consultations-price-is-zero',
+		);
+		await navigateTo(
+			app,
+			'doctors-non-alphabetical',
+			'/issues/doctors-non-alphabetical',
+		);
+	});
+
 	it('should allow to reach /settings/* tabs', async () => {
 		const username = randomUserId();
 		const password = randomPassword();