Compare commits

..

8 commits

43 changed files with 802 additions and 297 deletions

View file

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

View file

@ -29,6 +29,9 @@ dependencies:
zod: zod:
specifier: ^3.22.4 specifier: ^3.22.4
version: 3.22.4 version: 3.22.4
zod-form-data:
specifier: ^2.0.2
version: 2.0.2(zod@3.22.4)
devDependencies: devDependencies:
'@faker-js/faker': '@faker-js/faker':
@ -103,6 +106,9 @@ devDependencies:
svelte-check: svelte-check:
specifier: ^3.6.3 specifier: ^3.6.3
version: 3.6.3(postcss@8.4.33)(svelte@4.2.9) 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: tailwindcss:
specifier: ^3.4.1 specifier: ^3.4.1
version: 3.4.1 version: 3.4.1
@ -2023,6 +2029,11 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
/klona@2.0.6:
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
engines: {node: '>= 8'}
dev: true
/known-css-properties@0.29.0: /known-css-properties@0.29.0:
resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==}
dev: true dev: true
@ -2962,6 +2973,20 @@ packages:
magic-string: 0.30.5 magic-string: 0.30.5
periscopic: 3.1.0 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: /tailwindcss@3.4.1:
resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -3333,5 +3358,13 @@ packages:
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
dev: true 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: /zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}

View file

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

View file

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

View file

@ -72,7 +72,7 @@ gap-1 pl-1"
hiddenIds={hids} hiddenIds={hids}
{cache} {cache}
cacheKey={filter.id} cacheKey={filter.id}
defaultSelection={fdata.selection} selection={fdata.selection}
padding={false} padding={false}
onSelect={(item) => { onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant // 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 } = { export const PATIENT_FILTER: { [key: string]: FilterDef } = {
search: {
id: "search",
name: "Suche",
icon: mdiMagnify,
inputType: 1,
},
station: { station: {
id: "station", id: "station",
name: "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"> <script lang="ts">
import { URL_ENTRIES } from "$lib/shared/constants";
import type { Category } from "$lib/shared/model"; import type { Category } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util"; import { gotoEntityQuery } from "$lib/shared/util";
export let category: Category; export let category: Category;
export let baseUrl = URL_ENTRIES;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
gotoEntityQuery( gotoEntityQuery(
@ -11,7 +13,7 @@
category: [{ id: category.id, name: category.name }], category: [{ id: category.id, name: category.name }],
}, },
}, },
"/plan" baseUrl
); );
e.stopPropagation(); e.stopPropagation();
} }

View file

@ -11,7 +11,9 @@
export let entries: RouterOutput["entry"]["list"]; export let entries: RouterOutput["entry"]["list"];
export let sortData: SortRequest | undefined = undefined; 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> </script>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -19,8 +21,10 @@
<thead> <thead>
<tr> <tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} /> <SortHeader title="ID" key="id" {sortData} {sortUpdate} />
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} /> {#if !perPatient}
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} /> <SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
{/if}
<SortHeader title="Kategorie" key="category" {sortData} {sortUpdate} /> <SortHeader title="Kategorie" key="category" {sortData} {sortUpdate} />
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} /> <SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} />
<SortHeader title="Aktualisiert am" key="updated_at" {sortData} {sortUpdate} /> <SortHeader title="Aktualisiert am" key="updated_at" {sortData} {sortUpdate} />
@ -44,24 +48,32 @@
aria-label="Eintrag anzeigen">{entry.id}</a aria-label="Eintrag anzeigen">{entry.id}</a
></td ></td
> >
<td><PatientField patient={entry.patient} /></td> {#if !perPatient}
<td <td><PatientField patient={entry.patient} {baseUrl} /></td>
>{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</td <td>
> {#if entry.patient.room}
<RoomField room={entry.patient.room} {baseUrl} />
{/if}
</td>
{/if}
<td> <td>
{#if entry.current_version.category} {#if entry.current_version.category}
<CategoryField category={entry.current_version.category} /> <CategoryField category={entry.current_version.category} {baseUrl} />
{/if} {/if}
</td> </td>
<td>{formatDate(entry.created_at, true)}</td> <td>{formatDate(entry.created_at, true)}</td>
<td>{formatDate(entry.current_version.created_at, true)}</td> <td>{formatDate(entry.current_version.created_at, true)}</td>
<td>{formatDate(entry.current_version.date)}</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><span class="line-clamp-2">{entry.current_version.text}</span></td>
<td> <td>
{#if entry.execution} {#if entry.execution}
{formatDate(entry.execution.created_at, true)} {formatDate(entry.execution.created_at, true)}
<UserField user={entry.execution.author} filterName="executor" /> <UserField
user={entry.execution.author}
filterName="executor"
{baseUrl}
/>
{/if} {/if}
</td> </td>
</tr> </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"> <script lang="ts">
import type { Patient } from "$lib/shared/model"; import type { RouterOutput } from "$lib/shared/trpc";
import { gotoEntityQuery } from "$lib/shared/util"; 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) { function onClick(e: MouseEvent) {
gotoEntityQuery( gotoEntityQuery(
@ -13,7 +14,7 @@
], ],
}, },
}, },
"/plan" baseUrl
); );
e.stopPropagation(); e.stopPropagation();
} }

View file

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

View file

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

View file

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

View file

@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import { URL_ENTRIES } from "$lib/shared/constants";
import type { EntityQuery, UserTag } from "$lib/shared/model"; import type { EntityQuery, UserTag } from "$lib/shared/model";
import { gotoEntityQuery } from "$lib/shared/util"; import { gotoEntityQuery } from "$lib/shared/util";
export let user: UserTag; export let user: UserTag;
export let baseUrl = URL_ENTRIES;
export let filterName: string = "author"; export let filterName: string = "author";
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
let query: EntityQuery = { filter: {} }; let query: EntityQuery = { filter: {} };
// @ts-expect-error filterName is checked // @ts-expect-error filterName is checked
query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }]; query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }];
gotoEntityQuery(query, "/plan"); gotoEntityQuery(query, baseUrl);
e.stopPropagation(); e.stopPropagation();
} }
</script> </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) limit 1)
left join users xau on xau.id=ex.author_id left join users xau on xau.id=ex.author_id
join patients p on p.id = e.patient_id join patients p on p.id = e.patient_id
join rooms r on r.id = p.room_id left join rooms r on r.id = p.room_id
join stations s on s.id = r.station_id` left join stations s on s.id = r.station_id`
); );
if (filter?.search && filter.search.length > 0) { 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, created_at: item.patient_created_at,
age: item.patient_age, age: item.patient_age,
hidden: item.patient_hidden, hidden: item.patient_hidden,
room: { room: item.room_id
id: item.room_id, ? {
name: item.room_name, id: item.room_id,
station: { id: item.station_id, name: item.station_name }, name: item.room_name,
}, station: { id: item.station_id, name: item.station_name },
}
: null,
}, },
created_at: item.created_at, created_at: item.created_at,
current_version: { current_version: {

View file

@ -60,7 +60,7 @@ export async function getPatientNames(): Promise<PatientTag[]> {
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
}); });
return patients.map((p) => { 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, `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`, r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`,
`from patients p `from patients p
join rooms r on r.id = p.room_id left join rooms r on r.id = p.room_id
join stations s on s.id = r.station_id` left join stations s on s.id = r.station_id`
); );
if (filter.search && filter.search.length > 0) { if (filter.search && filter.search.length > 0) {
const qvar = qb.pvar(); 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`); qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
} }
@ -134,14 +137,16 @@ export async function getPatients(
age: patient.age, age: patient.age,
created_at: patient.created_at, created_at: patient.created_at,
hidden: patient.hidden, hidden: patient.hidden,
room: { room: patient.room_id
id: patient.room_id, ? {
name: patient.room_name, id: patient.room_id,
station: { name: patient.room_name,
id: patient.station_id, station: {
name: patient.station_name, 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 { trpcWrap } from "$lib/server/trpc/handleError";
export const t = initTRPC.context<Context>().create({ export const t = initTRPC.context<Context>().create();
errorFormatter(opts) {
const { shape, error } = opts;
console.log("formatted", error);
return {
...shape,
data: {
...shape.data,
},
};
},
});

View file

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

View file

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

View file

@ -2,3 +2,6 @@ import type { Renderers } from "svelte-markdown";
export const PAGINATION_LIMIT = 20; export const PAGINATION_LIMIT = 20;
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined }; 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; first_name: string;
last_name: string; last_name: string;
age: Option<number>; age: Option<number>;
room_id: number; room_id?: number | null;
}; };
export type Entry = { export type Entry = {

View file

@ -46,7 +46,7 @@ export const ZPatientNew = implement<PatientNew>().with({
first_name: ZNameString, first_name: ZNameString,
last_name: ZNameString, last_name: ZNameString,
age: z.number().int().nonnegative().lt(200).nullable(), age: z.number().int().nonnegative().lt(200).nullable(),
room_id: ZEntityId, room_id: ZEntityId.optional().nullable(),
}); });
export const ZEntryVersionNew = implement<EntryVersionNew>().with({ 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 { 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); 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="/rooms">Zimmer</a></li>
<li><a href="/categories">Kategorien</a></li> <li><a href="/categories">Kategorien</a></li>
<li><a href="/users">Benutzer</a></li> <li><a href="/users">Benutzer</a></li>
<li> <li><a href="/logout">Abmelden</a></li>
<button
on:click={() =>
signOut({ redirect: true, callbackUrl: "/login?noAuto=1" })}
>Abmelden</button
>
</li>
</ul> </ul>
</div> </div>
{/if} {/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"> <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 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; 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> </script>
<svelte:head> <svelte:head>
<title>Patienten</title> <title>Patienten</title>
</svelte:head> </svelte:head>
<FilterBar <FilteredPatientTable
FILTERS={PATIENT_FILTER} baseUrl={URL_PATIENTS}
filterData={data.query.filter} patients={data.patients}
onUpdate={filterUpdate} query={data.query}
/> >
<a class="btn btn-sm btn-primary" href="/patient/new">Neuer Patient</a>
{#if loading} </FilteredPatientTable>
<p>Loading...</p>
{/if}
<PatientTable patients={data.patients} sortData={data.query.sort} {sortUpdate} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.patients}
onUpdate={paginationUpdate}
/>

View file

@ -1,90 +1,14 @@
<script lang="ts"> <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 FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte"; import { URL_ENTRIES } from "$lib/shared/constants";
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";
export let data: PageData; 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> </script>
<svelte:head> <svelte:head>
<title>Planung</title> <title>Planung</title>
</svelte:head> </svelte:head>
<FilterBar <FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} />
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}

View file

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

View file

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