Compare commits
6 commits
37e4cdda0b
...
df01725d23
Author | SHA1 | Date | |
---|---|---|---|
df01725d23 | |||
5ebcd33965 | |||
a1119ebd68 | |||
a932409142 | |||
a4fd3c9a4d | |||
3971ea9a01 |
45 changed files with 1226 additions and 92 deletions
|
@ -19,8 +19,10 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.18.6",
|
"@auth/core": "^0.18.6",
|
||||||
"@auth/sveltekit": "^0.5.3",
|
"@auth/sveltekit": "^0.5.3",
|
||||||
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@prisma/client": "^5.8.1",
|
"@prisma/client": "^5.8.1",
|
||||||
|
"svelte-floating-ui": "^1.5.8",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -11,12 +11,18 @@ dependencies:
|
||||||
'@auth/sveltekit':
|
'@auth/sveltekit':
|
||||||
specifier: ^0.5.3
|
specifier: ^0.5.3
|
||||||
version: 0.5.3(@sveltejs/kit@2.5.0)(svelte@4.2.9)
|
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':
|
'@mdi/js':
|
||||||
specifier: ^7.4.47
|
specifier: ^7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.8.1
|
specifier: ^5.8.1
|
||||||
version: 5.8.1(prisma@5.8.1)
|
version: 5.8.1(prisma@5.8.1)
|
||||||
|
svelte-floating-ui:
|
||||||
|
specifier: ^1.5.8
|
||||||
|
version: 1.5.8
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.22.4
|
specifier: ^3.22.4
|
||||||
version: 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'}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'}
|
||||||
dev: true
|
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:
|
/@humanwhocodes/config-array@0.11.14:
|
||||||
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
|
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
|
@ -2834,6 +2857,13 @@ packages:
|
||||||
svelte: 4.2.9
|
svelte: 4.2.9
|
||||||
dev: true
|
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):
|
/svelte-hmr@0.15.3(svelte@4.2.9):
|
||||||
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
|
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
|
||||||
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
||||||
|
|
|
@ -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");
|
|
@ -80,8 +80,8 @@ model Patient {
|
||||||
|
|
||||||
full_name String? @default(dbgenerated("((first_name || ' '::text) || last_name)")) @ignore
|
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")
|
@@map("patients")
|
||||||
|
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry category (e.g. Blood test, Exams, ...)
|
// Entry category (e.g. Blood test, Exams, ...)
|
||||||
|
@ -128,6 +128,7 @@ model EntryVersion {
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
@@map("entry_versions")
|
@@map("entry_versions")
|
||||||
|
@@index([entry_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model EntryExecution {
|
model EntryExecution {
|
||||||
|
@ -143,4 +144,5 @@ model EntryExecution {
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
@@map("entry_executions")
|
@@map("entry_executions")
|
||||||
|
@@index([entry_id])
|
||||||
}
|
}
|
||||||
|
|
7
src/app.d.ts
vendored
7
src/app.d.ts
vendored
|
@ -7,6 +7,13 @@ declare global {
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare namespace svelteHTML {
|
||||||
|
// Custom events (https://stackoverflow.com/a/75279911)
|
||||||
|
interface HTMLAttributes<T> {
|
||||||
|
"on:outclick"?: CompositionEventHandler<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
@ -2,3 +2,9 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
@ -17,9 +17,11 @@ import { router } from "$lib/server/trpc/router";
|
||||||
/**
|
/**
|
||||||
* Protect the application against unauthorized access.
|
* Protect the application against unauthorized access.
|
||||||
* If the user is not logged in, all requests get redirected to the login page
|
* 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 }) => {
|
const authorization: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname !== "/login") {
|
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
|
||||||
const session = await event.locals.getSession();
|
const session = await event.locals.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
||||||
|
@ -44,6 +46,25 @@ export const handle = sequence(
|
||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
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,
|
authorization,
|
||||||
createTRPCHandle({ router, createContext })
|
createTRPCHandle({ router, createContext })
|
||||||
|
|
17
src/lib/actions/outclick.ts
Normal file
17
src/lib/actions/outclick.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
262
src/lib/components/filter/Autocomplete.svelte
Normal file
262
src/lib/components/filter/Autocomplete.svelte
Normal 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>
|
198
src/lib/components/filter/FilterBar.svelte
Normal file
198
src/lib/components/filter/FilterBar.svelte
Normal 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>
|
127
src/lib/components/filter/FilterChip.svelte
Normal file
127
src/lib/components/filter/FilterChip.svelte
Normal 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>
|
93
src/lib/components/filter/filters.ts
Normal file
93
src/lib/components/filter/filters.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
39
src/lib/components/filter/types.ts
Normal file
39
src/lib/components/filter/types.ts
Normal 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;
|
||||||
|
}
|
47
src/lib/components/table/EntryTable.svelte
Normal file
47
src/lib/components/table/EntryTable.svelte
Normal 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>
|
7
src/lib/components/table/PatientField.svelte
Normal file
7
src/lib/components/table/PatientField.svelte
Normal 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})`}
|
7
src/lib/components/table/RoomField.svelte
Normal file
7
src/lib/components/table/RoomField.svelte
Normal 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})`}
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a
|
<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}
|
class:underline={$page.route.id === route}
|
||||||
{href}><slot /></a
|
{href}><slot /></a
|
||||||
>
|
>
|
||||||
|
|
73
src/lib/components/ui/PaginationButtons.svelte
Normal file
73
src/lib/components/ui/PaginationButtons.svelte
Normal 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>
|
|
@ -43,7 +43,7 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
getUser: async (id) =>
|
getUser: async (id) =>
|
||||||
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })),
|
mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })),
|
||||||
getUserByEmail: async (email) =>
|
getUserByEmail: async (email) =>
|
||||||
mapUserOpt(await p.user.findUnique({ where: { email } })),
|
mapUserOpt(await p.user.findUnique({ where: { email } })),
|
||||||
async getUserByAccount(provider_providerAccountId) {
|
async getUserByAccount(provider_providerAccountId) {
|
||||||
|
@ -54,14 +54,14 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
|
||||||
return mapUserOpt(account?.user) ?? null;
|
return mapUserOpt(account?.user) ?? null;
|
||||||
},
|
},
|
||||||
updateUser: async ({ id, ...data }) =>
|
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) =>
|
deleteUser: async (id) =>
|
||||||
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
|
mapUser(await p.user.delete({ where: { id: Number(id) } })),
|
||||||
linkAccount: async (data) =>
|
linkAccount: async (data) =>
|
||||||
mapAccount(
|
mapAccount(
|
||||||
await p.account.create({
|
await p.account.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: parseInt(data.userId),
|
user_id: Number(data.userId),
|
||||||
type: data.type,
|
type: data.type,
|
||||||
provider: data.provider,
|
provider: data.provider,
|
||||||
providerAccountId: data.providerAccountId,
|
providerAccountId: data.providerAccountId,
|
||||||
|
|
|
@ -12,7 +12,7 @@ import type {
|
||||||
} from "$lib/shared/model";
|
} from "$lib/shared/model";
|
||||||
import { ErrorConflict } from "$lib/shared/util/error";
|
import { ErrorConflict } from "$lib/shared/util/error";
|
||||||
import { mapEntry, mapVersion, mapExecution } from "./mapping";
|
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 } };
|
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);
|
qb.addFilter("ev.priority", filter?.priority);
|
||||||
|
|
||||||
if (filter?.author) {
|
if (filter?.author) {
|
||||||
let author = filter?.author;
|
const author = filterListToArray(filter?.author);
|
||||||
if (!Array.isArray(author)) {
|
|
||||||
author = [author];
|
|
||||||
}
|
|
||||||
qb.addFilterClause(
|
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
|
author
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
Room,
|
Room,
|
||||||
EntryVersion,
|
EntryVersion,
|
||||||
EntryExecution,
|
EntryExecution,
|
||||||
|
UserTagNameNonnull,
|
||||||
} from "$lib/shared/model";
|
} from "$lib/shared/model";
|
||||||
import { ErrorNotFound } from "$lib/shared/util/error";
|
import { ErrorNotFound } from "$lib/shared/util/error";
|
||||||
import type {
|
import type {
|
||||||
|
@ -54,10 +55,14 @@ export function mapUser(user: DbUser): User {
|
||||||
return { id: user.id, name: user.name, email: user.email };
|
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 };
|
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 {
|
export function mapRoom(room: DbRoomLn): Room {
|
||||||
return { id: room.id, name: room.name, station: room.station };
|
return { id: room.id, name: room.name, station: room.station };
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationRequest,
|
PaginationRequest,
|
||||||
PatientsFilter,
|
PatientsFilter,
|
||||||
|
PatientTag,
|
||||||
} from "$lib/shared/model";
|
} from "$lib/shared/model";
|
||||||
import { prisma } from "$lib/server/prisma";
|
import { prisma } from "$lib/server/prisma";
|
||||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||||
|
@ -51,6 +52,17 @@ export async function getPatient(id: number): Promise<Patient> {
|
||||||
return mapPatient(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(
|
export async function getPatients(
|
||||||
filter: PatientsFilter,
|
filter: PatientsFilter,
|
||||||
pagination: PaginationRequest
|
pagination: PaginationRequest
|
||||||
|
|
|
@ -51,7 +51,7 @@ export async function getRoom(id: number): Promise<Room> {
|
||||||
export async function getRooms(): Promise<Room[]> {
|
export async function getRooms(): Promise<Room[]> {
|
||||||
const rooms = await prisma.room.findMany({
|
const rooms = await prisma.room.findMany({
|
||||||
include: { station: true },
|
include: { station: true },
|
||||||
orderBy: { name: "asc" },
|
orderBy: [{ station: { name: "asc" } }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
return rooms.map(mapRoom);
|
return rooms.map(mapRoom);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { prisma } from "$lib/server/prisma";
|
||||||
import { mapUser, mapUserTag } from "./mapping";
|
import { mapUser, mapUserTagNameNonnull } from "./mapping";
|
||||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||||
|
|
||||||
export async function getUser(id: number): Promise<User> {
|
export async function getUser(id: number): Promise<User> {
|
||||||
|
@ -10,7 +15,7 @@ export async function getUser(id: number): Promise<User> {
|
||||||
|
|
||||||
export async function getUsers(
|
export async function getUsers(
|
||||||
pagination: PaginationRequest
|
pagination: PaginationRequest
|
||||||
): Promise<Pagination<UserTag>> {
|
): Promise<Pagination<User>> {
|
||||||
const offset = pagination.offset || 0;
|
const offset = pagination.offset || 0;
|
||||||
const [users, total] = await Promise.all([
|
const [users, total] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
|
@ -21,8 +26,17 @@ export async function getUsers(
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
items: users.map(mapUserTag),
|
items: users.map(mapUser),
|
||||||
offset,
|
offset,
|
||||||
total,
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ test("query builder", () => {
|
||||||
|
|
||||||
const query = qb.getQuery();
|
const query = qb.getQuery();
|
||||||
expect(query).toBe(
|
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();
|
const params = qb.getParams();
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||||
import type { FilterList, PaginationRequest } from "$lib/shared/model";
|
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 {
|
enum QueryComponentType {
|
||||||
Normal = 1,
|
Normal = 1,
|
||||||
Exact,
|
Exact,
|
||||||
Trailing,
|
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 {
|
class SearchQueryComponent {
|
||||||
word: string;
|
word: string;
|
||||||
typ: QueryComponentType;
|
typ: QueryComponentType;
|
||||||
|
@ -149,12 +152,8 @@ export class QueryBuilder {
|
||||||
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
||||||
if (fl === undefined) return;
|
if (fl === undefined) return;
|
||||||
|
|
||||||
this.params.push(fl);
|
this.filterClauses.push(`${fname} = any (${this.pvar()})`);
|
||||||
if (Array.isArray(fl)) {
|
this.params.push(filterListToArray(fl));
|
||||||
this.filterClauses.push(`${fname} in ${this.pvar()}`);
|
|
||||||
} else {
|
|
||||||
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getQuery(): string {
|
getQuery(): string {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export async function createContext(event: RequestEvent) {
|
||||||
const session = await event.locals.getSession();
|
const session = await event.locals.getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
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);
|
const user = ZUser.parse(session?.user);
|
||||||
|
|
|
@ -2,13 +2,21 @@ import { t } from ".";
|
||||||
|
|
||||||
import { categoryRouter } from "./routes/category";
|
import { categoryRouter } from "./routes/category";
|
||||||
import { entryRouter } from "./routes/entry";
|
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({
|
export const router = t.router({
|
||||||
greeting: t.procedure.query(async () => {
|
greeting: t.procedure.query(async () => {
|
||||||
return `Hello tRPC v10 @ ${new Date().toLocaleTimeString()}`;
|
return `Hello tRPC @ ${new Date().toLocaleTimeString()}`;
|
||||||
}),
|
}),
|
||||||
category: categoryRouter,
|
category: categoryRouter,
|
||||||
entry: entryRouter,
|
entry: entryRouter,
|
||||||
|
station: stationRouter,
|
||||||
|
room: roomRouter,
|
||||||
|
patient: patientRouter,
|
||||||
|
user: userRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Router = typeof router;
|
export type Router = typeof router;
|
||||||
|
|
13
src/lib/server/trpc/routes/patient.ts
Normal file
13
src/lib/server/trpc/routes/patient.ts
Normal 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 || {});
|
||||||
|
}),
|
||||||
|
});
|
7
src/lib/server/trpc/routes/room.ts
Normal file
7
src/lib/server/trpc/routes/room.ts
Normal 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),
|
||||||
|
});
|
7
src/lib/server/trpc/routes/station.ts
Normal file
7
src/lib/server/trpc/routes/station.ts
Normal 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),
|
||||||
|
});
|
13
src/lib/server/trpc/routes/user.ts
Normal file
13
src/lib/server/trpc/routes/user.ts
Normal 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),
|
||||||
|
});
|
|
@ -30,6 +30,11 @@ export type UserTag = {
|
||||||
name: Option<string>;
|
name: Option<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserTagNameNonnull = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Station = {
|
export type Station = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -67,6 +72,11 @@ export type Patient = {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PatientTag = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PatientNew = {
|
export type PatientNew = {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export type PaginationRequest = Partial<{
|
export type PaginationRequest = Partial<{
|
||||||
limit: number;
|
limit: number | undefined;
|
||||||
offset: number;
|
offset: number | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FilterList<T> = T | T[];
|
export type FilterList<T> = T | T[] | { id: T }[];
|
||||||
|
|
||||||
export type EntriesFilter = Partial<{
|
export type EntriesFilter = Partial<{
|
||||||
search: string;
|
search: string;
|
||||||
|
|
|
@ -18,7 +18,7 @@ const ZNameString = z.string().min(1).max(200).trim();
|
||||||
const ZTextString = z.string().trim();
|
const ZTextString = z.string().trim();
|
||||||
|
|
||||||
export const ZUser = implement<User>().with({
|
export const ZUser = implement<User>().with({
|
||||||
id: ZEntityId,
|
id: z.coerce.number().int().nonnegative(),
|
||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
@ -73,12 +73,10 @@ export const ZPagination = implement<PaginationRequest>().with({
|
||||||
offset: ZEntityId.optional(),
|
offset: ZEntityId.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// const ZFilterList = z
|
const ZFilterListEntry = z.object({ id: ZEntityId, name: ZNameString.optional() });
|
||||||
// .string()
|
const ZFilterList = z.array(ZFilterListEntry);
|
||||||
// .regex(/^\d+(;\d+)*$/)
|
const paginatedQuery = (f: z.ZodTypeAny) =>
|
||||||
// .transform((s) => s.split(";").map(Number))
|
z.object({ filter: f, pagination: ZPagination }).partial();
|
||||||
// .optional();
|
|
||||||
const ZFilterList = z.array(ZEntityId).or(ZEntityId);
|
|
||||||
|
|
||||||
export const ZEntriesFilter = z
|
export const ZEntriesFilter = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -94,7 +92,9 @@ export const ZEntriesFilter = z
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
export const ZPatientsFilterUrl = z
|
export const ZEntriesQuery = paginatedQuery(ZEntriesFilter);
|
||||||
|
|
||||||
|
export const ZPatientsFilter = z
|
||||||
.object({
|
.object({
|
||||||
search: z.string(),
|
search: z.string(),
|
||||||
room: ZFilterList,
|
room: ZFilterList,
|
||||||
|
@ -102,3 +102,5 @@ export const ZPatientsFilterUrl = z
|
||||||
hidden: z.boolean(),
|
hidden: z.boolean(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
|
export const ZPatientsQuery = paginatedQuery(ZPatientsFilter);
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||||
import type { Router } from "$lib/server/trpc/router";
|
import type { Router } from "$lib/server/trpc/router";
|
||||||
import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
|
import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
|
||||||
|
|
||||||
|
export type RouterInput = inferRouterInputs<Router>;
|
||||||
|
export type RouterOutput = inferRouterOutputs<Router>;
|
||||||
|
|
||||||
let browserClient: ReturnType<typeof createTRPCClient<Router>>;
|
let browserClient: ReturnType<typeof createTRPCClient<Router>>;
|
||||||
|
|
||||||
/** Get a new tRPC client
|
/** Get a new tRPC client
|
||||||
|
|
21
src/lib/shared/util/index.ts
Normal file
21
src/lib/shared/util/index.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,10 +8,10 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-30 flex h-16 w-full
|
class="sticky top-0 z-30 flex h-12 w-full
|
||||||
justify-center bg-opacity-90 backdrop-blur"
|
justify-center bg-neutral"
|
||||||
>
|
>
|
||||||
<nav class="navbar w-full">
|
<nav class="navbar w-full min-h-12">
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<NavLink route="/(app)" href="/"
|
<NavLink route="/(app)" href="/"
|
||||||
><Icon
|
><Icon
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
<div class="flex-0">
|
<div class="flex-0">
|
||||||
{#if $page.data.session?.user}
|
{#if $page.data.session?.user}
|
||||||
<div class="dropdown dropdown-hover dropdown-end">
|
<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} />
|
<Icon path={mdiAccount} />
|
||||||
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
|
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,6 +47,6 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2">
|
<div class="max-w-[100vw] p-4 pb-8">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,30 +2,37 @@
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="prose">
|
<svelte:head>
|
||||||
<h1>SvelteKit Auth Example</h1>
|
<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>
|
||||||
|
|
||||||
<p>
|
<div class="card bg-base-100 shadow-xl">
|
||||||
{#if $page.data.session}
|
<div class="card-body">
|
||||||
{#if $page.data.session.user?.image}
|
<h2 class="card-title">Visite</h2>
|
||||||
<span
|
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
||||||
style="background-image: url('{$page.data.session.user.image}')"
|
<p>Heute müssen (n) Einträge erledigt werden.</p>
|
||||||
class="avatar"
|
<div class="card-actions justify-end">
|
||||||
/>
|
<a href="/visit" class="btn btn-primary">Visite</a>
|
||||||
{/if}
|
</div>
|
||||||
<span class="signedInText">
|
</div>
|
||||||
<small>Signed in as</small><br />
|
</div>
|
||||||
<strong>{$page.data.session.user?.name ?? "User"}</strong>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
|
@ -1,10 +1,67 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
import EntryTable from "$lib/components/table/EntryTable.svelte";
|
||||||
|
import 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;
|
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>
|
</script>
|
||||||
|
|
||||||
<h1 class="text-4xl">Planung</h1>
|
<svelte:head>
|
||||||
|
<title>Visitenbuch - Planung</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<p>{data.greeting}</p>
|
<FilterBar
|
||||||
<p>{JSON.stringify(data.categories)}</p>
|
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}
|
||||||
|
/>
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
||||||
import { trpc } from "$lib/shared/trpc";
|
import { trpc } from "$lib/shared/trpc";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async (event) => {
|
export const load: PageLoad = async (event) => {
|
||||||
const [greeting, categories] = await Promise.all([
|
const q = event.url.searchParams.get("q");
|
||||||
trpc(event).greeting.query(),
|
let query: z.infer<typeof ZEntriesQuery> = {};
|
||||||
trpc(event).category.list.query(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { greeting, categories };
|
if (q) {
|
||||||
|
query = ZEntriesQuery.parse(JSON.parse(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await trpc(event).entry.list.query(query);
|
||||||
|
|
||||||
|
return { query, entries };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Visitenbuch - Visite</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="text-4xl">Visite</h1>
|
<h1 class="text-4xl">Visite</h1>
|
||||||
|
|
|
@ -204,9 +204,11 @@ test("get entries", async () => {
|
||||||
|
|
||||||
// Filter by category
|
// Filter by category
|
||||||
const entriesCategory = await getEntries({ category: 3 }, {});
|
const entriesCategory = await getEntries({ category: 3 }, {});
|
||||||
|
const entriesCategory2 = await getEntries({ category: [3] }, {});
|
||||||
expect(entriesCategory.items).length(1);
|
expect(entriesCategory.items).length(1);
|
||||||
expect(entriesCategory.total).toBe(1);
|
expect(entriesCategory.total).toBe(1);
|
||||||
expect(entriesCategory.items[0].id).toBe(eId1);
|
expect(entriesCategory.items[0].id).toBe(eId1);
|
||||||
|
expect(entriesCategory2).toStrictEqual(entriesCategory);
|
||||||
|
|
||||||
// Filter by author
|
// Filter by author
|
||||||
const entriesAuthor = await getEntries({ author: 2 }, {});
|
const entriesAuthor = await getEntries({ author: 2 }, {});
|
||||||
|
|
|
@ -17,10 +17,12 @@ test("get users", async () => {
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Sven Schulz",
|
name: "Sven Schulz",
|
||||||
|
email: "sven.schulz@example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "Sabrina Loewe",
|
name: "Sabrina Loewe",
|
||||||
|
email: "sabrina.loewe@example.com",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|
|
@ -8,8 +8,8 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true
|
"strict": true,
|
||||||
}
|
},
|
||||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
// 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
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
|
Loading…
Reference in a new issue