Compare commits

...

6 commits

45 changed files with 1226 additions and 92 deletions

View file

@ -19,8 +19,10 @@
"dependencies": {
"@auth/core": "^0.18.6",
"@auth/sveltekit": "^0.5.3",
"@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.8.1",
"svelte-floating-ui": "^1.5.8",
"zod": "^3.22.4"
},
"devDependencies": {

View file

@ -11,12 +11,18 @@ dependencies:
'@auth/sveltekit':
specifier: ^0.5.3
version: 0.5.3(@sveltejs/kit@2.5.0)(svelte@4.2.9)
'@floating-ui/core':
specifier: ^1.6.0
version: 1.6.0
'@mdi/js':
specifier: ^7.4.47
version: 7.4.47
'@prisma/client':
specifier: ^5.8.1
version: 5.8.1(prisma@5.8.1)
svelte-floating-ui:
specifier: ^1.5.8
version: 1.5.8
zod:
specifier: ^3.22.4
version: 3.22.4
@ -417,6 +423,23 @@ packages:
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'}
dev: true
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/dom@1.6.0:
resolution: {integrity: sha512-SZ0BEXzsaaS6THZfZJUcAobbZTD+MvfGM42bxgeg0Tnkp4/an/avqwAXiVLsFtIBZtfsx3Ymvwx0+KnnhdA/9g==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/utils@0.2.1:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
/@humanwhocodes/config-array@0.11.14:
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@ -2834,6 +2857,13 @@ packages:
svelte: 4.2.9
dev: true
/svelte-floating-ui@1.5.8:
resolution: {integrity: sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/dom': 1.6.0
dev: false
/svelte-hmr@0.15.3(svelte@4.2.9):
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}

View file

@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "entry_executions_entry_id_idx" ON "entry_executions"("entry_id");
-- CreateIndex
CREATE INDEX "entry_versions_entry_id_idx" ON "entry_versions"("entry_id");

View file

@ -80,8 +80,8 @@ model Patient {
full_name String? @default(dbgenerated("((first_name || ' '::text) || last_name)")) @ignore
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
@@map("patients")
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
}
// Entry category (e.g. Blood test, Exams, ...)
@ -128,6 +128,7 @@ model EntryVersion {
created_at DateTime @default(now())
@@map("entry_versions")
@@index([entry_id])
}
model EntryExecution {
@ -143,4 +144,5 @@ model EntryExecution {
created_at DateTime @default(now())
@@map("entry_executions")
@@index([entry_id])
}

7
src/app.d.ts vendored
View file

@ -7,6 +7,13 @@ declare global {
// interface PageData {}
// interface Platform {}
}
declare namespace svelteHTML {
// Custom events (https://stackoverflow.com/a/75279911)
interface HTMLAttributes<T> {
"on:outclick"?: CompositionEventHandler<T>;
}
}
}
export {};

View file

@ -2,3 +2,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View file

@ -17,9 +17,11 @@ import { router } from "$lib/server/trpc/router";
/**
* Protect the application against unauthorized access.
* If the user is not logged in, all requests get redirected to the login page
* with the exception of the login page and the TRPC API (which has its own
* auth mechanism)
*/
const authorization: Handle = async ({ event, resolve }) => {
if (event.url.pathname !== "/login") {
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
const session = await event.locals.getSession();
if (!session) {
const params = new URLSearchParams({ returnURL: event.url.pathname });
@ -44,6 +46,25 @@ export const handle = sequence(
session: {
strategy: "jwt",
},
callbacks: {
// Add user ID to session token
jwt({ token, account, user }) {
if (account) {
token.accessToken = account.access_token;
token.id = user?.id;
}
return token;
},
session(opt) {
// @ts-expect-error because of union type
if (opt.session.user && opt.token.id) {
// @ts-expect-error because of union type
opt.session.user.id = opt.token.id;
}
return opt.session;
},
},
}),
authorization,
createTRPCHandle({ router, createContext })

View file

@ -0,0 +1,17 @@
export default function clickOutside(node: Element) {
const handleClick = (event: MouseEvent) => {
const tnode = event.target as Element;
if (!node.contains(tnode)) {
node.dispatchEvent(new CustomEvent("outclick"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}

View file

@ -0,0 +1,262 @@
<script lang="ts">
/**
* This is a simplified version of simple-svelte-autocomplete
* Original source: https://github.com/pstanoev/simple-svelte-autocomplete
* Author: pstaneov
* MIT License
*/
import { browser } from "$app/environment";
import { createFloatingActions } from "svelte-floating-ui";
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";
type OnSelectResult = { newValue: string; close: boolean };
export let items: BaseItem[] | (() => Promise<BaseItem[]>);
export let defaultSelection: 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;
/** 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 };
};
export let onClose = (kb: boolean) => {};
export let onBackspace = () => {};
let opened = false;
let highlightIndex = 0;
let isLoading = false;
let srcItems: BaseItem[];
let filteredItems: BaseItem[] = [];
$: if (hiddenIds) updateSearch();
// HTML elements
let inputElm: HTMLInputElement | undefined;
function inputValue(): string {
if (inputElm) return inputElm.value;
return "";
}
function setInputValue(v: string) {
if (inputElm) inputElm.value = v;
}
/** If necessary, fetch the autocompletion items and store them in the cache */
function loadSrcItems(): boolean {
if (isLoading) return false;
if (!srcItems) {
if (Array.isArray(items)) srcItems = items;
else if (cacheKey && cache[cacheKey]) {
srcItems = cache[cacheKey];
} else if (!browser) {
return false;
} else {
isLoading = true;
items().then((items) => {
srcItems = items;
if (cacheKey) cache[cacheKey] = items;
isLoading = false;
onInput();
});
return false;
}
}
return true;
}
function selectDefault() {
if (defaultSelection) {
if (defaultSelection.id) {
const i = srcItems.findIndex((itm) => itm.id === defaultSelection?.id);
if (i !== -1) {
highlightIndex = i;
}
} else {
setInputValue(defaultSelection.name || "");
highlightIndex = 0;
}
} else {
highlightIndex = 0;
}
}
function onInput() {
updateSearch();
opened = true;
}
function updateSearch() {
if (loadSrcItems()) {
let searchWord = inputValue().toLowerCase().trim();
filteredItems =
searchWord.length > 0
? srcItems.filter(
(it) =>
!hiddenIds.has(it.id) && it.name.toLowerCase().includes(searchWord)
)
: srcItems.filter((it) => !hiddenIds.has(it.id));
selectDefault();
}
}
export function open() {
if (!opened) {
updateSearch();
}
opened = true;
if (inputElm) inputElm.focus();
}
export function close(kb: boolean) {
if (opened) {
onClose(kb);
}
opened = false;
}
function selectListItem(item: BaseItem | undefined, kb: boolean) {
const selRes = onSelect(item ?? { name: inputValue() }, kb);
setInputValue(selRes.newValue);
if (selRes.close) {
close(kb);
} else {
updateSearch();
}
}
function onKeyDown(e: KeyboardEvent) {
let key = e.key;
if (key === "Tab" && e.shiftKey) key = "ShiftTab";
const fnmap = {
Tab: close,
ShiftTab: close,
ArrowDown: () => {
open();
if (highlightIndex < filteredItems.length - 1) {
highlightIndex++;
}
},
ArrowUp: () => {
open();
if (highlightIndex > 0) {
highlightIndex--;
}
},
Escape: () => {
e.stopPropagation();
if (opened) {
if (inputElm) inputElm.focus();
close(true);
}
},
Backspace: () => {
if (inputValue().length === 0) {
onBackspace();
}
},
};
// @ts-expect-error unknown map type
const fn = fnmap[key];
if (typeof fn === "function") {
fn(e);
}
}
function onKeyPress(e: KeyboardEvent) {
if (e.key === "Enter") {
if (opened) {
e.preventDefault();
selectItem();
}
}
}
function selectItem() {
const listItem = filteredItems[highlightIndex];
selectListItem(listItem, true);
}
const [floatingRef, floatingContent] = createFloatingActions({
strategy: "absolute",
placement: "bottom-start",
middleware: [shift()],
});
</script>
<div class="flex-grow" use:outclick on:outclick={close}>
<input
class="w-full bg-transparent"
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}
use:floatingRef
/>
{#if opened && filteredItems.length > 0}
<div class="autocomplete-list" use:floatingContent>
{#each filteredItems as item, i}
<div
role="option"
class="autocomplete-list-item"
class:selected={i === highlightIndex}
aria-selected={i === highlightIndex}
tabindex="-1"
on:click={() => selectListItem(item, false)}
on:keypress={(e) => {
e.key == "Enter" && selectListItem(item, true);
}}
on:pointerenter={() => {
highlightIndex = i;
}}
>
{#if item.icon}
<Icon size={1.2} path={item.icon} />
{/if}
{item.name}
</div>
{/each}
</div>
{/if}
</div>
<style lang="postcss">
.autocomplete-list {
width: 280px;
overflow-y: auto;
z-index: 99;
top: 0px;
max-height: calc(15 * (1rem + 10px) + 15px);
user-select: none;
@apply bg-neutral text-neutral-content rounded-btn p-2;
}
.autocomplete-list:empty {
padding: 0;
}
.autocomplete-list-item {
cursor: pointer;
line-height: 1;
@apply rounded-btn px-4 py-2 transition duration-200 ease-out;
}
.autocomplete-list-item.selected {
@apply bg-primary text-primary-content;
}
</style>

View file

@ -0,0 +1,198 @@
<script lang="ts">
import { mdiClose } from "@mdi/js";
import EntryFilterChip from "./FilterChip.svelte";
import Autocomplete from "./Autocomplete.svelte";
import type {
FilterDef,
FilterQdata,
FilterData,
BaseItem,
SelectionOrText,
} from "./types";
import { isFilterValueless } from "./types";
import Icon from "$lib/components/ui/Icon.svelte";
export let FILTERS: { [key: string]: FilterDef };
export let filterData: FilterQdata | null | undefined = undefined;
export let onUpdate: (filterData: FilterQdata) => void = () => {};
let autocomplete: Autocomplete | undefined;
let activeFilters: FilterData[] = [];
let cache: { [key: string]: BaseItem[] } = {};
// Filter items to be displayed in the autocomplete menu
let filterMenuItems: BaseItem[];
$: filterMenuItems = Object.values(FILTERS).flatMap((f) => {
if (f.toggleOff) {
return [
f,
{ id: f.id, name: f.toggleOff.name, icon: f.toggleOff.icon, toggle: false },
];
}
return [f];
});
// Filter menu items to be hidden
$: hiddenIds = new Set(
Object.values(FILTERS).flatMap((f) =>
f.inputType === 2 || activeFilters.every((af) => af.id !== f.id) ? [] : [f.id]
)
);
// Load query data if present
if (filterData) {
activeFilters = parseFilterQdata(filterData);
}
function parseFilterQdata(filterData: FilterQdata): FilterData[] {
const filters: FilterData[] = [];
for (const [id, value] of Object.entries(filterData)) {
if (Array.isArray(value)) {
value.forEach((v) => {
filters.push({
id,
selection: { id: v.id, name: v.name },
editing: false,
});
});
} else {
let selection: SelectionOrText = {};
if (typeof value === "string") selection.name = value;
else if (typeof value === "boolean") selection.toggle = value;
else selection.id = value;
filters.push({ id, selection, editing: false });
}
}
return filters;
}
function getHiddenIds(fid: string, fpos: number): Set<string | number> {
if (FILTERS[fid].inputType === 2) {
return new Set(
activeFilters.flatMap((f, i) => {
return i !== fpos && f.selection?.id ? [f.selection?.id] : [];
})
);
} else {
return new Set();
}
}
function focusInput() {
if (autocomplete) autocomplete.open();
}
function getFilterQdata(): FilterQdata {
let fd: FilterQdata = {};
activeFilters.forEach((fdata) => {
const filter = FILTERS[fdata.id];
const key = filter.id;
let val = null;
// Valueless filter (val = true)
if (filter.inputType === 0) {
val = true;
}
// Text input
else if (filter.inputType === 1) {
val = fdata.selection?.name;
}
// Filter list
else if (filter.inputType === 2 && fdata.selection) {
//@ts-expect-error TODO
val = { id: fdata.selection.id, name: fdata.selection.name };
} else if (filter.inputType === 3) {
val = Boolean(fdata.selection?.toggle);
}
if (val !== null && val !== undefined) {
if (filter.inputType === 2) {
//@ts-expect-error fd[key] is checked
if (Array.isArray(fd[key])) fd[key].push(val);
//@ts-expect-error fd[key] is checked
else fd[key] = [val];
} else {
fd[key] = val;
}
}
});
return fd;
}
function addFilter(item: SelectionOrText): boolean {
if (!item.id) return false;
const valueless = isFilterValueless(FILTERS[item.id].inputType);
let selection = null;
if (FILTERS[item.id].inputType === 3) {
selection = { toggle: item.toggle ?? true };
}
activeFilters.push({ id: item.id.toString(), selection, editing: !valueless });
activeFilters = activeFilters;
if (valueless) updateFilter();
// Returns true if the main autocomplete should be closed
return !valueless;
}
function removeFilter(i: number) {
const shouldUpdate =
isFilterValueless(FILTERS[activeFilters[i].id].inputType) ||
activeFilters[i].selection;
activeFilters.splice(i, 1);
activeFilters = activeFilters;
if (shouldUpdate) updateFilter();
}
function updateFilter() {
onUpdate(getFilterQdata());
}
</script>
<div class="flex flex-col 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"
>
{#each activeFilters as fdata, i}
<EntryFilterChip
filter={FILTERS[fdata.id]}
{fdata}
hiddenIds={() => getHiddenIds(fdata.id, i)}
{cache}
onRemove={() => removeFilter(i)}
onSelection={(sel, kb) => {
updateFilter();
if (kb) focusInput();
}}
/>
{/each}
<Autocomplete
bind:this={autocomplete}
items={filterMenuItems}
{hiddenIds}
placeholder="Filter"
onSelect={(item) => {
const close = addFilter(item);
return { newValue: "", close };
}}
onBackspace={() => {
activeFilters.pop();
activeFilters = activeFilters;
updateFilter();
}}
/>
<button
class="btn btn-sm btn-circle btn-ghost"
aria-label="Alle Filter entfernen"
on:click={() => {
activeFilters = [];
updateFilter();
}}
>
<Icon size={1.2} path={mdiClose} />
</button>
</div>
</div>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { mdiClose } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte";
import type { BaseItem, FilterData, FilterDef, SelectionOrText } from "./types";
import Autocomplete from "./Autocomplete.svelte";
export let filter: FilterDef;
export let hiddenIds: () => Set<string | number> = () => new Set();
export let cache: { [key: string]: BaseItem[] } = {};
export let fdata: FilterData;
export let onRemove = () => {};
export let onSelection = (selection: SelectionOrText, kb: boolean) => {};
let autocomplete: Autocomplete | undefined;
function startEditing() {
fdata.editing = true;
}
function stopEditing(kb = false) {
fdata.editing = false;
if (fdata.selection) onSelection(fdata.selection, kb);
}
function onClose(kb = false) {
if (fdata.selection) stopEditing(kb);
else onRemove();
}
$: if (fdata.editing && autocomplete) {
autocomplete.open();
}
$: toggleState = fdata.selection?.toggle !== false;
$: filterName = toggleState ? filter.name : filter.toggleOff?.name;
$: filterIcon = toggleState ? filter.icon : filter.toggleOff?.icon;
$: hasInputField = filter.inputType !== 0 && filter.inputType !== 3;
</script>
<div
class="flex items-center overflow-hidden rounded-md bg-primary text-primary-content
gap-1 pl-1"
>
<button
class="flex items-center gap-1"
disabled={filter.inputType !== 3}
on:click={() => {
if (fdata.selection) {
fdata.selection.toggle = !fdata.selection.toggle;
} else {
fdata.selection = { toggle: false };
}
onSelection(fdata.selection, false);
}}
>
{#if filterIcon}
<Icon size={1.2} path={filterIcon} />
{/if}
<span class="flex items-center">
{filterName + (hasInputField ? ":" : "")}
</span>
</button>
{#if hasInputField}
{#if fdata.editing}
{#if filter.inputType === 2}
{@const hids = hiddenIds()}
<Autocomplete
bind:this={autocomplete}
items={filter.options || []}
hiddenIds={hids}
{cache}
cacheKey={filter.id}
defaultSelection={fdata.selection}
padding={false}
onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant
if (filter.inputType !== 2 || item.id) {
fdata.selection = item;
return { close: true, newValue: "" };
} else {
return { close: false, newValue: item.name || "" };
}
}}
{onClose}
onBackspace={onRemove}
/>
{:else}
<!-- svelte-ignore a11y-autofocus -->
<input
class="bg-transparent"
type="text"
autofocus
value={fdata.selection?.name ?? ""}
on:keydown={(e) => {
if (e.key === "Escape") onClose(true);
}}
on:keypress={(e) => {
if (e.key === "Enter") {
// @ts-expect-error Input value is checked
if (e.target?.value) {
// @ts-expect-error Input value is checked
fdata.selection = { id: null, name: e.target.value };
}
stopEditing(true);
}
}}
/>
{/if}
{:else}
<button
class="hover:underline focus:underline"
aria-label={`Filter "${filterName}" bearbeiten`}
on:click={startEditing}>{fdata.selection?.name}</button
>
{/if}
{/if}
<button
class=" border-l border-primary-content/10 px-1 flex h-full items-center
hover:bg-error/80 active:bg-error focus:bg-error/80"
aria-label={`Filter "${filterName}" entfernen"`}
on:click={onRemove}
>
<Icon size={1} path={mdiClose} />
</button>
</div>

View file

@ -0,0 +1,93 @@
import { trpc } from "$lib/shared/trpc";
import {
mdiAccount,
mdiAccountInjury,
mdiBedKingOutline,
mdiCheckboxBlankOutline,
mdiCheckboxOutline,
mdiDoctor,
mdiDomain,
mdiExclamation,
mdiMagnify,
mdiTag,
} from "@mdi/js";
import type { FilterDef } from "./types";
export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
category: {
id: "category",
name: "Kategorie",
icon: mdiTag,
inputType: 2,
options: async () => {
return await trpc().category.list.query();
},
},
author: {
id: "author",
name: "Autor",
icon: mdiAccount,
inputType: 2,
options: async () => {
return await trpc().user.getNames.query();
},
},
executor: {
id: "executor",
name: "Erledigt von",
icon: mdiDoctor,
inputType: 2,
options: async () => {
return await trpc().user.getNames.query();
},
},
patient: {
id: "patient",
name: "Patient",
icon: mdiAccountInjury,
inputType: 2,
options: async () => {
return await trpc().patient.getNames.query();
},
},
station: {
id: "station",
name: "Station",
icon: mdiDomain,
inputType: 2,
options: async () => {
return await trpc().station.list.query();
},
},
room: {
id: "room",
name: "Zimmer",
icon: mdiBedKingOutline,
inputType: 2,
options: async () => {
return await trpc().room.list.query();
},
},
done: {
id: "done",
name: "Erledigt",
icon: mdiCheckboxOutline,
inputType: 3,
toggleOff: {
name: "Zu erledigen",
icon: mdiCheckboxBlankOutline,
},
},
priority: {
id: "priority",
name: "Priorität",
icon: mdiExclamation,
inputType: 0,
},
search: {
id: "search",
name: "Beschreibung",
icon: mdiMagnify,
inputType: 1,
},
};

View file

@ -0,0 +1,39 @@
export type BaseItem = {
id: string | number;
name: string;
icon?: string;
toggle?: boolean;
};
export type SelectionOrText = {
id?: string | number;
name?: string;
toggle?: boolean;
};
export type FilterDef = {
id: string;
name: string;
icon?: string;
// 0: No input (value: true); 1: Free text input; 2: Filter list; 3: Boolean switch
inputType: 0 | 1 | 2 | 3;
toggleOff?: {
name: string;
icon?: string;
};
options?: () => Promise<BaseItem[]>;
};
export type FilterData = {
id: string;
selection: SelectionOrText | null;
editing: boolean;
};
export type FilterQdata = {
[key: string]: string | number | boolean | { id: string | number; name?: string }[];
};
export function isFilterValueless(inputType: 0 | 1 | 2 | 3): boolean {
return inputType === 0 || inputType === 3;
}

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate } from "$lib/shared/util";
import PatientField from "./PatientField.svelte";
import RoomField from "./RoomField.svelte";
export let entries: RouterOutput["entry"]["list"];
</script>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Patient</th>
<th>Zimmer</th>
<th>Kategorie</th>
<th>Erstellt am</th>
<th>Zu erledigen am</th>
<th>Autor</th>
<th>Beschreibung</th>
<th>Erledigt</th>
</tr>
</thead>
<tbody>
{#each entries.items as entry}
<tr>
<td><PatientField patient={entry.patient} /></td>
<td
>{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</td
>
<td>{entry.current_version.category?.name}</td>
<td>{formatDate(entry.current_version.created_at, true)}</td>
<td>{formatDate(entry.current_version.date)}</td>
<td>{entry.current_version.author.name}</td>
<td><span class="line-clamp-1">{entry.current_version.text}</span></td>
<td>
{#if entry.execution}
{formatDate(entry.execution.created_at, true)}
{entry.execution.author.name}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { Patient } from "$lib/shared/model";
export let patient: Pick<Patient, "first_name" | "last_name" | "age">;
</script>
{`${patient.first_name} ${patient.last_name} (${patient.age})`}

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { Room } from "$lib/shared/model";
export let room: Room;
</script>
{`${room.name} (${room.station.name})`}

View file

@ -7,7 +7,7 @@
<div>
<a
class="btn btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
class="btn btn-sm btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
class:underline={$page.route.id === route}
{href}><slot /></a
>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { mdiChevronLeft, mdiChevronRight, mdiPageFirst, mdiPageLast } from "@mdi/js";
import type { Pagination, PaginationRequest } from "$lib/shared/model";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import Icon from "./Icon.svelte";
export let paginationData: PaginationRequest | null | undefined = undefined;
export let data: Pagination<unknown>;
export let onUpdate: (pagination: PaginationRequest) => void = () => {};
$: limit = paginationData?.limit || PAGINATION_LIMIT;
$: thisPage = Math.floor(data.offset / limit) + 1; // current page number (starting from 1)
$: nPages = Math.ceil(data.total / limit);
let windowBottom: number;
let windowTop: number;
$: if (nPages <= 10) {
windowBottom = 1;
windowTop = nPages;
} else if (thisPage <= 6) {
windowBottom = 1;
windowTop = 10;
} else if (thisPage + 4 >= nPages) {
windowBottom = nPages - 9;
windowTop = nPages;
} else {
windowBottom = thisPage - 5;
windowTop = thisPage + 4;
}
function getPaginationRequest(page: number): PaginationRequest | null {
if (page < 1 || page > nPages) return null;
let pag = paginationData ? structuredClone(paginationData) : {};
pag.offset = (page - 1) * limit;
return pag;
}
function pagClick(page: number) {
let pag = getPaginationRequest(page);
if (pag) onUpdate(pag);
}
</script>
<div class="flex flex-col gap-2 items-center">
<p class="text-sm">
{data.offset + 1}-{data.offset + data.items.length} von {data.total}
</p>
<div class="join">
<button class="join-item btn btn-sm" on:click={() => pagClick(1)}>
<Icon path={mdiPageFirst} />
</button>
<button class="join-item btn btn-sm" on:click={() => pagClick(thisPage - 1)}
><Icon path={mdiChevronLeft} /></button
>
{#each { length: windowTop + 1 - windowBottom } as _, i}
{@const n = windowBottom + i}
<button
class="join-item btn btn-sm"
class:btn-primary={n === thisPage}
on:click={() => pagClick(n)}>{n}</button
>
{/each}
<button class="join-item btn btn-sm" on:click={() => pagClick(thisPage + 1)}
><Icon path={mdiChevronRight} /></button
>
<button class="join-item btn btn-sm" on:click={() => pagClick(nPages)}
><Icon path={mdiPageLast} /></button
>
</div>
</div>

View file

@ -43,7 +43,7 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
})
),
getUser: async (id) =>
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })),
mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })),
getUserByEmail: async (email) =>
mapUserOpt(await p.user.findUnique({ where: { email } })),
async getUserByAccount(provider_providerAccountId) {
@ -54,14 +54,14 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
return mapUserOpt(account?.user) ?? null;
},
updateUser: async ({ id, ...data }) =>
mapUser(await p.user.update({ where: { id: parseInt(id) }, data })),
mapUser(await p.user.update({ where: { id: Number(id) }, data })),
deleteUser: async (id) =>
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
mapUser(await p.user.delete({ where: { id: Number(id) } })),
linkAccount: async (data) =>
mapAccount(
await p.account.create({
data: {
user_id: parseInt(data.userId),
user_id: Number(data.userId),
type: data.type,
provider: data.provider,
providerAccountId: data.providerAccountId,

View file

@ -12,7 +12,7 @@ import type {
} from "$lib/shared/model";
import { ErrorConflict } from "$lib/shared/util/error";
import { mapEntry, mapVersion, mapExecution } from "./mapping";
import { QueryBuilder, parseSearchQuery } from "./util";
import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util";
const USER_SELECT = { select: { id: true, name: true } };
@ -236,12 +236,9 @@ join stations s on s.id = r.station_id`
qb.addFilter("ev.priority", filter?.priority);
if (filter?.author) {
let author = filter?.author;
if (!Array.isArray(author)) {
author = [author];
}
const author = filterListToArray(filter?.author);
qb.addFilterClause(
`${qb.pvar()}::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
`(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
author
);
}

View file

@ -6,6 +6,7 @@ import type {
Room,
EntryVersion,
EntryExecution,
UserTagNameNonnull,
} from "$lib/shared/model";
import { ErrorNotFound } from "$lib/shared/util/error";
import type {
@ -54,10 +55,14 @@ export function mapUser(user: DbUser): User {
return { id: user.id, name: user.name, email: user.email };
}
export function mapUserTag(user: DbUser): UserTag {
export function mapUserTag(user: Omit<DbUser, "email">): UserTag {
return { id: user.id, name: user.name };
}
export function mapUserTagNameNonnull(user: Omit<DbUser, "email">): UserTagNameNonnull {
return { id: user.id, name: user.name || "" };
}
export function mapRoom(room: DbRoomLn): Room {
return { id: room.id, name: room.name, station: room.station };
}

View file

@ -4,6 +4,7 @@ import type {
Pagination,
PaginationRequest,
PatientsFilter,
PatientTag,
} from "$lib/shared/model";
import { prisma } from "$lib/server/prisma";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
@ -51,6 +52,17 @@ export async function getPatient(id: number): Promise<Patient> {
return mapPatient(patient);
}
export async function getPatientNames(): Promise<PatientTag[]> {
const patients = await prisma.patient.findMany({
select: { id: true, first_name: true, last_name: true },
where: { hidden: false },
orderBy: { last_name: "asc" },
});
return patients.map((p) => {
return { id: p.id, name: p.last_name + ", " + p.first_name };
});
}
export async function getPatients(
filter: PatientsFilter,
pagination: PaginationRequest

View file

@ -51,7 +51,7 @@ export async function getRoom(id: number): Promise<Room> {
export async function getRooms(): Promise<Room[]> {
const rooms = await prisma.room.findMany({
include: { station: true },
orderBy: { name: "asc" },
orderBy: [{ station: { name: "asc" } }, { name: "asc" }],
});
return rooms.map(mapRoom);
}

View file

@ -1,6 +1,11 @@
import type { Pagination, PaginationRequest, User, UserTag } from "$lib/shared/model";
import type {
Pagination,
PaginationRequest,
User,
UserTagNameNonnull,
} from "$lib/shared/model";
import { prisma } from "$lib/server/prisma";
import { mapUser, mapUserTag } from "./mapping";
import { mapUser, mapUserTagNameNonnull } from "./mapping";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
export async function getUser(id: number): Promise<User> {
@ -10,7 +15,7 @@ export async function getUser(id: number): Promise<User> {
export async function getUsers(
pagination: PaginationRequest
): Promise<Pagination<UserTag>> {
): Promise<Pagination<User>> {
const offset = pagination.offset || 0;
const [users, total] = await Promise.all([
prisma.user.findMany({
@ -21,8 +26,17 @@ export async function getUsers(
prisma.user.count(),
]);
return {
items: users.map(mapUserTag),
items: users.map(mapUser),
offset,
total,
};
}
export async function getUserNames(): Promise<UserTagNameNonnull[]> {
const users = await prisma.user.findMany({
select: { id: true, name: true },
where: { name: { not: null } },
orderBy: { id: "asc" },
});
return users.map(mapUserTagNameNonnull);
}

View file

@ -9,7 +9,7 @@ test("query builder", () => {
const query = qb.getQuery();
expect(query).toBe(
"select e.id, e.text, e.category from entries e where category in $1 and text = $2 limit $3 offset $4"
"select e.id, e.text, e.category from entries e where category = any ($1) and text = $2 limit $3 offset $4"
);
const params = qb.getParams();

View file

@ -1,24 +1,27 @@
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { FilterList, PaginationRequest } from "$lib/shared/model";
export function convertFilterList<T>(
fl: FilterList<T> | undefined
): { in: T[] } | T | undefined {
if (!fl) {
return undefined;
} else if (Array.isArray(fl)) {
return { in: fl };
} else {
return fl;
}
}
enum QueryComponentType {
Normal = 1,
Exact,
Trailing,
}
export function filterListToArray<T>(fl: FilterList<T>): T[] {
if (Array.isArray(fl)) {
// @ts-expect-error checked if id is present
if (fl[0].id) {
// @ts-expect-error checked if id is present
return fl.map((itm) => itm.id);
} else {
// @ts-expect-error output type checked
return fl;
}
} else {
return [fl];
}
}
class SearchQueryComponent {
word: string;
typ: QueryComponentType;
@ -149,12 +152,8 @@ export class QueryBuilder {
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
if (fl === undefined) return;
this.params.push(fl);
if (Array.isArray(fl)) {
this.filterClauses.push(`${fname} in ${this.pvar()}`);
} else {
this.filterClauses.push(`${fname} = ${this.pvar()}`);
}
this.filterClauses.push(`${fname} = any (${this.pvar()})`);
this.params.push(filterListToArray(fl));
}
getQuery(): string {

View file

@ -9,7 +9,7 @@ export async function createContext(event: RequestEvent) {
const session = await event.locals.getSession();
if (!session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "no session" });
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
}
const user = ZUser.parse(session?.user);

View file

@ -2,13 +2,21 @@ import { t } from ".";
import { categoryRouter } from "./routes/category";
import { entryRouter } from "./routes/entry";
import { stationRouter } from "./routes/station";
import { roomRouter } from "./routes/room";
import { patientRouter } from "./routes/patient";
import { userRouter } from "./routes/user";
export const router = t.router({
greeting: t.procedure.query(async () => {
return `Hello tRPC v10 @ ${new Date().toLocaleTimeString()}`;
return `Hello tRPC @ ${new Date().toLocaleTimeString()}`;
}),
category: categoryRouter,
entry: entryRouter,
station: stationRouter,
room: roomRouter,
patient: patientRouter,
user: userRouter,
});
export type Router = typeof router;

View file

@ -0,0 +1,13 @@
import { getPatientNames, getPatients } from "$lib/server/query";
import { ZPagination, ZPatientsFilter } from "$lib/shared/model/validation";
import { t } from "..";
import { z } from "zod";
export const patientRouter = t.router({
getNames: t.procedure.query(getPatientNames),
list: t.procedure
.input(z.object({ filter: ZPatientsFilter, pagination: ZPagination }).partial())
.query(async (opts) => {
return getPatients(opts.input.filter || {}, opts.input.pagination || {});
}),
});

View file

@ -0,0 +1,7 @@
import { getRooms } from "$lib/server/query";
import { t } from "..";
// import { z } from "zod";
export const roomRouter = t.router({
list: t.procedure.query(getRooms),
});

View file

@ -0,0 +1,7 @@
import { getStations } from "$lib/server/query";
import { t } from "..";
// import { z } from "zod";
export const stationRouter = t.router({
list: t.procedure.query(getStations),
});

View file

@ -0,0 +1,13 @@
import { getUserNames, getUsers } from "$lib/server/query";
import { ZPagination } from "$lib/shared/model/validation";
import { t } from "..";
import { z } from "zod";
export const userRouter = t.router({
list: t.procedure
.input(z.object({ pagination: ZPagination }).partial())
.query(async (opts) => {
return getUsers(opts.input.pagination || {});
}),
getNames: t.procedure.query(getUserNames),
});

View file

@ -30,6 +30,11 @@ export type UserTag = {
name: Option<string>;
};
export type UserTagNameNonnull = {
id: number;
name: string;
};
export type Station = {
id: number;
name: string;
@ -67,6 +72,11 @@ export type Patient = {
created_at: Date;
};
export type PatientTag = {
id: number;
name: string;
};
export type PatientNew = {
first_name: string;
last_name: string;

View file

@ -1,9 +1,9 @@
export type PaginationRequest = Partial<{
limit: number;
offset: number;
limit: number | undefined;
offset: number | undefined;
}>;
export type FilterList<T> = T | T[];
export type FilterList<T> = T | T[] | { id: T }[];
export type EntriesFilter = Partial<{
search: string;

View file

@ -18,7 +18,7 @@ const ZNameString = z.string().min(1).max(200).trim();
const ZTextString = z.string().trim();
export const ZUser = implement<User>().with({
id: ZEntityId,
id: z.coerce.number().int().nonnegative(),
name: z.string().nullable(),
email: z.string().nullable(),
});
@ -73,12 +73,10 @@ export const ZPagination = implement<PaginationRequest>().with({
offset: ZEntityId.optional(),
});
// const ZFilterList = z
// .string()
// .regex(/^\d+(;\d+)*$/)
// .transform((s) => s.split(";").map(Number))
// .optional();
const ZFilterList = z.array(ZEntityId).or(ZEntityId);
const ZFilterListEntry = z.object({ id: ZEntityId, name: ZNameString.optional() });
const ZFilterList = z.array(ZFilterListEntry);
const paginatedQuery = (f: z.ZodTypeAny) =>
z.object({ filter: f, pagination: ZPagination }).partial();
export const ZEntriesFilter = z
.object({
@ -94,7 +92,9 @@ export const ZEntriesFilter = z
})
.partial();
export const ZPatientsFilterUrl = z
export const ZEntriesQuery = paginatedQuery(ZEntriesFilter);
export const ZPatientsFilter = z
.object({
search: z.string(),
room: ZFilterList,
@ -102,3 +102,5 @@ export const ZPatientsFilterUrl = z
hidden: z.boolean(),
})
.partial();
export const ZPatientsQuery = paginatedQuery(ZPatientsFilter);

View file

@ -1,6 +1,10 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { Router } from "$lib/server/trpc/router";
import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
export type RouterInput = inferRouterInputs<Router>;
export type RouterOutput = inferRouterOutputs<Router>;
let browserClient: ReturnType<typeof createTRPCClient<Router>>;
/** Get a new tRPC client

View file

@ -0,0 +1,21 @@
export function formatDate(date: Date | string, time = false): string {
let dt = date;
if (!(dt instanceof Date)) {
dt = new Date(dt);
}
if (time) {
return dt.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} else {
return dt.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
}

View file

@ -8,10 +8,10 @@
</script>
<div
class="sticky top-0 z-30 flex h-16 w-full
justify-center bg-opacity-90 backdrop-blur"
class="sticky top-0 z-30 flex h-12 w-full
justify-center bg-neutral"
>
<nav class="navbar w-full">
<nav class="navbar w-full min-h-12">
<div class="flex flex-1">
<NavLink route="/(app)" href="/"
><Icon
@ -25,7 +25,7 @@
<div class="flex-0">
{#if $page.data.session?.user}
<div class="dropdown dropdown-hover dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost">
<div tabindex="0" role="button" class="btn btn-sm btn-ghost">
<Icon path={mdiAccount} />
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
</div>
@ -47,6 +47,6 @@
</div>
</nav>
</div>
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2">
<div class="max-w-[100vw] p-4 pb-8">
<slot />
</div>

View file

@ -2,30 +2,37 @@
import { page } from "$app/stores";
</script>
<div class="prose">
<h1>SvelteKit Auth Example</h1>
<svelte:head>
<title>Visitenbuch</title>
</svelte:head>
{#if $page.data.session?.user}
<h1 class="text-2xl font-bold">Hallo, {$page.data.session.user.name}</h1>
{:else}
<p>Sie sind nicht angemeldet</p>
{/if}
<div
class="grid grid-flow-row gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Planung</h2>
<p>Hier können sie neue Visitenbucheinträge erstellen.</p>
<div class="card-actions justify-end">
<a href="/plan" class="btn btn-primary">Planung</a>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Visite</h2>
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
<p>Heute müssen (n) Einträge erledigt werden.</p>
<div class="card-actions justify-end">
<a href="/visit" class="btn btn-primary">Visite</a>
</div>
</div>
</div>
</div>
<p>
{#if $page.data.session}
{#if $page.data.session.user?.image}
<span
style="background-image: url('{$page.data.session.user.image}')"
class="avatar"
/>
{/if}
<span class="signedInText">
<small>Signed in as</small><br />
<strong>{$page.data.session.user?.name ?? "User"}</strong>
</span>
{:else}
<span class="notSignedInText">You are not signed in</span>
{/if}
</p>
<p>
<code>
{JSON.stringify($page.data.session)}
</code>
</p>
<p><code>{new Date().toUTCString()}</code></p>

View file

@ -1,10 +1,67 @@
<script lang="ts">
import { browser } from "$app/environment";
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 } 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";
export let data: PageData;
let loading = false;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: data.query.filter,
pagination,
});
}
function filterUpdate(filter: FilterQdata) {
updateQuery({ filter });
}
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)
let url = "?q=" + JSON.stringify(query);
window.history.replaceState(null, "", url);
loading = true;
trpc()
.entry.list.query(query)
.then((entries) => {
data.entries = entries;
data.query = query;
loading = false;
});
}
}
</script>
<h1 class="text-4xl">Planung</h1>
<svelte:head>
<title>Visitenbuch - Planung</title>
</svelte:head>
<p>{data.greeting}</p>
<p>{JSON.stringify(data.categories)}</p>
<FilterBar
FILTERS={ENTRY_FILTERS}
filterData={data.query.filter}
onUpdate={filterUpdate}
/>
{#if loading}
<p>Loading...</p>
{/if}
<EntryTable entries={data.entries} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.entries}
onUpdate={paginationUpdate}
/>

View file

@ -1,11 +1,18 @@
import { z } from "zod";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const [greeting, categories] = await Promise.all([
trpc(event).greeting.query(),
trpc(event).category.list.query(),
]);
const q = event.url.searchParams.get("q");
let query: z.infer<typeof ZEntriesQuery> = {};
return { greeting, categories };
if (q) {
query = ZEntriesQuery.parse(JSON.parse(q));
}
const entries = await trpc(event).entry.list.query(query);
return { query, entries };
};

View file

@ -1,4 +1,8 @@
<script lang="ts">
</script>
<svelte:head>
<title>Visitenbuch - Visite</title>
</svelte:head>
<h1 class="text-4xl">Visite</h1>

View file

@ -204,9 +204,11 @@ test("get entries", async () => {
// Filter by category
const entriesCategory = await getEntries({ category: 3 }, {});
const entriesCategory2 = await getEntries({ category: [3] }, {});
expect(entriesCategory.items).length(1);
expect(entriesCategory.total).toBe(1);
expect(entriesCategory.items[0].id).toBe(eId1);
expect(entriesCategory2).toStrictEqual(entriesCategory);
// Filter by author
const entriesAuthor = await getEntries({ author: 2 }, {});

View file

@ -17,10 +17,12 @@ test("get users", async () => {
{
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
},
{
id: 2,
name: "Sabrina Loewe",
email: "sabrina.loewe@example.com",
},
],
offset: 0,

View file

@ -8,8 +8,8 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
"strict": true,
},
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes