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
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);

View file

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

View file

@ -23,7 +23,7 @@
export let placeholder: string | undefined = undefined;
export let padding = true;
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 idInputName: string | undefined = undefined;

View file

@ -21,6 +21,7 @@
export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {};
/** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */
export let hiddenFilters: string[] = [];
/** True if a separate search field should be displayed */
export let search = false;
let autocomplete: Autocomplete | undefined;
@ -59,7 +60,9 @@
function updateFromQueryData(filterData: FilterQdata) {
const filters: 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") {
searchVal = value.toString();
} else if (Array.isArray(value)) {
@ -183,10 +186,8 @@
}
</script>
<div class="flex flex-wrap w-full items-start justify-center gap-2 relative">
<div
class="flex flex-wrap flex-grow items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
>
<div class="filterbar-outer">
<div class="filterbar-inner input input-sm input-bordered">
{#each activeFilters as fdata, i}
<EntryFilterChip
filter={FILTERS[fdata.id]}
@ -221,6 +222,7 @@
aria-label="Alle Filter entfernen"
on:click={() => {
activeFilters = [];
searchVal = "";
updateFilter();
}}
>
@ -239,3 +241,14 @@
{/if}
<slot />
</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();
}
const TOFF = " aus";
$: toggleState = fdata.selection?.toggle !== false;
$: filterName = toggleState ? filter.name : filter.toggleOff?.name;
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon;
$: filterName = toggleState
? filter.name
: filter.toggleOff?.name ?? filter.name + TOFF;
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon ?? filter.icon;
$: hasInputField = filter.inputType !== 0 && filter.inputType !== 3;
</script>

View file

@ -2,6 +2,7 @@ import { trpc } from "$lib/shared/trpc";
import {
mdiAccount,
mdiAccountInjury,
mdiAccountMultipleOutline,
mdiAccountRemoveOutline,
mdiBedKingOutline,
mdiCheckboxBlankOutline,
@ -118,4 +119,10 @@ export const PATIENT_FILTER: { [key: string]: FilterDef } = {
icon: mdiAccountRemoveOutline,
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 type { SuperValidated } from "sveltekit-superforms";
import { ZPatientNew } from "$lib/shared/model/validation";
import { browser } from "$app/environment";
export let patient: RouterOutput["patient"]["get"] | null = null;
export let formData: SuperValidated<typeof ZPatientNew> =
@ -91,11 +92,14 @@
</label>
</div>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary max-w-32"
type="submit"
disabled={$tainted === undefined}
disabled={browser && $tainted === undefined}
>
Speichern
</button>
<slot />
</div>
</form>

View file

@ -4,11 +4,9 @@
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
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 { 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 { z } from "zod";
import { browser } from "$app/environment";
@ -19,9 +17,6 @@
export let baseUrl: string;
export let patientId: number | null = null;
let loadingBar: LoadingBar | undefined;
let loadError: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: query.filter,
@ -49,29 +44,6 @@
// Update page URL
const url = getQueryUrl(q, baseUrl);
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>
@ -85,22 +57,16 @@
<slot />
</FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown />
{#if loadError}
<ErrorMessage error={loadError} />
{:else}
<EntryTable
<EntryTable
{entries}
sortData={query.sort}
{sortUpdate}
perPatient={patientId !== null}
{baseUrl}
/>
/>
<PaginationButtons
<PaginationButtons
paginationData={query.pagination}
data={entries}
onUpdate={paginationUpdate}
/>
{/if}
/>

View file

@ -4,11 +4,9 @@
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
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 { 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 { z } from "zod";
import { browser } from "$app/environment";
@ -18,9 +16,6 @@
export let patients: RouterOutput["patient"]["list"];
export let baseUrl: string;
let loadingBar: LoadingBar | undefined;
let loadError: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: query.filter,
@ -46,22 +41,6 @@
// Update page URL
const url = getQueryUrl(q, baseUrl);
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>
@ -75,16 +54,10 @@
<slot />
</FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown />
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
{#if loadError}
<ErrorMessage error={loadError} />
{:else}
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
<PaginationButtons
<PaginationButtons
paginationData={query.pagination}
data={patients}
onUpdate={paginationUpdate}
/>
{/if}
/>

View file

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

View file

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

View file

@ -87,7 +87,7 @@ class SearchQueryComponents {
*/
export function parseSearchQuery(q: string): SearchQueryComponents {
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] === "-";
// Exact
if (m[2]) {

View file

@ -1,6 +1,7 @@
import {
deletePatient,
getPatient,
getPatientNEntries,
getPatientNames,
getPatients,
hidePatient,
@ -13,9 +14,15 @@ import { z } from "zod";
export const patientRouter = t.router({
getNames: t.procedure.query(async () => trpcWrap(getPatientNames)),
get: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getPatient(opts.input))),
get: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
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) => {
return getPatients(
opts.input.filter ?? {},

View file

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

View file

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

View file

@ -8,15 +8,23 @@ import { superValidate } from "sveltekit-superforms/server";
export const actions = {
default: async (event) => {
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 formData = schema.parse(form);
const form = await superValidate(event.request, ZPatientNew);
const hide = formData.get("hide");
const del = formData.get("delete");
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) {
// Again, return { form } and things will just work.
return fail(400, { form });
}
@ -28,5 +36,6 @@ export const actions = {
);
return { form };
}
},
} satisfies Actions;

View file

@ -4,16 +4,28 @@
import PatientForm from "$lib/components/form/PatientForm.svelte";
export let data: PageData;
$: hasEntries = data.patient.n_entries > 0;
</script>
<svelte:head>
<title>Patient #{data.patient.id}</title>
</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}
<h1 class="heading mt-8 mb-4">Einträge</h1>
{#if hasEntries}
<h1 class="heading mt-8 mb-4">Einträge ({data.patient.n_entries})</h1>
<FilteredEntryTable
baseUrl="/patient/{data.patient.id}"