Compare commits
4 commits
b21c1db887
...
622c8fdd39
Author | SHA1 | Date | |
---|---|---|---|
622c8fdd39 | |||
6589af741b | |||
f5239c787c | |||
c7ea0aa8db |
37 changed files with 611 additions and 123 deletions
|
@ -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": {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
28
src/hooks.client.ts
Normal 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 };
|
||||
};
|
|
@ -65,6 +65,7 @@ export const handle = sequence(
|
|||
return opt.session;
|
||||
},
|
||||
},
|
||||
trustHost: true,
|
||||
}),
|
||||
authorization,
|
||||
createTRPCHandle({ router, createContext })
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
>
|
||||
|
|
64
src/lib/components/table/PatientTable.svelte
Normal file
64
src/lib/components/table/PatientTable.svelte
Normal 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>
|
|
@ -14,4 +14,4 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<button on:click={onClick}>{user.name}</button>
|
||||
<button class="ellipsis" on:click={onClick}>{user.name}</button>
|
||||
|
|
34
src/lib/components/ui/ErrorMessage.svelte
Normal file
34
src/lib/components/ui/ErrorMessage.svelte
Normal 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}
|
60
src/lib/components/ui/LoadingBar.svelte
Normal file
60
src/lib/components/ui/LoadingBar.svelte
Normal 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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
*/
|
42
src/lib/server/trpc/handleError.ts
Normal file
42
src/lib/server/trpc/handleError.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -1 +1,4 @@
|
|||
import type { Renderers } from "svelte-markdown";
|
||||
|
||||
export const PAGINATION_LIMIT = 20;
|
||||
export const MARKDOWN_RENDERERS: Partial<Renderers> = { image: undefined };
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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={() =>
|
||||
|
|
|
@ -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>
|
||||
|
|
69
src/routes/(app)/entry/[id]/+page.svelte
Normal file
69
src/routes/(app)/entry/[id]/+page.svelte
Normal 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>
|
11
src/routes/(app)/entry/[id]/+page.ts
Normal file
11
src/routes/(app)/entry/[id]/+page.ts
Normal 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 };
|
||||
};
|
|
@ -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}
|
||||
/>
|
18
src/routes/(app)/patients/[[query]]/+page.ts
Normal file
18
src/routes/(app)/patients/[[query]]/+page.ts
Normal 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 };
|
||||
};
|
|
@ -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 };
|
||||
};
|
90
src/routes/(app)/plan/[[query]]/+page.svelte
Normal file
90
src/routes/(app)/plan/[[query]]/+page.svelte
Normal 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}
|
15
src/routes/(app)/plan/[[query]]/+page.ts
Normal file
15
src/routes/(app)/plan/[[query]]/+page.ts
Normal 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 };
|
||||
};
|
|
@ -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
15
src/routes/+error.svelte
Normal 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}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue