Compare commits

...

8 commits

43 changed files with 802 additions and 297 deletions

View file

@ -24,7 +24,8 @@
"@prisma/client": "^5.8.1",
"svelte-floating-ui": "^1.5.8",
"svelte-markdown": "^0.4.1",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zod-form-data": "^2.0.2"
},
"devDependencies": {
"@faker-js/faker": "^8.4.0",
@ -51,6 +52,7 @@
"prisma": "^5.8.1",
"svelte": "^4.2.9",
"svelte-check": "^3.6.3",
"sveltekit-superforms": "^1.13.4",
"tailwindcss": "^3.4.1",
"trpc-sveltekit": "^3.5.22",
"tslib": "^2.6.2",

View file

@ -29,6 +29,9 @@ dependencies:
zod:
specifier: ^3.22.4
version: 3.22.4
zod-form-data:
specifier: ^2.0.2
version: 2.0.2(zod@3.22.4)
devDependencies:
'@faker-js/faker':
@ -103,6 +106,9 @@ devDependencies:
svelte-check:
specifier: ^3.6.3
version: 3.6.3(postcss@8.4.33)(svelte@4.2.9)
sveltekit-superforms:
specifier: ^1.13.4
version: 1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4)
tailwindcss:
specifier: ^3.4.1
version: 3.4.1
@ -2023,6 +2029,11 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
/klona@2.0.6:
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
engines: {node: '>= 8'}
dev: true
/known-css-properties@0.29.0:
resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==}
dev: true
@ -2962,6 +2973,20 @@ packages:
magic-string: 0.30.5
periscopic: 3.1.0
/sveltekit-superforms@1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4):
resolution: {integrity: sha512-rM2+Ictaw7OAIorCLmvg82orci/mtO9ZouI4emtx8SyYngx9aED+eNZlHPLufgB6D7geL2a+hMSFtM3zmMQixQ==}
peerDependencies:
'@sveltejs/kit': 1.x || 2.x
svelte: 3.x || 4.x
zod: 3.x
dependencies:
'@sveltejs/kit': 2.5.0(@sveltejs/vite-plugin-svelte@3.0.1)(svelte@4.2.9)(vite@5.0.12)
devalue: 4.3.2
klona: 2.0.6
svelte: 4.2.9
zod: 3.22.4
dev: true
/tailwindcss@3.4.1:
resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==}
engines: {node: '>=14.0.0'}
@ -3333,5 +3358,13 @@ packages:
engines: {node: '>=12.20'}
dev: true
/zod-form-data@2.0.2(zod@3.22.4):
resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==}
peerDependencies:
zod: '>= 3.11.0'
dependencies:
zod: 3.22.4
dev: false
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}

View file

@ -16,3 +16,12 @@ button {
white-space: nowrap;
text-overflow: ellipsis;
}
.v-form-field > input {
@apply input input-bordered w-full max-w-xs;
}
.input[aria-invalid="true"],
.v-form-field > [aria-invalid="true"] {
@apply input-error;
}

View file

@ -65,6 +65,7 @@ export const handle = sequence(
return opt.session;
},
},
trustHost: true,
}),
authorization,

View file

@ -1,7 +0,0 @@
import { describe, it, expect } from "vitest";
describe("sum test", () => {
it("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});
});

View file

@ -11,26 +11,30 @@
import { shift } from "svelte-floating-ui/dom";
import outclick from "$lib/actions/outclick";
import type { BaseItem, SelectionOrText } from "./types";
import Icon from "../ui/Icon.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
type OnSelectResult = { newValue: string; close: boolean };
export let items: BaseItem[] | (() => Promise<BaseItem[]>);
export let defaultSelection: SelectionOrText | null = null;
export let selection: SelectionOrText | null = null;
export let hiddenIds: Set<string | number> = new Set();
export let cache: { [key: string]: BaseItem[] } = {};
export let cacheKey: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let padding = true;
export let cls = "";
export let inputCls = "w-full bg-transparent";
export let asTextInput = false;
export let idInputName: string | undefined = undefined;
/** Selection callback. Returns the new input value after selection */
export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
item,
kb
) => {
return { newValue: item.name || "", close: true };
return { newValue: item.name ?? "", close: true };
};
export let onUnselect = () => {};
export let onClose = (kb: boolean) => {};
export let onBackspace = () => {};
@ -40,10 +44,11 @@
let srcItems: BaseItem[];
let filteredItems: BaseItem[] = [];
$: if (hiddenIds) updateSearch();
$: if (hiddenIds || selection) updateSearch();
// HTML elements
let inputElm: HTMLInputElement | undefined;
let listElm: HTMLElement | undefined;
function inputValue(): string {
if (inputElm) return inputElm.value;
@ -69,7 +74,7 @@
srcItems = items;
if (cacheKey) cache[cacheKey] = items;
isLoading = false;
onInput();
updateSearch();
});
return false;
}
@ -77,38 +82,50 @@
return true;
}
function selectDefault() {
if (defaultSelection) {
if (defaultSelection.id) {
const i = srcItems.findIndex((itm) => itm.id === defaultSelection?.id);
function markSelection() {
if (selection) {
if (selection.id) {
const i = srcItems.findIndex((itm) => itm.id === selection?.id);
if (i !== -1) {
highlightIndex = i;
}
if (asTextInput) setInputValue(selection.name || "");
} else {
setInputValue(defaultSelection.name || "");
highlightIndex = 0;
setInputValue(selection.name || "");
}
} else {
highlightIndex = 0;
}
highlight();
}
function clearSelection() {
selection = null;
onUnselect();
setInputValue("");
highlightIndex = 0;
updateSearch();
}
function onInput() {
updateSearch();
selection = null;
onUnselect();
opened = true;
updateSearch();
}
function updateSearch() {
if (loadSrcItems()) {
let searchWord = inputValue().toLowerCase().trim();
filteredItems =
searchWord.length > 0
!selection && searchWord.length > 0
? srcItems.filter(
(it) =>
!hiddenIds.has(it.id) && it.name.toLowerCase().includes(searchWord)
)
: srcItems.filter((it) => !hiddenIds.has(it.id));
selectDefault();
markSelection();
}
}
@ -128,6 +145,7 @@
}
function selectListItem(item: BaseItem | undefined, kb: boolean) {
selection = { id: item?.id, name: item?.name };
const selRes = onSelect(item ?? { name: inputValue() }, kb);
setInputValue(selRes.newValue);
if (selRes.close) {
@ -147,12 +165,14 @@
open();
if (highlightIndex < filteredItems.length - 1) {
highlightIndex++;
highlight();
}
},
ArrowUp: () => {
open();
if (highlightIndex > 0) {
highlightIndex--;
highlight();
}
},
Escape: () => {
@ -165,6 +185,8 @@
Backspace: () => {
if (inputValue().length === 0) {
onBackspace();
} else if (selection) {
clearSelection();
}
},
};
@ -184,6 +206,35 @@
}
}
function onBlur() {
if (!selection) {
if (filteredItems.length === 1) {
selectListItem(filteredItems[0], true);
} else {
setInputValue("");
}
}
}
function highlight() {
if (browser && opened) {
window.setTimeout(() => {
const query = ".selected";
const el = listElm && listElm.querySelector(query);
if (el) {
// @ts-expect-error scrollIntoViewIfNeeded is unspecified (currently only on Chrome)
if (typeof el.scrollIntoViewIfNeeded === "function") {
// @ts-expect-error scrollIntoViewIfNeeded is unspecified
el.scrollIntoViewIfNeeded();
} else {
el.scrollIntoView({ block: "nearest" });
}
}
}, 1);
}
}
function selectItem() {
const listItem = filteredItems[highlightIndex];
selectListItem(listItem, true);
@ -198,21 +249,21 @@
<div class="flex-grow {cls}" use:outclick on:outclick={close}>
<input
class="w-full bg-transparent"
class={inputCls}
class:px-2={padding}
type="text"
{placeholder}
bind:this={inputElm}
on:input={onInput}
on:focus={open}
on:click={open}
on:keydown={onKeyDown}
on:keypress={onKeyPress}
on:blur={onBlur}
use:floatingRef
/>
{#if opened && filteredItems.length > 0}
<div class="autocomplete-list" use:floatingContent>
<div class="autocomplete-list" use:floatingContent bind:this={listElm}>
{#each filteredItems as item, i}
<div
role="option"
@ -220,7 +271,9 @@
class:selected={i === highlightIndex}
aria-selected={i === highlightIndex}
tabindex="-1"
on:click={() => selectListItem(item, false)}
on:click|preventDefault={() => {
selectListItem(item, false);
}}
on:keypress={(e) => {
e.key == "Enter" && selectListItem(item, true);
}}
@ -236,6 +289,11 @@
{/each}
</div>
{/if}
{#if idInputName}
<!-- Hidden input field (acting as form input) -->
<input name={idInputName} type="hidden" value={selection?.id ?? ""} />
{/if}
</div>
<style lang="postcss">

View file

@ -11,14 +11,25 @@
} from "./types";
import { isFilterValueless } from "./types";
import Icon from "$lib/components/ui/Icon.svelte";
import { Debouncer } from "$lib/shared/util";
/** Filter definitions */
export let FILTERS: { [key: string]: FilterDef };
/** Filter data from the query */
export let filterData: FilterQdata | null | undefined = undefined;
export let onUpdate: (filterData: FilterQdata) => void = () => {};
/** Callback when filters are updated */
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[] = [];
export let search = false;
let autocomplete: Autocomplete | undefined;
let activeFilters: FilterData[] = [];
let cache: { [key: string]: BaseItem[] } = {};
let searchVal = "";
let searchDebounce = new Debouncer(400, () => {
onUpdate(getFilterQdata());
});
// Filter items to be displayed in the autocomplete menu
let filterMenuItems: BaseItem[];
@ -33,11 +44,12 @@
});
// Filter menu items to be hidden
$: hiddenIds = new Set(
Object.values(FILTERS).flatMap((f) =>
$: hiddenIds = new Set([
...Object.values(FILTERS).flatMap((f) =>
f.inputType === 2 || activeFilters.every((af) => af.id !== f.id) ? [] : [f.id]
)
);
),
...hiddenFilters,
]);
// Load query data if present
$: if (filterData || !filterData) {
@ -47,7 +59,10 @@
function updateFromQueryData(filterData: FilterQdata) {
const filters: FilterData[] = [];
for (const [id, value] of Object.entries(filterData)) {
if (Array.isArray(value)) {
if (hiddenFilters.includes(id)) continue;
if (search && id === "search") {
searchVal = value.toString();
} else if (Array.isArray(value)) {
value.forEach((v) => {
filters.push({
id,
@ -83,7 +98,7 @@
if (autocomplete) autocomplete.open();
}
function getFilterQdata(): FilterQdata {
function getFilterQdata(): FilterQdata | undefined {
let fd: FilterQdata = {};
activeFilters.forEach((fdata) => {
const filter = FILTERS[fdata.id];
@ -117,6 +132,12 @@
}
}
});
if (searchVal) {
fd["search"] = searchVal;
}
if (Object.keys(fd).length === 0) return undefined;
return fd;
}
@ -141,7 +162,7 @@
function removeFilter(i: number) {
const shouldUpdate =
isFilterValueless(FILTERS[activeFilters[i].id].inputType) ||
activeFilters[i].selection;
activeFilters[i].selection !== null;
activeFilters.splice(i, 1);
activeFilters = activeFilters;
if (shouldUpdate) updateFilter();
@ -150,11 +171,21 @@
function updateFilter() {
onUpdate(getFilterQdata());
}
function onSearchInput(e: Event) {
searchDebounce.trigger();
}
function onSearchKeypress(e: KeyboardEvent) {
if (e.key === "Enter") {
searchDebounce.now();
}
}
</script>
<div class="flex flex-col items-start justify-center gap-2 relative">
<div class="flex flex-wrap w-full items-start justify-center gap-2 relative">
<div
class="flex flex-wrap w-full items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
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}
<EntryFilterChip
@ -196,4 +227,15 @@
<Icon size={1.2} path={mdiClose} />
</button>
</div>
{#if search}
<input
class="input input-sm input-bordered"
type="text"
placeholder="Suche"
bind:value={searchVal}
on:input={onSearchInput}
on:keypress={onSearchKeypress}
/>
{/if}
<slot />
</div>

View file

@ -72,7 +72,7 @@ gap-1 pl-1"
hiddenIds={hids}
{cache}
cacheKey={filter.id}
defaultSelection={fdata.selection}
selection={fdata.selection}
padding={false}
onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant

View file

@ -94,12 +94,6 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
};
export const PATIENT_FILTER: { [key: string]: FilterDef } = {
search: {
id: "search",
name: "Suche",
icon: mdiMagnify,
inputType: 1,
},
station: {
id: "station",
name: "Station",

View file

@ -0,0 +1,101 @@
<script lang="ts">
import FormField from "$lib/components/ui/FormField.svelte";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import { superForm, superValidateSync } from "sveltekit-superforms/client";
import type { SuperValidated } from "sveltekit-superforms";
import { ZPatientNew } from "$lib/shared/model/validation";
export let patient: RouterOutput["patient"]["get"] | null = null;
export let formData: SuperValidated<typeof ZPatientNew> =
superValidateSync(ZPatientNew);
let station = patient?.room?.station;
const { form, errors, constraints, enhance, tainted } = superForm(formData, {
validators: ZPatientNew,
});
</script>
<h1 class="heading">
{#if patient}
Patient #{patient.id}
{:else}
Neuer Patient
{/if}
</h1>
<form method="POST" class="flex flex-col gap-4" use:enhance>
<div class="flex flex-wrap gap-2">
<FormField label="Vorname" errors={$errors.first_name}>
<input
type="text"
name="first_name"
aria-invalid={$errors.first_name ? "true" : undefined}
bind:value={$form.first_name}
{...$constraints.last_name}
/>
</FormField>
<FormField label="Nachname" errors={$errors.last_name}>
<input
type="text"
name="last_name"
aria-invalid={$errors.last_name ? "true" : undefined}
bind:value={$form.last_name}
{...$constraints.last_name}
/>
</FormField>
<FormField label="Alter" errors={$errors.age}>
<input
type="number"
name="age"
aria-invalid={$errors.age ? "true" : undefined}
bind:value={$form.age}
{...$constraints.age}
/>
</FormField>
</div>
<div class="flex flex-wrap gap-2">
<FormField label="Zimmer" errors={$errors.room_id}>
<Autocomplete
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().room.list.query();
}}
selection={patient?.room}
onSelect={(item) => {
// @ts-expect-error room items have station attr
station = item.station;
// @ts-expect-error ids are always numeric
$form.room_id = item.id;
return { newValue: item.name ?? "", close: true };
}}
onUnselect={() => {
$form.room_id = null;
}}
asTextInput
idInputName="room_id"
/>
</FormField>
<label class="form-control w-full max-w-xs">
<div class="label">Station</div>
<input
type="text"
class="input input-bordered w-full max-w-xs"
disabled
value={station?.name ?? ""}
/>
</label>
</div>
<button
class="btn btn-primary max-w-32"
type="submit"
disabled={$tainted === undefined}
>
Speichern
</button>
</form>

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { URL_ENTRIES } from "$lib/shared/constants";
import type { Category } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util";
export let category: Category;
export let baseUrl = URL_ENTRIES;
function onClick(e: MouseEvent) {
gotoEntityQuery(
@ -11,7 +13,7 @@
category: [{ id: category.id, name: category.name }],
},
},
"/plan"
baseUrl
);
e.stopPropagation();
}

View file

@ -11,7 +11,9 @@
export let entries: RouterOutput["entry"]["list"];
export let sortData: SortRequest | undefined = undefined;
export let sortUpdate: (sort: SortRequest) => void = () => {};
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let perPatient = false;
export let baseUrl: string;
</script>
<div class="overflow-x-auto">
@ -19,8 +21,10 @@
<thead>
<tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} />
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
{#if !perPatient}
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
{/if}
<SortHeader title="Kategorie" key="category" {sortData} {sortUpdate} />
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} />
<SortHeader title="Aktualisiert am" key="updated_at" {sortData} {sortUpdate} />
@ -44,24 +48,32 @@
aria-label="Eintrag anzeigen">{entry.id}</a
></td
>
<td><PatientField patient={entry.patient} /></td>
<td
>{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</td
>
{#if !perPatient}
<td><PatientField patient={entry.patient} {baseUrl} /></td>
<td>
{#if entry.patient.room}
<RoomField room={entry.patient.room} {baseUrl} />
{/if}
</td>
{/if}
<td>
{#if entry.current_version.category}
<CategoryField category={entry.current_version.category} />
<CategoryField category={entry.current_version.category} {baseUrl} />
{/if}
</td>
<td>{formatDate(entry.created_at, true)}</td>
<td>{formatDate(entry.current_version.created_at, true)}</td>
<td>{formatDate(entry.current_version.date)}</td>
<td><UserField user={entry.current_version.author} /></td>
<td><UserField user={entry.current_version.author} {baseUrl} /></td>
<td><span class="line-clamp-2">{entry.current_version.text}</span></td>
<td>
{#if entry.execution}
{formatDate(entry.execution.created_at, true)}
<UserField user={entry.execution.author} filterName="executor" />
<UserField
user={entry.execution.author}
filterName="executor"
{baseUrl}
/>
{/if}
</td>
</tr>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import EntryTable from "$lib/components/table/EntryTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
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 { 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";
import { goto } from "$app/navigation";
export let query: z.infer<typeof ZEntriesQuery>;
export let entries: RouterOutput["entry"]["list"];
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,
pagination,
sort: query.sort,
});
}
function filterUpdate(filter: FilterQdata | undefined) {
updateQuery({ filter, sort: query.sort });
}
function sortUpdate(sort: SortRequest | undefined) {
updateQuery({
filter: query.filter,
pagination: query.pagination,
sort,
});
}
function updateQuery(q: typeof query) {
if (browser) {
if (patientId !== null && q.filter?.patient) delete q.filter.patient;
// 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>
<FilterBar
FILTERS={ENTRY_FILTERS}
hiddenFilters={patientId !== null ? ["patient"] : []}
filterData={query.filter}
onUpdate={filterUpdate}
>
<slot />
</FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown />
{#if loadError}
<ErrorMessage error={loadError} />
{:else}
<EntryTable
{entries}
sortData={query.sort}
{sortUpdate}
perPatient={patientId !== null}
{baseUrl}
/>
<PaginationButtons
paginationData={query.pagination}
data={entries}
onUpdate={paginationUpdate}
/>
{/if}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import PatientTable from "$lib/components/table/PatientTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
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 { 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";
import { goto } from "$app/navigation";
export let query: z.infer<typeof ZPatientsQuery>;
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,
pagination,
sort: query.sort,
});
}
function filterUpdate(filter: FilterQdata | undefined) {
updateQuery({ filter, sort: query.sort });
}
function sortUpdate(sort: SortRequest | undefined) {
updateQuery({
filter: query.filter,
pagination: query.pagination,
sort,
});
}
function updateQuery(q: typeof query) {
if (browser) {
// 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>
<FilterBar
FILTERS={PATIENT_FILTER}
filterData={query.filter}
onUpdate={filterUpdate}
search
>
<slot />
</FilterBar>
<LoadingBar bind:this={loadingBar} alwaysShown />
{#if loadError}
<ErrorMessage error={loadError} />
{:else}
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
<PaginationButtons
paginationData={query.pagination}
data={patients}
onUpdate={paginationUpdate}
/>
{/if}

View file

@ -1,8 +1,9 @@
<script lang="ts">
import type { Patient } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc";
import { gotoEntityQuery } from "$lib/shared/util";
export let patient: Pick<Patient, "id" | "first_name" | "last_name" | "age">;
export let patient: RouterOutput["patient"]["get"];
export let baseUrl: string;
function onClick(e: MouseEvent) {
gotoEntityQuery(
@ -13,7 +14,7 @@
],
},
},
"/plan"
baseUrl
);
e.stopPropagation();
}

View file

@ -3,14 +3,15 @@
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate } from "$lib/shared/util";
import { mdiClose } from "@mdi/js";
import Icon from "../ui/Icon.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import RoomField from "./RoomField.svelte";
import SortHeader from "./SortHeader.svelte";
export let patients: RouterOutput["patient"]["list"];
export let sortData: SortRequest | undefined = undefined;
export let sortUpdate: (sort: SortRequest) => void = () => {};
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let baseUrl: string;
</script>
<div class="overflow-x-auto">
@ -42,7 +43,7 @@
<td>{patient.age}</td>
<td>
{#if patient.room}
<RoomField room={patient.room} />
<RoomField room={patient.room} {baseUrl} />
{/if}
</td>
<td>{formatDate(patient.created_at, true)}</td>

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { URL_ENTRIES } from "$lib/shared/constants";
import type { Room } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util";
export let room: Room;
export let baseUrl = URL_ENTRIES;
function onClick(e: MouseEvent) {
gotoEntityQuery(
@ -11,7 +13,7 @@
room: [{ id: room.id, name: room.name }],
},
},
"/plan"
baseUrl
);
e.stopPropagation();
}

View file

@ -6,14 +6,14 @@
export let key: string;
export let title: string;
export let sortData: SortRequest | undefined = undefined;
export let sortUpdate: (sort: SortRequest) => void = () => {};
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
// 1: asc, 2: desc, 0: not sorted
$: sorting = sortData?.field === key ? (sortData.asc !== false ? 1 : 2) : 0;
function onClick() {
if (sorting === 2) {
sortUpdate({});
sortUpdate(undefined);
} else {
sortUpdate({ field: key, asc: sorting !== 1 });
}

View file

@ -1,15 +1,17 @@
<script lang="ts">
import { URL_ENTRIES } from "$lib/shared/constants";
import type { EntityQuery, UserTag } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util";
export let user: UserTag;
export let baseUrl = URL_ENTRIES;
export let filterName: string = "author";
function onClick(e: MouseEvent) {
let query: EntityQuery = { filter: {} };
// @ts-expect-error filterName is checked
query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }];
gotoEntityQuery(query, "/plan");
gotoEntityQuery(query, baseUrl);
e.stopPropagation();
}
</script>

View file

@ -0,0 +1,11 @@
<script lang="ts">
export let name = "";
export let type = "text";
export let label = "";
export let value: string | number | null = "";
</script>
<label class="form-control w-full max-w-xs">
<div class="label">{label}</div>
<input {name} {type} class="input input-bordered w-full max-w-xs" {value} />
</label>

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let label = "";
export let errors: string[] | undefined = undefined;
</script>
<label class="v-form-field form-control w-full max-w-xs">
<div class="label">{label}</div>
<slot />
{#if errors}
<div class="label flex-col items-start">
{#each errors as error}
<span class="label-text-alt text-error">{error}</span>
{/each}
</div>
{/if}
</label>

View file

@ -212,8 +212,8 @@ left join entry_executions ex on
limit 1)
left join users xau on xau.id=ex.author_id
join patients p on p.id = e.patient_id
join rooms r on r.id = p.room_id
join stations s on s.id = r.station_id`
left join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id`
);
if (filter?.search && filter.search.length > 0) {
@ -313,11 +313,13 @@ join stations s on s.id = r.station_id`
created_at: item.patient_created_at,
age: item.patient_age,
hidden: item.patient_hidden,
room: {
id: item.room_id,
name: item.room_name,
station: { id: item.station_id, name: item.station_name },
},
room: item.room_id
? {
id: item.room_id,
name: item.room_name,
station: { id: item.station_id, name: item.station_name },
}
: null,
},
created_at: item.created_at,
current_version: {

View file

@ -60,7 +60,7 @@ export async function getPatientNames(): Promise<PatientTag[]> {
orderBy: { last_name: "asc" },
});
return patients.map((p) => {
return { id: p.id, name: p.last_name + ", " + p.first_name };
return { id: p.id, name: p.first_name + " " + p.last_name };
});
}
@ -73,13 +73,16 @@ export async function getPatients(
`select p.id, p.first_name, p.last_name, p.created_at, p.age, p.hidden,
r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`,
`from patients p
join rooms r on r.id = p.room_id
join stations s on s.id = r.station_id`
left join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id`
);
if (filter.search && filter.search.length > 0) {
const qvar = qb.pvar();
qb.addFilterClause(`p.full_name % ${qvar}`, filter.search);
qb.addFilterClause(
`(p.full_name % ${qvar} or p.full_name ilike '%' || ${qvar} || '%')`,
filter.search
);
qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
}
@ -134,14 +137,16 @@ export async function getPatients(
age: patient.age,
created_at: patient.created_at,
hidden: patient.hidden,
room: {
id: patient.room_id,
name: patient.room_name,
station: {
id: patient.station_id,
name: patient.station_name,
},
},
room: patient.room_id
? {
id: patient.room_id,
name: patient.room_name,
station: {
id: patient.station_id,
name: patient.station_name,
},
}
: null,
};
});

View file

@ -3,15 +3,4 @@ import type { Context } from "./context";
export { trpcWrap } from "$lib/server/trpc/handleError";
export const t = initTRPC.context<Context>().create({
errorFormatter(opts) {
const { shape, error } = opts;
console.log("formatted", error);
return {
...shape,
data: {
...shape.data,
},
};
},
});
export const t = initTRPC.context<Context>().create();

View file

@ -3,12 +3,10 @@ import { z } from "zod";
import {
ZEntityId,
ZEntriesFilter,
ZEntriesQuery,
ZEntryExecutionNew,
ZEntryNew,
ZEntryVersionNew,
ZPagination,
ZSort,
} from "$lib/shared/model/validation";
import {
getEntries,
@ -25,15 +23,7 @@ export const entryRouter = t.router({
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntry(opts.input))),
list: t.procedure
.input(
z
.object({
filter: ZEntriesFilter,
pagination: ZPagination,
sort: ZSort,
})
.partial()
)
.input(ZEntriesQuery)
.query(async (opts) =>
trpcWrap(async () =>
getEntries(

View file

@ -4,15 +4,10 @@ import {
getPatientNames,
getPatients,
hidePatient,
newPatient,
updatePatient,
} from "$lib/server/query";
import {
ZEntityId,
ZPagination,
ZPatientNew,
ZPatientsFilter,
ZSort,
} from "$lib/shared/model/validation";
import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
import { t, trpcWrap } from "..";
import { z } from "zod";
@ -21,19 +16,16 @@ export const patientRouter = t.router({
get: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getPatient(opts.input))),
list: t.procedure
.input(
z
.object({ filter: ZPatientsFilter, pagination: ZPagination, sort: ZSort })
.partial()
)
.query(async (opts) => {
return getPatients(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {}
);
}),
list: t.procedure.input(ZPatientsQuery).query(async (opts) => {
return getPatients(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {}
);
}),
create: t.procedure
.input(ZPatientNew)
.mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))),
update: t.procedure
.input(z.object({ id: ZEntityId, patient: ZPatientNew.partial() }))
.mutation(async (opts) =>

View file

@ -2,3 +2,6 @@ import type { Renderers } from "svelte-markdown";
export const PAGINATION_LIMIT = 20;
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined };
export const URL_ENTRIES = "/plan";
export const URL_PATIENTS = "/patients";

View file

@ -81,7 +81,7 @@ export type PatientNew = {
first_name: string;
last_name: string;
age: Option<number>;
room_id: number;
room_id?: number | null;
};
export type Entry = {

View file

@ -46,7 +46,7 @@ export const ZPatientNew = implement<PatientNew>().with({
first_name: ZNameString,
last_name: ZNameString,
age: z.number().int().nonnegative().lt(200).nullable(),
room_id: ZEntityId,
room_id: ZEntityId.optional().nullable(),
});
export const ZEntryVersionNew = implement<EntryVersionNew>().with({

View file

@ -26,6 +26,7 @@ export function formatDate(date: Date | string, time = false): string {
}
export function getQueryUrl(q: EntityQuery, basePath: string): string {
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "/" + JSON.stringify(q);
}
@ -62,3 +63,34 @@ export async function loadWrap<T>(f: () => Promise<T>) {
}
}
}
export async function authjsCsrfToken(f: typeof fetch): Promise<string> {
const csrfResp = await f("/auth/csrf");
const csrfJson = await csrfResp.json();
return csrfJson.csrfToken;
}
export class Debouncer {
private delay: number;
private handler: () => unknown;
private timeout: number | null = null;
constructor(delay: number, handler: () => unknown) {
this.delay = delay;
this.handler = handler;
}
clear() {
if (this.timeout) window.clearTimeout(this.timeout);
}
trigger() {
this.clear();
this.timeout = window.setTimeout(this.handler, this.delay);
}
now() {
this.clear();
this.handler();
}
}

View file

@ -42,13 +42,7 @@
<li><a href="/rooms">Zimmer</a></li>
<li><a href="/categories">Kategorien</a></li>
<li><a href="/users">Benutzer</a></li>
<li>
<button
on:click={() =>
signOut({ redirect: true, callbackUrl: "/login?noAuto=1" })}
>Abmelden</button
>
</li>
<li><a href="/logout">Abmelden</a></li>
</ul>
</div>
{/if}

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { PageData } from "./$types";
import { page } from "$app/stores";
export let data: PageData;
let callbackUrl: string;
$: if ($page.url) {
let u = new URL($page.url.origin);
u.pathname = "/login";
u.searchParams.append("noAuto", "1");
callbackUrl = u.toString();
}
</script>
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
<h1 class="text-xl mt-4">Möchten sie sich abmelden?</h1>
<form action="/auth/signout" method="POST">
<input type="hidden" name="csrfToken" value={data.csrfToken} />
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<button class="btn btn-primary mt-4" type="submit">Abmelden</button>
</form>
</div>

View file

@ -0,0 +1,8 @@
import { authjsCsrfToken } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const csrfToken = await authjsCsrfToken(fetch);
return { csrfToken };
};

View file

@ -0,0 +1,32 @@
import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
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 schema = zfd.formData(ZPatientNew.partial());
// const formData = schema.parse(form);
const form = await superValidate(event.request, ZPatientNew);
// Convenient validation check:
if (!form.valid) {
// Again, return { form } and things will just work.
return fail(400, { form });
}
await loadWrap(async () =>
trpc(event).patient.update.mutate({
id,
patient: form.data,
})
);
return { form };
},
} satisfies Actions;

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { PageData } from "./$types";
import FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte";
import PatientForm from "$lib/components/form/PatientForm.svelte";
export let data: PageData;
</script>
<svelte:head>
<title>Patient #{data.patient.id}</title>
</svelte:head>
<PatientForm patient={data.patient} formData={data.form} />
{#if data.patient.room}
<h1 class="heading mt-8 mb-4">Einträge</h1>
<FilteredEntryTable
baseUrl="/patient/{data.patient.id}"
entries={data.entries}
query={data.query}
patientId={data.patient.id}
/>
{/if}

View file

@ -0,0 +1,29 @@
import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { superValidate } from "sveltekit-superforms/server";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any = {};
if (event.params.query) {
query = JSON.parse(event.params.query);
}
if (!query.filter) query.filter = {};
query.filter.patient = [{ id }];
const [patient, entries] = await Promise.all([
loadWrap(async () => trpc(event).patient.get.query(id)),
loadWrap(() => trpc(event).entry.list.query(query)),
]);
const form = await superValidate(
{ room_id: patient.room?.id, ...patient },
ZPatientNew
);
return { patient, query, entries, form };
};

View file

@ -0,0 +1,19 @@
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions = {
default: async (event) => {
const form = await event.request.formData();
const newId = await loadWrap(async () =>
trpc(event).patient.create.mutate({
first_name: form.get("first_name")!.toString(),
last_name: form.get("last_name")!.toString(),
age: Number(form.get("age")?.toString()),
room_id: Number(form.get("room")?.toString()) || undefined,
})
);
throw redirect(302, `/patient/${newId}`);
},
} satisfies Actions;

View file

@ -0,0 +1,9 @@
<script lang="ts">
import PatientForm from "$lib/components/form/PatientForm.svelte";
</script>
<svelte:head>
<title>Neuer Patient</title>
</svelte:head>
<PatientForm />

View file

@ -1,77 +1,19 @@
<script lang="ts">
import { browser } from "$app/environment";
import FilteredPatientTable from "$lib/components/table/FilteredPatientTable.svelte";
import { URL_PATIENTS } from "$lib/shared/constants";
import type { PageData } from "./$types";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
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 } from "$lib/shared/trpc";
import { ENTRY_FILTERS, PATIENT_FILTER } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util";
import PatientTable from "$lib/components/table/PatientTable.svelte";
export let data: PageData;
let loading = false;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: data.query.filter,
pagination,
sort: data.query.sort,
});
}
function filterUpdate(filter: FilterQdata) {
updateQuery({ filter, sort: data.query.sort });
}
function sortUpdate(sort: SortRequest) {
updateQuery({
filter: data.query.filter,
pagination: data.query.pagination,
sort,
});
}
function updateQuery(query: typeof data.query) {
if (browser) {
// Update page URL (not using the Svelte Router here because
// we do not need to reload the page)
const url = getQueryUrl(query, "/patients");
window.history.replaceState(null, "", url);
loading = true;
trpc()
.patient.list.query(query)
.then((patients) => {
data.patients = patients;
data.query = query;
loading = false;
});
}
}
</script>
<svelte:head>
<title>Patienten</title>
</svelte:head>
<FilterBar
FILTERS={PATIENT_FILTER}
filterData={data.query.filter}
onUpdate={filterUpdate}
/>
{#if loading}
<p>Loading...</p>
{/if}
<PatientTable patients={data.patients} sortData={data.query.sort} {sortUpdate} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.patients}
onUpdate={paginationUpdate}
/>
<FilteredPatientTable
baseUrl={URL_PATIENTS}
patients={data.patients}
query={data.query}
>
<a class="btn btn-sm btn-primary" href="/patient/new">Neuer Patient</a>
</FilteredPatientTable>

View file

@ -1,90 +1,14 @@
<script lang="ts">
import { browser } from "$app/environment";
import type { PageData } from "../$types";
import type { PageData } from "./$types";
import EntryTable from "$lib/components/table/EntryTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
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 } 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 FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte";
import { URL_ENTRIES } from "$lib/shared/constants";
export let data: PageData;
let loadingBar: LoadingBar | undefined;
let error: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: data.query.filter,
pagination,
sort: data.query.sort,
});
}
function filterUpdate(filter: FilterQdata) {
updateQuery({ filter, sort: data.query.sort });
}
function sortUpdate(sort: SortRequest) {
updateQuery({
filter: data.query.filter,
pagination: data.query.pagination,
sort,
});
}
function updateQuery(query: typeof data.query) {
if (browser) {
// Update page URL (not using the Svelte Router here because
// we do not need to reload the page)
const url = getQueryUrl(query, "/plan");
window.history.replaceState(null, "", url);
loadingBar?.start();
trpc()
.entry.list.query(query)
.then((entries) => {
data.entries = entries;
error = null;
loadingBar?.reset();
})
.catch((err) => {
data.query = query;
error = err;
loadingBar?.error();
})
.finally(() => {
data.query = query;
});
}
}
</script>
<svelte:head>
<title>Planung</title>
</svelte:head>
<FilterBar
FILTERS={ENTRY_FILTERS}
filterData={data.query.filter}
onUpdate={filterUpdate}
/>
<LoadingBar bind:this={loadingBar} alwaysShown />
{#if error}
<ErrorMessage {error} />
{:else}
<EntryTable entries={data.entries} sortData={data.query.sort} {sortUpdate} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.entries}
onUpdate={paginationUpdate}
/>
{/if}
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} />

View file

@ -1,19 +1,13 @@
<script lang="ts">
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { navigating } from "$app/stores";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
let loadingBar: LoadingBar | undefined;
beforeNavigate(() => {
if (loadingBar) loadingBar.start();
});
afterNavigate(() => {
if (loadingBar) loadingBar.reset();
});
$: $navigating ? loadingBar?.start() : loadingBar?.reset();
</script>
<LoadingBar cls="fixed top-0 left-0 z-50" bind:this={loadingBar} />

View file

@ -1,11 +1,20 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { PageData } from "./$types";
import { page } from "$app/stores";
import { signIn } from "@auth/sveltekit/client";
import { onMount } from "svelte";
import { signIn } from "@auth/sveltekit/client";
import { goto } from "$app/navigation";
let callbackUrl = $page.url.searchParams.get("returnURL") ?? "/";
export let data: PageData;
let callbackUrl: string;
$: if ($page.url) {
let u = new URL($page.url.origin);
u.pathname = $page.url.searchParams.get("returnURL") ?? "/";
callbackUrl = u.toString();
}
// Client side auto-login / redirect
onMount(() => {
if (!$page.url.searchParams.get("noAuto")) {
if (!$page.data.session?.user) {
@ -19,8 +28,9 @@
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
<h1 class="text-4xl mt-4">Visitenbuch</h1>
<button
class="btn btn-primary mt-4"
on:click={() => signIn("keycloak", { callbackUrl })}>Anmelden</button
>
<form action="/auth/signin/keycloak" method="POST">
<input type="hidden" name="csrfToken" value={data.csrfToken} />
<input type="hidden" name="callbackUrl" value={callbackUrl} />
<button class="btn btn-primary mt-4" type="submit">Anmelden</button>
</form>
</div>

View file

@ -0,0 +1,8 @@
import { authjsCsrfToken } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const csrfToken = await authjsCsrfToken(fetch);
return { csrfToken };
};