feat(frontend): improve lab result filter ergonomics
This commit is contained in:
parent
ed547703ad
commit
157cbc425e
7 changed files with 221 additions and 48 deletions
|
|
@ -265,6 +265,7 @@ def list_lab_results(
|
|||
test_code: Optional[str] = None,
|
||||
flag: Optional[ResultFlag] = None,
|
||||
flags: list[ResultFlag] = Query(default_factory=list),
|
||||
without_flag: bool = False,
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
latest_only: bool = False,
|
||||
|
|
@ -287,6 +288,8 @@ def list_lab_results(
|
|||
filters.append(LabResult.flag == flag)
|
||||
if flags:
|
||||
filters.append(col(LabResult.flag).in_(flags))
|
||||
if without_flag:
|
||||
filters.append(col(LabResult.flag).is_(None))
|
||||
if from_date is not None:
|
||||
filters.append(LabResult.collected_at >= from_date)
|
||||
if to_date is not None:
|
||||
|
|
|
|||
|
|
@ -367,6 +367,16 @@
|
|||
"labResults_flagFilter": "Flag:",
|
||||
"labResults_flagAll": "All",
|
||||
"labResults_flagNone": "None",
|
||||
"labResults_statusAll": "All",
|
||||
"labResults_statusAbnormal": "Abnormal",
|
||||
"labResults_statusNormal": "Normal",
|
||||
"labResults_statusUninterpreted": "No interpretation",
|
||||
"labResults_activeFilters": "Active filters",
|
||||
"labResults_activeFilterSearch": "Search: {value}",
|
||||
"labResults_activeFilterCode": "Code: {value}",
|
||||
"labResults_activeFilterFrom": "From: {value}",
|
||||
"labResults_activeFilterTo": "To: {value}",
|
||||
"labResults_activeFilterHistory": "Full history",
|
||||
"labResults_date": "Date *",
|
||||
"labResults_loincCode": "LOINC code *",
|
||||
"labResults_loincExample": "e.g. 718-7",
|
||||
|
|
|
|||
|
|
@ -387,6 +387,16 @@
|
|||
"labResults_flagFilter": "Flaga:",
|
||||
"labResults_flagAll": "Wszystkie",
|
||||
"labResults_flagNone": "Brak",
|
||||
"labResults_statusAll": "Wszystkie",
|
||||
"labResults_statusAbnormal": "Nieprawidłowe",
|
||||
"labResults_statusNormal": "Prawidłowe",
|
||||
"labResults_statusUninterpreted": "Bez interpretacji",
|
||||
"labResults_activeFilters": "Aktywne filtry",
|
||||
"labResults_activeFilterSearch": "Szukaj: {value}",
|
||||
"labResults_activeFilterCode": "Kod: {value}",
|
||||
"labResults_activeFilterFrom": "Od: {value}",
|
||||
"labResults_activeFilterTo": "Do: {value}",
|
||||
"labResults_activeFilterHistory": "Pełna historia",
|
||||
"labResults_date": "Data *",
|
||||
"labResults_loincCode": "Kod LOINC *",
|
||||
"labResults_loincExample": "np. 718-7",
|
||||
|
|
|
|||
|
|
@ -413,6 +413,23 @@ body {
|
|||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.lab-results-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid color-mix(in srgb, var(--page-accent) 30%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--page-accent) 10%, white);
|
||||
padding: 0.35rem 0.65rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lab-results-filter-chip:hover {
|
||||
background: color-mix(in srgb, var(--page-accent) 16%, white);
|
||||
}
|
||||
|
||||
.editorial-alert {
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid hsl(34 25% 75% / 0.8);
|
||||
|
|
|
|||
|
|
@ -274,6 +274,8 @@ export interface LabResultListParams {
|
|||
q?: string;
|
||||
test_code?: string;
|
||||
flag?: string;
|
||||
flags?: string[];
|
||||
without_flag?: boolean;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
latest_only?: boolean;
|
||||
|
|
@ -295,6 +297,10 @@ export function getLabResults(
|
|||
if (params.q) q.set("q", params.q);
|
||||
if (params.test_code) q.set("test_code", params.test_code);
|
||||
if (params.flag) q.set("flag", params.flag);
|
||||
if (params.flags?.length) {
|
||||
for (const flag of params.flags) q.append("flags", flag);
|
||||
}
|
||||
if (params.without_flag != null) q.set("without_flag", String(params.without_flag));
|
||||
if (params.from_date) q.set("from_date", params.from_date);
|
||||
if (params.to_date) q.set("to_date", params.to_date);
|
||||
if (params.latest_only != null) q.set("latest_only", String(params.latest_only));
|
||||
|
|
|
|||
|
|
@ -2,10 +2,18 @@ import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
|
|||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const STATUS_GROUP_FLAGS = {
|
||||
abnormal: ['ABN', 'H', 'L', 'POS'],
|
||||
normal: ['N', 'NEG']
|
||||
} as const;
|
||||
|
||||
type StatusGroup = 'all' | 'abnormal' | 'normal' | 'uninterpreted';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const q = url.searchParams.get('q') ?? undefined;
|
||||
const test_code = url.searchParams.get('test_code') ?? undefined;
|
||||
const flag = url.searchParams.get('flag') ?? undefined;
|
||||
const status_group = normalizeStatusGroup(url.searchParams.get('status_group'));
|
||||
const from_date = url.searchParams.get('from_date') ?? undefined;
|
||||
const to_date = url.searchParams.get('to_date') ?? undefined;
|
||||
const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false';
|
||||
|
|
@ -15,10 +23,19 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const statusFlags = status_group === 'all' || status_group === 'uninterpreted'
|
||||
? []
|
||||
: [...STATUS_GROUP_FLAGS[status_group]];
|
||||
const effectiveFlag = flag && statusFlags.includes(flag as (typeof statusFlags)[number]) ? flag : undefined;
|
||||
const flags = effectiveFlag ? undefined : statusFlags;
|
||||
const without_flag = status_group === 'uninterpreted' ? true : undefined;
|
||||
|
||||
const resultPage = await getLabResults({
|
||||
q,
|
||||
test_code,
|
||||
flag,
|
||||
flag: effectiveFlag,
|
||||
flags,
|
||||
without_flag,
|
||||
from_date,
|
||||
to_date,
|
||||
latest_only: latestOnly,
|
||||
|
|
@ -31,7 +48,8 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||
resultPage,
|
||||
q,
|
||||
test_code,
|
||||
flag,
|
||||
flag: effectiveFlag,
|
||||
status_group,
|
||||
from_date,
|
||||
to_date,
|
||||
latestOnly,
|
||||
|
|
@ -40,6 +58,11 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||
};
|
||||
};
|
||||
|
||||
function normalizeStatusGroup(value: string | null): StatusGroup {
|
||||
if (value === 'abnormal' || value === 'normal' || value === 'uninterpreted') return value;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
|
@ -23,6 +24,8 @@
|
|||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
||||
const statusGroups = ['all', 'abnormal', 'normal', 'uninterpreted'] as const;
|
||||
type StatusGroup = (typeof statusGroups)[number];
|
||||
const flagPills: Record<string, string> = {
|
||||
N: 'health-flag-pill health-flag-pill--normal',
|
||||
ABN: 'health-flag-pill health-flag-pill--abnormal',
|
||||
|
|
@ -100,8 +103,39 @@
|
|||
page?: number;
|
||||
testCode?: string;
|
||||
includeExistingTestCode?: boolean;
|
||||
forceLatestOnly?: 'true' | 'false';
|
||||
latestOnly?: 'true' | 'false';
|
||||
statusGroup?: StatusGroup;
|
||||
flag?: string;
|
||||
clearKeys?: Array<'q' | 'test_code' | 'flag' | 'from_date' | 'to_date' | 'latest_only' | 'status_group'>;
|
||||
};
|
||||
const statusGroupLabels: Record<StatusGroup, () => string> = {
|
||||
all: m['labResults_statusAll'],
|
||||
abnormal: m['labResults_statusAbnormal'],
|
||||
normal: m['labResults_statusNormal'],
|
||||
uninterpreted: m['labResults_statusUninterpreted']
|
||||
};
|
||||
const statusGroupFlags: Record<Exclude<StatusGroup, 'all' | 'uninterpreted'>, string[]> = {
|
||||
abnormal: ['ABN', 'H', 'L', 'POS'],
|
||||
normal: ['N', 'NEG']
|
||||
};
|
||||
|
||||
function matchesStatusGroup(flag: string | undefined, statusGroup: StatusGroup): boolean {
|
||||
if (statusGroup === 'all') return true;
|
||||
if (statusGroup === 'uninterpreted') return !flag;
|
||||
if (!flag) return false;
|
||||
return statusGroupFlags[statusGroup].includes(flag);
|
||||
}
|
||||
|
||||
function normalizeStatusGroup(statusGroup?: string | null): StatusGroup {
|
||||
if (statusGroup === 'abnormal' || statusGroup === 'normal' || statusGroup === 'uninterpreted') return statusGroup;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function inferStatusGroupForFlag(flag: string): StatusGroup {
|
||||
if (statusGroupFlags.abnormal.includes(flag)) return 'abnormal';
|
||||
if (statusGroupFlags.normal.includes(flag)) return 'normal';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function toQueryString(params: Array<[string, string]>): string {
|
||||
return params
|
||||
|
|
@ -110,20 +144,25 @@
|
|||
}
|
||||
|
||||
function buildQueryParams(options: QueryOptions = {}): Array<[string, string]> {
|
||||
const clearKeys = new Set(options.clearKeys ?? []);
|
||||
const params: Array<[string, string]> = [];
|
||||
if (data.q) params.push(['q', data.q]);
|
||||
if (data.q && !clearKeys.has('q')) params.push(['q', data.q]);
|
||||
if (options.testCode) {
|
||||
params.push(['test_code', options.testCode]);
|
||||
} else if (options.includeExistingTestCode && data.test_code) {
|
||||
} else if (options.includeExistingTestCode && data.test_code && !clearKeys.has('test_code')) {
|
||||
params.push(['test_code', data.test_code]);
|
||||
}
|
||||
if (data.flag) params.push(['flag', data.flag]);
|
||||
if (data.from_date) params.push(['from_date', data.from_date]);
|
||||
if (data.to_date) params.push(['to_date', data.to_date]);
|
||||
|
||||
if (options.forceLatestOnly) {
|
||||
params.push(['latest_only', options.forceLatestOnly]);
|
||||
} else if (!data.latestOnly) {
|
||||
const nextStatusGroup = options.statusGroup ?? (clearKeys.has('status_group') ? 'all' : normalizeStatusGroup(data.status_group));
|
||||
const nextFlag = options.flag !== undefined ? options.flag : clearKeys.has('flag') ? '' : data.flag ?? '';
|
||||
|
||||
if (nextStatusGroup !== 'all') params.push(['status_group', nextStatusGroup]);
|
||||
if (nextFlag) params.push(['flag', nextFlag]);
|
||||
if (data.from_date && !clearKeys.has('from_date')) params.push(['from_date', data.from_date]);
|
||||
if (data.to_date && !clearKeys.has('to_date')) params.push(['to_date', data.to_date]);
|
||||
|
||||
const nextLatestOnly = options.latestOnly ?? (clearKeys.has('latest_only') ? 'true' : data.latestOnly ? 'true' : 'false');
|
||||
if (nextLatestOnly === 'false') {
|
||||
params.push(['latest_only', 'false']);
|
||||
}
|
||||
|
||||
|
|
@ -136,16 +175,12 @@
|
|||
return qs ? `/health/lab-results?${qs}` : '/health/lab-results';
|
||||
}
|
||||
|
||||
function buildPageUrl(page: number) {
|
||||
return buildLabResultsUrl({ page, includeExistingTestCode: true });
|
||||
async function goToFilters(options: QueryOptions = {}) {
|
||||
await goto(buildLabResultsUrl(options));
|
||||
}
|
||||
|
||||
function filterByCode(code: string) {
|
||||
window.location.href = buildLabResultsUrl({ testCode: code, forceLatestOnly: 'false' });
|
||||
}
|
||||
|
||||
function clearCodeFilterOnly() {
|
||||
window.location.href = buildLabResultsUrl();
|
||||
async function filterByCode(code: string) {
|
||||
await goToFilters({ testCode: code, latestOnly: 'false' });
|
||||
}
|
||||
|
||||
const groupedByDate = $derived.by<GroupedByDate[]>(() => {
|
||||
|
|
@ -162,9 +197,6 @@
|
|||
});
|
||||
|
||||
const displayGroups = $derived.by<DisplayGroup[]>(() => {
|
||||
if (data.test_code) {
|
||||
return [{ key: 'filtered', label: null, items: data.resultPage.items }];
|
||||
}
|
||||
return groupedByDate.map((group) => ({ key: group.date, label: group.date, items: group.items }));
|
||||
});
|
||||
|
||||
|
|
@ -186,10 +218,61 @@
|
|||
}
|
||||
|
||||
const textareaClass = `${baseTextareaClass} min-h-[5rem] resize-y`;
|
||||
let filterFlagOverride = $state<string | null>(null);
|
||||
let filterLatestOnlyOverride = $state<'true' | 'false' | null>(null);
|
||||
const activeFilterFlag = $derived(filterFlagOverride ?? (data.flag ?? ''));
|
||||
const activeLatestOnly = $derived(filterLatestOnlyOverride ?? (data.latestOnly ? 'true' : 'false'));
|
||||
const activeStatusGroup = $derived(normalizeStatusGroup(data.status_group));
|
||||
const activeFilterFlag = $derived(data.flag ?? '');
|
||||
const activeLatestOnly = $derived(data.latestOnly ? 'true' : 'false');
|
||||
const visibleFlags = $derived.by(() => {
|
||||
if (activeStatusGroup === 'abnormal') return statusGroupFlags.abnormal;
|
||||
if (activeStatusGroup === 'normal') return statusGroupFlags.normal;
|
||||
if (activeStatusGroup === 'uninterpreted') return [];
|
||||
return flags;
|
||||
});
|
||||
const activeFilterChips = $derived.by(() => {
|
||||
const chips: Array<{ label: string; href: string }> = [];
|
||||
if (activeStatusGroup !== 'all') {
|
||||
chips.push({
|
||||
label: statusGroupLabels[activeStatusGroup](),
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['status_group', 'flag'] })
|
||||
});
|
||||
}
|
||||
if (activeFilterFlag) {
|
||||
chips.push({
|
||||
label: `${m['labResults_flag']()}: ${activeFilterFlag}`,
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['flag'] })
|
||||
});
|
||||
}
|
||||
if (data.test_code) {
|
||||
chips.push({
|
||||
label: m['labResults_activeFilterCode']({ value: data.test_code }),
|
||||
href: buildLabResultsUrl({ clearKeys: ['test_code'] })
|
||||
});
|
||||
}
|
||||
if (data.q) {
|
||||
chips.push({
|
||||
label: m['labResults_activeFilterSearch']({ value: data.q }),
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['q'] })
|
||||
});
|
||||
}
|
||||
if (data.from_date) {
|
||||
chips.push({
|
||||
label: m['labResults_activeFilterFrom']({ value: data.from_date.slice(0, 10) }),
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['from_date'] })
|
||||
});
|
||||
}
|
||||
if (data.to_date) {
|
||||
chips.push({
|
||||
label: m['labResults_activeFilterTo']({ value: data.to_date.slice(0, 10) }),
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, clearKeys: ['to_date'] })
|
||||
});
|
||||
}
|
||||
if (!data.latestOnly) {
|
||||
chips.push({
|
||||
label: m['labResults_activeFilterHistory'](),
|
||||
href: buildLabResultsUrl({ includeExistingTestCode: true, latestOnly: 'true' })
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
});
|
||||
const flashMessages = $derived([
|
||||
...(form?.error ? [{ kind: 'error' as const, text: form.error }] : []),
|
||||
...(form?.deleted ? [{ kind: 'success' as const, text: m["labResults_deleted"]() }] : []),
|
||||
|
|
@ -370,36 +453,53 @@
|
|||
type="button"
|
||||
size="sm"
|
||||
variant={activeLatestOnly === 'true' ? 'default' : 'outline'}
|
||||
onclick={() => (filterLatestOnlyOverride = 'true')}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, latestOnly: 'true' })}
|
||||
>
|
||||
{m["labResults_viewLatest"]()}
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeLatestOnly === 'false' ? 'default' : 'outline'}
|
||||
onclick={() => (filterLatestOnlyOverride = 'false')}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, latestOnly: 'false' })}
|
||||
>
|
||||
{m["labResults_viewAll"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editorial-filter-row">
|
||||
{#each statusGroups as statusGroup (statusGroup)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeStatusGroup === statusGroup ? 'default' : 'outline'}
|
||||
onclick={() => {
|
||||
const nextFlag = matchesStatusGroup(activeFilterFlag || undefined, statusGroup) ? activeFilterFlag : '';
|
||||
goToFilters({ includeExistingTestCode: true, statusGroup, flag: nextFlag });
|
||||
}}
|
||||
>
|
||||
{statusGroupLabels[statusGroup]()}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="editorial-filter-row">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeFilterFlag === '' ? 'default' : 'outline'}
|
||||
onclick={() => (filterFlagOverride = '')}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, flag: '' })}
|
||||
disabled={activeStatusGroup === 'uninterpreted'}
|
||||
>
|
||||
{m["labResults_flagAll"]()}
|
||||
</Button>
|
||||
{#each flags as f (f)}
|
||||
{#each visibleFlags as f (f)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeFilterFlag === f ? 'default' : 'outline'}
|
||||
onclick={() => (filterFlagOverride = f)}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, statusGroup: inferStatusGroupForFlag(f), flag: f })}
|
||||
>
|
||||
{f}
|
||||
</Button>
|
||||
|
|
@ -424,31 +524,35 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="hidden" name="flag" value={activeFilterFlag} />
|
||||
{#if activeFilterFlag}
|
||||
<input type="hidden" name="flag" value={activeFilterFlag} />
|
||||
{/if}
|
||||
{#if activeStatusGroup !== 'all'}
|
||||
<input type="hidden" name="status_group" value={activeStatusGroup} />
|
||||
{/if}
|
||||
<input type="hidden" name="latest_only" value={activeLatestOnly} />
|
||||
{#if data.test_code}
|
||||
<input type="hidden" name="test_code" value={data.test_code} />
|
||||
{/if}
|
||||
<Button type="submit">{m["labResults_applyFilters"]()}</Button>
|
||||
<Button type="button" variant="outline" onclick={() => (window.location.href = '/health/lab-results')}>
|
||||
<Button type="button" variant="outline" onclick={() => goToFilters({ clearKeys: ['q', 'test_code', 'flag', 'from_date', 'to_date', 'latest_only', 'status_group'] })}>
|
||||
{m["labResults_resetAllFilters"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if data.test_code}
|
||||
<div class="editorial-panel lab-results-filter-banner reveal-2 flex items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{m['labResults_filteredByCode']({ code: data.test_code })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={clearCodeFilterOnly}
|
||||
>
|
||||
{m['labResults_clearCodeFilter']()}
|
||||
</Button>
|
||||
{#if activeFilterChips.length}
|
||||
<div class="editorial-panel reveal-2 space-y-3">
|
||||
<p class="text-sm font-medium">{m['labResults_activeFilters']()}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each activeFilterChips as chip (chip.label)}
|
||||
<a class="lab-results-filter-chip" href={chip.href}>
|
||||
<span>{chip.label}</span>
|
||||
<X class="size-3.5" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -457,7 +561,7 @@
|
|||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => (window.location.href = buildPageUrl(data.page - 1))}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, page: data.page - 1 })}
|
||||
>
|
||||
{m["labResults_previous"]()}
|
||||
</Button>
|
||||
|
|
@ -467,7 +571,7 @@
|
|||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onclick={() => (window.location.href = buildPageUrl(data.page + 1))}
|
||||
onclick={() => goToFilters({ includeExistingTestCode: true, page: data.page + 1 })}
|
||||
>
|
||||
{m["labResults_next"]()}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue