Compare commits
No commits in common. "df01725d23a34924eb7ea149128b076ab5a9bf9e" and "37e4cdda0b7641cefbe3ab0f568abf97bec9b3b4" have entirely different histories.
df01725d23
...
37e4cdda0b
45 changed files with 92 additions and 1226 deletions
|
@ -19,10 +19,8 @@
|
||||||
"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,18 +11,12 @@ 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
|
||||||
|
@ -423,23 +417,6 @@ 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'}
|
||||||
|
@ -2857,13 +2834,6 @@ 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}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
-- 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
|
||||||
|
|
||||||
@@map("patients")
|
|
||||||
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
|
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
|
||||||
|
@@map("patients")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entry category (e.g. Blood test, Exams, ...)
|
// Entry category (e.g. Blood test, Exams, ...)
|
||||||
|
@ -128,7 +128,6 @@ 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 {
|
||||||
|
@ -144,5 +143,4 @@ 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,13 +7,6 @@ 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,9 +2,3 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
.ellipsis {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
|
@ -17,11 +17,9 @@ 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 (!/^\/(login|trpc)/.test(event.url.pathname)) {
|
if (event.url.pathname !== "/login") {
|
||||||
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 });
|
||||||
|
@ -46,25 +44,6 @@ 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 })
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,262 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,198 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,127 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,93 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,39 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,7 +0,0 @@
|
||||||
<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})`}
|
|
|
@ -1,7 +0,0 @@
|
||||||
<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-sm btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
|
class="btn 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
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
<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: Number(id) } })),
|
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(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: Number(id) }, data })),
|
mapUser(await p.user.update({ where: { id: parseInt(id) }, data })),
|
||||||
deleteUser: async (id) =>
|
deleteUser: async (id) =>
|
||||||
mapUser(await p.user.delete({ where: { id: Number(id) } })),
|
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
|
||||||
linkAccount: async (data) =>
|
linkAccount: async (data) =>
|
||||||
mapAccount(
|
mapAccount(
|
||||||
await p.account.create({
|
await p.account.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: Number(data.userId),
|
user_id: parseInt(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, filterListToArray, parseSearchQuery } from "./util";
|
import { QueryBuilder, parseSearchQuery } from "./util";
|
||||||
|
|
||||||
const USER_SELECT = { select: { id: true, name: true } };
|
const USER_SELECT = { select: { id: true, name: true } };
|
||||||
|
|
||||||
|
@ -236,9 +236,12 @@ 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) {
|
||||||
const author = filterListToArray(filter?.author);
|
let author = 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,7 +6,6 @@ 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 {
|
||||||
|
@ -55,14 +54,10 @@ 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: Omit<DbUser, "email">): UserTag {
|
export function mapUserTag(user: DbUser): 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,7 +4,6 @@ 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";
|
||||||
|
@ -52,17 +51,6 @@ 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: [{ station: { name: "asc" } }, { name: "asc" }],
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
return rooms.map(mapRoom);
|
return rooms.map(mapRoom);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
import type {
|
import type { Pagination, PaginationRequest, User, UserTag } from "$lib/shared/model";
|
||||||
Pagination,
|
|
||||||
PaginationRequest,
|
|
||||||
User,
|
|
||||||
UserTagNameNonnull,
|
|
||||||
} from "$lib/shared/model";
|
|
||||||
import { prisma } from "$lib/server/prisma";
|
import { prisma } from "$lib/server/prisma";
|
||||||
import { mapUser, mapUserTagNameNonnull } from "./mapping";
|
import { mapUser, mapUserTag } 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> {
|
||||||
|
@ -15,7 +10,7 @@ export async function getUser(id: number): Promise<User> {
|
||||||
|
|
||||||
export async function getUsers(
|
export async function getUsers(
|
||||||
pagination: PaginationRequest
|
pagination: PaginationRequest
|
||||||
): Promise<Pagination<User>> {
|
): Promise<Pagination<UserTag>> {
|
||||||
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({
|
||||||
|
@ -26,17 +21,8 @@ export async function getUsers(
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
items: users.map(mapUser),
|
items: users.map(mapUserTag),
|
||||||
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 = any ($1) and text = $2 limit $3 offset $4"
|
"select e.id, e.text, e.category from entries e where category in $1 and text = $2 limit $3 offset $4"
|
||||||
);
|
);
|
||||||
|
|
||||||
const params = qb.getParams();
|
const params = qb.getParams();
|
||||||
|
|
|
@ -1,27 +1,24 @@
|
||||||
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;
|
||||||
|
@ -152,8 +149,12 @@ 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.filterClauses.push(`${fname} = any (${this.pvar()})`);
|
this.params.push(fl);
|
||||||
this.params.push(filterListToArray(fl));
|
if (Array.isArray(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: "not logged in" });
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "no session" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = ZUser.parse(session?.user);
|
const user = ZUser.parse(session?.user);
|
||||||
|
|
|
@ -2,21 +2,13 @@ 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 @ ${new Date().toLocaleTimeString()}`;
|
return `Hello tRPC v10 @ ${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;
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
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 || {});
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { getRooms } from "$lib/server/query";
|
|
||||||
import { t } from "..";
|
|
||||||
// import { z } from "zod";
|
|
||||||
|
|
||||||
export const roomRouter = t.router({
|
|
||||||
list: t.procedure.query(getRooms),
|
|
||||||
});
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { getStations } from "$lib/server/query";
|
|
||||||
import { t } from "..";
|
|
||||||
// import { z } from "zod";
|
|
||||||
|
|
||||||
export const stationRouter = t.router({
|
|
||||||
list: t.procedure.query(getStations),
|
|
||||||
});
|
|
|
@ -1,13 +0,0 @@
|
||||||
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,11 +30,6 @@ 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;
|
||||||
|
@ -72,11 +67,6 @@ 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 | undefined;
|
limit: number;
|
||||||
offset: number | undefined;
|
offset: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FilterList<T> = T | T[] | { id: T }[];
|
export type FilterList<T> = T | 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: z.coerce.number().int().nonnegative(),
|
id: ZEntityId,
|
||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
@ -73,10 +73,12 @@ export const ZPagination = implement<PaginationRequest>().with({
|
||||||
offset: ZEntityId.optional(),
|
offset: ZEntityId.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZFilterListEntry = z.object({ id: ZEntityId, name: ZNameString.optional() });
|
// const ZFilterList = z
|
||||||
const ZFilterList = z.array(ZFilterListEntry);
|
// .string()
|
||||||
const paginatedQuery = (f: z.ZodTypeAny) =>
|
// .regex(/^\d+(;\d+)*$/)
|
||||||
z.object({ filter: f, pagination: ZPagination }).partial();
|
// .transform((s) => s.split(";").map(Number))
|
||||||
|
// .optional();
|
||||||
|
const ZFilterList = z.array(ZEntityId).or(ZEntityId);
|
||||||
|
|
||||||
export const ZEntriesFilter = z
|
export const ZEntriesFilter = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -92,9 +94,7 @@ export const ZEntriesFilter = z
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
export const ZEntriesQuery = paginatedQuery(ZEntriesFilter);
|
export const ZPatientsFilterUrl = z
|
||||||
|
|
||||||
export const ZPatientsFilter = z
|
|
||||||
.object({
|
.object({
|
||||||
search: z.string(),
|
search: z.string(),
|
||||||
room: ZFilterList,
|
room: ZFilterList,
|
||||||
|
@ -102,5 +102,3 @@ export const ZPatientsFilter = z
|
||||||
hidden: z.boolean(),
|
hidden: z.boolean(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
export const ZPatientsQuery = paginatedQuery(ZPatientsFilter);
|
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
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
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
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-12 w-full
|
class="sticky top-0 z-30 flex h-16 w-full
|
||||||
justify-center bg-neutral"
|
justify-center bg-opacity-90 backdrop-blur"
|
||||||
>
|
>
|
||||||
<nav class="navbar w-full min-h-12">
|
<nav class="navbar w-full">
|
||||||
<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-sm btn-ghost">
|
<div tabindex="0" role="button" class="btn 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] p-4 pb-8">
|
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,37 +2,30 @@
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="prose">
|
||||||
<title>Visitenbuch</title>
|
<h1>SvelteKit Auth Example</h1>
|
||||||
</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>
|
</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>
|
||||||
|
|
|
@ -1,67 +1,10 @@
|
||||||
<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>
|
||||||
|
|
||||||
<svelte:head>
|
<h1 class="text-4xl">Planung</h1>
|
||||||
<title>Visitenbuch - Planung</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<FilterBar
|
<p>{data.greeting}</p>
|
||||||
FILTERS={ENTRY_FILTERS}
|
<p>{JSON.stringify(data.categories)}</p>
|
||||||
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,18 +1,11 @@
|
||||||
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 q = event.url.searchParams.get("q");
|
const [greeting, categories] = await Promise.all([
|
||||||
let query: z.infer<typeof ZEntriesQuery> = {};
|
trpc(event).greeting.query(),
|
||||||
|
trpc(event).category.list.query(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (q) {
|
return { greeting, categories };
|
||||||
query = ZEntriesQuery.parse(JSON.parse(q));
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await trpc(event).entry.list.query(query);
|
|
||||||
|
|
||||||
return { query, entries };
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,4 @@
|
||||||
<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,11 +204,9 @@ 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,12 +17,10 @@ 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