Compare commits

...

4 commits

37 changed files with 611 additions and 123 deletions

View file

@ -23,6 +23,7 @@
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.8.1",
"svelte-floating-ui": "^1.5.8",
"svelte-markdown": "^0.4.1",
"zod": "^3.22.4"
},
"devDependencies": {

View file

@ -23,6 +23,9 @@ dependencies:
svelte-floating-ui:
specifier: ^1.5.8
version: 1.5.8
svelte-markdown:
specifier: ^0.4.1
version: 0.4.1(svelte@4.2.9)
zod:
specifier: ^3.22.4
version: 3.22.4
@ -856,6 +859,10 @@ packages:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
/@types/marked@5.0.2:
resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==}
dev: false
/@types/node@20.11.7:
resolution: {integrity: sha512-GPmeN1C3XAyV5uybAf4cMLWT9fDWcmQhZVtMFu7OR32WjrqGG+Wnk2V1d0bmtUyE/Zy1QJ9BxyiTih9z8Oks8A==}
dependencies:
@ -2096,6 +2103,12 @@ packages:
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
/marked@5.1.2:
resolution: {integrity: sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==}
engines: {node: '>= 16'}
hasBin: true
dev: false
/mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
@ -2872,6 +2885,16 @@ packages:
dependencies:
svelte: 4.2.9
/svelte-markdown@0.4.1(svelte@4.2.9):
resolution: {integrity: sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==}
peerDependencies:
svelte: ^4.0.0
dependencies:
'@types/marked': 5.0.2
marked: 5.1.2
svelte: 4.2.9
dev: false
/svelte-preprocess@5.1.3(postcss@8.4.33)(svelte@4.2.9)(typescript@5.3.3):
resolution: {integrity: sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==}
engines: {node: '>= 16.0.0', pnpm: ^8.0.0}

View file

@ -78,7 +78,7 @@ CREATE TABLE "entry_versions" (
"id" SERIAL NOT NULL,
"entry_id" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"date" DATE NOT NULL,
"category_id" INTEGER,
"priority" BOOLEAN NOT NULL,
"author_id" INTEGER NOT NULL,

View file

@ -116,7 +116,7 @@ model EntryVersion {
entry_id Int
text String
date DateTime
date DateTime @db.Date
category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull)
category_id Int?

View file

@ -7,6 +7,10 @@ button {
text-align: initial;
}
.heading {
@apply text-2xl font-bold;
}
.ellipsis {
overflow: hidden;
white-space: nowrap;

28
src/hooks.client.ts Normal file
View file

@ -0,0 +1,28 @@
import type { HandleClientError } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client";
const CHECK_CONNECTION =
"Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
export const handleError: HandleClientError = async ({ error, message, status }) => {
// If there are client-side errors, SvelteKit always returns the nondescript
// "Internal error" message. The most common errors should be mapped to a more
// detailed description
if (status === 500) {
// eslint-disable-next-line no-console
console.error("Client error:", error);
if (
error instanceof TypeError &&
error.message.includes("dynamically imported module")
) {
// Could not load JS module
message = CHECK_CONNECTION;
} else if (error instanceof TRPCClientError && !error.data) {
// Could not load fetched data
message = CHECK_CONNECTION;
}
}
return { message };
};

View file

@ -65,6 +65,7 @@ export const handle = sequence(
return opt.session;
},
},
trustHost: true,
}),
authorization,
createTRPCHandle({ router, createContext })

View file

@ -22,6 +22,7 @@
export let cacheKey: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let padding = true;
export let cls = "";
/** Selection callback. Returns the new input value after selection */
export let onSelect: (item: SelectionOrText, kb: boolean) => OnSelectResult = (
@ -195,7 +196,7 @@
});
</script>
<div class="flex-grow" use:outclick on:outclick={close}>
<div class="flex-grow {cls}" use:outclick on:outclick={close}>
<input
class="w-full bg-transparent"
class:px-2={padding}

View file

@ -40,8 +40,8 @@
);
// Load query data if present
$: if (filterData) {
updateFromQueryData(filterData);
$: if (filterData || !filterData) {
updateFromQueryData(filterData ?? {});
}
function updateFromQueryData(filterData: FilterQdata) {
@ -154,7 +154,7 @@
<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"
class="flex flex-wrap w-full items-stretch h-auto p-0 gap-2 input input-sm input-bordered relative"
>
{#each activeFilters as fdata, i}
<EntryFilterChip
@ -171,6 +171,7 @@
{/each}
<Autocomplete
bind:this={autocomplete}
cls="mr-8"
items={filterMenuItems}
{hiddenIds}
placeholder="Filter"
@ -185,7 +186,7 @@
}}
/>
<button
class="btn btn-sm btn-circle btn-ghost"
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"
aria-label="Alle Filter entfernen"
on:click={() => {
activeFilters = [];

View file

@ -2,6 +2,7 @@ import { trpc } from "$lib/shared/trpc";
import {
mdiAccount,
mdiAccountInjury,
mdiAccountRemoveOutline,
mdiBedKingOutline,
mdiCheckboxBlankOutline,
mdiCheckboxOutline,
@ -91,3 +92,36 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
inputType: 1,
},
};
export const PATIENT_FILTER: { [key: string]: FilterDef } = {
search: {
id: "search",
name: "Suche",
icon: mdiMagnify,
inputType: 1,
},
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();
},
},
hidden: {
id: "hidden",
name: "Ausgeblendet",
icon: mdiAccountRemoveOutline,
inputType: 0,
},
};

View file

@ -33,10 +33,17 @@
<tbody>
{#each entries.items as entry}
<tr
class="transition-colors hover:bg-neutral-content/10"
class:done={entry.execution}
class:priority={entry.current_version.priority}
>
<td>{entry.id}</td>
<td
><a
class="btn btn-xs btn-primary"
href="/entry/{entry.id}"
aria-label="Eintrag anzeigen">{entry.id}</a
></td
>
<td><PatientField patient={entry.patient} /></td>
<td
>{#if entry.patient.room}<RoomField room={entry.patient.room} />{/if}</td

View file

@ -19,4 +19,6 @@
}
</script>
<button on:click={onClick}>{`${patient.first_name} ${patient.last_name}`}</button>
<button class="ellipsis" on:click={onClick}
>{`${patient.first_name} ${patient.last_name}`}</button
>

View file

@ -0,0 +1,64 @@
<script lang="ts">
import type { SortRequest } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate } from "$lib/shared/util";
import { mdiClose } from "@mdi/js";
import Icon from "../ui/Icon.svelte";
import RoomField from "./RoomField.svelte";
import SortHeader from "./SortHeader.svelte";
export let patients: RouterOutput["patient"]["list"];
export let sortData: SortRequest | undefined = undefined;
export let sortUpdate: (sort: SortRequest) => void = () => {};
</script>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} />
<SortHeader title="Name" key="name" {sortData} {sortUpdate} />
<SortHeader title="Alter" key="age" {sortData} {sortUpdate} />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} />
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} />
<th />
</tr>
</thead>
<tbody>
{#each patients.items as patient}
<tr
class="transition-colors hover:bg-neutral-content/10"
class:hidden={patient.hidden}
>
<td
><a
class="btn btn-xs btn-primary"
href="/patient/{patient.id}"
aria-label="Eintrag anzeigen">{patient.id}</a
></td
>
<td>{patient.first_name} {patient.last_name}</td>
<td>{patient.age}</td>
<td>
{#if patient.room}
<RoomField room={patient.room} />
{/if}
</td>
<td>{formatDate(patient.created_at, true)}</td>
<td>
<button class="btn btn-circle btn-ghost btn-xs"
><Icon size={1.2} path={mdiClose} /></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style lang="postcss">
.hidden {
@apply bg-red-500/20;
}
</style>

View file

@ -14,4 +14,4 @@
}
</script>
<button on:click={onClick}>{user.name}</button>
<button class="ellipsis" on:click={onClick}>{user.name}</button>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { TRPCErrorResponse } from "$lib/shared/model";
export let error: TRPCErrorResponse;
export let status: number | undefined = undefined;
export let withBtn = false;
$: statusCode = status ?? error.data?.httpStatus;
</script>
{#if statusCode === 404}
<div class="card m-4 bg-primary text-primary-content">
<div class="card-body items-center">
<h2 class="card-title">Fehler 404:</h2>
<p>Die Seite wurde nicht gefunden</p>
{#if withBtn}
<a class="btn" href="/">Zur Startseite</a>
{/if}
</div>
</div>
{:else}
<div class="card m-4 bg-error text-error-content">
<div class="card-body items-center">
<h2 class="card-title">Fehler {statusCode}:</h2>
<p>{error.message}</p>
{#if withBtn}
{#if statusCode && statusCode < 500}
<a class="btn" href="/">Zur Startseite</a>
{:else}
<a class="btn" data-sveltekit-reload href=".">Neu laden</a>{/if}
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,60 @@
<script lang="ts">
export let cls = "";
export let alwaysShown = false;
let navprogress = 0;
let navInterval: ReturnType<typeof setInterval>;
let showProgress = false;
let showError = false;
export function start() {
navprogress = 5;
showProgress = true;
showError = false;
navInterval = setInterval(() => {
navprogress += navprogress <= 90 ? 2.5 : navprogress < 95 ? 0.1 : 0;
}, 500);
}
export function reset() {
clearInterval(navInterval);
navprogress = 100;
setTimeout(() => {
showProgress = false;
}, 300);
setTimeout(() => {
navprogress = 0;
}, 500);
}
export function error() {
showError = true;
reset();
}
</script>
<div
class={cls}
style:width={`${navprogress}%`}
class:active={alwaysShown || showProgress}
class:error={showError}
/>
<style lang="postcss">
div {
height: 0;
transition-property: width, height;
transition-duration: 150ms;
@apply bg-primary;
}
.active {
height: 0.2rem;
}
.error {
@apply bg-error !important;
}
</style>

View file

@ -1,71 +0,0 @@
/*
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
import { ZodError } from "zod";
import {
Ok,
BadRequest,
NotFound,
Conflict,
InternalServerError,
} from "sveltekit-zero-api/http";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { userId } from "./util";
import type { RequestEvent } from "@sveltejs/kit";
function handleError(error: unknown) {
if (error instanceof ZodError) {
// return { status: 400, msg: "Invalid input", data: error.flatten() };
return BadRequest({
body: { status: 400, msg: "Invalid input", data: error.flatten() },
});
} else if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2025") {
return NotFound({
body: { status: 404, msg: error.message },
});
} else {
return InternalServerError({
body: { status: 500, msg: error.message, prismaCode: error.code },
});
}
} else if (error instanceof ErrorNotFound) {
return NotFound({
body: { status: 404, msg: error.message },
});
} else if (error instanceof ErrorConflict) {
return Conflict({
body: { status: 409, msg: error.message },
});
} else if (error instanceof Error) {
return InternalServerError({
body: { status: 500, msg: error.message },
});
} else {
return InternalServerError({
body: { status: 500, msg: "unknown error" },
});
}
}
export async function apiWrap<T>(f: () => Promise<T>) {
try {
const body = await f();
return Ok({ body });
} catch (error) {
return handleError(error);
}
}
export async function apiWrapUser<T>(
event: RequestEvent,
f: (uId: number) => Promise<T>
) {
try {
const uId = await userId(event);
const body = await f(uId);
return Ok({ body });
} catch (error) {
return handleError(error);
}
}
*/

View file

@ -0,0 +1,42 @@
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
import { ZodError } from "zod";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { TRPCError } from "@trpc/server";
function handleError(error: unknown): void {
if (error instanceof ZodError) {
// return { status: 400, msg: "Invalid input", data: error.flatten() };
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input",
cause: error.flatten(),
});
} else if (error instanceof PrismaClientKnownRequestError) {
if (error.code === "P2025") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error.message,
cause: "Prisma error " + error.code,
});
}
} else if (error instanceof ErrorNotFound) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
} else if (error instanceof ErrorConflict) {
throw new TRPCError({ code: "CONFLICT", message: error.message });
} else if (error instanceof Error) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: error.message });
} else {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "unknown error" });
}
}
/** Wrap a TRPC query to handle occuring errors */
export async function trpcWrap<T>(f: () => Promise<T>) {
try {
return await f();
} catch (error) {
return handleError(error);
}
}

View file

@ -1,4 +1,6 @@
import { initTRPC } from "@trpc/server";
import type { Context } from "./context";
export { trpcWrap } from "$lib/server/trpc/handleError";
export const t = initTRPC.context<Context>().create();

View file

@ -1 +1,4 @@
import type { Renderers } from "svelte-markdown";
export const PAGINATION_LIMIT = 20;
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined };

View file

@ -34,3 +34,13 @@ export type PatientsFilter = Partial<{
room: FilterList<number>;
hidden: boolean;
}>;
export type TRPCErrorResponse = {
message: string;
data?: {
code: string;
httpStatus: number;
stack?: string;
path?: string;
};
};

View file

@ -15,6 +15,7 @@ import type {
} from ".";
export const ZEntityId = z.number().int().nonnegative();
export const ZUrlEntityId = z.coerce.number().int().nonnegative();
const ZNameString = z.string().min(1).max(200).trim();
const ZTextString = z.string().trim();

View file

@ -1,5 +1,7 @@
import { goto } from "$app/navigation";
import type { EntityQuery } from "$lib/shared/model";
import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit";
export function formatDate(date: Date | string, time = false): string {
let dt = date;
@ -23,14 +25,13 @@ export function formatDate(date: Date | string, time = false): string {
}
}
export function getQueryUrl(q: EntityQuery, basePath: string = ""): string {
return basePath + "?q=" + JSON.stringify(q);
export function getQueryUrl(q: EntityQuery, basePath: string): string {
return basePath + "/" + JSON.stringify(q);
}
export function gotoEntityQuery(q: EntityQuery, basePath: string) {
if (window && basePath === window.location.pathname) {
const ws = new URLSearchParams(window.location.search);
const qstr = ws.get("q");
if (window && window.location.pathname.startsWith(basePath + "/")) {
const qstr = decodeURI(window.location.pathname.substring(basePath.length + 1));
if (qstr) {
const oldq: EntityQuery = JSON.parse(qstr);
const nq: EntityQuery = {
@ -43,3 +44,21 @@ export function gotoEntityQuery(q: EntityQuery, basePath: string) {
}
goto(getQueryUrl(q, basePath));
}
/** Wrap a page load query to handle occuring errors
*
* Converts TRPC errors to SvelteKit ones
*/
export async function loadWrap<T>(f: () => Promise<T>) {
try {
return await f();
} catch (e) {
if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof Error) {
error(500, e.message);
} else {
error(500, "unknown error");
}
}
}

View file

@ -19,8 +19,9 @@
cls={$page.route.id === "/(app)" ? "text-primary" : ""}
/></NavLink
>
<NavLink route="/(app)/plan" href="/plan">Planung</NavLink>
<NavLink route="/(app)/plan/[[query]]" href="/plan">Planung</NavLink>
<NavLink route="/(app)/visit" href="/visit">Visite</NavLink>
<NavLink route="/(app)/patients/[[query]]" href="/patients">Patienten</NavLink>
</div>
<div class="flex-0">
{#if $page.data.session?.user}
@ -34,6 +35,9 @@
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
>
<li><a href="/rooms">Zimmer</a></li>
<li><a href="/categories">Kategorien</a></li>
<li><a href="/users">Benutzer</a></li>
<li>
<button
on:click={() =>

View file

@ -7,7 +7,7 @@
</svelte:head>
{#if $page.data.session?.user}
<h1 class="text-2xl font-bold">Hallo, {$page.data.session.user.name}</h1>
<h1 class="heading">Hallo, {$page.data.session.user.name}</h1>
{:else}
<p>Sie sind nicht angemeldet</p>
{/if}
@ -35,4 +35,6 @@
</div>
</div>
</div>
<a href="/plan/%7B%22filter%22:%7B%22done%22:%22foo%22%7D%7D">Throw error</a>
</div>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import SvelteMarkdown from "svelte-markdown";
import type { PageData } from "./$types";
import { formatDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte";
import RoomField from "$lib/components/table/RoomField.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import { MARKDOWN_RENDERERS } from "$lib/shared/constants";
export let data: PageData;
</script>
<svelte:head>
<title>Eintrag #{data.entry.id}</title>
</svelte:head>
<h1 class="heading">
Eintrag #{data.entry.id}
{#if data.entry.current_version.category}
<CategoryField category={data.entry.current_version.category} />
{/if}
</h1>
<div class="box">
<p>
{#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} />
{/if}
{data.entry.patient.first_name}
{data.entry.patient.last_name}
({data.entry.patient.age})
</p>
</div>
<div class="box">
<p class="prose">
<SvelteMarkdown
source={data.entry.current_version.text}
renderers={MARKDOWN_RENDERERS}
/>
</p>
</div>
{#if data.entry.execution}
<div class="box">
<p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />:
</p>
<p class="prose">
<SvelteMarkdown
source={data.entry.execution?.text}
renderers={MARKDOWN_RENDERERS}
/>
</p>
</div>
{/if}
<style lang="postcss">
p {
@apply my-2;
}
.box {
@apply rounded-xl border-solid border-2 border-base-content/40;
@apply px-2 my-4;
}
</style>

View file

@ -0,0 +1,11 @@
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const id = ZUrlEntityId.parse(event.params.id);
const entry = await loadWrap(async () => trpc(event).entry.get.query(id));
return { entry };
};

View file

@ -2,14 +2,14 @@
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, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types";
import { trpc } from "$lib/shared/trpc";
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
import { ENTRY_FILTERS, PATIENT_FILTER } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util";
import PatientTable from "$lib/components/table/PatientTable.svelte";
export let data: PageData;
@ -39,14 +39,14 @@
if (browser) {
// Update page URL (not using the Svelte Router here because
// we do not need to reload the page)
const url = getQueryUrl(query);
const url = getQueryUrl(query, "/patients");
window.history.replaceState(null, "", url);
loading = true;
trpc()
.entry.list.query(query)
.then((entries) => {
data.entries = entries;
.patient.list.query(query)
.then((patients) => {
data.patients = patients;
data.query = query;
loading = false;
});
@ -55,11 +55,11 @@
</script>
<svelte:head>
<title>Visitenbuch - Planung</title>
<title>Patienten</title>
</svelte:head>
<FilterBar
FILTERS={ENTRY_FILTERS}
FILTERS={PATIENT_FILTER}
filterData={data.query.filter}
onUpdate={filterUpdate}
/>
@ -68,10 +68,10 @@
<p>Loading...</p>
{/if}
<EntryTable entries={data.entries} sortData={data.query.sort} {sortUpdate} />
<PatientTable patients={data.patients} sortData={data.query.sort} {sortUpdate} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.entries}
data={data.patients}
onUpdate={paginationUpdate}
/>

View file

@ -0,0 +1,18 @@
import { z } from "zod";
import { ZPatientsQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import type { PageLoad } from "./$types";
import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => {
let query: z.infer<typeof ZPatientsQuery> = {};
if (event.params.query) {
query = ZPatientsQuery.parse(JSON.parse(event.params.query));
}
const patients = await loadWrap(async () => trpc(event).patient.list.query(query));
return { query, patients };
};

View file

@ -1,18 +0,0 @@
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 q = event.url.searchParams.get("q");
let query: z.infer<typeof ZEntriesQuery> = {};
if (q) {
query = ZEntriesQuery.parse(JSON.parse(q));
}
const entries = await trpc(event).entry.list.query(query);
return { query, entries };
};

View file

@ -0,0 +1,90 @@
<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, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types";
import { trpc } from "$lib/shared/trpc";
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
export let data: PageData;
let loadingBar: LoadingBar | undefined;
let error: Error | null = null;
function paginationUpdate(pagination: PaginationRequest) {
updateQuery({
filter: data.query.filter,
pagination,
sort: data.query.sort,
});
}
function filterUpdate(filter: FilterQdata) {
updateQuery({ filter, sort: data.query.sort });
}
function sortUpdate(sort: SortRequest) {
updateQuery({
filter: data.query.filter,
pagination: data.query.pagination,
sort,
});
}
function updateQuery(query: typeof data.query) {
if (browser) {
// Update page URL (not using the Svelte Router here because
// we do not need to reload the page)
const url = getQueryUrl(query, "/plan");
window.history.replaceState(null, "", url);
loadingBar?.start();
trpc()
.entry.list.query(query)
.then((entries) => {
data.entries = entries;
error = null;
loadingBar?.reset();
})
.catch((err) => {
data.query = query;
error = err;
loadingBar?.error();
})
.finally(() => {
data.query = query;
});
}
}
</script>
<svelte:head>
<title>Planung</title>
</svelte:head>
<FilterBar
FILTERS={ENTRY_FILTERS}
filterData={data.query.filter}
onUpdate={filterUpdate}
/>
<LoadingBar bind:this={loadingBar} alwaysShown />
{#if error}
<ErrorMessage {error} />
{:else}
<EntryTable entries={data.entries} sortData={data.query.sort} {sortUpdate} />
<PaginationButtons
paginationData={data.query.pagination}
data={data.entries}
onUpdate={paginationUpdate}
/>
{/if}

View file

@ -0,0 +1,15 @@
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
let query = {};
if (event.params.query) {
query = JSON.parse(event.params.query);
}
const entries = await loadWrap(() => trpc(event).entry.list.query(query));
return { query, entries };
};

View file

@ -2,7 +2,7 @@
</script>
<svelte:head>
<title>Visitenbuch - Visite</title>
<title>Visite</title>
</svelte:head>
<h1 class="text-4xl">Visite</h1>

15
src/routes/+error.svelte Normal file
View file

@ -0,0 +1,15 @@
<script lang="ts">
import { page } from "$app/stores";
import ErrorMessage from "$lib/components/ui/ErrorMessage.svelte";
console.log("Fehlerhafte Seite", $page);
</script>
<svelte:head>
<title>Visitenbuch: Fehler {$page.status}</title>
</svelte:head>
{#if $page.error}
<ErrorMessage error={$page.error} status={$page.status} withBtn />
{/if}

View file

@ -1,7 +1,23 @@
<script lang="ts">
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
import { afterNavigate, beforeNavigate } from "$app/navigation";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
let loadingBar: LoadingBar | undefined;
beforeNavigate(() => {
if (loadingBar) loadingBar.start();
});
afterNavigate(() => {
if (loadingBar) loadingBar.reset();
});
</script>
<LoadingBar cls="fixed top-0 left-0 z-50" bind:this={loadingBar} />
<div class="bg-base-100 text-base-content">
<slot />
</div>

View file

@ -15,7 +15,7 @@ const CATEGORY_IDS: { [id: string]: number } = {
Entlassung: 5,
Sonstiges: 6,
};
const refDate = new Date(2023, 11, 1);
const refDate = new Date(Date.UTC(2023, 11, 1));
const N_USERS = 10;
const N_ROOMS = 20;

View file

@ -12,7 +12,7 @@ import { expect, test } from "vitest";
const TEST_VERSION = {
category_id: 1,
text: "10ml Blut abnehmen",
date: new Date(2024, 1, 1),
date: new Date(Date.UTC(2024, 1, 1)),
priority: false,
};
@ -40,7 +40,7 @@ test("create entry version", async () => {
version: TEST_VERSION,
});
const text = "10ml Blut abnehmen\n\nPS: Nadel nicht vergessen";
const date = new Date(2024, 1, 2);
const date = new Date(Date.UTC(2024, 1, 2));
await newEntryVersion(1, eId, {
date,
@ -155,7 +155,7 @@ async function insertTestEntries() {
patient_id: 2,
version: {
text: "Carrot cake jelly-o bonbon toffee chocolate.",
date: new Date(2024, 1, 5),
date: new Date(Date.UTC(2024, 1, 5)),
priority: false,
category_id: null,
},
@ -164,7 +164,7 @@ async function insertTestEntries() {
patient_id: 1,
version: {
text: "Cheesecake danish donut oat cake caramels.",
date: new Date(2024, 1, 6),
date: new Date(Date.UTC(2024, 1, 6)),
priority: false,
category_id: null,
},
@ -174,7 +174,7 @@ async function insertTestEntries() {
await newEntryVersion(2, eId1, {
category_id: 3,
text: TEST_VERSION.text + "\n\n> Hello World",
date: new Date(2024, 1, 1),
date: new Date(Date.UTC(2024, 1, 1)),
priority: true,
});