Compare commits
4 commits
5f396ccaf2
...
12dbf7227b
Author | SHA1 | Date | |
---|---|---|---|
12dbf7227b | |||
39b97c6042 | |||
881dc583e3 | |||
dee579ab46 |
18 changed files with 153 additions and 133 deletions
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,11 +57,6 @@
|
|||
<slot />
|
||||
</FilterBar>
|
||||
|
||||
<LoadingBar bind:this={loadingBar} alwaysShown />
|
||||
|
||||
{#if loadError}
|
||||
<ErrorMessage error={loadError} />
|
||||
{:else}
|
||||
<EntryTable
|
||||
{entries}
|
||||
sortData={query.sort}
|
||||
|
@ -103,4 +70,3 @@
|
|||
data={entries}
|
||||
onUpdate={paginationUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -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,11 +54,6 @@
|
|||
<slot />
|
||||
</FilterBar>
|
||||
|
||||
<LoadingBar bind:this={loadingBar} alwaysShown />
|
||||
|
||||
{#if loadError}
|
||||
<ErrorMessage error={loadError} />
|
||||
{:else}
|
||||
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
|
||||
|
||||
<PaginationButtons
|
||||
|
@ -87,4 +61,3 @@
|
|||
data={patients}
|
||||
onUpdate={paginationUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]) {
|
||||
|
|
|
@ -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 ?? {},
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -107,6 +107,7 @@ export const ZPatientsFilter = z
|
|||
room: ZFilterList,
|
||||
station: ZFilterList,
|
||||
hidden: z.boolean(),
|
||||
includeHidden: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}"
|
||||
|
|
Loading…
Reference in a new issue