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) {
highlightIndex = i;
}
if (asTextInput) setInputValue(selection.name || "");
if (asTextInput) setInputValue(selection.name ?? "");
} else {
highlightIndex = 0;
setInputValue(selection.name || "");
setInputValue(selection.name ?? "");
}
} else {
highlightIndex = 0;

View file

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

View file

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

View file

@ -9,6 +9,7 @@
export let errors: string[] | undefined = undefined;
export let ariaInvalid: boolean | undefined = undefined;
export let constraints: InputConstraint | undefined = undefined;
export let marginTop = false;
let editMode = true;
@ -17,20 +18,17 @@
}
</script>
<FormField {label} {errors} fullWidth>
<button
type="button"
slot="topLabel"
class="label-text"
on:click={toggle}
tabindex="-1"
>
<div class="card2" class:mt-4={marginTop}>
<div class="row c-light text-sm items-center justify-between">
<span>{label}</span>
<button type="button" class="label-text" on:click={toggle} tabindex="-1">
{editMode ? "Vorschau" : "Bearbeiten"}
</button>
</div>
<div class="p-2">
{#if editMode}
<textarea
class="textarea textarea-bordered w-full"
class="textarea w-full h-48"
{name}
aria-invalid={ariaInvalid}
bind:value
@ -39,4 +37,14 @@
{:else}
<Markdown src={value} />
{/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 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)
$: nPages = Math.ceil(data.total / limit);
let windowBottom: number;

View file

@ -105,16 +105,28 @@ export async function newEntryVersion(
throw new ErrorConflict("old version id does not match");
}
const created = await tx.entryVersion.create({
data: {
const updatedVersion = {
// Old version
entry_id,
author_id,
text: version.text || cver.text,
date: new Date(version.date || cver.date),
category_id: version.category_id || cver.category_id,
priority: version.priority || cver.priority,
},
text: version.text ?? cver.text,
date: new Date(version.date ?? cver.date),
category_id: version.category_id ?? cver.category_id,
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 },
});
return created.id;
@ -147,6 +159,11 @@ export async function newEntryExecution(
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({
data: {
entry_id,

View file

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

View file

@ -26,6 +26,16 @@ export const fields = {
.regex(/^\d{4}-\d{2}-\d{2}$/)
// @ts-expect-error check date for NaN is valid
.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();

View file

@ -30,18 +30,23 @@ function newOrUndef<T>(o: T, n: T): T | undefined {
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[] {
let prev = versions[versions.length - 1];
const changes: EntryVersionChange[] = [
{
...prev,
text: diffWords("", prev.text),
text: [{ value: prev.text }],
},
];
for (let i = versions.length - 2; i >= 0; i--) {
const v = versions[i];
const text = diffWords(prev.text, v.text);
const text = doDiffWords(prev.text, v.text);
changes.push({
id: v.id,
@ -64,13 +69,13 @@ export function executionsDiff(executions: EntryExecution[]): EntryExecutionChan
const changes: EntryExecutionChange[] = [
{
...prev,
text: diffWords("", prev.text),
text: [{ value: prev.text }],
},
];
for (let i = executions.length - 2; i >= 0; i--) {
const v = executions[i];
const text = diffWords(prev.text, v.text);
const text = doDiffWords(prev.text, v.text);
changes.push({
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 { formatDate, humanDate } 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 from "$lib/components/ui/Markdown.svelte";
import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { mdiPencil } from "@mdi/js";
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;
$: basePath = `/entry/${data.entry.id}`;
let formData = defaults(SchemaEntryExecution);
const { form, errors, enhance } = superForm(formData, {
validators: SchemaEntryExecution,
});
</script>
<svelte:head>
@ -26,6 +34,9 @@
{#if data.entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div>
{/if}
<a slot="rightBtn" href="{basePath}/edit" class="btn btn-sm btn-primary ml-auto">
Bearbeiten
</a>
</Header>
<p class="text-sm flex flex-row gap-2">
@ -34,28 +45,16 @@
<span>Zu erledigen {humanDate(data.entry.current_version.date)}</span>
</p>
<div class="card2">
<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>
<PatientCard patient={data.entry.patient} />
<div class="card2">
<div class="row c-light text-sm items-center justify-between">
Beschreibung
<div>
<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} />
</button>
</a>
</div>
</div>
<div class="row">
@ -64,8 +63,10 @@
</p>
</div>
<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" />
</p>
</div>
</div>
@ -74,19 +75,35 @@
<div class="row c-light text-sm items-center justify-between">
<p>
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>
<div>
<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} />
</button>
</a>
</div>
</div>
{#if data.entry.execution?.text}
<div class="row">
<p class="prose">
<Markdown src={data.entry.execution?.text} />
</p>
</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}

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

View file

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