Compare commits
8 commits
d11b495155
...
5f396ccaf2
Author | SHA1 | Date | |
---|---|---|---|
5f396ccaf2 | |||
a4e48a2aa8 | |||
44e99568dc | |||
2ce1b26a0b | |||
a4ae64dd79 | |||
41d098cc90 | |||
b59b0138ff | |||
372b7d6948 |
43 changed files with 802 additions and 297 deletions
|
@ -24,7 +24,8 @@
|
|||
"@prisma/client": "^5.8.1",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"svelte-markdown": "^0.4.1",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.22.4",
|
||||
"zod-form-data": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
|
@ -51,6 +52,7 @@
|
|||
"prisma": "^5.8.1",
|
||||
"svelte": "^4.2.9",
|
||||
"svelte-check": "^3.6.3",
|
||||
"sveltekit-superforms": "^1.13.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"trpc-sveltekit": "^3.5.22",
|
||||
"tslib": "^2.6.2",
|
||||
|
|
|
@ -29,6 +29,9 @@ dependencies:
|
|||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
zod-form-data:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2(zod@3.22.4)
|
||||
|
||||
devDependencies:
|
||||
'@faker-js/faker':
|
||||
|
@ -103,6 +106,9 @@ devDependencies:
|
|||
svelte-check:
|
||||
specifier: ^3.6.3
|
||||
version: 3.6.3(postcss@8.4.33)(svelte@4.2.9)
|
||||
sveltekit-superforms:
|
||||
specifier: ^1.13.4
|
||||
version: 1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
|
@ -2023,6 +2029,11 @@ packages:
|
|||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
/klona@2.0.6:
|
||||
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
||||
engines: {node: '>= 8'}
|
||||
dev: true
|
||||
|
||||
/known-css-properties@0.29.0:
|
||||
resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==}
|
||||
dev: true
|
||||
|
@ -2962,6 +2973,20 @@ packages:
|
|||
magic-string: 0.30.5
|
||||
periscopic: 3.1.0
|
||||
|
||||
/sveltekit-superforms@1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4):
|
||||
resolution: {integrity: sha512-rM2+Ictaw7OAIorCLmvg82orci/mtO9ZouI4emtx8SyYngx9aED+eNZlHPLufgB6D7geL2a+hMSFtM3zmMQixQ==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': 1.x || 2.x
|
||||
svelte: 3.x || 4.x
|
||||
zod: 3.x
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.5.0(@sveltejs/vite-plugin-svelte@3.0.1)(svelte@4.2.9)(vite@5.0.12)
|
||||
devalue: 4.3.2
|
||||
klona: 2.0.6
|
||||
svelte: 4.2.9
|
||||
zod: 3.22.4
|
||||
dev: true
|
||||
|
||||
/tailwindcss@3.4.1:
|
||||
resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
@ -3333,5 +3358,13 @@ packages:
|
|||
engines: {node: '>=12.20'}
|
||||
dev: true
|
||||
|
||||
/zod-form-data@2.0.2(zod@3.22.4):
|
||||
resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==}
|
||||
peerDependencies:
|
||||
zod: '>= 3.11.0'
|
||||
dependencies:
|
||||
zod: 3.22.4
|
||||
dev: false
|
||||
|
||||
/zod@3.22.4:
|
||||
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
|
||||
|
|
|
@ -16,3 +16,12 @@ button {
|
|||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.v-form-field > input {
|
||||
@apply input input-bordered w-full max-w-xs;
|
||||
}
|
||||
|
||||
.input[aria-invalid="true"],
|
||||
.v-form-field > [aria-invalid="true"] {
|
||||
@apply input-error;
|
||||
}
|
||||
|
|
|
@ -65,6 +65,7 @@ export const handle = sequence(
|
|||
return opt.session;
|
||||
},
|
||||
},
|
||||
|
||||
trustHost: true,
|
||||
}),
|
||||
authorization,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -11,26 +11,30 @@
|
|||
import { shift } from "svelte-floating-ui/dom";
|
||||
import outclick from "$lib/actions/outclick";
|
||||
import type { BaseItem, SelectionOrText } from "./types";
|
||||
import Icon from "../ui/Icon.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
|
||||
type OnSelectResult = { newValue: string; close: boolean };
|
||||
|
||||
export let items: BaseItem[] | (() => Promise<BaseItem[]>);
|
||||
export let defaultSelection: SelectionOrText | null = null;
|
||||
export let selection: SelectionOrText | null = null;
|
||||
export let hiddenIds: Set<string | number> = new Set();
|
||||
export let cache: { [key: string]: BaseItem[] } = {};
|
||||
export let cacheKey: string | undefined = undefined;
|
||||
export let placeholder: string | undefined = undefined;
|
||||
export let padding = true;
|
||||
export let cls = "";
|
||||
export let inputCls = "w-full bg-transparent";
|
||||
export let asTextInput = false;
|
||||
export let idInputName: string | undefined = undefined;
|
||||
|
||||
/** Selection callback. Returns the new input value after selection */
|
||||
export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
|
||||
item,
|
||||
kb
|
||||
) => {
|
||||
return { newValue: item.name || "", close: true };
|
||||
return { newValue: item.name ?? "", close: true };
|
||||
};
|
||||
export let onUnselect = () => {};
|
||||
export let onClose = (kb: boolean) => {};
|
||||
export let onBackspace = () => {};
|
||||
|
||||
|
@ -40,10 +44,11 @@
|
|||
let srcItems: BaseItem[];
|
||||
let filteredItems: BaseItem[] = [];
|
||||
|
||||
$: if (hiddenIds) updateSearch();
|
||||
$: if (hiddenIds || selection) updateSearch();
|
||||
|
||||
// HTML elements
|
||||
let inputElm: HTMLInputElement | undefined;
|
||||
let listElm: HTMLElement | undefined;
|
||||
|
||||
function inputValue(): string {
|
||||
if (inputElm) return inputElm.value;
|
||||
|
@ -69,7 +74,7 @@
|
|||
srcItems = items;
|
||||
if (cacheKey) cache[cacheKey] = items;
|
||||
isLoading = false;
|
||||
onInput();
|
||||
updateSearch();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
@ -77,38 +82,50 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
function selectDefault() {
|
||||
if (defaultSelection) {
|
||||
if (defaultSelection.id) {
|
||||
const i = srcItems.findIndex((itm) => itm.id === defaultSelection?.id);
|
||||
function markSelection() {
|
||||
if (selection) {
|
||||
if (selection.id) {
|
||||
const i = srcItems.findIndex((itm) => itm.id === selection?.id);
|
||||
if (i !== -1) {
|
||||
highlightIndex = i;
|
||||
}
|
||||
if (asTextInput) setInputValue(selection.name || "");
|
||||
} else {
|
||||
setInputValue(defaultSelection.name || "");
|
||||
highlightIndex = 0;
|
||||
setInputValue(selection.name || "");
|
||||
}
|
||||
} else {
|
||||
highlightIndex = 0;
|
||||
}
|
||||
highlight();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection = null;
|
||||
onUnselect();
|
||||
setInputValue("");
|
||||
highlightIndex = 0;
|
||||
updateSearch();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
updateSearch();
|
||||
selection = null;
|
||||
onUnselect();
|
||||
opened = true;
|
||||
updateSearch();
|
||||
}
|
||||
|
||||
function updateSearch() {
|
||||
if (loadSrcItems()) {
|
||||
let searchWord = inputValue().toLowerCase().trim();
|
||||
filteredItems =
|
||||
searchWord.length > 0
|
||||
!selection && searchWord.length > 0
|
||||
? srcItems.filter(
|
||||
(it) =>
|
||||
!hiddenIds.has(it.id) && it.name.toLowerCase().includes(searchWord)
|
||||
)
|
||||
: srcItems.filter((it) => !hiddenIds.has(it.id));
|
||||
selectDefault();
|
||||
markSelection();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,6 +145,7 @@
|
|||
}
|
||||
|
||||
function selectListItem(item: BaseItem | undefined, kb: boolean) {
|
||||
selection = { id: item?.id, name: item?.name };
|
||||
const selRes = onSelect(item ?? { name: inputValue() }, kb);
|
||||
setInputValue(selRes.newValue);
|
||||
if (selRes.close) {
|
||||
|
@ -147,12 +165,14 @@
|
|||
open();
|
||||
if (highlightIndex < filteredItems.length - 1) {
|
||||
highlightIndex++;
|
||||
highlight();
|
||||
}
|
||||
},
|
||||
ArrowUp: () => {
|
||||
open();
|
||||
if (highlightIndex > 0) {
|
||||
highlightIndex--;
|
||||
highlight();
|
||||
}
|
||||
},
|
||||
Escape: () => {
|
||||
|
@ -165,6 +185,8 @@
|
|||
Backspace: () => {
|
||||
if (inputValue().length === 0) {
|
||||
onBackspace();
|
||||
} else if (selection) {
|
||||
clearSelection();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -184,6 +206,35 @@
|
|||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (!selection) {
|
||||
if (filteredItems.length === 1) {
|
||||
selectListItem(filteredItems[0], true);
|
||||
} else {
|
||||
setInputValue("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
if (browser && opened) {
|
||||
window.setTimeout(() => {
|
||||
const query = ".selected";
|
||||
|
||||
const el = listElm && listElm.querySelector(query);
|
||||
if (el) {
|
||||
// @ts-expect-error scrollIntoViewIfNeeded is unspecified (currently only on Chrome)
|
||||
if (typeof el.scrollIntoViewIfNeeded === "function") {
|
||||
// @ts-expect-error scrollIntoViewIfNeeded is unspecified
|
||||
el.scrollIntoViewIfNeeded();
|
||||
} else {
|
||||
el.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function selectItem() {
|
||||
const listItem = filteredItems[highlightIndex];
|
||||
selectListItem(listItem, true);
|
||||
|
@ -198,21 +249,21 @@
|
|||
|
||||
<div class="flex-grow {cls}" use:outclick on:outclick={close}>
|
||||
<input
|
||||
class="w-full bg-transparent"
|
||||
class={inputCls}
|
||||
class:px-2={padding}
|
||||
type="text"
|
||||
{placeholder}
|
||||
bind:this={inputElm}
|
||||
on:input={onInput}
|
||||
on:focus={open}
|
||||
on:click={open}
|
||||
on:keydown={onKeyDown}
|
||||
on:keypress={onKeyPress}
|
||||
on:blur={onBlur}
|
||||
use:floatingRef
|
||||
/>
|
||||
|
||||
{#if opened && filteredItems.length > 0}
|
||||
<div class="autocomplete-list" use:floatingContent>
|
||||
<div class="autocomplete-list" use:floatingContent bind:this={listElm}>
|
||||
{#each filteredItems as item, i}
|
||||
<div
|
||||
role="option"
|
||||
|
@ -220,7 +271,9 @@
|
|||
class:selected={i === highlightIndex}
|
||||
aria-selected={i === highlightIndex}
|
||||
tabindex="-1"
|
||||
on:click={() => selectListItem(item, false)}
|
||||
on:click|preventDefault={() => {
|
||||
selectListItem(item, false);
|
||||
}}
|
||||
on:keypress={(e) => {
|
||||
e.key == "Enter" && selectListItem(item, true);
|
||||
}}
|
||||
|
@ -236,6 +289,11 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if idInputName}
|
||||
<!-- Hidden input field (acting as form input) -->
|
||||
<input name={idInputName} type="hidden" value={selection?.id ?? ""} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
|
|
|
@ -11,14 +11,25 @@
|
|||
} from "./types";
|
||||
import { isFilterValueless } from "./types";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { Debouncer } from "$lib/shared/util";
|
||||
|
||||
/** Filter definitions */
|
||||
export let FILTERS: { [key: string]: FilterDef };
|
||||
/** Filter data from the query */
|
||||
export let filterData: FilterQdata | null | undefined = undefined;
|
||||
export let onUpdate: (filterData: FilterQdata) => void = () => {};
|
||||
/** Callback when filters are updated */
|
||||
export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {};
|
||||
/** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */
|
||||
export let hiddenFilters: string[] = [];
|
||||
export let search = false;
|
||||
|
||||
let autocomplete: Autocomplete | undefined;
|
||||
let activeFilters: FilterData[] = [];
|
||||
let cache: { [key: string]: BaseItem[] } = {};
|
||||
let searchVal = "";
|
||||
let searchDebounce = new Debouncer(400, () => {
|
||||
onUpdate(getFilterQdata());
|
||||
});
|
||||
|
||||
// Filter items to be displayed in the autocomplete menu
|
||||
let filterMenuItems: BaseItem[];
|
||||
|
@ -33,11 +44,12 @@
|
|||
});
|
||||
|
||||
// Filter menu items to be hidden
|
||||
$: hiddenIds = new Set(
|
||||
Object.values(FILTERS).flatMap((f) =>
|
||||
$: hiddenIds = new Set([
|
||||
...Object.values(FILTERS).flatMap((f) =>
|
||||
f.inputType === 2 || activeFilters.every((af) => af.id !== f.id) ? [] : [f.id]
|
||||
)
|
||||
);
|
||||
),
|
||||
...hiddenFilters,
|
||||
]);
|
||||
|
||||
// Load query data if present
|
||||
$: if (filterData || !filterData) {
|
||||
|
@ -47,7 +59,10 @@
|
|||
function updateFromQueryData(filterData: FilterQdata) {
|
||||
const filters: FilterData[] = [];
|
||||
for (const [id, value] of Object.entries(filterData)) {
|
||||
if (Array.isArray(value)) {
|
||||
if (hiddenFilters.includes(id)) continue;
|
||||
if (search && id === "search") {
|
||||
searchVal = value.toString();
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
filters.push({
|
||||
id,
|
||||
|
@ -83,7 +98,7 @@
|
|||
if (autocomplete) autocomplete.open();
|
||||
}
|
||||
|
||||
function getFilterQdata(): FilterQdata {
|
||||
function getFilterQdata(): FilterQdata | undefined {
|
||||
let fd: FilterQdata = {};
|
||||
activeFilters.forEach((fdata) => {
|
||||
const filter = FILTERS[fdata.id];
|
||||
|
@ -117,6 +132,12 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (searchVal) {
|
||||
fd["search"] = searchVal;
|
||||
}
|
||||
|
||||
if (Object.keys(fd).length === 0) return undefined;
|
||||
return fd;
|
||||
}
|
||||
|
||||
|
@ -141,7 +162,7 @@
|
|||
function removeFilter(i: number) {
|
||||
const shouldUpdate =
|
||||
isFilterValueless(FILTERS[activeFilters[i].id].inputType) ||
|
||||
activeFilters[i].selection;
|
||||
activeFilters[i].selection !== null;
|
||||
activeFilters.splice(i, 1);
|
||||
activeFilters = activeFilters;
|
||||
if (shouldUpdate) updateFilter();
|
||||
|
@ -150,11 +171,21 @@
|
|||
function updateFilter() {
|
||||
onUpdate(getFilterQdata());
|
||||
}
|
||||
|
||||
function onSearchInput(e: Event) {
|
||||
searchDebounce.trigger();
|
||||
}
|
||||
|
||||
function onSearchKeypress(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
searchDebounce.now();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start justify-center gap-2 relative">
|
||||
<div class="flex flex-wrap w-full items-start justify-center gap-2 relative">
|
||||
<div
|
||||
class="flex flex-wrap w-full items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
|
||||
class="flex flex-wrap flex-grow items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
|
||||
>
|
||||
{#each activeFilters as fdata, i}
|
||||
<EntryFilterChip
|
||||
|
@ -196,4 +227,15 @@
|
|||
<Icon size={1.2} path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
{#if search}
|
||||
<input
|
||||
class="input input-sm input-bordered"
|
||||
type="text"
|
||||
placeholder="Suche"
|
||||
bind:value={searchVal}
|
||||
on:input={onSearchInput}
|
||||
on:keypress={onSearchKeypress}
|
||||
/>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -72,7 +72,7 @@ gap-1 pl-1"
|
|||
hiddenIds={hids}
|
||||
{cache}
|
||||
cacheKey={filter.id}
|
||||
defaultSelection={fdata.selection}
|
||||
selection={fdata.selection}
|
||||
padding={false}
|
||||
onSelect={(item) => {
|
||||
// Accept the selection if this is a free text field or the user selected a variant
|
||||
|
|
|
@ -94,12 +94,6 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
|
|||
};
|
||||
|
||||
export const PATIENT_FILTER: { [key: string]: FilterDef } = {
|
||||
search: {
|
||||
id: "search",
|
||||
name: "Suche",
|
||||
icon: mdiMagnify,
|
||||
inputType: 1,
|
||||
},
|
||||
station: {
|
||||
id: "station",
|
||||
name: "Station",
|
||||
|
|
101
src/lib/components/form/PatientForm.svelte
Normal file
101
src/lib/components/form/PatientForm.svelte
Normal 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>
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||
import type { Category } from "$lib/shared/model";
|
||||
import { gotoEntityQuery } from "$lib/shared/util";
|
||||
|
||||
export let category: Category;
|
||||
export let baseUrl = URL_ENTRIES;
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
gotoEntityQuery(
|
||||
|
@ -11,7 +13,7 @@
|
|||
category: [{ id: category.id, name: category.name }],
|
||||
},
|
||||
},
|
||||
"/plan"
|
||||
baseUrl
|
||||
);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
|
||||
export let entries: RouterOutput["entry"]["list"];
|
||||
export let sortData: SortRequest | undefined = undefined;
|
||||
export let sortUpdate: (sort: SortRequest) => void = () => {};
|
||||
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
|
||||
export let perPatient = false;
|
||||
export let baseUrl: string;
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
|
@ -19,8 +21,10 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<SortHeader title="ID" key="id" {sortData} {sortUpdate} />
|
||||
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
|
||||
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
|
||||
{#if !perPatient}
|
||||
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} />
|
||||
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
|
||||
{/if}
|
||||
<SortHeader title="Kategorie" key="category" {sortData} {sortUpdate} />
|
||||
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} />
|
||||
<SortHeader title="Aktualisiert am" key="updated_at" {sortData} {sortUpdate} />
|
||||
|
@ -44,24 +48,32 @@
|
|||
aria-label="Eintrag anzeigen">{entry.id}</a
|
||||
></td
|
||||
>
|
||||
<td><PatientField patient={entry.patient} /></td>
|
||||
<td
|
||||
>{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</td
|
||||
>
|
||||
{#if !perPatient}
|
||||
<td><PatientField patient={entry.patient} {baseUrl} /></td>
|
||||
<td>
|
||||
{#if entry.patient.room}
|
||||
<RoomField room={entry.patient.room} {baseUrl} />
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
<td>
|
||||
{#if entry.current_version.category}
|
||||
<CategoryField category={entry.current_version.category} />
|
||||
<CategoryField category={entry.current_version.category} {baseUrl} />
|
||||
{/if}
|
||||
</td>
|
||||
<td>{formatDate(entry.created_at, true)}</td>
|
||||
<td>{formatDate(entry.current_version.created_at, true)}</td>
|
||||
<td>{formatDate(entry.current_version.date)}</td>
|
||||
<td><UserField user={entry.current_version.author} /></td>
|
||||
<td><UserField user={entry.current_version.author} {baseUrl} /></td>
|
||||
<td><span class="line-clamp-2">{entry.current_version.text}</span></td>
|
||||
<td>
|
||||
{#if entry.execution}
|
||||
{formatDate(entry.execution.created_at, true)}
|
||||
<UserField user={entry.execution.author} filterName="executor" />
|
||||
<UserField
|
||||
user={entry.execution.author}
|
||||
filterName="executor"
|
||||
{baseUrl}
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
106
src/lib/components/table/FilteredEntryTable.svelte
Normal file
106
src/lib/components/table/FilteredEntryTable.svelte
Normal 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}
|
90
src/lib/components/table/FilteredPatientTable.svelte
Normal file
90
src/lib/components/table/FilteredPatientTable.svelte
Normal 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}
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type { Patient } from "$lib/shared/model";
|
||||
import type { RouterOutput } from "$lib/shared/trpc";
|
||||
import { gotoEntityQuery } from "$lib/shared/util";
|
||||
|
||||
export let patient: Pick<Patient, "id" | "first_name" | "last_name" | "age">;
|
||||
export let patient: RouterOutput["patient"]["get"];
|
||||
export let baseUrl: string;
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
gotoEntityQuery(
|
||||
|
@ -13,7 +14,7 @@
|
|||
],
|
||||
},
|
||||
},
|
||||
"/plan"
|
||||
baseUrl
|
||||
);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
|
|
@ -3,14 +3,15 @@
|
|||
import type { RouterOutput } from "$lib/shared/trpc";
|
||||
import { formatDate } from "$lib/shared/util";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import Icon from "../ui/Icon.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
|
||||
import RoomField from "./RoomField.svelte";
|
||||
import SortHeader from "./SortHeader.svelte";
|
||||
|
||||
export let patients: RouterOutput["patient"]["list"];
|
||||
export let sortData: SortRequest | undefined = undefined;
|
||||
export let sortUpdate: (sort: SortRequest) => void = () => {};
|
||||
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
|
||||
export let baseUrl: string;
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
|
@ -42,7 +43,7 @@
|
|||
<td>{patient.age}</td>
|
||||
<td>
|
||||
{#if patient.room}
|
||||
<RoomField room={patient.room} />
|
||||
<RoomField room={patient.room} {baseUrl} />
|
||||
{/if}
|
||||
</td>
|
||||
<td>{formatDate(patient.created_at, true)}</td>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||
import type { Room } from "$lib/shared/model";
|
||||
import { gotoEntityQuery } from "$lib/shared/util";
|
||||
|
||||
export let room: Room;
|
||||
export let baseUrl = URL_ENTRIES;
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
gotoEntityQuery(
|
||||
|
@ -11,7 +13,7 @@
|
|||
room: [{ id: room.id, name: room.name }],
|
||||
},
|
||||
},
|
||||
"/plan"
|
||||
baseUrl
|
||||
);
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
export let key: string;
|
||||
export let title: string;
|
||||
export let sortData: SortRequest | undefined = undefined;
|
||||
export let sortUpdate: (sort: SortRequest) => void = () => {};
|
||||
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
|
||||
|
||||
// 1: asc, 2: desc, 0: not sorted
|
||||
$: sorting = sortData?.field === key ? (sortData.asc !== false ? 1 : 2) : 0;
|
||||
|
||||
function onClick() {
|
||||
if (sorting === 2) {
|
||||
sortUpdate({});
|
||||
sortUpdate(undefined);
|
||||
} else {
|
||||
sortUpdate({ field: key, asc: sorting !== 1 });
|
||||
}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||
import type { EntityQuery, UserTag } from "$lib/shared/model";
|
||||
import { gotoEntityQuery } from "$lib/shared/util";
|
||||
|
||||
export let user: UserTag;
|
||||
export let baseUrl = URL_ENTRIES;
|
||||
export let filterName: string = "author";
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
let query: EntityQuery = { filter: {} };
|
||||
// @ts-expect-error filterName is checked
|
||||
query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }];
|
||||
gotoEntityQuery(query, "/plan");
|
||||
gotoEntityQuery(query, baseUrl);
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
|
11
src/lib/components/ui/EditableField.svelte
Normal file
11
src/lib/components/ui/EditableField.svelte
Normal 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>
|
16
src/lib/components/ui/FormField.svelte
Normal file
16
src/lib/components/ui/FormField.svelte
Normal 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>
|
|
@ -212,8 +212,8 @@ left join entry_executions ex on
|
|||
limit 1)
|
||||
left join users xau on xau.id=ex.author_id
|
||||
join patients p on p.id = e.patient_id
|
||||
join rooms r on r.id = p.room_id
|
||||
join stations s on s.id = r.station_id`
|
||||
left join rooms r on r.id = p.room_id
|
||||
left join stations s on s.id = r.station_id`
|
||||
);
|
||||
|
||||
if (filter?.search && filter.search.length > 0) {
|
||||
|
@ -313,11 +313,13 @@ join stations s on s.id = r.station_id`
|
|||
created_at: item.patient_created_at,
|
||||
age: item.patient_age,
|
||||
hidden: item.patient_hidden,
|
||||
room: {
|
||||
id: item.room_id,
|
||||
name: item.room_name,
|
||||
station: { id: item.station_id, name: item.station_name },
|
||||
},
|
||||
room: item.room_id
|
||||
? {
|
||||
id: item.room_id,
|
||||
name: item.room_name,
|
||||
station: { id: item.station_id, name: item.station_name },
|
||||
}
|
||||
: null,
|
||||
},
|
||||
created_at: item.created_at,
|
||||
current_version: {
|
||||
|
|
|
@ -60,7 +60,7 @@ export async function getPatientNames(): Promise<PatientTag[]> {
|
|||
orderBy: { last_name: "asc" },
|
||||
});
|
||||
return patients.map((p) => {
|
||||
return { id: p.id, name: p.last_name + ", " + p.first_name };
|
||||
return { id: p.id, name: p.first_name + " " + p.last_name };
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -73,13 +73,16 @@ export async function getPatients(
|
|||
`select p.id, p.first_name, p.last_name, p.created_at, p.age, p.hidden,
|
||||
r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`,
|
||||
`from patients p
|
||||
join rooms r on r.id = p.room_id
|
||||
join stations s on s.id = r.station_id`
|
||||
left join rooms r on r.id = p.room_id
|
||||
left join stations s on s.id = r.station_id`
|
||||
);
|
||||
|
||||
if (filter.search && filter.search.length > 0) {
|
||||
const qvar = qb.pvar();
|
||||
qb.addFilterClause(`p.full_name % ${qvar}`, filter.search);
|
||||
qb.addFilterClause(
|
||||
`(p.full_name % ${qvar} or p.full_name ilike '%' || ${qvar} || '%')`,
|
||||
filter.search
|
||||
);
|
||||
qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
|
||||
}
|
||||
|
||||
|
@ -134,14 +137,16 @@ export async function getPatients(
|
|||
age: patient.age,
|
||||
created_at: patient.created_at,
|
||||
hidden: patient.hidden,
|
||||
room: {
|
||||
id: patient.room_id,
|
||||
name: patient.room_name,
|
||||
station: {
|
||||
id: patient.station_id,
|
||||
name: patient.station_name,
|
||||
},
|
||||
},
|
||||
room: patient.room_id
|
||||
? {
|
||||
id: patient.room_id,
|
||||
name: patient.room_name,
|
||||
station: {
|
||||
id: patient.station_id,
|
||||
name: patient.station_name,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -3,15 +3,4 @@ import type { Context } from "./context";
|
|||
|
||||
export { trpcWrap } from "$lib/server/trpc/handleError";
|
||||
|
||||
export const t = initTRPC.context<Context>().create({
|
||||
errorFormatter(opts) {
|
||||
const { shape, error } = opts;
|
||||
console.log("formatted", error);
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
export const t = initTRPC.context<Context>().create();
|
||||
|
|
|
@ -3,12 +3,10 @@ import { z } from "zod";
|
|||
|
||||
import {
|
||||
ZEntityId,
|
||||
ZEntriesFilter,
|
||||
ZEntriesQuery,
|
||||
ZEntryExecutionNew,
|
||||
ZEntryNew,
|
||||
ZEntryVersionNew,
|
||||
ZPagination,
|
||||
ZSort,
|
||||
} from "$lib/shared/model/validation";
|
||||
import {
|
||||
getEntries,
|
||||
|
@ -25,15 +23,7 @@ export const entryRouter = t.router({
|
|||
.input(ZEntityId)
|
||||
.query(async (opts) => trpcWrap(async () => getEntry(opts.input))),
|
||||
list: t.procedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
filter: ZEntriesFilter,
|
||||
pagination: ZPagination,
|
||||
sort: ZSort,
|
||||
})
|
||||
.partial()
|
||||
)
|
||||
.input(ZEntriesQuery)
|
||||
.query(async (opts) =>
|
||||
trpcWrap(async () =>
|
||||
getEntries(
|
||||
|
|
|
@ -4,15 +4,10 @@ import {
|
|||
getPatientNames,
|
||||
getPatients,
|
||||
hidePatient,
|
||||
newPatient,
|
||||
updatePatient,
|
||||
} from "$lib/server/query";
|
||||
import {
|
||||
ZEntityId,
|
||||
ZPagination,
|
||||
ZPatientNew,
|
||||
ZPatientsFilter,
|
||||
ZSort,
|
||||
} from "$lib/shared/model/validation";
|
||||
import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
|
||||
import { t, trpcWrap } from "..";
|
||||
import { z } from "zod";
|
||||
|
||||
|
@ -21,19 +16,16 @@ export const patientRouter = t.router({
|
|||
get: t.procedure
|
||||
.input(ZEntityId)
|
||||
.query(async (opts) => trpcWrap(async () => getPatient(opts.input))),
|
||||
list: t.procedure
|
||||
.input(
|
||||
z
|
||||
.object({ filter: ZPatientsFilter, pagination: ZPagination, sort: ZSort })
|
||||
.partial()
|
||||
)
|
||||
.query(async (opts) => {
|
||||
return getPatients(
|
||||
opts.input.filter ?? {},
|
||||
opts.input.pagination ?? {},
|
||||
opts.input.sort ?? {}
|
||||
);
|
||||
}),
|
||||
list: t.procedure.input(ZPatientsQuery).query(async (opts) => {
|
||||
return getPatients(
|
||||
opts.input.filter ?? {},
|
||||
opts.input.pagination ?? {},
|
||||
opts.input.sort ?? {}
|
||||
);
|
||||
}),
|
||||
create: t.procedure
|
||||
.input(ZPatientNew)
|
||||
.mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))),
|
||||
update: t.procedure
|
||||
.input(z.object({ id: ZEntityId, patient: ZPatientNew.partial() }))
|
||||
.mutation(async (opts) =>
|
||||
|
|
|
@ -2,3 +2,6 @@ import type { Renderers } from "svelte-markdown";
|
|||
|
||||
export const PAGINATION_LIMIT = 20;
|
||||
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined };
|
||||
|
||||
export const URL_ENTRIES = "/plan";
|
||||
export const URL_PATIENTS = "/patients";
|
||||
|
|
|
@ -81,7 +81,7 @@ export type PatientNew = {
|
|||
first_name: string;
|
||||
last_name: string;
|
||||
age: Option<number>;
|
||||
room_id: number;
|
||||
room_id?: number | null;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
|
|
|
@ -46,7 +46,7 @@ export const ZPatientNew = implement<PatientNew>().with({
|
|||
first_name: ZNameString,
|
||||
last_name: ZNameString,
|
||||
age: z.number().int().nonnegative().lt(200).nullable(),
|
||||
room_id: ZEntityId,
|
||||
room_id: ZEntityId.optional().nullable(),
|
||||
});
|
||||
|
||||
export const ZEntryVersionNew = implement<EntryVersionNew>().with({
|
||||
|
|
|
@ -26,6 +26,7 @@ export function formatDate(date: Date | string, time = false): string {
|
|||
}
|
||||
|
||||
export function getQueryUrl(q: EntityQuery, basePath: string): string {
|
||||
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
|
||||
return basePath + "/" + JSON.stringify(q);
|
||||
}
|
||||
|
||||
|
@ -62,3 +63,34 @@ export async function loadWrap<T>(f: () => Promise<T>) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function authjsCsrfToken(f: typeof fetch): Promise<string> {
|
||||
const csrfResp = await f("/auth/csrf");
|
||||
const csrfJson = await csrfResp.json();
|
||||
return csrfJson.csrfToken;
|
||||
}
|
||||
|
||||
export class Debouncer {
|
||||
private delay: number;
|
||||
private handler: () => unknown;
|
||||
private timeout: number | null = null;
|
||||
|
||||
constructor(delay: number, handler: () => unknown) {
|
||||
this.delay = delay;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.timeout) window.clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
trigger() {
|
||||
this.clear();
|
||||
this.timeout = window.setTimeout(this.handler, this.delay);
|
||||
}
|
||||
|
||||
now() {
|
||||
this.clear();
|
||||
this.handler();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,13 +42,7 @@
|
|||
<li><a href="/rooms">Zimmer</a></li>
|
||||
<li><a href="/categories">Kategorien</a></li>
|
||||
<li><a href="/users">Benutzer</a></li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() =>
|
||||
signOut({ redirect: true, callbackUrl: "/login?noAuto=1" })}
|
||||
>Abmelden</button
|
||||
>
|
||||
</li>
|
||||
<li><a href="/logout">Abmelden</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
23
src/routes/(app)/logout/+page.svelte
Normal file
23
src/routes/(app)/logout/+page.svelte
Normal 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>
|
8
src/routes/(app)/logout/+page.ts
Normal file
8
src/routes/(app)/logout/+page.ts
Normal 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 };
|
||||
};
|
32
src/routes/(app)/patient/[id]/[[query]]/+page.server.ts
Normal file
32
src/routes/(app)/patient/[id]/[[query]]/+page.server.ts
Normal 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;
|
24
src/routes/(app)/patient/[id]/[[query]]/+page.svelte
Normal file
24
src/routes/(app)/patient/[id]/[[query]]/+page.svelte
Normal 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}
|
29
src/routes/(app)/patient/[id]/[[query]]/+page.ts
Normal file
29
src/routes/(app)/patient/[id]/[[query]]/+page.ts
Normal 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 };
|
||||
};
|
19
src/routes/(app)/patient/new/+page.server.ts
Normal file
19
src/routes/(app)/patient/new/+page.server.ts
Normal 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;
|
9
src/routes/(app)/patient/new/+page.svelte
Normal file
9
src/routes/(app)/patient/new/+page.svelte
Normal 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 />
|
|
@ -1,77 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import FilteredPatientTable from "$lib/components/table/FilteredPatientTable.svelte";
|
||||
import { URL_PATIENTS } from "$lib/shared/constants";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
|
||||
import FilterBar from "$lib/components/filter/FilterBar.svelte";
|
||||
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
|
||||
import type { FilterQdata } from "$lib/components/filter/types";
|
||||
import { trpc } from "$lib/shared/trpc";
|
||||
import { ENTRY_FILTERS, PATIENT_FILTER } from "$lib/components/filter/filters";
|
||||
import { getQueryUrl } from "$lib/shared/util";
|
||||
import PatientTable from "$lib/components/table/PatientTable.svelte";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let loading = false;
|
||||
|
||||
function paginationUpdate(pagination: PaginationRequest) {
|
||||
updateQuery({
|
||||
filter: data.query.filter,
|
||||
pagination,
|
||||
sort: data.query.sort,
|
||||
});
|
||||
}
|
||||
|
||||
function filterUpdate(filter: FilterQdata) {
|
||||
updateQuery({ filter, sort: data.query.sort });
|
||||
}
|
||||
|
||||
function sortUpdate(sort: SortRequest) {
|
||||
updateQuery({
|
||||
filter: data.query.filter,
|
||||
pagination: data.query.pagination,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
function updateQuery(query: typeof data.query) {
|
||||
if (browser) {
|
||||
// Update page URL (not using the Svelte Router here because
|
||||
// we do not need to reload the page)
|
||||
const url = getQueryUrl(query, "/patients");
|
||||
window.history.replaceState(null, "", url);
|
||||
|
||||
loading = true;
|
||||
trpc()
|
||||
.patient.list.query(query)
|
||||
.then((patients) => {
|
||||
data.patients = patients;
|
||||
data.query = query;
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Patienten</title>
|
||||
</svelte:head>
|
||||
|
||||
<FilterBar
|
||||
FILTERS={PATIENT_FILTER}
|
||||
filterData={data.query.filter}
|
||||
onUpdate={filterUpdate}
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
|
||||
<PatientTable patients={data.patients} sortData={data.query.sort} {sortUpdate} />
|
||||
|
||||
<PaginationButtons
|
||||
paginationData={data.query.pagination}
|
||||
data={data.patients}
|
||||
onUpdate={paginationUpdate}
|
||||
/>
|
||||
<FilteredPatientTable
|
||||
baseUrl={URL_PATIENTS}
|
||||
patients={data.patients}
|
||||
query={data.query}
|
||||
>
|
||||
<a class="btn btn-sm btn-primary" href="/patient/new">Neuer Patient</a>
|
||||
</FilteredPatientTable>
|
||||
|
|
|
@ -1,90 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import type { PageData } from "../$types";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
import EntryTable from "$lib/components/table/EntryTable.svelte";
|
||||
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
|
||||
import FilterBar from "$lib/components/filter/FilterBar.svelte";
|
||||
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
|
||||
import type { FilterQdata } from "$lib/components/filter/types";
|
||||
import { trpc } from "$lib/shared/trpc";
|
||||
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
|
||||
import { getQueryUrl } from "$lib/shared/util";
|
||||
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
|
||||
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
|
||||
import FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte";
|
||||
import { URL_ENTRIES } from "$lib/shared/constants";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let loadingBar: LoadingBar | undefined;
|
||||
let error: Error | null = null;
|
||||
|
||||
function paginationUpdate(pagination: PaginationRequest) {
|
||||
updateQuery({
|
||||
filter: data.query.filter,
|
||||
pagination,
|
||||
sort: data.query.sort,
|
||||
});
|
||||
}
|
||||
|
||||
function filterUpdate(filter: FilterQdata) {
|
||||
updateQuery({ filter, sort: data.query.sort });
|
||||
}
|
||||
|
||||
function sortUpdate(sort: SortRequest) {
|
||||
updateQuery({
|
||||
filter: data.query.filter,
|
||||
pagination: data.query.pagination,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
function updateQuery(query: typeof data.query) {
|
||||
if (browser) {
|
||||
// Update page URL (not using the Svelte Router here because
|
||||
// we do not need to reload the page)
|
||||
const url = getQueryUrl(query, "/plan");
|
||||
window.history.replaceState(null, "", url);
|
||||
|
||||
loadingBar?.start();
|
||||
trpc()
|
||||
.entry.list.query(query)
|
||||
.then((entries) => {
|
||||
data.entries = entries;
|
||||
error = null;
|
||||
loadingBar?.reset();
|
||||
})
|
||||
.catch((err) => {
|
||||
data.query = query;
|
||||
error = err;
|
||||
loadingBar?.error();
|
||||
})
|
||||
.finally(() => {
|
||||
data.query = query;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Planung</title>
|
||||
</svelte:head>
|
||||
|
||||
<FilterBar
|
||||
FILTERS={ENTRY_FILTERS}
|
||||
filterData={data.query.filter}
|
||||
onUpdate={filterUpdate}
|
||||
/>
|
||||
|
||||
<LoadingBar bind:this={loadingBar} alwaysShown />
|
||||
|
||||
{#if error}
|
||||
<ErrorMessage {error} />
|
||||
{:else}
|
||||
<EntryTable entries={data.entries} sortData={data.query.sort} {sortUpdate} />
|
||||
|
||||
<PaginationButtons
|
||||
paginationData={data.query.pagination}
|
||||
data={data.entries}
|
||||
onUpdate={paginationUpdate}
|
||||
/>
|
||||
{/if}
|
||||
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} />
|
||||
|
|
|
@ -1,19 +1,13 @@
|
|||
<script lang="ts">
|
||||
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
|
||||
|
||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||
import { navigating } from "$app/stores";
|
||||
|
||||
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
|
||||
|
||||
let loadingBar: LoadingBar | undefined;
|
||||
|
||||
beforeNavigate(() => {
|
||||
if (loadingBar) loadingBar.start();
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
if (loadingBar) loadingBar.reset();
|
||||
});
|
||||
$: $navigating ? loadingBar?.start() : loadingBar?.reset();
|
||||
</script>
|
||||
|
||||
<LoadingBar cls="fixed top-0 left-0 z-50" bind:this={loadingBar} />
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { PageData } from "./$types";
|
||||
import { page } from "$app/stores";
|
||||
import { signIn } from "@auth/sveltekit/client";
|
||||
import { onMount } from "svelte";
|
||||
import { signIn } from "@auth/sveltekit/client";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let callbackUrl = $page.url.searchParams.get("returnURL") ?? "/";
|
||||
export let data: PageData;
|
||||
|
||||
let callbackUrl: string;
|
||||
$: if ($page.url) {
|
||||
let u = new URL($page.url.origin);
|
||||
u.pathname = $page.url.searchParams.get("returnURL") ?? "/";
|
||||
callbackUrl = u.toString();
|
||||
}
|
||||
|
||||
// Client side auto-login / redirect
|
||||
onMount(() => {
|
||||
if (!$page.url.searchParams.get("noAuto")) {
|
||||
if (!$page.data.session?.user) {
|
||||
|
@ -19,8 +28,9 @@
|
|||
|
||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2 text-center">
|
||||
<h1 class="text-4xl mt-4">Visitenbuch</h1>
|
||||
<button
|
||||
class="btn btn-primary mt-4"
|
||||
on:click={() => signIn("keycloak", { callbackUrl })}>Anmelden</button
|
||||
>
|
||||
<form action="/auth/signin/keycloak" method="POST">
|
||||
<input type="hidden" name="csrfToken" value={data.csrfToken} />
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||
<button class="btn btn-primary mt-4" type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
8
src/routes/login/+page.ts
Normal file
8
src/routes/login/+page.ts
Normal 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 };
|
||||
};
|
Loading…
Reference in a new issue