Compare commits

..

2 commits

Author SHA1 Message Date
16cd49456c
feat: add entry version editor 2024-03-05 01:31:21 +01:00
80c8371253
feat: create entry execution 2024-03-04 17:25:01 +01:00
19 changed files with 348 additions and 72 deletions

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { RouterOutput } from "$lib/shared/trpc";
import RoomField from "$lib/components/table/RoomField.svelte";
export let patient: RouterOutput["patient"]["list"]["items"][0];
</script>
<div class="card2">
<div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2">
{#if patient.room}
<RoomField room={patient.room} />
{/if}
<a href="/patient/{patient.id}">
{patient.first_name}
{patient.last_name}
({patient.age})
</a>
</div>
</div>

View file

@ -92,10 +92,10 @@
if (i !== -1) { if (i !== -1) {
highlightIndex = i; highlightIndex = i;
} }
if (asTextInput) setInputValue(selection.name || ""); if (asTextInput) setInputValue(selection.name ?? "");
} else { } else {
highlightIndex = 0; highlightIndex = 0;
setInputValue(selection.name || ""); setInputValue(selection.name ?? "");
} }
} else { } else {
highlightIndex = 0; highlightIndex = 0;

View file

@ -72,7 +72,7 @@ gap-1 pl-1"
{@const hids = hiddenIds()} {@const hids = hiddenIds()}
<Autocomplete <Autocomplete
bind:this={autocomplete} bind:this={autocomplete}
items={filter.options || []} items={filter.options ?? []}
hiddenIds={hids} hiddenIds={hids}
{cache} {cache}
cacheKey={filter.id} cacheKey={filter.id}
@ -84,7 +84,7 @@ gap-1 pl-1"
fdata.selection = item; fdata.selection = item;
return { close: true, newValue: "" }; return { close: true, newValue: "" };
} else { } else {
return { close: false, newValue: item.name || "" }; return { close: false, newValue: item.name ?? "" };
} }
}} }}
{onClose} {onClose}

View file

@ -16,8 +16,8 @@
{/if} {/if}
<h1 class="heading">{title}</h1> <h1 class="heading">{title}</h1>
<slot /> <slot />
</div> </div>
<!-- Button on the right side (apply ml-auto) -->
<slot name="rightBtn" /> <slot name="rightBtn" />
</div> </div>

View file

@ -9,6 +9,7 @@
export let errors: string[] | undefined = undefined; export let errors: string[] | undefined = undefined;
export let ariaInvalid: boolean | undefined = undefined; export let ariaInvalid: boolean | undefined = undefined;
export let constraints: InputConstraint | undefined = undefined; export let constraints: InputConstraint | undefined = undefined;
export let marginTop = false;
let editMode = true; let editMode = true;
@ -17,20 +18,17 @@
} }
</script> </script>
<FormField {label} {errors} fullWidth> <div class="card2" class:mt-4={marginTop}>
<button <div class="row c-light text-sm items-center justify-between">
type="button" <span>{label}</span>
slot="topLabel" <button type="button" class="label-text" on:click={toggle} tabindex="-1">
class="label-text"
on:click={toggle}
tabindex="-1"
>
{editMode ? "Vorschau" : "Bearbeiten"} {editMode ? "Vorschau" : "Bearbeiten"}
</button> </button>
</div>
<div class="p-2">
{#if editMode} {#if editMode}
<textarea <textarea
class="textarea textarea-bordered w-full" class="textarea w-full h-48"
{name} {name}
aria-invalid={ariaInvalid} aria-invalid={ariaInvalid}
bind:value bind:value
@ -39,4 +37,14 @@
{:else} {:else}
<Markdown src={value} /> <Markdown src={value} />
{/if} {/if}
</FormField>
{#if errors}
<div class="label flex-col items-start">
{#each errors as error}
<span class="label-text-alt text-error">{error}</span>
{/each}
</div>
{/if}
</div>
<slot />
</div>

View file

@ -10,7 +10,7 @@
export let data: Pagination<unknown>; export let data: Pagination<unknown>;
export let onUpdate: (pagination: PaginationRequest) => void = () => {}; export let onUpdate: (pagination: PaginationRequest) => void = () => {};
$: limit = paginationData?.limit || PAGINATION_LIMIT; $: limit = paginationData?.limit ?? PAGINATION_LIMIT;
$: thisPage = Math.floor(data.offset / limit) + 1; // current page number (starting from 1) $: thisPage = Math.floor(data.offset / limit) + 1; // current page number (starting from 1)
$: nPages = Math.ceil(data.total / limit); $: nPages = Math.ceil(data.total / limit);
let windowBottom: number; let windowBottom: number;

View file

@ -105,16 +105,28 @@ export async function newEntryVersion(
throw new ErrorConflict("old version id does not match"); throw new ErrorConflict("old version id does not match");
} }
const created = await tx.entryVersion.create({ const updatedVersion = {
data: {
// Old version // Old version
entry_id, entry_id,
author_id, author_id,
text: version.text || cver.text, text: version.text ?? cver.text,
date: new Date(version.date || cver.date), date: new Date(version.date ?? cver.date),
category_id: version.category_id || cver.category_id, category_id: version.category_id ?? cver.category_id,
priority: version.priority || cver.priority, priority: version.priority ?? cver.priority,
}, };
// Check if there are any updates
if (
cver.text === updatedVersion.text &&
cver.date.getTime() === updatedVersion.date.getTime() &&
cver.category_id === updatedVersion.category_id &&
cver.priority === updatedVersion.priority
) {
return cver.id;
}
const created = await tx.entryVersion.create({
data: updatedVersion,
select: { id: true }, select: { id: true },
}); });
return created.id; return created.id;
@ -147,6 +159,11 @@ export async function newEntryExecution(
throw new ErrorConflict("old execution id does not match"); throw new ErrorConflict("old execution id does not match");
} }
// Check if there are any updates
if (execution.text === cex.text) {
return cex.id;
}
const created = await prisma.entryExecution.create({ const created = await prisma.entryExecution.create({
data: { data: {
entry_id, entry_id,

View file

@ -16,12 +16,12 @@ export async function getUser(id: number): Promise<User> {
export async function getUsers( export async function getUsers(
pagination: PaginationRequest pagination: PaginationRequest
): Promise<Pagination<User>> { ): Promise<Pagination<User>> {
const offset = pagination.offset || 0; const offset = pagination.offset ?? 0;
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
orderBy: { id: "asc" }, orderBy: { id: "asc" },
skip: offset, skip: offset,
take: pagination.limit || PAGINATION_LIMIT, take: pagination.limit ?? PAGINATION_LIMIT,
}), }),
prisma.user.count(), prisma.user.count(),
]); ]);

View file

@ -26,6 +26,16 @@ export const fields = {
.regex(/^\d{4}-\d{2}-\d{2}$/) .regex(/^\d{4}-\d{2}-\d{2}$/)
// @ts-expect-error check date for NaN is valid // @ts-expect-error check date for NaN is valid
.refine((val) => !isNaN(new Date(val))), .refine((val) => !isNaN(new Date(val))),
DateStringFuture: () =>
fields.DateString().refine(
(d) => {
const inp = new Date(d);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return inp >= today;
},
{ message: "Datum muss in der Zukunft liegen" }
),
}; };
export const ZUrlEntityId = z.coerce.number().int().nonnegative(); export const ZUrlEntityId = z.coerce.number().int().nonnegative();

View file

@ -30,18 +30,23 @@ function newOrUndef<T>(o: T, n: T): T | undefined {
return o === n ? undefined : n; return o === n ? undefined : n;
} }
function doDiffWords(s1: string, s2: string): Diff.Change[] {
if (s1 === s2) return [];
return diffWords(s1, s2);
}
export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] { export function versionsDiff(versions: EntryVersion[]): EntryVersionChange[] {
let prev = versions[versions.length - 1]; let prev = versions[versions.length - 1];
const changes: EntryVersionChange[] = [ const changes: EntryVersionChange[] = [
{ {
...prev, ...prev,
text: diffWords("", prev.text), text: [{ value: prev.text }],
}, },
]; ];
for (let i = versions.length - 2; i >= 0; i--) { for (let i = versions.length - 2; i >= 0; i--) {
const v = versions[i]; const v = versions[i];
const text = diffWords(prev.text, v.text); const text = doDiffWords(prev.text, v.text);
changes.push({ changes.push({
id: v.id, id: v.id,
@ -64,13 +69,13 @@ export function executionsDiff(executions: EntryExecution[]): EntryExecutionChan
const changes: EntryExecutionChange[] = [ const changes: EntryExecutionChange[] = [
{ {
...prev, ...prev,
text: diffWords("", prev.text), text: [{ value: prev.text }],
}, },
]; ];
for (let i = executions.length - 2; i >= 0; i--) { for (let i = executions.length - 2; i >= 0; i--) {
const v = executions[i]; const v = executions[i];
const text = diffWords(prev.text, v.text); const text = doDiffWords(prev.text, v.text);
changes.push({ changes.push({
id: v.id, id: v.id,

View file

@ -0,0 +1,25 @@
import { superValidate } from "sveltekit-superforms";
import type { Actions } from "./$types";
import { SchemaEntryExecution } from "./schema";
import { fail } from "@sveltejs/kit";
import { loadWrap } from "$lib/shared/util";
import { trpc } from "$lib/shared/trpc";
import { ZUrlEntityId } from "$lib/shared/model/validation";
export const actions = {
default: async (event) => {
const form = await superValidate(event.request, SchemaEntryExecution);
if (!form.valid) {
return fail(400, { form });
}
await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newExecution.mutate({
id,
old_execution_id: null,
execution: { text: form.data.text },
});
});
},
} satisfies Actions;

View file

@ -2,17 +2,25 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { formatDate, humanDate } from "$lib/shared/util"; import { formatDate, humanDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte"; 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 CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte"; import Markdown from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { mdiPencil } from "@mdi/js"; import { mdiPencil } from "@mdi/js";
import VersionsButton from "$lib/components/ui/VersionsButton.svelte"; import VersionsButton from "$lib/components/ui/VersionsButton.svelte";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
import { SchemaEntryExecution } from "./schema";
import { defaults, superForm } from "sveltekit-superforms";
import PatientCard from "$lib/components/entry/PatientCard.svelte";
export let data: PageData; export let data: PageData;
$: basePath = `/entry/${data.entry.id}`; $: basePath = `/entry/${data.entry.id}`;
let formData = defaults(SchemaEntryExecution);
const { form, errors, enhance } = superForm(formData, {
validators: SchemaEntryExecution,
});
</script> </script>
<svelte:head> <svelte:head>
@ -26,6 +34,9 @@
{#if data.entry.current_version.priority} {#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div> <div class="badge ellipsis badge-warning">Priorität</div>
{/if} {/if}
<a slot="rightBtn" href="{basePath}/edit" class="btn btn-sm btn-primary ml-auto">
Bearbeiten
</a>
</Header> </Header>
<p class="text-sm flex flex-row gap-2"> <p class="text-sm flex flex-row gap-2">
@ -34,28 +45,16 @@
<span>Zu erledigen {humanDate(data.entry.current_version.date)}</span> <span>Zu erledigen {humanDate(data.entry.current_version.date)}</span>
</p> </p>
<div class="card2"> <PatientCard patient={data.entry.patient} />
<div class="row c-light text-sm">Patient</div>
<div class="row items-center gap-2">
{#if data.entry.patient.room}
<RoomField room={data.entry.patient.room} />
{/if}
<a href="/patient/{data.entry.patient.id}">
{data.entry.patient.first_name}
{data.entry.patient.last_name}
({data.entry.patient.age})
</a>
</div>
</div>
<div class="card2"> <div class="card2">
<div class="row c-light text-sm items-center justify-between"> <div class="row c-light text-sm items-center justify-between">
Beschreibung Beschreibung
<div> <div>
<VersionsButton href="{basePath}/versions" n={data.entry.n_versions} /> <VersionsButton href="{basePath}/versions" n={data.entry.n_versions} />
<button class="btn btn-circle btn-sm btn-ghost"> <a href="{basePath}/edit" class="btn btn-circle btn-sm btn-ghost">
<Icon path={mdiPencil} size={1.2} /> <Icon path={mdiPencil} size={1.2} />
</button> </a>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -64,8 +63,10 @@
</p> </p>
</div> </div>
<div class="row c-vlight text-sm"> <div class="row c-vlight text-sm">
Zuletzt bearbeitet am {formatDate(data.entry.current_version.created_at, true)} von&nbsp; <p>
Zuletzt bearbeitet am {formatDate(data.entry.current_version.created_at, true)} von
<UserField user={data.entry.current_version.author} filterName="author" /> <UserField user={data.entry.current_version.author} filterName="author" />
</p>
</div> </div>
</div> </div>
@ -74,19 +75,35 @@
<div class="row c-light text-sm items-center justify-between"> <div class="row c-light text-sm items-center justify-between">
<p> <p>
Erledigt am {formatDate(data.entry.execution.created_at, true)} von Erledigt am {formatDate(data.entry.execution.created_at, true)} von
<UserField user={data.entry.execution.author} filterName="executor" />: <UserField user={data.entry.execution.author} filterName="executor" />
</p> </p>
<div> <div>
<VersionsButton href="{basePath}/executions" n={data.entry.n_executions} /> <VersionsButton href="{basePath}/executions" n={data.entry.n_executions} />
<button class="btn btn-circle btn-xs btn-ghost"> <a href="{basePath}/editExecution" class="btn btn-circle btn-xs btn-ghost">
<Icon path={mdiPencil} size={1.2} /> <Icon path={mdiPencil} size={1.2} />
</button> </a>
</div> </div>
</div> </div>
{#if data.entry.execution?.text}
<div class="row"> <div class="row">
<p class="prose"> <p class="prose">
<Markdown src={data.entry.execution?.text} /> <Markdown src={data.entry.execution?.text} />
</p> </p>
</div> </div>
</div> {/if}
</div>
{:else}
<form method="POST" use:enhance>
<MarkdownInput
label="Eintrag erledigen"
name="text"
ariaInvalid={Boolean($errors.text)}
bind:value={$form.text}
errors={$errors.text}
>
<div class="row c-vlight">
<button type="submit" class="btn btn-sm btn-primary">Erledigt</button>
</div>
</MarkdownInput>
</form>
{/if} {/if}

View file

@ -0,0 +1,28 @@
import { superValidate } from "sveltekit-superforms";
import type { Actions } from "./$types";
import { SchemaNewEntryVersion } from "./schema";
import { fail, redirect } from "@sveltejs/kit";
import { loadWrap } from "$lib/shared/util";
import { trpc } from "$lib/shared/trpc";
import { ZUrlEntityId } from "$lib/shared/model/validation";
export const actions = {
default: async (event) => {
const form = await superValidate(event.request, SchemaNewEntryVersion);
if (!form.valid) {
return fail(400, { form });
}
const entryId = await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newVersion.mutate({
id,
version: form.data,
old_version_id: form.data.old_version_id,
});
return id;
});
throw redirect(302, `/entry/${entryId}`);
},
} satisfies Actions;

View file

@ -0,0 +1,103 @@
<script lang="ts">
import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte";
import PatientCard from "$lib/components/entry/PatientCard.svelte";
import { formatDate, humanDate } from "$lib/shared/util";
import FormField from "$lib/components/ui/FormField.svelte";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import { trpc } from "$lib/shared/trpc";
import { superForm } from "sveltekit-superforms";
import { SchemaNewEntryVersion } from "./schema";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
import UserField from "$lib/components/table/UserField.svelte";
import { browser } from "$app/environment";
export let data: PageData;
$: basePath = `/entry/${data.entry.id}`;
const { form, errors, constraints, enhance, tainted } = superForm(data.form, {
validators: SchemaNewEntryVersion,
});
</script>
<svelte:head>
<title>Eintrag #{data.entry.id}</title>
</svelte:head>
<Header title="Eintrag #{data.entry.id} bearbeiten" backHref={basePath}></Header>
<p class="text-sm flex flex-row gap-2">
<span>Erstellt {humanDate(data.entry.created_at, true)}</span>
<span>·</span>
<span>
Zuletzt bearbeitet am {formatDate(data.entry.current_version.created_at, true)} von
<UserField user={data.entry.current_version.author} filterName="author" />
</span>
</p>
<PatientCard patient={data.entry.patient} />
<form method="POST" use:enhance>
<input type="hidden" name="old_version_id" value={$form.old_version_id} />
<div class="flex flex-wrap gap-2">
<FormField label="Kategorie" errors={$errors.category_id}>
<Autocomplete
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().category.list.query();
}}
selection={data.entry.current_version.category}
onSelect={(item) => {
// @ts-expect-error ids are always numeric
$form.category_id = item.id;
return { newValue: item.name ?? "", close: true };
}}
onUnselect={() => {
$form.category_id = null;
}}
asTextInput
idInputName="category_id"
/>
</FormField>
<FormField label="Zu erledigen am" errors={$errors.date}>
<input
type="date"
name="date"
aria-invalid={Boolean($errors.date)}
bind:value={$form.date}
{...$constraints.date}
/>
</FormField>
<div class="form-control w-full max-w-xs">
<label class="label cursor-pointer gap-2 justify-start">
<span class="label-text text-right">Priorität</span>
<input
type="checkbox"
name="priority"
class="checkbox checkbox-warning"
bind:checked={$form.priority}
/>
</label>
</div>
</div>
<MarkdownInput
label="Beschreibung"
name="text"
marginTop
ariaInvalid={Boolean($errors.text)}
bind:value={$form.text}
errors={$errors.text}
/>
<button
class="btn btn-primary max-w-32 mt-4"
type="submit"
disabled={browser && $tainted === undefined}>Speichern</button
>
</form>

View file

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

View file

@ -0,0 +1,11 @@
import { ZEntryVersionNew, fields } from "$lib/shared/model/validation";
import { zod } from "sveltekit-superforms/adapters";
import { z } from "zod";
export const SchemaNewEntryVersion = zod(
ZEntryVersionNew.extend({
old_version_id: fields.EntityId(),
// Override priority field so checkbox value is always true/false, not optional
priority: z.boolean(),
})
);

View file

@ -0,0 +1,9 @@
import { fields } from "$lib/shared/model/validation";
import { zod } from "sveltekit-superforms/adapters";
import { z } from "zod";
const ZEntryDone = z.object({
text: fields.TextString(),
});
export const SchemaEntryExecution = zod(ZEntryDone);

View file

@ -7,7 +7,6 @@
import { SchemaNewEntryWithPatient } from "./schema"; import { SchemaNewEntryWithPatient } from "./schema";
let formData = defaults(SchemaNewEntryWithPatient); let formData = defaults(SchemaNewEntryWithPatient);
const { form, errors, constraints, enhance } = superForm(formData, { const { form, errors, constraints, enhance } = superForm(formData, {
validators: SchemaNewEntryWithPatient, validators: SchemaNewEntryWithPatient,
}); });
@ -158,6 +157,7 @@
<MarkdownInput <MarkdownInput
label="Beschreibung" label="Beschreibung"
name="text" name="text"
marginTop
ariaInvalid={Boolean($errors.text)} ariaInvalid={Boolean($errors.text)}
bind:value={$form.text} bind:value={$form.text}
errors={$errors.text} errors={$errors.text}

View file

@ -5,7 +5,7 @@ import { dateToYMD } from "$lib/shared/util";
const emsg = "Erforderlich für einen neuen Patienten"; const emsg = "Erforderlich für einen neuen Patienten";
export const ZNewEntryWithPatient = z const ZNewEntryWithPatient = z
.object({ .object({
room_id: fields.EntityId().nullable(), room_id: fields.EntityId().nullable(),
patient_id: fields.EntityId().nullable(), patient_id: fields.EntityId().nullable(),
@ -13,7 +13,7 @@ export const ZNewEntryWithPatient = z
patient_last_name: fields.NameString().nullable(), patient_last_name: fields.NameString().nullable(),
patient_age: fields.Age().nullable(), patient_age: fields.Age().nullable(),
category_id: fields.EntityId().nullable(), category_id: fields.EntityId().nullable(),
date: fields.DateString().default(() => dateToYMD(new Date())), date: fields.DateStringFuture().default(() => dateToYMD(new Date())),
priority: z.boolean(), priority: z.boolean(),
text: fields.TextString(), text: fields.TextString(),
}) })