feat(repo): expand lab results workflows across backend and frontend
This commit is contained in:
parent
f1b104909d
commit
0a4ccefe28
19 changed files with 1330 additions and 170 deletions
|
|
@ -280,12 +280,55 @@
|
|||
"labResults_unitPlaceholder": "e.g. g/dL",
|
||||
"labResults_flag": "Flag",
|
||||
"labResults_added": "Result added.",
|
||||
"labResults_deleted": "Result deleted.",
|
||||
"labResults_updated": "Result updated.",
|
||||
"labResults_editTitle": "Edit result",
|
||||
"labResults_confirmDelete": "Delete this result?",
|
||||
"labResults_search": "Search",
|
||||
"labResults_searchPlaceholder": "test name or code",
|
||||
"labResults_from": "From",
|
||||
"labResults_to": "To",
|
||||
"labResults_sort": "Sort",
|
||||
"labResults_sortNewest": "Newest first",
|
||||
"labResults_sortOldest": "Oldest first",
|
||||
"labResults_applyFilters": "Apply filters",
|
||||
"labResults_resetFilters": "Reset",
|
||||
"labResults_resetAllFilters": "Reset all",
|
||||
"labResults_filteredByCode": "Filtered by test code: {code}",
|
||||
"labResults_clearCodeFilter": "Clear code filter",
|
||||
"labResults_previous": "Previous",
|
||||
"labResults_next": "Next",
|
||||
"labResults_view": "View",
|
||||
"labResults_viewLatest": "Latest per test",
|
||||
"labResults_viewAll": "Full history",
|
||||
"labResults_loincName": "LOINC name",
|
||||
"labResults_valueType": "Value type",
|
||||
"labResults_valueTypeNumeric": "Numeric",
|
||||
"labResults_valueTypeText": "Text",
|
||||
"labResults_valueTypeBoolean": "Boolean",
|
||||
"labResults_valueTypeEmpty": "Empty",
|
||||
"labResults_valueEmpty": "No value",
|
||||
"labResults_boolTrue": "True",
|
||||
"labResults_boolFalse": "False",
|
||||
"labResults_advanced": "Advanced fields",
|
||||
"labResults_unitUcum": "UCUM unit",
|
||||
"labResults_refLow": "Reference low",
|
||||
"labResults_refHigh": "Reference high",
|
||||
"labResults_refText": "Reference text",
|
||||
"labResults_sourceFile": "Source file",
|
||||
"labResults_notes": "Notes",
|
||||
"labResults_changeNew": "new marker",
|
||||
"labResults_changeBecameAbnormal": "became abnormal",
|
||||
"labResults_changeFlagChanged": "flag changed",
|
||||
"labResults_changeDelta": "Δ {delta}",
|
||||
"labResults_pageIndicator": "Page {page} / {total}",
|
||||
"labResults_colDate": "Date",
|
||||
"labResults_colTest": "Test",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Value",
|
||||
"labResults_colFlag": "Flag",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_colActions": "Actions",
|
||||
"labResults_noResults": "No lab results found.",
|
||||
|
||||
"skin_title": "Skin Snapshots",
|
||||
|
|
|
|||
|
|
@ -292,12 +292,55 @@
|
|||
"labResults_unitPlaceholder": "np. g/dL",
|
||||
"labResults_flag": "Flaga",
|
||||
"labResults_added": "Wynik dodany.",
|
||||
"labResults_deleted": "Wynik usunięty.",
|
||||
"labResults_updated": "Wynik zaktualizowany.",
|
||||
"labResults_editTitle": "Edytuj wynik",
|
||||
"labResults_confirmDelete": "Usunąć ten wynik?",
|
||||
"labResults_search": "Szukaj",
|
||||
"labResults_searchPlaceholder": "nazwa badania lub kod",
|
||||
"labResults_from": "Od",
|
||||
"labResults_to": "Do",
|
||||
"labResults_sort": "Sortowanie",
|
||||
"labResults_sortNewest": "Najnowsze najpierw",
|
||||
"labResults_sortOldest": "Najstarsze najpierw",
|
||||
"labResults_applyFilters": "Zastosuj filtry",
|
||||
"labResults_resetFilters": "Reset",
|
||||
"labResults_resetAllFilters": "Wyczyść wszystko",
|
||||
"labResults_filteredByCode": "Filtrowanie po kodzie badania: {code}",
|
||||
"labResults_clearCodeFilter": "Wyczyść filtr kodu",
|
||||
"labResults_previous": "Poprzednia",
|
||||
"labResults_next": "Następna",
|
||||
"labResults_view": "Widok",
|
||||
"labResults_viewLatest": "Najnowszy wynik na badanie",
|
||||
"labResults_viewAll": "Pełna historia",
|
||||
"labResults_loincName": "Nazwa LOINC",
|
||||
"labResults_valueType": "Typ wartości",
|
||||
"labResults_valueTypeNumeric": "Liczba",
|
||||
"labResults_valueTypeText": "Tekst",
|
||||
"labResults_valueTypeBoolean": "Boolean",
|
||||
"labResults_valueTypeEmpty": "Puste",
|
||||
"labResults_valueEmpty": "Brak wartości",
|
||||
"labResults_boolTrue": "Prawda",
|
||||
"labResults_boolFalse": "Fałsz",
|
||||
"labResults_advanced": "Pola zaawansowane",
|
||||
"labResults_unitUcum": "Jednostka UCUM",
|
||||
"labResults_refLow": "Dolna norma",
|
||||
"labResults_refHigh": "Górna norma",
|
||||
"labResults_refText": "Opis normy",
|
||||
"labResults_sourceFile": "Plik źródłowy",
|
||||
"labResults_notes": "Notatki",
|
||||
"labResults_changeNew": "nowy marker",
|
||||
"labResults_changeBecameAbnormal": "poza normą",
|
||||
"labResults_changeFlagChanged": "zmiana flagi",
|
||||
"labResults_changeDelta": "Δ {delta}",
|
||||
"labResults_pageIndicator": "Strona {page} / {total}",
|
||||
"labResults_colDate": "Data",
|
||||
"labResults_colTest": "Badanie",
|
||||
"labResults_colLoinc": "LOINC",
|
||||
"labResults_colValue": "Wartość",
|
||||
"labResults_colFlag": "Flaga",
|
||||
"labResults_colLab": "Lab",
|
||||
"labResults_colActions": "Akcje",
|
||||
"labResults_noResults": "Nie znaleziono wyników badań.",
|
||||
|
||||
"skin_title": "Stan skóry",
|
||||
|
|
|
|||
|
|
@ -240,6 +240,8 @@ body {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.editorial-filter-row {
|
||||
|
|
@ -389,6 +391,108 @@ body {
|
|||
color: hsl(28 55% 30%);
|
||||
}
|
||||
|
||||
.lab-results-meta-strip {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.73rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lab-results-meta-pill--alert {
|
||||
border-color: hsl(12 56% 69%);
|
||||
background: hsl(10 66% 90%);
|
||||
color: hsl(10 63% 30%);
|
||||
min-width: 2.25rem;
|
||||
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));
|
||||
background: color-mix(in srgb, var(--page-accent-soft) 52%, white);
|
||||
}
|
||||
|
||||
.lab-results-pager {
|
||||
border-color: color-mix(in srgb, var(--page-accent) 26%, var(--border));
|
||||
}
|
||||
|
||||
.lab-results-table table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.lab-results-row td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.lab-results-row-actions {
|
||||
opacity: 0.62;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.lab-results-row:hover .lab-results-row-actions,
|
||||
.lab-results-row:focus-within .lab-results-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lab-results-code-link {
|
||||
border-radius: 0.32rem;
|
||||
text-decoration: none;
|
||||
transition: color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.lab-results-code-link:hover {
|
||||
color: var(--page-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.lab-results-code-link:focus-visible {
|
||||
outline: 2px solid var(--page-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lab-results-value-cell {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.lab-results-mobile-grid .products-section-title {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.lab-results-mobile-card {
|
||||
gap: 0.45rem;
|
||||
background: linear-gradient(170deg, hsl(44 34% 96%), hsl(42 30% 94%));
|
||||
}
|
||||
|
||||
.lab-results-mobile-value {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.lab-results-mobile-actions {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
[data-slot='card'] {
|
||||
border-color: hsl(35 22% 75% / 0.8);
|
||||
background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%));
|
||||
|
|
@ -416,6 +520,37 @@ body {
|
|||
.app-main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editorial-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'kicker actions'
|
||||
'title actions'
|
||||
'subtitle actions';
|
||||
column-gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.editorial-kicker {
|
||||
grid-area: kicker;
|
||||
}
|
||||
|
||||
.editorial-title {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.editorial-subtitle {
|
||||
grid-area: subtitle;
|
||||
}
|
||||
|
||||
.editorial-toolbar {
|
||||
grid-area: actions;
|
||||
margin-top: 0;
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.editorial-dashboard {
|
||||
|
|
@ -487,6 +622,7 @@ body {
|
|||
}
|
||||
|
||||
.hero-strip {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 1.3rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -807,6 +943,19 @@ body {
|
|||
.routine-pill {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.lab-results-meta-strip {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-results-meta-pill {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.lab-results-filter-banner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -264,22 +264,35 @@ export const createMedicationUsage = (
|
|||
// ─── Health – Lab results ────────────────────────────────────────────────────
|
||||
|
||||
export interface LabResultListParams {
|
||||
q?: string;
|
||||
test_code?: string;
|
||||
flag?: string;
|
||||
lab?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
latest_only?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LabResultListResponse {
|
||||
items: LabResult[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export function getLabResults(
|
||||
params: LabResultListParams = {},
|
||||
): Promise<LabResult[]> {
|
||||
): Promise<LabResultListResponse> {
|
||||
const q = new URLSearchParams();
|
||||
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.lab) q.set("lab", params.lab);
|
||||
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));
|
||||
if (params.limit != null) q.set("limit", String(params.limit));
|
||||
if (params.offset != null) q.set("offset", String(params.offset));
|
||||
const qs = q.toString();
|
||||
return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@
|
|||
|
||||
const navItems = $derived([
|
||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
||||
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
||||
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
|
||||
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
|
||||
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
|
||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles },
|
||||
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
|
||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical },
|
||||
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles }
|
||||
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,43 @@
|
|||
import { createLabResult, getLabResults } from '$lib/api';
|
||||
import { createLabResult, deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
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 from_date = url.searchParams.get('from_date') ?? undefined;
|
||||
const results = await getLabResults({ flag, from_date });
|
||||
return { results, flag };
|
||||
const to_date = url.searchParams.get('to_date') ?? undefined;
|
||||
const requestedLatestOnly = url.searchParams.get('latest_only') !== 'false';
|
||||
const latestOnly = test_code ? false : requestedLatestOnly;
|
||||
const pageRaw = Number(url.searchParams.get('page') ?? '1');
|
||||
const page = Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1;
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const resultPage = await getLabResults({
|
||||
q,
|
||||
test_code,
|
||||
flag,
|
||||
from_date,
|
||||
to_date,
|
||||
latest_only: latestOnly,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(resultPage.total / limit));
|
||||
|
||||
return {
|
||||
resultPage,
|
||||
q,
|
||||
test_code,
|
||||
flag,
|
||||
from_date,
|
||||
to_date,
|
||||
latestOnly,
|
||||
page,
|
||||
totalPages
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
|
@ -40,5 +71,97 @@ export const actions: Actions = {
|
|||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = form.get('id') as string;
|
||||
const collected_at = form.get('collected_at') as string;
|
||||
const test_code = form.get('test_code') as string;
|
||||
const test_name_original = form.get('test_name_original') as string;
|
||||
const test_name_loinc = form.get('test_name_loinc') as string;
|
||||
const value_mode = form.get('value_mode') as string;
|
||||
const value_num = form.get('value_num') as string;
|
||||
const value_text = form.get('value_text') as string;
|
||||
const value_bool = form.get('value_bool') as string;
|
||||
const unit_original = form.get('unit_original') as string;
|
||||
const unit_ucum = form.get('unit_ucum') as string;
|
||||
const ref_low = form.get('ref_low') as string;
|
||||
const ref_high = form.get('ref_high') as string;
|
||||
const ref_text = form.get('ref_text') as string;
|
||||
const flag = form.get('flag') as string;
|
||||
const lab = form.get('lab') as string;
|
||||
const source_file = form.get('source_file') as string;
|
||||
const notes = form.get('notes') as string;
|
||||
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
if (!collected_at || !test_code) {
|
||||
return fail(400, { error: 'Date and test code are required' });
|
||||
}
|
||||
|
||||
const nullableText = (raw: string): string | null => {
|
||||
const v = raw?.trim();
|
||||
return v ? v : null;
|
||||
};
|
||||
const nullableNumber = (raw: string): number | null => {
|
||||
const v = raw?.trim();
|
||||
if (!v) return null;
|
||||
const parsed = Number(v);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
collected_at,
|
||||
test_code,
|
||||
test_name_original: nullableText(test_name_original),
|
||||
test_name_loinc: nullableText(test_name_loinc),
|
||||
unit_original: nullableText(unit_original),
|
||||
unit_ucum: nullableText(unit_ucum),
|
||||
ref_low: nullableNumber(ref_low),
|
||||
ref_high: nullableNumber(ref_high),
|
||||
ref_text: nullableText(ref_text),
|
||||
flag: nullableText(flag),
|
||||
lab: nullableText(lab),
|
||||
source_file: nullableText(source_file),
|
||||
notes: nullableText(notes)
|
||||
};
|
||||
|
||||
if (value_mode === 'num') {
|
||||
body.value_num = nullableNumber(value_num);
|
||||
body.value_text = null;
|
||||
body.value_bool = null;
|
||||
} else if (value_mode === 'text') {
|
||||
body.value_num = null;
|
||||
body.value_text = nullableText(value_text);
|
||||
body.value_bool = null;
|
||||
} else if (value_mode === 'bool') {
|
||||
body.value_num = null;
|
||||
body.value_text = null;
|
||||
body.value_bool = value_bool === 'true' ? true : value_bool === 'false' ? false : null;
|
||||
} else {
|
||||
body.value_num = null;
|
||||
body.value_text = null;
|
||||
body.value_bool = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLabResult(id, body);
|
||||
return { updated: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = form.get('id') as string;
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
|
||||
try {
|
||||
await deleteLabResult(id);
|
||||
return { deleted: true };
|
||||
} catch (e) {
|
||||
return fail(500, { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
|
@ -10,6 +8,7 @@
|
|||
import { baseSelectClass } 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';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -33,15 +32,146 @@
|
|||
|
||||
let showForm = $state(false);
|
||||
let selectedFlag = $state('');
|
||||
let filterFlag = $derived(data.flag ?? '');
|
||||
let editingId = $state<string | null>(null);
|
||||
let editCollectedAt = $state('');
|
||||
let editTestCode = $state('');
|
||||
let editTestNameOriginal = $state('');
|
||||
let editTestNameLoinc = $state('');
|
||||
let editValueMode = $state<'num' | 'text' | 'bool' | 'empty'>('empty');
|
||||
let editValueNum = $state('');
|
||||
let editValueText = $state('');
|
||||
let editValueBool = $state('');
|
||||
let editUnitOriginal = $state('');
|
||||
let editUnitUcum = $state('');
|
||||
let editRefLow = $state('');
|
||||
let editRefHigh = $state('');
|
||||
let editRefText = $state('');
|
||||
let editFlag = $state('');
|
||||
let editLab = $state('');
|
||||
let editSourceFile = $state('');
|
||||
let editNotes = $state('');
|
||||
|
||||
function startEdit(item: LabResultItem) {
|
||||
editingId = item.record_id;
|
||||
editCollectedAt = item.collected_at.slice(0, 10);
|
||||
editTestCode = item.test_code;
|
||||
editTestNameOriginal = item.test_name_original ?? '';
|
||||
editTestNameLoinc = item.test_name_loinc ?? '';
|
||||
if (item.value_num != null) {
|
||||
editValueMode = 'num';
|
||||
editValueNum = String(item.value_num);
|
||||
editValueText = '';
|
||||
editValueBool = '';
|
||||
} else if (item.value_text != null && item.value_text !== '') {
|
||||
editValueMode = 'text';
|
||||
editValueNum = '';
|
||||
editValueText = item.value_text;
|
||||
editValueBool = '';
|
||||
} else if (item.value_bool != null) {
|
||||
editValueMode = 'bool';
|
||||
editValueNum = '';
|
||||
editValueText = '';
|
||||
editValueBool = item.value_bool ? 'true' : 'false';
|
||||
} else {
|
||||
editValueMode = 'empty';
|
||||
editValueNum = '';
|
||||
editValueText = '';
|
||||
editValueBool = '';
|
||||
}
|
||||
editUnitOriginal = item.unit_original ?? '';
|
||||
editUnitUcum = item.unit_ucum ?? '';
|
||||
editRefLow = item.ref_low != null ? String(item.ref_low) : '';
|
||||
editRefHigh = item.ref_high != null ? String(item.ref_high) : '';
|
||||
editRefText = item.ref_text ?? '';
|
||||
editFlag = item.flag ?? '';
|
||||
editLab = item.lab ?? '';
|
||||
editSourceFile = item.source_file ?? '';
|
||||
editNotes = item.notes ?? '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
type LabResultItem = PageData['resultPage']['items'][number];
|
||||
type GroupedByDate = { date: string; items: LabResultItem[] };
|
||||
|
||||
function buildPageUrl(page: number) {
|
||||
const base = '/health/lab-results';
|
||||
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 (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;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
const groupedByDate = $derived.by<GroupedByDate[]>(() => {
|
||||
const groups: Record<string, LabResultItem[]> = {};
|
||||
for (const item of data.resultPage.items) {
|
||||
const date = item.collected_at.slice(0, 10);
|
||||
if (groups[date]) {
|
||||
groups[date].push(item);
|
||||
} else {
|
||||
groups[date] = [item];
|
||||
}
|
||||
}
|
||||
return Object.entries(groups).map(([date, items]) => ({ date, items }));
|
||||
});
|
||||
|
||||
const shouldGroupByDate = $derived(!data.test_code);
|
||||
|
||||
const notableFlags = new Set(['ABN', 'H', 'L', 'POS']);
|
||||
const flaggedCount = $derived.by(() =>
|
||||
data.resultPage.items.reduce((count, item) => {
|
||||
if (!item.flag) return count;
|
||||
return notableFlags.has(item.flag) ? count + 1 : count;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
function formatValue(item: LabResultItem): string {
|
||||
if (item.value_num != null) {
|
||||
return `${item.value_num}${item.unit_original ? ` ${item.unit_original}` : ''}`;
|
||||
}
|
||||
if (item.value_text) return item.value_text;
|
||||
if (item.value_bool != null) return item.value_bool ? m['labResults_boolTrue']() : m['labResults_boolFalse']();
|
||||
return '—';
|
||||
}
|
||||
|
||||
const flagOptions = flags.map((f) => ({ value: f, label: f }));
|
||||
|
||||
function onFlagChange(v: string) {
|
||||
const base = resolve('/health/lab-results');
|
||||
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
|
||||
goto(target, { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||
|
|
@ -50,9 +180,20 @@
|
|||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
|
||||
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.results.length })}</p>
|
||||
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.resultPage.total })}</p>
|
||||
<div class="lab-results-meta-strip">
|
||||
<span class="lab-results-meta-pill">
|
||||
{m['labResults_view']()}: {data.latestOnly ? m['labResults_viewLatest']() : m['labResults_viewAll']()}
|
||||
</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>
|
||||
{#if data.test_code}
|
||||
<span class="lab-results-meta-pill lab-results-meta-pill--alert">{data.test_code}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
||||
</Button>
|
||||
|
|
@ -65,66 +206,254 @@
|
|||
{#if form?.created}
|
||||
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
|
||||
{/if}
|
||||
{#if form?.deleted}
|
||||
<div class="editorial-alert editorial-alert--success">{m["labResults_deleted"]()}</div>
|
||||
{/if}
|
||||
{#if form?.updated}
|
||||
<div class="editorial-alert editorial-alert--success">{m["labResults_updated"]()}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="editorial-panel reveal-2 flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span>
|
||||
<select
|
||||
class={`${baseSelectClass} w-32`}
|
||||
value={filterFlag}
|
||||
onchange={(e) => onFlagChange(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{m["labResults_flagAll"]()}</option>
|
||||
{#each flags as f (f)}
|
||||
<option value={f}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
||||
<Input id="collected_at" name="collected_at" type="date" required />
|
||||
{#if editingId}
|
||||
<FormSectionCard title={m["labResults_editTitle"]()} className="reveal-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingId = null;
|
||||
};
|
||||
}}
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={editingId} />
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_collected_at">{m["labResults_date"]()}</Label>
|
||||
<Input id="edit_collected_at" name="collected_at" type="date" bind:value={editCollectedAt} required />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_test_code">{m["labResults_loincCode"]()}</Label>
|
||||
<Input id="edit_test_code" name="test_code" bind:value={editTestCode} required />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_test_name_original">{m["labResults_testName"]()}</Label>
|
||||
<Input id="edit_test_name_original" name="test_name_original" bind:value={editTestNameOriginal} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_test_name_loinc">{m["labResults_loincName"]()}</Label>
|
||||
<Input id="edit_test_name_loinc" name="test_name_loinc" bind:value={editTestNameLoinc} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_value_mode">{m["labResults_valueType"]()}</Label>
|
||||
<select class={`${baseSelectClass} w-full`} id="edit_value_mode" name="value_mode" bind:value={editValueMode}>
|
||||
<option value="num">{m["labResults_valueTypeNumeric"]()}</option>
|
||||
<option value="text">{m["labResults_valueTypeText"]()}</option>
|
||||
<option value="bool">{m["labResults_valueTypeBoolean"]()}</option>
|
||||
<option value="empty">{m["labResults_valueTypeEmpty"]()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
{#if editValueMode === 'num'}
|
||||
<Label for="edit_value_num">{m["labResults_value"]()}</Label>
|
||||
<Input id="edit_value_num" name="value_num" type="number" step="any" bind:value={editValueNum} />
|
||||
{:else if editValueMode === 'text'}
|
||||
<Label for="edit_value_text">{m["labResults_value"]()}</Label>
|
||||
<Input id="edit_value_text" name="value_text" bind:value={editValueText} />
|
||||
{:else if editValueMode === 'bool'}
|
||||
<Label for="edit_value_bool">{m["labResults_value"]()}</Label>
|
||||
<select class={`${baseSelectClass} w-full`} id="edit_value_bool" name="value_bool" bind:value={editValueBool}>
|
||||
<option value="">{m["labResults_flagNone"]()}</option>
|
||||
<option value="true">{m["labResults_boolTrue"]()}</option>
|
||||
<option value="false">{m["labResults_boolFalse"]()}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<Label for="edit_value_placeholder">{m["labResults_value"]()}</Label>
|
||||
<Input id="edit_value_placeholder" value={m["labResults_valueEmpty"]()} disabled />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_unit_original">{m["labResults_unit"]()}</Label>
|
||||
<Input id="edit_unit_original" name="unit_original" bind:value={editUnitOriginal} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_flag">{m["labResults_flag"]()}</Label>
|
||||
<select class={`${baseSelectClass} w-full`} id="edit_flag" name="flag" bind:value={editFlag}>
|
||||
<option value="">{m["labResults_flagNone"]()}</option>
|
||||
{#each flags as f (f)}
|
||||
<option value={f}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_lab">{m["labResults_lab"]()}</Label>
|
||||
<Input id="edit_lab" name="lab" bind:value={editLab} />
|
||||
</div>
|
||||
<details class="sm:col-span-2 rounded-xl border p-3">
|
||||
<summary class="cursor-pointer text-sm text-muted-foreground">{m["labResults_advanced"]()}</summary>
|
||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_unit_ucum">{m["labResults_unitUcum"]()}</Label>
|
||||
<Input id="edit_unit_ucum" name="unit_ucum" bind:value={editUnitUcum} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_ref_low">{m["labResults_refLow"]()}</Label>
|
||||
<Input id="edit_ref_low" name="ref_low" type="number" step="any" bind:value={editRefLow} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_ref_high">{m["labResults_refHigh"]()}</Label>
|
||||
<Input id="edit_ref_high" name="ref_high" type="number" step="any" bind:value={editRefHigh} />
|
||||
</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} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_source_file">{m["labResults_sourceFile"]()}</Label>
|
||||
<Input id="edit_source_file" name="source_file" bind:value={editSourceFile} />
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="test_code">{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground">({m["labResults_loincExample"]()})</span></Label>
|
||||
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="test_name_original">{m["labResults_testName"]()}</Label>
|
||||
<Input id="test_name_original" name="test_name_original" placeholder={m["labResults_testNamePlaceholder"]()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="lab">{m["labResults_lab"]()}</Label>
|
||||
<Input id="lab" name="lab" placeholder={m["labResults_labPlaceholder"]()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="value_num">{m["labResults_value"]()}</Label>
|
||||
<Input id="value_num" name="value_num" type="number" step="any" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
||||
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id="flag"
|
||||
name="flag"
|
||||
label={m["labResults_flag"]()}
|
||||
options={flagOptions}
|
||||
placeholder={m["labResults_flagNone"]()}
|
||||
bind:value={selectedFlag}
|
||||
/>
|
||||
<div class="flex items-end">
|
||||
<Button type="submit">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<div class="sm:col-span-2 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={cancelEdit}>{m.common_cancel()}</Button>
|
||||
<Button type="submit">{m.common_save()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</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"]()} />
|
||||
</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"]()}
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
||||
<Input id="collected_at" name="collected_at" type="date" required />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="test_code"
|
||||
>{m["labResults_loincCode"]()} <span class="text-xs text-muted-foreground"
|
||||
>({m["labResults_loincExample"]()})</span
|
||||
></Label
|
||||
>
|
||||
<Input id="test_code" name="test_code" required placeholder="718-7" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="test_name_original">{m["labResults_testName"]()}</Label>
|
||||
<Input
|
||||
id="test_name_original"
|
||||
name="test_name_original"
|
||||
placeholder={m["labResults_testNamePlaceholder"]()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="lab_create">{m["labResults_lab"]()}</Label>
|
||||
<Input id="lab_create" name="lab" placeholder={m["labResults_labPlaceholder"]()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="value_num">{m["labResults_value"]()}</Label>
|
||||
<Input id="value_num" name="value_num" type="number" step="any" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
||||
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id="flag_create"
|
||||
name="flag"
|
||||
label={m["labResults_flag"]()}
|
||||
options={flagOptions}
|
||||
placeholder={m["labResults_flagNone"]()}
|
||||
bind:value={selectedFlag}
|
||||
/>
|
||||
<div class="flex items-end">
|
||||
<Button type="submit">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormSectionCard>
|
||||
{/if}
|
||||
|
||||
{#if data.totalPages > 1}
|
||||
<div class="editorial-panel lab-results-pager reveal-2 flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page <= 1}
|
||||
onclick={() => (window.location.href = buildPageUrl(data.page - 1))}
|
||||
>
|
||||
{m["labResults_previous"]()}
|
||||
</Button>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{m["labResults_pageIndicator"]({ page: data.page, total: data.totalPages })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onclick={() => (window.location.href = buildPageUrl(data.page + 1))}
|
||||
>
|
||||
{m["labResults_next"]()}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<div class="products-table-shell hidden md:block reveal-2">
|
||||
<div class="products-table-shell lab-results-table hidden md:block reveal-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -134,72 +463,289 @@
|
|||
<TableHead>{m["labResults_colValue"]()}</TableHead>
|
||||
<TableHead>{m["labResults_colFlag"]()}</TableHead>
|
||||
<TableHead>{m["labResults_colLab"]()}</TableHead>
|
||||
<TableHead class="text-right">{m["labResults_colActions"]()}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each data.results as r (r.record_id)}
|
||||
<TableRow>
|
||||
<TableCell class="text-sm">{r.collected_at.slice(0, 10)}</TableCell>
|
||||
<TableCell class="font-medium">{r.test_name_original ?? r.test_code}</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground font-mono">{r.test_code}</TableCell>
|
||||
<TableCell>
|
||||
{#if r.value_num != null}
|
||||
{r.value_num} {r.unit_original ?? ''}
|
||||
{:else if r.value_text}
|
||||
{r.value_text}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</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>
|
||||
</TableRow>
|
||||
{#if shouldGroupByDate}
|
||||
{#each groupedByDate as group (group.date)}
|
||||
<TableRow>
|
||||
<TableCell colspan={7} class="bg-muted/35 py-2">
|
||||
<div class="products-section-title text-xs uppercase tracking-[0.12em]">
|
||||
{group.date}
|
||||
</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}
|
||||
{/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}
|
||||
{#if data.resultPage.items.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={6} class="text-center text-muted-foreground py-8">
|
||||
<TableCell colspan={7} class="text-center text-muted-foreground py-8">
|
||||
{m["labResults_noResults"]()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="flex flex-col gap-3 md:hidden reveal-3">
|
||||
{#each data.results as r (r.record_id)}
|
||||
<div class="products-mobile-card flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
|
||||
{#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="flex items-center gap-2 text-sm">
|
||||
<span class="font-mono text-xs text-muted-foreground">{r.test_code}</span>
|
||||
{#if r.value_num != null}
|
||||
<span>{r.value_num} {r.unit_original ?? ''}</span>
|
||||
{:else if r.value_text}
|
||||
<span>{r.value_text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if r.lab}
|
||||
<p class="text-xs text-muted-foreground">{r.lab}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
{: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}
|
||||
{#if data.resultPage.items.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["labResults_noResults"]()}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
|
@ -43,7 +42,6 @@
|
|||
<h2 class="editorial-title">{m.medications_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
</div>
|
||||
|
||||
<div class="editorial-page space-y-4 pb-20 md:pb-0">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="break-words editorial-title">{product.name}</h2>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
|
||||
|
|
|
|||
|
|
@ -33,10 +33,11 @@
|
|||
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
|
||||
<p class="editorial-subtitle">{m["products_suggestSubtitle"]()}</p>
|
||||
</section>
|
||||
|
||||
{#if errorMsg}
|
||||
|
|
@ -112,7 +113,7 @@
|
|||
|
||||
<form method="POST" action="?/suggest" use:enhance={enhanceForm}>
|
||||
<Button variant="outline" type="submit" disabled={loading}>
|
||||
{m["products_suggestRegenerate"]()}
|
||||
<Sparkles class="size-4" /> {m["products_suggestRegenerate"]()}
|
||||
</Button>
|
||||
</form>
|
||||
{:else if suggestions && suggestions.length === 0}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sparkles } from 'lucide-svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
<h2 class="editorial-title">{m.routines_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||
<Button href={resolve('/routines/suggest')} variant="outline"><Sparkles class="size-4" /> {m["routines_suggestAI"]()}</Button>
|
||||
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@
|
|||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -48,17 +48,15 @@
|
|||
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||
<p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
|
||||
<div class="editorial-toolbar">
|
||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
||||
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
||||
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
|
||||
import { ChevronUp, ChevronDown, ArrowLeft, Sparkles } from 'lucide-svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<section class="editorial-hero reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.suggest_title()}</h2>
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
{m.suggest_generating()}
|
||||
{:else}
|
||||
{m["suggest_generateBtn"]()}
|
||||
<Sparkles class="size-4" /> {m["suggest_generateBtn"]()}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -255,7 +255,7 @@
|
|||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" type="submit" form="suggest-single-form" disabled={loadingSingle}>
|
||||
{m.suggest_regenerate()}
|
||||
<Sparkles class="size-4" /> {m.suggest_regenerate()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -305,7 +305,7 @@
|
|||
<span class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
{m["suggest_generatingPlan"]()}
|
||||
{:else}
|
||||
{m["suggest_generatePlan"]()}
|
||||
<Sparkles class="size-4" /> {m["suggest_generatePlan"]()}
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -420,7 +420,7 @@
|
|||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" type="submit" form="batch-form" disabled={loadingBatch}>
|
||||
{m.suggest_regenerate()}
|
||||
<Sparkles class="size-4" /> {m.suggest_regenerate()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue