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": {
|
||||
"@auth/core": "^0.18.6",
|
||||
"@auth/sveltekit": "^0.5.3",
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"svelte-floating-ui": "^1.5.8",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -11,12 +11,18 @@ dependencies:
|
|||
'@auth/sveltekit':
|
||||
specifier: ^0.5.3
|
||||
version: 0.5.3(@sveltejs/kit@2.5.0)(svelte@4.2.9)
|
||||
'@floating-ui/core':
|
||||
specifier: ^1.6.0
|
||||
version: 1.6.0
|
||||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
'@prisma/client':
|
||||
specifier: ^5.8.1
|
||||
version: 5.8.1(prisma@5.8.1)
|
||||
svelte-floating-ui:
|
||||
specifier: ^1.5.8
|
||||
version: 1.5.8
|
||||
zod:
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
|
@ -417,6 +423,23 @@ packages:
|
|||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'}
|
||||
dev: true
|
||||
|
||||
/@floating-ui/core@1.6.0:
|
||||
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/dom@1.6.0:
|
||||
resolution: {integrity: sha512-SZ0BEXzsaaS6THZfZJUcAobbZTD+MvfGM42bxgeg0Tnkp4/an/avqwAXiVLsFtIBZtfsx3Ymvwx0+KnnhdA/9g==}
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.6.0
|
||||
'@floating-ui/utils': 0.2.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/utils@0.2.1:
|
||||
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
|
||||
dev: false
|
||||
|
||||
/@humanwhocodes/config-array@0.11.14:
|
||||
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
|
@ -2834,6 +2857,13 @@ packages:
|
|||
svelte: 4.2.9
|
||||
dev: true
|
||||
|
||||
/svelte-floating-ui@1.5.8:
|
||||
resolution: {integrity: sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw==}
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.6.0
|
||||
'@floating-ui/dom': 1.6.0
|
||||
dev: false
|
||||
|
||||
/svelte-hmr@0.15.3(svelte@4.2.9):
|
||||
resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==}
|
||||
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
||||
|
|
|
@ -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
|
||||
|
||||
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
|
||||
@@map("patients")
|
||||
@@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin)
|
||||
}
|
||||
|
||||
// Entry category (e.g. Blood test, Exams, ...)
|
||||
|
@ -128,6 +128,7 @@ model EntryVersion {
|
|||
created_at DateTime @default(now())
|
||||
|
||||
@@map("entry_versions")
|
||||
@@index([entry_id])
|
||||
}
|
||||
|
||||
model EntryExecution {
|
||||
|
@ -143,4 +144,5 @@ model EntryExecution {
|
|||
created_at DateTime @default(now())
|
||||
|
||||
@@map("entry_executions")
|
||||
@@index([entry_id])
|
||||
}
|
||||
|
|
7
src/app.d.ts
vendored
7
src/app.d.ts
vendored
|
@ -7,6 +7,13 @@ declare global {
|
|||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
declare namespace svelteHTML {
|
||||
// Custom events (https://stackoverflow.com/a/75279911)
|
||||
interface HTMLAttributes<T> {
|
||||
"on:outclick"?: CompositionEventHandler<T>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
@ -2,3 +2,9 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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.
|
||||
* If the user is not logged in, all requests get redirected to the login page
|
||||
* with the exception of the login page and the TRPC API (which has its own
|
||||
* auth mechanism)
|
||||
*/
|
||||
const authorization: Handle = async ({ event, resolve }) => {
|
||||
if (event.url.pathname !== "/login") {
|
||||
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
|
||||
const session = await event.locals.getSession();
|
||||
if (!session) {
|
||||
const params = new URLSearchParams({ returnURL: event.url.pathname });
|
||||
|
@ -44,6 +46,25 @@ export const handle = sequence(
|
|||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
// Add user ID to session token
|
||||
jwt({ token, account, user }) {
|
||||
if (account) {
|
||||
token.accessToken = account.access_token;
|
||||
token.id = user?.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session(opt) {
|
||||
// @ts-expect-error because of union type
|
||||
if (opt.session.user && opt.token.id) {
|
||||
// @ts-expect-error because of union type
|
||||
opt.session.user.id = opt.token.id;
|
||||
}
|
||||
|
||||
return opt.session;
|
||||
},
|
||||
},
|
||||
}),
|
||||
authorization,
|
||||
createTRPCHandle({ router, createContext })
|
||||
|
|
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>
|
||||
<a
|
||||
class="btn btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
|
||||
class="btn btn-sm btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
|
||||
class:underline={$page.route.id === route}
|
||||
{href}><slot /></a
|
||||
>
|
||||
|
|
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) =>
|
||||
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })),
|
||||
mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })),
|
||||
getUserByEmail: async (email) =>
|
||||
mapUserOpt(await p.user.findUnique({ where: { email } })),
|
||||
async getUserByAccount(provider_providerAccountId) {
|
||||
|
@ -54,14 +54,14 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
|
|||
return mapUserOpt(account?.user) ?? null;
|
||||
},
|
||||
updateUser: async ({ id, ...data }) =>
|
||||
mapUser(await p.user.update({ where: { id: parseInt(id) }, data })),
|
||||
mapUser(await p.user.update({ where: { id: Number(id) }, data })),
|
||||
deleteUser: async (id) =>
|
||||
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
|
||||
mapUser(await p.user.delete({ where: { id: Number(id) } })),
|
||||
linkAccount: async (data) =>
|
||||
mapAccount(
|
||||
await p.account.create({
|
||||
data: {
|
||||
user_id: parseInt(data.userId),
|
||||
user_id: Number(data.userId),
|
||||
type: data.type,
|
||||
provider: data.provider,
|
||||
providerAccountId: data.providerAccountId,
|
||||
|
|
|
@ -12,7 +12,7 @@ import type {
|
|||
} from "$lib/shared/model";
|
||||
import { ErrorConflict } from "$lib/shared/util/error";
|
||||
import { mapEntry, mapVersion, mapExecution } from "./mapping";
|
||||
import { QueryBuilder, parseSearchQuery } from "./util";
|
||||
import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util";
|
||||
|
||||
const USER_SELECT = { select: { id: true, name: true } };
|
||||
|
||||
|
@ -236,12 +236,9 @@ join stations s on s.id = r.station_id`
|
|||
qb.addFilter("ev.priority", filter?.priority);
|
||||
|
||||
if (filter?.author) {
|
||||
let author = filter?.author;
|
||||
if (!Array.isArray(author)) {
|
||||
author = [author];
|
||||
}
|
||||
const author = filterListToArray(filter?.author);
|
||||
qb.addFilterClause(
|
||||
`${qb.pvar()}::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
|
||||
`(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
|
||||
author
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
|||
Room,
|
||||
EntryVersion,
|
||||
EntryExecution,
|
||||
UserTagNameNonnull,
|
||||
} from "$lib/shared/model";
|
||||
import { ErrorNotFound } from "$lib/shared/util/error";
|
||||
import type {
|
||||
|
@ -54,10 +55,14 @@ export function mapUser(user: DbUser): User {
|
|||
return { id: user.id, name: user.name, email: user.email };
|
||||
}
|
||||
|
||||
export function mapUserTag(user: DbUser): UserTag {
|
||||
export function mapUserTag(user: Omit<DbUser, "email">): UserTag {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function mapUserTagNameNonnull(user: Omit<DbUser, "email">): UserTagNameNonnull {
|
||||
return { id: user.id, name: user.name || "" };
|
||||
}
|
||||
|
||||
export function mapRoom(room: DbRoomLn): Room {
|
||||
return { id: room.id, name: room.name, station: room.station };
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
Pagination,
|
||||
PaginationRequest,
|
||||
PatientsFilter,
|
||||
PatientTag,
|
||||
} from "$lib/shared/model";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
|
@ -51,6 +52,17 @@ export async function getPatient(id: number): Promise<Patient> {
|
|||
return mapPatient(patient);
|
||||
}
|
||||
|
||||
export async function getPatientNames(): Promise<PatientTag[]> {
|
||||
const patients = await prisma.patient.findMany({
|
||||
select: { id: true, first_name: true, last_name: true },
|
||||
where: { hidden: false },
|
||||
orderBy: { last_name: "asc" },
|
||||
});
|
||||
return patients.map((p) => {
|
||||
return { id: p.id, name: p.last_name + ", " + p.first_name };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPatients(
|
||||
filter: PatientsFilter,
|
||||
pagination: PaginationRequest
|
||||
|
|
|
@ -51,7 +51,7 @@ export async function getRoom(id: number): Promise<Room> {
|
|||
export async function getRooms(): Promise<Room[]> {
|
||||
const rooms = await prisma.room.findMany({
|
||||
include: { station: true },
|
||||
orderBy: { name: "asc" },
|
||||
orderBy: [{ station: { name: "asc" } }, { name: "asc" }],
|
||||
});
|
||||
return rooms.map(mapRoom);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import type { Pagination, PaginationRequest, User, UserTag } from "$lib/shared/model";
|
||||
import type {
|
||||
Pagination,
|
||||
PaginationRequest,
|
||||
User,
|
||||
UserTagNameNonnull,
|
||||
} from "$lib/shared/model";
|
||||
import { prisma } from "$lib/server/prisma";
|
||||
import { mapUser, mapUserTag } from "./mapping";
|
||||
import { mapUser, mapUserTagNameNonnull } from "./mapping";
|
||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
|
||||
export async function getUser(id: number): Promise<User> {
|
||||
|
@ -10,7 +15,7 @@ export async function getUser(id: number): Promise<User> {
|
|||
|
||||
export async function getUsers(
|
||||
pagination: PaginationRequest
|
||||
): Promise<Pagination<UserTag>> {
|
||||
): Promise<Pagination<User>> {
|
||||
const offset = pagination.offset || 0;
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
|
@ -21,8 +26,17 @@ export async function getUsers(
|
|||
prisma.user.count(),
|
||||
]);
|
||||
return {
|
||||
items: users.map(mapUserTag),
|
||||
items: users.map(mapUser),
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserNames(): Promise<UserTagNameNonnull[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, name: true },
|
||||
where: { name: { not: null } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return users.map(mapUserTagNameNonnull);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ test("query builder", () => {
|
|||
|
||||
const query = qb.getQuery();
|
||||
expect(query).toBe(
|
||||
"select e.id, e.text, e.category from entries e where category in $1 and text = $2 limit $3 offset $4"
|
||||
"select e.id, e.text, e.category from entries e where category = any ($1) and text = $2 limit $3 offset $4"
|
||||
);
|
||||
|
||||
const params = qb.getParams();
|
||||
|
|
|
@ -1,24 +1,27 @@
|
|||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||
import type { FilterList, PaginationRequest } from "$lib/shared/model";
|
||||
|
||||
export function convertFilterList<T>(
|
||||
fl: FilterList<T> | undefined
|
||||
): { in: T[] } | T | undefined {
|
||||
if (!fl) {
|
||||
return undefined;
|
||||
} else if (Array.isArray(fl)) {
|
||||
return { in: fl };
|
||||
} else {
|
||||
return fl;
|
||||
}
|
||||
}
|
||||
|
||||
enum QueryComponentType {
|
||||
Normal = 1,
|
||||
Exact,
|
||||
Trailing,
|
||||
}
|
||||
|
||||
export function filterListToArray<T>(fl: FilterList<T>): T[] {
|
||||
if (Array.isArray(fl)) {
|
||||
// @ts-expect-error checked if id is present
|
||||
if (fl[0].id) {
|
||||
// @ts-expect-error checked if id is present
|
||||
return fl.map((itm) => itm.id);
|
||||
} else {
|
||||
// @ts-expect-error output type checked
|
||||
return fl;
|
||||
}
|
||||
} else {
|
||||
return [fl];
|
||||
}
|
||||
}
|
||||
|
||||
class SearchQueryComponent {
|
||||
word: string;
|
||||
typ: QueryComponentType;
|
||||
|
@ -149,12 +152,8 @@ export class QueryBuilder {
|
|||
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
||||
if (fl === undefined) return;
|
||||
|
||||
this.params.push(fl);
|
||||
if (Array.isArray(fl)) {
|
||||
this.filterClauses.push(`${fname} in ${this.pvar()}`);
|
||||
} else {
|
||||
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
||||
}
|
||||
this.filterClauses.push(`${fname} = any (${this.pvar()})`);
|
||||
this.params.push(filterListToArray(fl));
|
||||
}
|
||||
|
||||
getQuery(): string {
|
||||
|
|
|
@ -9,7 +9,7 @@ export async function createContext(event: RequestEvent) {
|
|||
const session = await event.locals.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "no session" });
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
|
||||
}
|
||||
|
||||
const user = ZUser.parse(session?.user);
|
||||
|
|
|
@ -2,13 +2,21 @@ import { t } from ".";
|
|||
|
||||
import { categoryRouter } from "./routes/category";
|
||||
import { entryRouter } from "./routes/entry";
|
||||
import { stationRouter } from "./routes/station";
|
||||
import { roomRouter } from "./routes/room";
|
||||
import { patientRouter } from "./routes/patient";
|
||||
import { userRouter } from "./routes/user";
|
||||
|
||||
export const router = t.router({
|
||||
greeting: t.procedure.query(async () => {
|
||||
return `Hello tRPC v10 @ ${new Date().toLocaleTimeString()}`;
|
||||
return `Hello tRPC @ ${new Date().toLocaleTimeString()}`;
|
||||
}),
|
||||
category: categoryRouter,
|
||||
entry: entryRouter,
|
||||
station: stationRouter,
|
||||
room: roomRouter,
|
||||
patient: patientRouter,
|
||||
user: userRouter,
|
||||
});
|
||||
|
||||
export type Router = typeof router;
|
||||
|
|
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>;
|
||||
};
|
||||
|
||||
export type UserTagNameNonnull = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Station = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
@ -67,6 +72,11 @@ export type Patient = {
|
|||
created_at: Date;
|
||||
};
|
||||
|
||||
export type PatientTag = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PatientNew = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
export type PaginationRequest = Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
limit: number | undefined;
|
||||
offset: number | undefined;
|
||||
}>;
|
||||
|
||||
export type FilterList<T> = T | T[];
|
||||
export type FilterList<T> = T | T[] | { id: T }[];
|
||||
|
||||
export type EntriesFilter = Partial<{
|
||||
search: string;
|
||||
|
|
|
@ -18,7 +18,7 @@ const ZNameString = z.string().min(1).max(200).trim();
|
|||
const ZTextString = z.string().trim();
|
||||
|
||||
export const ZUser = implement<User>().with({
|
||||
id: ZEntityId,
|
||||
id: z.coerce.number().int().nonnegative(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
});
|
||||
|
@ -73,12 +73,10 @@ export const ZPagination = implement<PaginationRequest>().with({
|
|||
offset: ZEntityId.optional(),
|
||||
});
|
||||
|
||||
// const ZFilterList = z
|
||||
// .string()
|
||||
// .regex(/^\d+(;\d+)*$/)
|
||||
// .transform((s) => s.split(";").map(Number))
|
||||
// .optional();
|
||||
const ZFilterList = z.array(ZEntityId).or(ZEntityId);
|
||||
const ZFilterListEntry = z.object({ id: ZEntityId, name: ZNameString.optional() });
|
||||
const ZFilterList = z.array(ZFilterListEntry);
|
||||
const paginatedQuery = (f: z.ZodTypeAny) =>
|
||||
z.object({ filter: f, pagination: ZPagination }).partial();
|
||||
|
||||
export const ZEntriesFilter = z
|
||||
.object({
|
||||
|
@ -94,7 +92,9 @@ export const ZEntriesFilter = z
|
|||
})
|
||||
.partial();
|
||||
|
||||
export const ZPatientsFilterUrl = z
|
||||
export const ZEntriesQuery = paginatedQuery(ZEntriesFilter);
|
||||
|
||||
export const ZPatientsFilter = z
|
||||
.object({
|
||||
search: z.string(),
|
||||
room: ZFilterList,
|
||||
|
@ -102,3 +102,5 @@ export const ZPatientsFilterUrl = z
|
|||
hidden: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const ZPatientsQuery = paginatedQuery(ZPatientsFilter);
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
import type { Router } from "$lib/server/trpc/router";
|
||||
import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
|
||||
|
||||
export type RouterInput = inferRouterInputs<Router>;
|
||||
export type RouterOutput = inferRouterOutputs<Router>;
|
||||
|
||||
let browserClient: ReturnType<typeof createTRPCClient<Router>>;
|
||||
|
||||
/** Get a new tRPC client
|
||||
|
|
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>
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-30 flex h-16 w-full
|
||||
justify-center bg-opacity-90 backdrop-blur"
|
||||
class="sticky top-0 z-30 flex h-12 w-full
|
||||
justify-center bg-neutral"
|
||||
>
|
||||
<nav class="navbar w-full">
|
||||
<nav class="navbar w-full min-h-12">
|
||||
<div class="flex flex-1">
|
||||
<NavLink route="/(app)" href="/"
|
||||
><Icon
|
||||
|
@ -25,7 +25,7 @@
|
|||
<div class="flex-0">
|
||||
{#if $page.data.session?.user}
|
||||
<div class="dropdown dropdown-hover dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost">
|
||||
<div tabindex="0" role="button" class="btn btn-sm btn-ghost">
|
||||
<Icon path={mdiAccount} />
|
||||
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
|
||||
</div>
|
||||
|
@ -47,6 +47,6 @@
|
|||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="max-w-[100vw] px-6 pb-16 xl:pr-2">
|
||||
<div class="max-w-[100vw] p-4 pb-8">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -2,30 +2,37 @@
|
|||
import { page } from "$app/stores";
|
||||
</script>
|
||||
|
||||
<div class="prose">
|
||||
<h1>SvelteKit Auth Example</h1>
|
||||
<svelte:head>
|
||||
<title>Visitenbuch</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $page.data.session?.user}
|
||||
<h1 class="text-2xl font-bold">Hallo, {$page.data.session.user.name}</h1>
|
||||
{:else}
|
||||
<p>Sie sind nicht angemeldet</p>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="grid grid-flow-row gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Planung</h2>
|
||||
<p>Hier können sie neue Visitenbucheinträge erstellen.</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/plan" class="btn btn-primary">Planung</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Visite</h2>
|
||||
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
||||
<p>Heute müssen (n) Einträge erledigt werden.</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/visit" class="btn btn-primary">Visite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{#if $page.data.session}
|
||||
{#if $page.data.session.user?.image}
|
||||
<span
|
||||
style="background-image: url('{$page.data.session.user.image}')"
|
||||
class="avatar"
|
||||
/>
|
||||
{/if}
|
||||
<span class="signedInText">
|
||||
<small>Signed in as</small><br />
|
||||
<strong>{$page.data.session.user?.name ?? "User"}</strong>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="notSignedInText">You are not signed in</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<code>
|
||||
{JSON.stringify($page.data.session)}
|
||||
</code>
|
||||
</p>
|
||||
<p><code>{new Date().toUTCString()}</code></p>
|
||||
|
|
|
@ -1,10 +1,67 @@
|
|||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
import EntryTable from "$lib/components/table/EntryTable.svelte";
|
||||
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
|
||||
import FilterBar from "$lib/components/filter/FilterBar.svelte";
|
||||
import type { PaginationRequest } from "$lib/shared/model";
|
||||
import type { FilterQdata } from "$lib/components/filter/types";
|
||||
import { trpc } from "$lib/shared/trpc";
|
||||
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let loading = false;
|
||||
|
||||
function paginationUpdate(pagination: PaginationRequest) {
|
||||
updateQuery({
|
||||
filter: data.query.filter,
|
||||
pagination,
|
||||
});
|
||||
}
|
||||
|
||||
function filterUpdate(filter: FilterQdata) {
|
||||
updateQuery({ filter });
|
||||
}
|
||||
|
||||
function updateQuery(query: typeof data.query) {
|
||||
if (browser) {
|
||||
// Update page URL (not using the Svelte Router here because
|
||||
// we do not need to reload the page)
|
||||
let url = "?q=" + JSON.stringify(query);
|
||||
window.history.replaceState(null, "", url);
|
||||
|
||||
loading = true;
|
||||
trpc()
|
||||
.entry.list.query(query)
|
||||
.then((entries) => {
|
||||
data.entries = entries;
|
||||
data.query = query;
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-4xl">Planung</h1>
|
||||
<svelte:head>
|
||||
<title>Visitenbuch - Planung</title>
|
||||
</svelte:head>
|
||||
|
||||
<p>{data.greeting}</p>
|
||||
<p>{JSON.stringify(data.categories)}</p>
|
||||
<FilterBar
|
||||
FILTERS={ENTRY_FILTERS}
|
||||
filterData={data.query.filter}
|
||||
onUpdate={filterUpdate}
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
|
||||
<EntryTable entries={data.entries} />
|
||||
|
||||
<PaginationButtons
|
||||
paginationData={data.query.pagination}
|
||||
data={data.entries}
|
||||
onUpdate={paginationUpdate}
|
||||
/>
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
||||
import { trpc } from "$lib/shared/trpc";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async (event) => {
|
||||
const [greeting, categories] = await Promise.all([
|
||||
trpc(event).greeting.query(),
|
||||
trpc(event).category.list.query(),
|
||||
]);
|
||||
const q = event.url.searchParams.get("q");
|
||||
let query: z.infer<typeof ZEntriesQuery> = {};
|
||||
|
||||
return { greeting, categories };
|
||||
if (q) {
|
||||
query = ZEntriesQuery.parse(JSON.parse(q));
|
||||
}
|
||||
|
||||
const entries = await trpc(event).entry.list.query(query);
|
||||
|
||||
return { query, entries };
|
||||
};
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Visitenbuch - Visite</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-4xl">Visite</h1>
|
||||
|
|
|
@ -204,9 +204,11 @@ test("get entries", async () => {
|
|||
|
||||
// Filter by category
|
||||
const entriesCategory = await getEntries({ category: 3 }, {});
|
||||
const entriesCategory2 = await getEntries({ category: [3] }, {});
|
||||
expect(entriesCategory.items).length(1);
|
||||
expect(entriesCategory.total).toBe(1);
|
||||
expect(entriesCategory.items[0].id).toBe(eId1);
|
||||
expect(entriesCategory2).toStrictEqual(entriesCategory);
|
||||
|
||||
// Filter by author
|
||||
const entriesAuthor = await getEntries({ author: 2 }, {});
|
||||
|
|
|
@ -17,10 +17,12 @@ test("get users", async () => {
|
|||
{
|
||||
id: 1,
|
||||
name: "Sven Schulz",
|
||||
email: "sven.schulz@example.com",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Sabrina Loewe",
|
||||
email: "sabrina.loewe@example.com",
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
"strict": true,
|
||||
},
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
|
|
Loading…
Reference in a new issue