Compare commits

...

4 commits

Author SHA1 Message Date
12dbf7227b
fix: replace single quotes in ts query string, add ts index 2024-02-15 14:03:55 +01:00
39b97c6042
feat: add hide/delete buttons 2024-02-13 00:34:10 +01:00
881dc583e3
fix: remove client-side table loading 2024-02-11 02:51:42 +01:00
dee579ab46
fix: filter bar height
feat: add filter button to patients table
2024-02-11 02:45:38 +01:00
18 changed files with 153 additions and 133 deletions

View file

@ -33,4 +33,5 @@ EXECUTE PROCEDURE update_entry_tsvec ();
ALTER TABLE patients ALTER TABLE patients
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED; ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;
CREATE INDEX entries_tsvec ON entries USING GIN (tsvec);
CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops); CREATE INDEX patients_full_name ON patients USING gin (full_name gin_trgm_ops);

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="tap">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View file

@ -23,7 +23,7 @@
export let placeholder: string | undefined = undefined; export let placeholder: string | undefined = undefined;
export let padding = true; export let padding = true;
export let cls = ""; export let cls = "";
export let inputCls = "w-full bg-transparent"; export let inputCls = "w-full bg-transparent outline-none";
export let asTextInput = false; export let asTextInput = false;
export let idInputName: string | undefined = undefined; export let idInputName: string | undefined = undefined;

View file

@ -21,6 +21,7 @@
export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {}; export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {};
/** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */ /** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */
export let hiddenFilters: string[] = []; export let hiddenFilters: string[] = [];
/** True if a separate search field should be displayed */
export let search = false; export let search = false;
let autocomplete: Autocomplete | undefined; let autocomplete: Autocomplete | undefined;
@ -59,7 +60,9 @@
function updateFromQueryData(filterData: FilterQdata) { function updateFromQueryData(filterData: FilterQdata) {
const filters: FilterData[] = []; const filters: FilterData[] = [];
for (const [id, value] of Object.entries(filterData)) { for (const [id, value] of Object.entries(filterData)) {
if (hiddenFilters.includes(id)) continue; // If filter is hidden or undefined, dont display it
if (hiddenFilters.includes(id) || !FILTERS[id]) continue;
// Extract search parameter if a separate search field is used
if (search && id === "search") { if (search && id === "search") {
searchVal = value.toString(); searchVal = value.toString();
} else if (Array.isArray(value)) { } else if (Array.isArray(value)) {
@ -183,10 +186,8 @@
} }
</script> </script>
<div class="flex flex-wrap w-full items-start justify-center gap-2 relative"> <div class="filterbar-outer">
<div <div class="filterbar-inner input input-sm input-bordered">
class="flex flex-wrap flex-grow items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
>
{#each activeFilters as fdata, i} {#each activeFilters as fdata, i}
<EntryFilterChip <EntryFilterChip
filter={FILTERS[fdata.id]} filter={FILTERS[fdata.id]}
@ -221,6 +222,7 @@
aria-label="Alle Filter entfernen" aria-label="Alle Filter entfernen"
on:click={() => { on:click={() => {
activeFilters = []; activeFilters = [];
searchVal = "";
updateFilter(); updateFilter();
}} }}
> >
@ -239,3 +241,14 @@
{/if} {/if}
<slot /> <slot />
</div> </div>
<style lang="postcss">
.filterbar-outer {
@apply flex flex-wrap w-full items-start justify-center gap-2 relative;
}
.filterbar-inner {
@apply flex flex-wrap flex-grow items-stretch h-min p-0 gap-2 relative;
line-height: 30px;
}
</style>

View file

@ -32,9 +32,13 @@
autocomplete.open(); autocomplete.open();
} }
const TOFF = " aus";
$: toggleState = fdata.selection?.toggle !== false; $: toggleState = fdata.selection?.toggle !== false;
$: filterName = toggleState ? filter.name : filter.toggleOff?.name; $: filterName = toggleState
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon; ? filter.name
: filter.toggleOff?.name ?? filter.name + TOFF;
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon ?? filter.icon;
$: hasInputField = filter.inputType !== 0 && filter.inputType !== 3; $: hasInputField = filter.inputType !== 0 && filter.inputType !== 3;
</script> </script>

View file

@ -2,6 +2,7 @@ import { trpc } from "$lib/shared/trpc";
import { import {
mdiAccount, mdiAccount,
mdiAccountInjury, mdiAccountInjury,
mdiAccountMultipleOutline,
mdiAccountRemoveOutline, mdiAccountRemoveOutline,
mdiBedKingOutline, mdiBedKingOutline,
mdiCheckboxBlankOutline, mdiCheckboxBlankOutline,
@ -118,4 +119,10 @@ export const PATIENT_FILTER: { [key: string]: FilterDef } = {
icon: mdiAccountRemoveOutline, icon: mdiAccountRemoveOutline,
inputType: 0, inputType: 0,
}, },
includeHidden: {
id: "includeHidden",
name: "Alle anzeigen",
icon: mdiAccountMultipleOutline,
inputType: 0,
},
}; };

View file

@ -6,6 +6,7 @@
import { superForm, superValidateSync } from "sveltekit-superforms/client"; import { superForm, superValidateSync } from "sveltekit-superforms/client";
import type { SuperValidated } from "sveltekit-superforms"; import type { SuperValidated } from "sveltekit-superforms";
import { ZPatientNew } from "$lib/shared/model/validation"; import { ZPatientNew } from "$lib/shared/model/validation";
import { browser } from "$app/environment";
export let patient: RouterOutput["patient"]["get"] | null = null; export let patient: RouterOutput["patient"]["get"] | null = null;
export let formData: SuperValidated<typeof ZPatientNew> = export let formData: SuperValidated<typeof ZPatientNew> =
@ -91,11 +92,14 @@
</label> </label>
</div> </div>
<div class="flex flex-wrap gap-2">
<button <button
class="btn btn-primary max-w-32" class="btn btn-primary max-w-32"
type="submit" type="submit"
disabled={$tainted === undefined} disabled={browser && $tainted === undefined}
> >
Speichern Speichern
</button> </button>
<slot />
</div>
</form> </form>

View file

@ -4,11 +4,9 @@
import FilterBar from "$lib/components/filter/FilterBar.svelte"; import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model"; import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types"; import type { FilterQdata } from "$lib/components/filter/types";
import { trpc, type RouterOutput } from "$lib/shared/trpc"; import { type RouterOutput } from "$lib/shared/trpc";
import { ENTRY_FILTERS } from "$lib/components/filter/filters"; import { ENTRY_FILTERS } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util"; import { getQueryUrl } from "$lib/shared/util";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
import type { ZEntriesQuery } from "$lib/shared/model/validation"; import type { ZEntriesQuery } from "$lib/shared/model/validation";
import { z } from "zod"; import { z } from "zod";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
@ -19,9 +17,6 @@
export let baseUrl: string; export let baseUrl: string;
export let patientId: number | null = null; export let patientId: number | null = null;
let loadingBar: LoadingBar | undefined;
let loadError: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) { function paginationUpdate(pagination: PaginationRequest) {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
@ -49,29 +44,6 @@
// Update page URL // Update page URL
const url = getQueryUrl(q, baseUrl); const url = getQueryUrl(q, baseUrl);
goto(url, { replaceState: true, keepFocus: true }); goto(url, { replaceState: true, keepFocus: true });
// Apply patient filter
if (patientId !== null) {
if (!q.filter) q.filter = {};
q.filter.patient = [{ id: patientId }];
}
loadingBar?.start();
trpc()
.entry.list.query(q)
.then((ent) => {
entries = ent;
loadError = null;
loadingBar?.reset();
})
.catch((err) => {
query = q;
loadError = err;
loadingBar?.error();
})
.finally(() => {
query = q;
});
} }
} }
</script> </script>
@ -85,22 +57,16 @@
<slot /> <slot />
</FilterBar> </FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown /> <EntryTable
{#if loadError}
<ErrorMessage error={loadError} />
{:else}
<EntryTable
{entries} {entries}
sortData={query.sort} sortData={query.sort}
{sortUpdate} {sortUpdate}
perPatient={patientId !== null} perPatient={patientId !== null}
{baseUrl} {baseUrl}
/> />
<PaginationButtons <PaginationButtons
paginationData={query.pagination} paginationData={query.pagination}
data={entries} data={entries}
onUpdate={paginationUpdate} onUpdate={paginationUpdate}
/> />
{/if}

View file

@ -4,11 +4,9 @@
import FilterBar from "$lib/components/filter/FilterBar.svelte"; import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model"; import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types"; import type { FilterQdata } from "$lib/components/filter/types";
import { trpc, type RouterOutput } from "$lib/shared/trpc"; import { type RouterOutput } from "$lib/shared/trpc";
import { PATIENT_FILTER } from "$lib/components/filter/filters"; import { PATIENT_FILTER } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util"; import { getQueryUrl } from "$lib/shared/util";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
import type { ZPatientsQuery } from "$lib/shared/model/validation"; import type { ZPatientsQuery } from "$lib/shared/model/validation";
import { z } from "zod"; import { z } from "zod";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
@ -18,9 +16,6 @@
export let patients: RouterOutput["patient"]["list"]; export let patients: RouterOutput["patient"]["list"];
export let baseUrl: string; export let baseUrl: string;
let loadingBar: LoadingBar | undefined;
let loadError: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) { function paginationUpdate(pagination: PaginationRequest) {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
@ -46,22 +41,6 @@
// Update page URL // Update page URL
const url = getQueryUrl(q, baseUrl); const url = getQueryUrl(q, baseUrl);
goto(url, { replaceState: true, keepFocus: true }); goto(url, { replaceState: true, keepFocus: true });
loadingBar?.start();
trpc()
.patient.list.query(q)
.then((p) => {
patients = p;
loadError = null;
loadingBar?.reset();
})
.catch((err) => {
loadError = err;
loadingBar?.error();
})
.finally(() => {
query = q;
});
} }
} }
</script> </script>
@ -75,16 +54,10 @@
<slot /> <slot />
</FilterBar> </FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown /> <PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
{#if loadError} <PaginationButtons
<ErrorMessage error={loadError} />
{:else}
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
<PaginationButtons
paginationData={query.pagination} paginationData={query.pagination}
data={patients} data={patients}
onUpdate={paginationUpdate} onUpdate={paginationUpdate}
/> />
{/if}

View file

@ -2,7 +2,7 @@
import type { RouterOutput } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc";
import { gotoEntityQuery } from "$lib/shared/util"; import { gotoEntityQuery } from "$lib/shared/util";
export let patient: RouterOutput["patient"]["get"]; export let patient: RouterOutput["patient"]["list"]["items"][0];
export let baseUrl: string; export let baseUrl: string;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {

View file

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { SortRequest } from "$lib/shared/model"; import type { SortRequest } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate } from "$lib/shared/util"; import { formatDate, gotoEntityQuery } from "$lib/shared/util";
import { mdiClose } from "@mdi/js"; import { mdiClose, mdiFilter } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import RoomField from "./RoomField.svelte"; import RoomField from "./RoomField.svelte";
import SortHeader from "./SortHeader.svelte"; import SortHeader from "./SortHeader.svelte";
import { URL_ENTRIES } from "$lib/shared/constants";
export let patients: RouterOutput["patient"]["list"]; export let patients: RouterOutput["patient"]["list"];
export let sortData: SortRequest | undefined = undefined; export let sortData: SortRequest | undefined = undefined;
@ -28,9 +29,10 @@
</thead> </thead>
<tbody> <tbody>
{#each patients.items as patient} {#each patients.items as patient}
{@const full_name = patient.first_name + " " + patient.last_name}
<tr <tr
class="transition-colors hover:bg-neutral-content/10" class="transition-colors hover:bg-neutral-content/10"
class:hidden={patient.hidden} class:p-hidden={patient.hidden}
> >
<td <td
><a ><a
@ -39,18 +41,29 @@
aria-label="Eintrag anzeigen">{patient.id}</a aria-label="Eintrag anzeigen">{patient.id}</a
></td ></td
> >
<td>{patient.first_name} {patient.last_name}</td> <td>{full_name}</td>
<td>{patient.age}</td> <td>{patient.age ?? ""}</td>
<td> <td>
{#if patient.room} {#if patient.room}
<RoomField room={patient.room} {baseUrl} /> <RoomField room={patient.room} {baseUrl} />
{/if} {/if}
</td> </td>
<td>{formatDate(patient.created_at, true)}</td> <td>{formatDate(patient.created_at, true)}</td>
<td> <td class="text-right">
<button class="btn btn-circle btn-ghost btn-xs" <button
><Icon size={1.2} path={mdiClose} /></button class="btn btn-circle btn-ghost btn-xs inline"
on:click={() => {
gotoEntityQuery(
{ filter: { patient: [{ id: patient.id, name: full_name }] } },
URL_ENTRIES
);
}}
> >
<Icon size={1.2} path={mdiFilter} />
</button>
<button class="btn btn-circle btn-ghost btn-xs inline">
<Icon size={1.2} path={mdiClose} />
</button>
</td> </td>
</tr> </tr>
{/each} {/each}
@ -59,7 +72,7 @@
</div> </div>
<style lang="postcss"> <style lang="postcss">
.hidden { .p-hidden {
@apply bg-red-500/20; @apply bg-red-500/20;
} }
</style> </style>

View file

@ -64,6 +64,10 @@ export async function getPatientNames(): Promise<PatientTag[]> {
}); });
} }
export async function getPatientNEntries(id: number): Promise<number> {
return prisma.entry.count({ where: { patient_id: id } });
}
export async function getPatients( export async function getPatients(
filter: PatientsFilter = {}, filter: PatientsFilter = {},
pagination: PaginationRequest = {}, pagination: PaginationRequest = {},
@ -86,8 +90,10 @@ export async function getPatients(
qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`); qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
} }
if (filter.hidden !== undefined) { if (filter.hidden) {
qb.addFilter("p.hidden", filter.hidden); qb.addFilter("p.hidden", true);
} else if (!filter.includeHidden) {
qb.addFilter("p.hidden", false);
} }
qb.addFilterList("r.id", filter.room); qb.addFilterList("r.id", filter.room);

View file

@ -87,7 +87,7 @@ class SearchQueryComponents {
*/ */
export function parseSearchQuery(q: string): SearchQueryComponents { export function parseSearchQuery(q: string): SearchQueryComponents {
const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g; const regexpParts = /(-)?(?:"([^"]*)"|([^"\s]+))(?:\s|$)/g;
const components = Array.from(q.matchAll(regexpParts), (m) => { const components = Array.from(q.replaceAll("'", '"').matchAll(regexpParts), (m) => {
const negative = m[1] === "-"; const negative = m[1] === "-";
// Exact // Exact
if (m[2]) { if (m[2]) {

View file

@ -1,6 +1,7 @@
import { import {
deletePatient, deletePatient,
getPatient, getPatient,
getPatientNEntries,
getPatientNames, getPatientNames,
getPatients, getPatients,
hidePatient, hidePatient,
@ -13,9 +14,15 @@ import { z } from "zod";
export const patientRouter = t.router({ export const patientRouter = t.router({
getNames: t.procedure.query(async () => trpcWrap(getPatientNames)), getNames: t.procedure.query(async () => trpcWrap(getPatientNames)),
get: t.procedure get: t.procedure.input(ZEntityId).query(async (opts) =>
.input(ZEntityId) trpcWrap(async () => {
.query(async (opts) => trpcWrap(async () => getPatient(opts.input))), const [patient, n_entries] = await Promise.all([
getPatient(opts.input),
getPatientNEntries(opts.input),
]);
return { ...patient, n_entries };
})
),
list: t.procedure.input(ZPatientsQuery).query(async (opts) => { list: t.procedure.input(ZPatientsQuery).query(async (opts) => {
return getPatients( return getPatients(
opts.input.filter ?? {}, opts.input.filter ?? {},

View file

@ -29,10 +29,14 @@ export type EntriesFilter = Partial<{
}>; }>;
export type PatientsFilter = Partial<{ export type PatientsFilter = Partial<{
/** Search patient names */
search: string; search: string;
station: FilterList<number>; station: FilterList<number>;
room: FilterList<number>; room: FilterList<number>;
/** Show only hidden patients */
hidden: boolean; hidden: boolean;
/** Show visible and hidden patients */
includeHidden: boolean;
}>; }>;
export type TRPCErrorResponse = { export type TRPCErrorResponse = {

View file

@ -107,6 +107,7 @@ export const ZPatientsFilter = z
room: ZFilterList, room: ZFilterList,
station: ZFilterList, station: ZFilterList,
hidden: z.boolean(), hidden: z.boolean(),
includeHidden: z.boolean(),
}) })
.partial(); .partial();

View file

@ -8,15 +8,23 @@ import { superValidate } from "sveltekit-superforms/server";
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
// const form = await event.request.formData(); const formData = await event.request.formData();
// const schema = zfd.formData(ZPatientNew.partial()); const hide = formData.get("hide");
// const formData = schema.parse(form); const del = formData.get("delete");
const form = await superValidate(event.request, ZPatientNew); if (hide) {
await loadWrap(async () =>
trpc(event).patient.hide.mutate({
id,
hidden: Boolean(parseInt(hide.toString())),
})
);
} else if (del) {
await loadWrap(async () => trpc(event).patient.delete.mutate(id));
} else {
const form = await superValidate(formData, ZPatientNew);
// Convenient validation check:
if (!form.valid) { if (!form.valid) {
// Again, return { form } and things will just work.
return fail(400, { form }); return fail(400, { form });
} }
@ -28,5 +36,6 @@ export const actions = {
); );
return { form }; return { form };
}
}, },
} satisfies Actions; } satisfies Actions;

View file

@ -4,16 +4,28 @@
import PatientForm from "$lib/components/form/PatientForm.svelte"; import PatientForm from "$lib/components/form/PatientForm.svelte";
export let data: PageData; export let data: PageData;
$: hasEntries = data.patient.n_entries > 0;
</script> </script>
<svelte:head> <svelte:head>
<title>Patient #{data.patient.id}</title> <title>Patient #{data.patient.id}</title>
</svelte:head> </svelte:head>
<PatientForm patient={data.patient} formData={data.form} /> <PatientForm patient={data.patient} formData={data.form}>
{#if data.patient.hidden}
<button type="submit" name="hide" value="0" class="btn btn-primary"
>Einblenden</button
>
{:else if hasEntries}
<button type="submit" name="hide" value="1" class="btn">Ausblenden</button>
{:else}
<button type="submit" name="delete" value="1" class="btn btn-error">Löschen</button>
{/if}
</PatientForm>
{#if data.patient.room} {#if hasEntries}
<h1 class="heading mt-8 mb-4">Einträge</h1> <h1 class="heading mt-8 mb-4">Einträge ({data.patient.n_entries})</h1>
<FilteredEntryTable <FilteredEntryTable
baseUrl="/patient/{data.patient.id}" baseUrl="/patient/{data.patient.id}"