Compare commits

..

No commits in common. "5f396ccaf2ab025488039a0e19379776e5b1c0cf" and "d11b495155b0ad539402ed554a4bcea4187662ee" have entirely different histories.

43 changed files with 298 additions and 803 deletions

View file

@ -24,8 +24,7 @@
"@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",
@ -52,7 +51,6 @@
"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,9 +29,6 @@ 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':
@ -106,9 +103,6 @@ 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
@ -2029,11 +2023,6 @@ 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
@ -2973,20 +2962,6 @@ 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'}
@ -3358,13 +3333,5 @@ 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,12 +16,3 @@ 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,7 +65,6 @@ export const handle = sequence(
return opt.session; return opt.session;
}, },
}, },
trustHost: true, trustHost: true,
}), }),
authorization, authorization,

7
src/index.test.ts Normal file
View file

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

View file

@ -11,30 +11,26 @@
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 "$lib/components/ui/Icon.svelte"; import Icon from "../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 selection: SelectionOrText | null = null; export let defaultSelection: 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 = () => {};
@ -44,11 +40,10 @@
let srcItems: BaseItem[]; let srcItems: BaseItem[];
let filteredItems: BaseItem[] = []; let filteredItems: BaseItem[] = [];
$: if (hiddenIds || selection) updateSearch(); $: if (hiddenIds) 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;
@ -74,7 +69,7 @@
srcItems = items; srcItems = items;
if (cacheKey) cache[cacheKey] = items; if (cacheKey) cache[cacheKey] = items;
isLoading = false; isLoading = false;
updateSearch(); onInput();
}); });
return false; return false;
} }
@ -82,50 +77,38 @@
return true; return true;
} }
function markSelection() { function selectDefault() {
if (selection) { if (defaultSelection) {
if (selection.id) { if (defaultSelection.id) {
const i = srcItems.findIndex((itm) => itm.id === selection?.id); const i = srcItems.findIndex((itm) => itm.id === defaultSelection?.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() {
selection = null;
onUnselect();
opened = true;
updateSearch(); updateSearch();
opened = true;
} }
function updateSearch() { function updateSearch() {
if (loadSrcItems()) { if (loadSrcItems()) {
let searchWord = inputValue().toLowerCase().trim(); let searchWord = inputValue().toLowerCase().trim();
filteredItems = filteredItems =
!selection && searchWord.length > 0 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));
markSelection(); selectDefault();
} }
} }
@ -145,7 +128,6 @@
} }
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) {
@ -165,14 +147,12 @@
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: () => {
@ -185,8 +165,6 @@
Backspace: () => { Backspace: () => {
if (inputValue().length === 0) { if (inputValue().length === 0) {
onBackspace(); onBackspace();
} else if (selection) {
clearSelection();
} }
}, },
}; };
@ -206,35 +184,6 @@
} }
} }
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);
@ -249,21 +198,21 @@
<div class="flex-grow {cls}" use:outclick on:outclick={close}> <div class="flex-grow {cls}" use:outclick on:outclick={close}>
<input <input
class={inputCls} class="w-full bg-transparent"
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 bind:this={listElm}> <div class="autocomplete-list" use:floatingContent>
{#each filteredItems as item, i} {#each filteredItems as item, i}
<div <div
role="option" role="option"
@ -271,9 +220,7 @@
class:selected={i === highlightIndex} class:selected={i === highlightIndex}
aria-selected={i === highlightIndex} aria-selected={i === highlightIndex}
tabindex="-1" tabindex="-1"
on:click|preventDefault={() => { on:click={() => selectListItem(item, false)}
selectListItem(item, false);
}}
on:keypress={(e) => { on:keypress={(e) => {
e.key == "Enter" && selectListItem(item, true); e.key == "Enter" && selectListItem(item, true);
}} }}
@ -289,11 +236,6 @@
{/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,25 +11,14 @@
} 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;
/** Callback when filters are updated */ export let onUpdate: (filterData: FilterQdata) => void = () => {};
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[];
@ -44,12 +33,11 @@
}); });
// 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) {
@ -59,10 +47,7 @@
function updateFromQueryData(filterData: FilterQdata) { function updateFromQueryData(filterData: FilterQdata) {
const filters: FilterData[] = []; const filters: FilterData[] = [];
for (const [id, value] of Object.entries(filterData)) { for (const [id, value] of Object.entries(filterData)) {
if (hiddenFilters.includes(id)) continue; if (Array.isArray(value)) {
if (search && id === "search") {
searchVal = value.toString();
} else if (Array.isArray(value)) {
value.forEach((v) => { value.forEach((v) => {
filters.push({ filters.push({
id, id,
@ -98,7 +83,7 @@
if (autocomplete) autocomplete.open(); if (autocomplete) autocomplete.open();
} }
function getFilterQdata(): FilterQdata | undefined { function getFilterQdata(): FilterQdata {
let fd: FilterQdata = {}; let fd: FilterQdata = {};
activeFilters.forEach((fdata) => { activeFilters.forEach((fdata) => {
const filter = FILTERS[fdata.id]; const filter = FILTERS[fdata.id];
@ -132,12 +117,6 @@
} }
} }
}); });
if (searchVal) {
fd["search"] = searchVal;
}
if (Object.keys(fd).length === 0) return undefined;
return fd; return fd;
} }
@ -162,7 +141,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 !== null; activeFilters[i].selection;
activeFilters.splice(i, 1); activeFilters.splice(i, 1);
activeFilters = activeFilters; activeFilters = activeFilters;
if (shouldUpdate) updateFilter(); if (shouldUpdate) updateFilter();
@ -171,21 +150,11 @@
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-wrap w-full items-start justify-center gap-2 relative"> <div class="flex flex-col items-start justify-center gap-2 relative">
<div <div
class="flex flex-wrap flex-grow items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative" class="flex flex-wrap w-full 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
@ -227,15 +196,4 @@
<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}
selection={fdata.selection} defaultSelection={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,6 +94,12 @@ 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

@ -1,101 +0,0 @@
<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,10 +1,8 @@
<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(
@ -13,7 +11,7 @@
category: [{ id: category.id, name: category.name }], category: [{ id: category.id, name: category.name }],
}, },
}, },
baseUrl "/plan"
); );
e.stopPropagation(); e.stopPropagation();
} }

View file

@ -11,9 +11,7 @@
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 | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest) => void = () => {};
export let perPatient = false;
export let baseUrl: string;
</script> </script>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -21,10 +19,8 @@
<thead> <thead>
<tr> <tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} /> <SortHeader title="ID" key="id" {sortData} {sortUpdate} />
{#if !perPatient}
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} /> <SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
<SortHeader title="Zimmer" key="room" {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} />
@ -48,32 +44,24 @@
aria-label="Eintrag anzeigen">{entry.id}</a aria-label="Eintrag anzeigen">{entry.id}</a
></td ></td
> >
{#if !perPatient} <td><PatientField patient={entry.patient} /></td>
<td><PatientField patient={entry.patient} {baseUrl} /></td> <td
<td> >{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</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} {baseUrl} /> <CategoryField category={entry.current_version.category} />
{/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} {baseUrl} /></td> <td><UserField user={entry.current_version.author} /></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 <UserField user={entry.execution.author} filterName="executor" />
user={entry.execution.author}
filterName="executor"
{baseUrl}
/>
{/if} {/if}
</td> </td>
</tr> </tr>

View file

@ -1,106 +0,0 @@
<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

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

View file

@ -3,15 +3,14 @@
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 "$lib/components/ui/Icon.svelte"; import Icon from "../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 | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest) => void = () => {};
export let baseUrl: string;
</script> </script>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@ -43,7 +42,7 @@
<td>{patient.age}</td> <td>{patient.age}</td>
<td> <td>
{#if patient.room} {#if patient.room}
<RoomField room={patient.room} {baseUrl} /> <RoomField room={patient.room} />
{/if} {/if}
</td> </td>
<td>{formatDate(patient.created_at, true)}</td> <td>{formatDate(patient.created_at, true)}</td>

View file

@ -1,10 +1,8 @@
<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(
@ -13,7 +11,7 @@
room: [{ id: room.id, name: room.name }], room: [{ id: room.id, name: room.name }],
}, },
}, },
baseUrl "/plan"
); );
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 | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest) => 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(undefined); sortUpdate({});
} else { } else {
sortUpdate({ field: key, asc: sorting !== 1 }); sortUpdate({ field: key, asc: sorting !== 1 });
} }

View file

@ -1,17 +1,15 @@
<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, baseUrl); gotoEntityQuery(query, "/plan");
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>

View file

@ -1,11 +0,0 @@
<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

@ -1,16 +0,0 @@
<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
left join rooms r on r.id = p.room_id join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id` join stations s on s.id = r.station_id`
); );
if (filter?.search && filter.search.length > 0) { if (filter?.search && filter.search.length > 0) {
@ -313,13 +313,11 @@ left 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: item.room_id room: {
? {
id: item.room_id, id: item.room_id,
name: item.room_name, name: item.room_name,
station: { id: item.station_id, name: item.station_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.first_name + " " + p.last_name }; return { id: p.id, name: p.last_name + ", " + p.first_name };
}); });
} }
@ -73,16 +73,13 @@ 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
left join rooms r on r.id = p.room_id join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id` 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( qb.addFilterClause(`p.full_name % ${qvar}`, filter.search);
`(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`);
} }
@ -137,16 +134,14 @@ 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: patient.room_id room: {
? {
id: patient.room_id, id: patient.room_id,
name: patient.room_name, name: patient.room_name,
station: { station: {
id: patient.station_id, id: patient.station_id,
name: patient.station_name, name: patient.station_name,
}, },
} },
: null,
}; };
}); });

View file

@ -3,4 +3,15 @@ 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,10 +3,12 @@ import { z } from "zod";
import { import {
ZEntityId, ZEntityId,
ZEntriesQuery, ZEntriesFilter,
ZEntryExecutionNew, ZEntryExecutionNew,
ZEntryNew, ZEntryNew,
ZEntryVersionNew, ZEntryVersionNew,
ZPagination,
ZSort,
} from "$lib/shared/model/validation"; } from "$lib/shared/model/validation";
import { import {
getEntries, getEntries,
@ -23,7 +25,15 @@ 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(ZEntriesQuery) .input(
z
.object({
filter: ZEntriesFilter,
pagination: ZPagination,
sort: ZSort,
})
.partial()
)
.query(async (opts) => .query(async (opts) =>
trpcWrap(async () => trpcWrap(async () =>
getEntries( getEntries(

View file

@ -4,10 +4,15 @@ import {
getPatientNames, getPatientNames,
getPatients, getPatients,
hidePatient, hidePatient,
newPatient,
updatePatient, updatePatient,
} from "$lib/server/query"; } from "$lib/server/query";
import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; import {
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";
@ -16,16 +21,19 @@ 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.input(ZPatientsQuery).query(async (opts) => { list: t.procedure
.input(
z
.object({ filter: ZPatientsFilter, pagination: ZPagination, sort: ZSort })
.partial()
)
.query(async (opts) => {
return getPatients( return getPatients(
opts.input.filter ?? {}, opts.input.filter ?? {},
opts.input.pagination ?? {}, opts.input.pagination ?? {},
opts.input.sort ?? {} opts.input.sort ?? {}
); );
}), }),
create: t.procedure
.input(ZPatientNew)
.mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))),
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,6 +2,3 @@ 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 | null; room_id: number;
}; };
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.optional().nullable(), room_id: ZEntityId,
}); });
export const ZEntryVersionNew = implement<EntryVersionNew>().with({ export const ZEntryVersionNew = implement<EntryVersionNew>().with({

View file

@ -26,7 +26,6 @@ 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);
} }
@ -63,34 +62,3 @@ 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,7 +42,13 @@
<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><a href="/logout">Abmelden</a></li> <li>
<button
on:click={() =>
signOut({ redirect: true, callbackUrl: "/login?noAuto=1" })}
>Abmelden</button
>
</li>
</ul> </ul>
</div> </div>
{/if} {/if}

View file

@ -1,23 +0,0 @@
<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

@ -1,8 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,24 +0,0 @@
<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

@ -1,29 +0,0 @@
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

@ -1,19 +0,0 @@
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

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

View file

@ -1,19 +1,77 @@
<script lang="ts"> <script lang="ts">
import FilteredPatientTable from "$lib/components/table/FilteredPatientTable.svelte"; import { browser } from "$app/environment";
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>
<FilteredPatientTable <FilterBar
baseUrl={URL_PATIENTS} FILTERS={PATIENT_FILTER}
patients={data.patients} filterData={data.query.filter}
query={data.query} onUpdate={filterUpdate}
> />
<a class="btn btn-sm btn-primary" href="/patient/new">Neuer Patient</a>
</FilteredPatientTable> {#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}
/>

View file

@ -1,14 +1,90 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import { browser } from "$app/environment";
import type { PageData } from "../$types";
import FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte"; import EntryTable from "$lib/components/table/EntryTable.svelte";
import { URL_ENTRIES } from "$lib/shared/constants"; 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";
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>
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} /> <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}

View file

@ -1,13 +1,19 @@
<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 { navigating } from "$app/stores"; import { afterNavigate, beforeNavigate } from "$app/navigation";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte"; import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
let loadingBar: LoadingBar | undefined; let loadingBar: LoadingBar | undefined;
$: $navigating ? loadingBar?.start() : loadingBar?.reset(); beforeNavigate(() => {
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,20 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types";
import { page } from "$app/stores";
import { onMount } from "svelte";
import { signIn } from "@auth/sveltekit/client";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { signIn } from "@auth/sveltekit/client";
import { onMount } from "svelte";
export let data: PageData; let callbackUrl = $page.url.searchParams.get("returnURL") ?? "/";
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) {
@ -28,9 +19,8 @@
<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>
<form action="/auth/signin/keycloak" method="POST"> <button
<input type="hidden" name="csrfToken" value={data.csrfToken} /> class="btn btn-primary mt-4"
<input type="hidden" name="callbackUrl" value={callbackUrl} /> on:click={() => signIn("keycloak", { callbackUrl })}>Anmelden</button
<button class="btn btn-primary mt-4" type="submit">Anmelden</button> >
</form>
</div> </div>

View file

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