refactor(frontend): align lab results filters with products style
This commit is contained in:
parent
7eca2391a9
commit
c4be7dd1be
3 changed files with 298 additions and 342 deletions
|
|
@ -102,7 +102,7 @@ These classes are already in use and should be reused:
|
|||
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
|
||||
- Lab results utilities:
|
||||
- metadata chips: `lab-results-meta-strip`, `lab-results-meta-pill`
|
||||
- filter surfaces: `lab-results-filter-panel`, `lab-results-filter-banner`, `lab-results-pager`
|
||||
- filter/paging surfaces: `editorial-filter-row`, `lab-results-filter-banner`, `lab-results-pager`
|
||||
- row/link rhythm: `lab-results-row`, `lab-results-code-link`, `lab-results-value-cell`
|
||||
- mobile density: `lab-results-mobile-grid`, `lab-results-mobile-card`, `lab-results-mobile-value`
|
||||
|
||||
|
|
@ -112,12 +112,13 @@ These classes are already in use and should be reused:
|
|||
- Validation/error states should be explicit and never color-only.
|
||||
- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata.
|
||||
- Filter toolbars for data-heavy routes should use `GET` forms with URL params so state is shareable and pagination links preserve active filters.
|
||||
- Use the products filter pattern as the shared baseline: compact search input, chip-style toggle rows (`editorial-filter-row` + small `Button` variants), and apply/reset actions aligned at the end of the toolbar.
|
||||
- For high-volume medical data lists, default the primary view to condensed/latest mode and offer full-history as an explicit secondary option.
|
||||
- In condensed/latest mode, group rows by collection date using lightweight section headers (`products-section-title`) to preserve report context without introducing heavy card nesting.
|
||||
- Change/highlight pills in dense tables should stay compact (`text-[10px]`), semantic (new/flag change/abnormal), and avoid overwhelming color blocks.
|
||||
- For lab results, keep ordering fixed to newest collection date (`collected_at DESC`) and remove non-essential controls (no lab filter and no manual sort selector).
|
||||
- For lab results, keep code links visibly interactive (`lab-results-code-link`) because they are a primary in-context drill-down interaction.
|
||||
- For lab results, use compact metadata chips in hero sections (`lab-results-meta-pill`) for active view/filter context instead of introducing a second heavy summary card.
|
||||
- For lab results, use compact metadata chips in hero sections (`lab-results-meta-pill`) for active view/filter context instead of introducing a second heavy summary card; keep this strip terse (one context chip + one stats chip, with optional alert chip).
|
||||
- In dense row-based lists, prefer `ghost` action controls; use icon-only buttons on desktop tables and short text+icon `ghost` actions on mobile cards to keep row actions subordinate to data.
|
||||
- For editable data tables, open a dedicated inline edit panel above the list (instead of per-row expanded forms) and prefill it from row actions; keep users on the same filtered/paginated context after save.
|
||||
- When a list is narrowed to a single entity key (for example `test_code`), display an explicit "filtered by" banner with a one-click clear action and avoid extra grouping wrappers that add no context.
|
||||
|
|
|
|||
|
|
@ -392,21 +392,22 @@ body {
|
|||
}
|
||||
|
||||
.lab-results-meta-strip {
|
||||
margin-top: 0.9rem;
|
||||
margin-top: 0.65rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
gap: 0.35rem;
|
||||
max-width: 48ch;
|
||||
}
|
||||
|
||||
.lab-results-meta-pill {
|
||||
border: 1px solid hsl(36 22% 74% / 0.9);
|
||||
border-radius: 999px;
|
||||
background: hsl(44 32% 93%);
|
||||
padding: 0.2rem 0.62rem;
|
||||
padding: 0.14rem 0.56rem;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.73rem;
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
|
@ -418,13 +419,6 @@ body {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.lab-results-filter-panel {
|
||||
border-color: color-mix(in srgb, var(--page-accent) 24%, var(--border));
|
||||
background:
|
||||
linear-gradient(180deg, hsl(44 36% 96%), hsl(42 29% 93%)),
|
||||
repeating-linear-gradient(90deg, hsl(0 0% 100% / 0), hsl(0 0% 100% / 0) 18px, hsl(36 24% 70% / 0.1) 18px, hsl(36 24% 70% / 0.1) 19px);
|
||||
}
|
||||
|
||||
.lab-results-filter-banner {
|
||||
border-style: dashed;
|
||||
border-color: color-mix(in srgb, var(--page-accent) 48%, var(--border));
|
||||
|
|
@ -945,11 +939,11 @@ body {
|
|||
}
|
||||
|
||||
.lab-results-meta-strip {
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.lab-results-meta-pill {
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.lab-results-filter-banner {
|
||||
|
|
@ -958,6 +952,13 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.lab-results-meta-strip {
|
||||
grid-column: 1 / 2;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-1,
|
||||
.reveal-2,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { baseSelectClass } from '$lib/components/forms/form-classes';
|
||||
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { Pencil, X } from 'lucide-svelte';
|
||||
|
|
@ -95,48 +95,57 @@
|
|||
|
||||
type LabResultItem = PageData['resultPage']['items'][number];
|
||||
type GroupedByDate = { date: string; items: LabResultItem[] };
|
||||
type DisplayGroup = { key: string; label: string | null; items: LabResultItem[] };
|
||||
type QueryOptions = {
|
||||
page?: number;
|
||||
testCode?: string;
|
||||
includeExistingTestCode?: boolean;
|
||||
forceLatestOnly?: 'true' | 'false';
|
||||
};
|
||||
|
||||
function buildPageUrl(page: number) {
|
||||
const base = '/health/lab-results';
|
||||
function toQueryString(params: Array<[string, string]>): string {
|
||||
return params
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function buildQueryParams(options: QueryOptions = {}): Array<[string, string]> {
|
||||
const params: Array<[string, string]> = [];
|
||||
if (data.q) params.push(['q', data.q]);
|
||||
if (data.test_code) params.push(['test_code', data.test_code]);
|
||||
if (options.testCode) {
|
||||
params.push(['test_code', options.testCode]);
|
||||
} else if (options.includeExistingTestCode && data.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 (!data.latestOnly) params.push(['latest_only', 'false']);
|
||||
if (page > 1) params.push(['page', String(page)]);
|
||||
const qs = params
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
|
||||
if (options.forceLatestOnly) {
|
||||
params.push(['latest_only', options.forceLatestOnly]);
|
||||
} else if (!data.latestOnly) {
|
||||
params.push(['latest_only', 'false']);
|
||||
}
|
||||
|
||||
if (options.page && options.page > 1) params.push(['page', String(options.page)]);
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildLabResultsUrl(options: QueryOptions = {}): string {
|
||||
const qs = toQueryString(buildQueryParams(options));
|
||||
return qs ? `/health/lab-results?${qs}` : '/health/lab-results';
|
||||
}
|
||||
|
||||
function buildPageUrl(page: number) {
|
||||
return buildLabResultsUrl({ page, includeExistingTestCode: true });
|
||||
}
|
||||
|
||||
function filterByCode(code: string) {
|
||||
const params: Array<[string, string]> = [];
|
||||
if (data.q) params.push(['q', data.q]);
|
||||
params.push(['test_code', 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]);
|
||||
params.push(['latest_only', 'false']);
|
||||
const qs = params
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results';
|
||||
window.location.href = buildLabResultsUrl({ testCode: code, forceLatestOnly: 'false' });
|
||||
}
|
||||
|
||||
function clearCodeFilterOnly() {
|
||||
const params: Array<[string, string]> = [];
|
||||
if (data.q) params.push(['q', data.q]);
|
||||
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 (!data.latestOnly) params.push(['latest_only', 'false']);
|
||||
const qs = params
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
window.location.href = qs ? `/health/lab-results?${qs}` : '/health/lab-results';
|
||||
window.location.href = buildLabResultsUrl();
|
||||
}
|
||||
|
||||
const groupedByDate = $derived.by<GroupedByDate[]>(() => {
|
||||
|
|
@ -152,7 +161,12 @@
|
|||
return Object.entries(groups).map(([date, items]) => ({ date, items }));
|
||||
});
|
||||
|
||||
const shouldGroupByDate = $derived(!data.test_code);
|
||||
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 }));
|
||||
});
|
||||
|
||||
const notableFlags = new Set(['ABN', 'H', 'L', 'POS']);
|
||||
const flaggedCount = $derived.by(() =>
|
||||
|
|
@ -172,6 +186,11 @@
|
|||
}
|
||||
|
||||
const flagOptions = flags.map((f) => ({ value: f, label: f }));
|
||||
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'));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||
|
|
@ -184,8 +203,10 @@
|
|||
<div class="lab-results-meta-strip">
|
||||
<span class="lab-results-meta-pill">
|
||||
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
|
||||
{#if data.flag}
|
||||
· {m['labResults_flagFilter']()} {data.flag}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="lab-results-meta-pill">{m['labResults_flagFilter']()} {data.flag || m['labResults_flagAll']()}</span>
|
||||
<span class="lab-results-meta-pill">
|
||||
{m['labResults_flag']()}: {flaggedCount}
|
||||
</span>
|
||||
|
|
@ -305,7 +326,13 @@
|
|||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_ref_text">{m["labResults_refText"]()}</Label>
|
||||
<Input id="edit_ref_text" name="ref_text" bind:value={editRefText} />
|
||||
<textarea
|
||||
id="edit_ref_text"
|
||||
name="ref_text"
|
||||
rows="3"
|
||||
class={textareaClass}
|
||||
bind:value={editRefText}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_source_file">{m["labResults_sourceFile"]()}</Label>
|
||||
|
|
@ -313,7 +340,13 @@
|
|||
</div>
|
||||
<div class="space-y-1 sm:col-span-2">
|
||||
<Label for="edit_notes">{m["labResults_notes"]()}</Label>
|
||||
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||
<textarea
|
||||
id="edit_notes"
|
||||
name="notes"
|
||||
rows="4"
|
||||
class={textareaClass}
|
||||
bind:value={editNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -325,43 +358,87 @@
|
|||
</FormSectionCard>
|
||||
{/if}
|
||||
|
||||
<form method="GET" class="editorial-panel lab-results-filter-panel reveal-2 grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div class="md:col-span-2">
|
||||
<Label for="q">{m["labResults_search"]()}</Label>
|
||||
<Input id="q" name="q" value={data.q ?? ''} placeholder={m["labResults_searchPlaceholder"]()} />
|
||||
<form method="GET" class="editorial-panel reveal-2 space-y-3">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-full lg:max-w-md">
|
||||
<Input
|
||||
id="q"
|
||||
name="q"
|
||||
type="search"
|
||||
value={data.q ?? ''}
|
||||
placeholder={m["labResults_searchPlaceholder"]()}
|
||||
aria-label={m["labResults_search"]()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeLatestOnly === 'true' ? 'default' : 'outline'}
|
||||
onclick={() => (filterLatestOnlyOverride = 'true')}
|
||||
>
|
||||
{m["labResults_viewLatest"]()}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeLatestOnly === 'false' ? 'default' : 'outline'}
|
||||
onclick={() => (filterLatestOnlyOverride = 'false')}
|
||||
>
|
||||
{m["labResults_viewAll"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="flag">{m["labResults_flagFilter"]()}</Label>
|
||||
<select class={`${baseSelectClass} w-full`} id="flag" name="flag" value={data.flag ?? ''}>
|
||||
<option value="">{m["labResults_flagAll"]()}</option>
|
||||
{#each flags as f (f)}
|
||||
<option value={f}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label for="from_date">{m["labResults_from"]()}</Label>
|
||||
<Input id="from_date" name="from_date" type="date" value={(data.from_date ?? '').slice(0, 10)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="to_date">{m["labResults_to"]()}</Label>
|
||||
<Input id="to_date" name="to_date" type="date" value={(data.to_date ?? '').slice(0, 10)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="latest_only">{m["labResults_view"]()}</Label>
|
||||
<select class={`${baseSelectClass} w-full`} id="latest_only" name="latest_only">
|
||||
<option value="true" selected={data.latestOnly}>{m["labResults_viewLatest"]()}</option>
|
||||
<option value="false" selected={!data.latestOnly}>{m["labResults_viewAll"]()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-6 flex items-center gap-2">
|
||||
{#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')}>
|
||||
{m["labResults_resetAllFilters"]()}
|
||||
|
||||
<div class="editorial-filter-row">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeFilterFlag === '' ? 'default' : 'outline'}
|
||||
onclick={() => (filterFlagOverride = '')}
|
||||
>
|
||||
{m["labResults_flagAll"]()}
|
||||
</Button>
|
||||
{#each flags as f (f)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={activeFilterFlag === f ? 'default' : 'outline'}
|
||||
onclick={() => (filterFlagOverride = f)}
|
||||
>
|
||||
{f}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
id="from_date"
|
||||
name="from_date"
|
||||
type="date"
|
||||
value={(data.from_date ?? '').slice(0, 10)}
|
||||
aria-label={m["labResults_from"]()}
|
||||
/>
|
||||
<Input
|
||||
id="to_date"
|
||||
name="to_date"
|
||||
type="date"
|
||||
value={(data.to_date ?? '').slice(0, 10)}
|
||||
aria-label={m["labResults_to"]()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="hidden" name="flag" value={activeFilterFlag} />
|
||||
<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')}>
|
||||
{m["labResults_resetAllFilters"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -452,6 +529,125 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet desktopActions(r: LabResultItem)}
|
||||
<div class="lab-results-row-actions flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0"
|
||||
onclick={() => startEdit(r)}
|
||||
aria-label={m.common_edit()}
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
||||
aria-label={m.common_delete()}
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet mobileActions(r: LabResultItem)}
|
||||
<div class="lab-results-mobile-actions flex gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" onclick={() => startEdit(r)}>
|
||||
<Pencil class="size-4" />
|
||||
{m.common_edit()}
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||
<X class="size-4" />
|
||||
{m.common_delete()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet desktopRow(r: LabResultItem)}
|
||||
<TableRow class="lab-results-row">
|
||||
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||
<TableCell class="font-medium">
|
||||
<button type="button" onclick={() => filterByCode(r.test_code)} class="lab-results-code-link text-left">
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground font-mono">
|
||||
<button type="button" onclick={() => filterByCode(r.test_code)} class="lab-results-code-link">
|
||||
{r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="lab-results-value-cell">{formatValue(r)}</TableCell>
|
||||
<TableCell>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>{r.flag}</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{@render desktopActions(r)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/snippet}
|
||||
|
||||
{#snippet mobileCard(r: LabResultItem)}
|
||||
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link text-left font-medium"
|
||||
>
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</div>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>{r.flag}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
|
||||
<div class="lab-results-mobile-value flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{r.test_code}
|
||||
</button>
|
||||
<span>{formatValue(r)}</span>
|
||||
</div>
|
||||
{#if r.lab}
|
||||
<p class="text-xs text-muted-foreground">{r.lab}</p>
|
||||
{/if}
|
||||
{@render mobileActions(r)}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<div class="products-table-shell lab-results-table hidden md:block reveal-2">
|
||||
<Table>
|
||||
|
|
@ -467,156 +663,20 @@
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if shouldGroupByDate}
|
||||
{#each groupedByDate as group (group.date)}
|
||||
{#each displayGroups as group (group.key)}
|
||||
{#if group.label}
|
||||
<TableRow>
|
||||
<TableCell colspan={7} class="bg-muted/35 py-2">
|
||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">
|
||||
{group.date}
|
||||
{group.label}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#each group.items as r (r.record_id)}
|
||||
<TableRow class="lab-results-row">
|
||||
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||
<TableCell class="font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link text-left"
|
||||
>
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground font-mono">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link"
|
||||
>
|
||||
{r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="lab-results-value-cell">
|
||||
{formatValue(r)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="lab-results-row-actions flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0"
|
||||
onclick={() => startEdit(r)}
|
||||
aria-label={m.common_edit()}
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
||||
aria-label={m.common_delete()}
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
{#each group.items as r (r.record_id)}
|
||||
{@render desktopRow(r)}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each data.resultPage.items as r (r.record_id)}
|
||||
<TableRow class="lab-results-row">
|
||||
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||
<TableCell class="font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link text-left"
|
||||
>
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground font-mono">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link"
|
||||
>
|
||||
{r.test_code}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell class="lab-results-value-cell">
|
||||
{formatValue(r)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">{r.lab ?? '—'}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="lab-results-row-actions flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0"
|
||||
onclick={() => startEdit(r)}
|
||||
aria-label={m.common_edit()}
|
||||
>
|
||||
<Pencil class="size-4" />
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m["labResults_confirmDelete"]())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 shrink-0 p-0 text-destructive hover:text-destructive"
|
||||
aria-label={m.common_delete()}
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if data.resultPage.items.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={7} class="text-center text-muted-foreground py-8">
|
||||
|
|
@ -630,120 +690,14 @@
|
|||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="lab-results-mobile-grid flex flex-col gap-3 md:hidden reveal-3">
|
||||
{#if shouldGroupByDate}
|
||||
{#each groupedByDate as group (group.date)}
|
||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">{group.date}</div>
|
||||
{#each group.items as r (r.record_id)}
|
||||
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link text-left font-medium"
|
||||
>
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</div>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
|
||||
<div class="lab-results-mobile-value flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{r.test_code}
|
||||
</button>
|
||||
<span>{formatValue(r)}</span>
|
||||
</div>
|
||||
{#if r.lab}
|
||||
<p class="text-xs text-muted-foreground">{r.lab}</p>
|
||||
{/if}
|
||||
<div class="lab-results-mobile-actions flex gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" onclick={() => startEdit(r)}>
|
||||
<Pencil class="size-4" />
|
||||
{m.common_edit()}
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m['labResults_confirmDelete']())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||
<X class="size-4" />
|
||||
{m.common_delete()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#each displayGroups as group (group.key)}
|
||||
{#if group.label}
|
||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">{group.label}</div>
|
||||
{/if}
|
||||
{#each group.items as r (r.record_id)}
|
||||
{@render mobileCard(r)}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each data.resultPage.items as r (r.record_id)}
|
||||
<div class="products-mobile-card lab-results-mobile-card flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link text-left font-medium"
|
||||
>
|
||||
{r.test_name_original ?? r.test_code}
|
||||
</button>
|
||||
</div>
|
||||
{#if r.flag}
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{r.collected_at.slice(0, 10)}</p>
|
||||
<div class="lab-results-mobile-value flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filterByCode(r.test_code)}
|
||||
class="lab-results-code-link font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{r.test_code}
|
||||
</button>
|
||||
<span>{formatValue(r)}</span>
|
||||
</div>
|
||||
{#if r.lab}
|
||||
<p class="text-xs text-muted-foreground">{r.lab}</p>
|
||||
{/if}
|
||||
<div class="lab-results-mobile-actions flex gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" onclick={() => startEdit(r)}>
|
||||
<Pencil class="size-4" />
|
||||
{m.common_edit()}
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance
|
||||
onsubmit={(event) => {
|
||||
if (!confirm(m["labResults_confirmDelete"]())) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.record_id} />
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||
<X class="size-4" />
|
||||
{m.common_delete()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if data.resultPage.items.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue