Compare commits
8 commits
152824f6c0
...
cfe5fc2d9f
Author | SHA1 | Date | |
---|---|---|---|
cfe5fc2d9f | |||
a6f45e32ed | |||
9d64d40127 | |||
66a584f7e5 | |||
a2b448814c | |||
0aba22fb40 | |||
52d50e75ce | |||
95a5c17e5a |
43 changed files with 649 additions and 110 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
36
Dockerfile
Normal file
36
Dockerfile
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# stage build
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# copy everything to the container
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# clean install all dependencies
|
||||||
|
RUN corepack enable && pnpm i
|
||||||
|
|
||||||
|
# remove potential security issues
|
||||||
|
RUN pnpm audit fix
|
||||||
|
|
||||||
|
# build SvelteKit appmakeAuthjsRequest
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|
||||||
|
# stage run
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# copy dependency list
|
||||||
|
COPY --from=0 /app/package*.json /app/pnpm-lock.yaml ./
|
||||||
|
COPY --from=0 /app/patches ./patches
|
||||||
|
COPY --from=0 /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Setup pnpm, install Prisma CLI (for generating client) and install dependencies
|
||||||
|
RUN corepack enable && pnpm i --prod && pnpm audit fix && npx prisma generate
|
||||||
|
|
||||||
|
# copy built SvelteKit app to /app
|
||||||
|
COPY --from=0 /app/build ./
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "./index.js"]
|
|
@ -415,18 +415,6 @@ export default [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// disallow dangling underscores in identifiers
|
|
||||||
// https://eslint.org/docs/rules/no-underscore-dangle
|
|
||||||
"no-underscore-dangle": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
allow: [],
|
|
||||||
allowAfterThis: false,
|
|
||||||
allowAfterSuper: false,
|
|
||||||
enforceInMethodNames: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// disallow the use of Boolean literals in conditional expressions
|
// disallow the use of Boolean literals in conditional expressions
|
||||||
// also, prefer `a || b` over `a ? a : b`
|
// also, prefer `a || b` over `a ? a : b`
|
||||||
// https://eslint.org/docs/rules/no-unneeded-ternary
|
// https://eslint.org/docs/rules/no-unneeded-ternary
|
||||||
|
|
|
@ -21,9 +21,10 @@
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.6.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@prisma/client": "^5.12.1",
|
"@prisma/client": "^5.12.1",
|
||||||
"carta-md": "^4.0.0",
|
"carta-md": "4.0.2",
|
||||||
"diff": "^5.2.0",
|
"diff": "^5.2.0",
|
||||||
"isomorphic-dompurify": "^2.7.0",
|
"isomorphic-dompurify": "^2.7.0",
|
||||||
|
"prisma": "^5.12.1",
|
||||||
"qs": "^6.12.1",
|
"qs": "^6.12.1",
|
||||||
"set-cookie-parser": "^2.6.0",
|
"set-cookie-parser": "^2.6.0",
|
||||||
"svelte-floating-ui": "^1.5.8",
|
"svelte-floating-ui": "^1.5.8",
|
||||||
|
@ -56,7 +57,6 @@
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
"postcss-import": "^16.1.0",
|
"postcss-import": "^16.1.0",
|
||||||
"postcss-nesting": "^12.1.1",
|
"postcss-nesting": "^12.1.1",
|
||||||
"prisma": "^5.12.1",
|
|
||||||
"svelte": "^4.2.15",
|
"svelte": "^4.2.15",
|
||||||
"svelte-check": "^3.6.9",
|
"svelte-check": "^3.6.9",
|
||||||
"sveltekit-superforms": "^2.12.5",
|
"sveltekit-superforms": "^2.12.5",
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"carta-md@4.0.0": "patches/carta-md@4.0.0.patch"
|
"carta-md@4.0.2": "patches/carta-md@4.0.2.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,9 @@ settings:
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
carta-md@4.0.0:
|
carta-md@4.0.2:
|
||||||
hash: 4dz4dhp4dewr3xkzk2rwum2jdq
|
hash: i33ea43vfgrg3ziu25cfu7s2zq
|
||||||
path: patches/carta-md@4.0.0.patch
|
path: patches/carta-md@4.0.2.patch
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core':
|
'@auth/core':
|
||||||
|
@ -23,8 +23,8 @@ dependencies:
|
||||||
specifier: ^5.12.1
|
specifier: ^5.12.1
|
||||||
version: 5.12.1(prisma@5.12.1)
|
version: 5.12.1(prisma@5.12.1)
|
||||||
carta-md:
|
carta-md:
|
||||||
specifier: ^4.0.0
|
specifier: 4.0.2
|
||||||
version: 4.0.0(patch_hash=4dz4dhp4dewr3xkzk2rwum2jdq)(svelte@4.2.15)
|
version: 4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15)
|
||||||
diff:
|
diff:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
@ -1075,8 +1075,8 @@ packages:
|
||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@shikijs/core@1.3.0:
|
/@shikijs/core@1.4.0:
|
||||||
resolution: {integrity: sha512-7fedsBfuILDTBmrYZNFI8B6ATTxhQAasUHllHmjvSZPnoq4bULWoTpHwmuQvZ8Aq03/tAa2IGo6RXqWtHdWaCA==}
|
resolution: {integrity: sha512-CxpKLntAi64h3j+TwWqVIQObPTED0FyXLHTTh3MKXtqiQNn2JGcMQQ362LftDbc9kYbDtrksNMNoVmVXzKFYUQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@sideway/address@4.1.5:
|
/@sideway/address@4.1.5:
|
||||||
|
@ -1935,8 +1935,8 @@ packages:
|
||||||
resolution: {integrity: sha512-19NuN1/3PjA3QI8Eki55N8my4LzfkMCRLgCVfrl/slbSAchQfV0+GwjPrK3rq37As4UCLlM/DHajbKkAqbv92Q==}
|
resolution: {integrity: sha512-19NuN1/3PjA3QI8Eki55N8my4LzfkMCRLgCVfrl/slbSAchQfV0+GwjPrK3rq37As4UCLlM/DHajbKkAqbv92Q==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/carta-md@4.0.0(patch_hash=4dz4dhp4dewr3xkzk2rwum2jdq)(svelte@4.2.15):
|
/carta-md@4.0.2(patch_hash=i33ea43vfgrg3ziu25cfu7s2zq)(svelte@4.2.15):
|
||||||
resolution: {integrity: sha512-mxIoN3dqcjgv8i5FIUBH69lclx8A1/FB/FaFymWBzKE4AvUdy/X6VQGBNzAO3ybSAdceMT0RrAhY5/KnoFI8Hg==}
|
resolution: {integrity: sha512-wMlw0r5RZiVwvF3dyxE/vHj9pXlXbzpijJ3m/o9zqZe7Cf6D96AjyBHBpa0A0OPj/uEJVF3k0R6ctopBJCpCQg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^3.54.0 || ^4.0.0
|
svelte: ^3.54.0 || ^4.0.0
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -1944,7 +1944,7 @@ packages:
|
||||||
remark-gfm: 4.0.0
|
remark-gfm: 4.0.0
|
||||||
remark-parse: 11.0.0
|
remark-parse: 11.0.0
|
||||||
remark-rehype: 11.1.0
|
remark-rehype: 11.1.0
|
||||||
shiki: 1.3.0
|
shiki: 1.4.0
|
||||||
svelte: 4.2.15
|
svelte: 4.2.15
|
||||||
unified: 11.0.4
|
unified: 11.0.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
@ -4860,10 +4860,10 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/shiki@1.3.0:
|
/shiki@1.4.0:
|
||||||
resolution: {integrity: sha512-9aNdQy/etMXctnPzsje1h1XIGm9YfRcSksKOGqZWXA/qP9G18/8fpz5Bjpma8bOgz3tqIpjERAd6/lLjFyzoww==}
|
resolution: {integrity: sha512-5WIn0OL8PWm7JhnTwRWXniy6eEDY234mRrERVlFa646V2ErQqwIFd2UML7e0Pq9eqSKLoMa3Ke+xbsF+DAuy+Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/core': 1.3.0
|
'@shikijs/core': 1.4.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/side-channel@1.0.6:
|
/side-channel@1.0.6:
|
||||||
|
|
15
src/app.pcss
15
src/app.pcss
|
@ -4,11 +4,9 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn-transition {
|
.btn-animation {
|
||||||
@apply duration-200 ease-out;
|
@apply duration-200 ease-out;
|
||||||
animation: button-pop var(--animation-btn, 0.25s) ease-out;
|
animation: button-pop var(--animation-btn, 0.25s) ease-out;
|
||||||
transition-property: color, background-color, border-color, opacity, box-shadow,
|
|
||||||
transform;
|
|
||||||
&:active:hover,
|
&:active:hover,
|
||||||
&:active:focus {
|
&:active:focus {
|
||||||
animation: button-pop 0s ease-out;
|
animation: button-pop 0s ease-out;
|
||||||
|
@ -47,7 +45,7 @@ button {
|
||||||
@apply border border-solid border-base-content/30;
|
@apply border border-solid border-base-content/30;
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@apply flex flex-row;
|
@apply flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row,
|
.row,
|
||||||
|
@ -76,3 +74,12 @@ button {
|
||||||
@apply bg-primary text-primary-content py-1;
|
@apply bg-primary text-primary-content py-1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-animation {
|
||||||
|
@apply duration-200 ease-out;
|
||||||
|
&:active:hover,
|
||||||
|
&:active:focus {
|
||||||
|
animation: button-pop 0s ease-out;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -31,3 +31,7 @@ export const handle = sequence(
|
||||||
authorization,
|
authorization,
|
||||||
createTRPCHandle({ router, createContext }),
|
createTRPCHandle({ router, createContext }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Allow server application to exit
|
||||||
|
process.on("SIGINT", process.exit); // Ctrl+C
|
||||||
|
process.on("SIGTERM", process.exit); // docker stop
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
</a>
|
</a>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<p class="text-sm flex flex-row gap-2">
|
<p class="text-sm flex gap-2">
|
||||||
<span>Erstellt {humanDate(entry.created_at, true)}</span>
|
<span>Erstellt {humanDate(entry.created_at, true)}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
{#if entry.execution}
|
{#if entry.execution}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RouterOutput } from "$lib/shared/trpc";
|
import type { RouterOutput } from "$lib/shared/trpc";
|
||||||
|
import { formatPatientName } from "$lib/shared/util";
|
||||||
|
|
||||||
import RoomField from "$lib/components/table/RoomField.svelte";
|
import RoomField from "$lib/components/table/RoomField.svelte";
|
||||||
|
|
||||||
|
@ -13,9 +14,7 @@
|
||||||
<RoomField room={patient.room} />
|
<RoomField room={patient.room} />
|
||||||
{/if}
|
{/if}
|
||||||
<a href="/patient/{patient.id}">
|
<a href="/patient/{patient.id}">
|
||||||
{patient.first_name}
|
{formatPatientName(patient)}
|
||||||
{patient.last_name}
|
|
||||||
({patient.age})
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { createFloatingActions } from "svelte-floating-ui";
|
||||||
|
import { shift } from "svelte-floating-ui/dom";
|
||||||
|
|
||||||
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
|
import outclick from "$lib/actions/outclick";
|
||||||
|
|
||||||
|
import type { BaseItem } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a simplified version of simple-svelte-autocomplete
|
* This is a simplified version of simple-svelte-autocomplete
|
||||||
* Original source: https://github.com/pstanoev/simple-svelte-autocomplete
|
* Original source: https://github.com/pstanoev/simple-svelte-autocomplete
|
||||||
|
@ -6,17 +19,6 @@
|
||||||
* MIT License
|
* MIT License
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { createFloatingActions } from "svelte-floating-ui";
|
|
||||||
import { shift } from "svelte-floating-ui/dom";
|
|
||||||
|
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
|
||||||
import outclick from "$lib/actions/outclick";
|
|
||||||
|
|
||||||
import type { BaseItem } from "./types";
|
|
||||||
|
|
||||||
type T = $$Generic<BaseItem>;
|
type T = $$Generic<BaseItem>;
|
||||||
type OnSelectResult = { newValue: string; close: boolean };
|
type OnSelectResult = { newValue: string; close: boolean };
|
||||||
|
|
||||||
|
@ -48,6 +50,7 @@
|
||||||
/** Name of the form input. This sets a hidden field to the ID of the selected element
|
/** Name of the form input. This sets a hidden field to the ID of the selected element
|
||||||
* (to allow Svelte Superforms to work) */
|
* (to allow Svelte Superforms to work) */
|
||||||
export let idInputName: string | undefined = undefined;
|
export let idInputName: string | undefined = undefined;
|
||||||
|
export let clearBtn = false;
|
||||||
/** Function to filter shown items */
|
/** Function to filter shown items */
|
||||||
export let filterFn: (item: T) => boolean = () => true;
|
export let filterFn: (item: T) => boolean = () => true;
|
||||||
|
|
||||||
|
@ -278,7 +281,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-grow {cls}" on:outclick={close} use:outclick>
|
<div class="flex-grow {cls}" class:relative={clearBtn} on:outclick={close} use:outclick>
|
||||||
<input
|
<input
|
||||||
bind:this={inputElm}
|
bind:this={inputElm}
|
||||||
class={inputCls}
|
class={inputCls}
|
||||||
|
@ -329,6 +332,12 @@
|
||||||
<!-- Hidden input field (acting as form input) -->
|
<!-- Hidden input field (acting as form input) -->
|
||||||
<input name={idInputName} type="hidden" value={selection?.id ?? ""} />
|
<input name={idInputName} type="hidden" value={selection?.id ?? ""} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if clearBtn && selection}
|
||||||
|
<div class="absolute bottom-0 right-0 h-full flex items-center">
|
||||||
|
<IconButton cls="" path={mdiClose} on:click={clearSelection} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { Debouncer } from "$lib/shared/util";
|
import { Debouncer } from "$lib/shared/util";
|
||||||
|
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import IconButton from "$lib/components/ui/IconButton.svelte";
|
||||||
|
|
||||||
import Autocomplete from "./Autocomplete.svelte";
|
import Autocomplete from "./Autocomplete.svelte";
|
||||||
import EntryFilterChip from "./FilterChip.svelte";
|
import EntryFilterChip from "./FilterChip.svelte";
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
BaseItem,
|
BaseItem,
|
||||||
SelectionOrText,
|
SelectionOrText,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { isFilterValueless } from "./types";
|
import { InputType, isFilterValueless } from "./types";
|
||||||
|
|
||||||
/** Filter definitions */
|
/** Filter definitions */
|
||||||
export let FILTERS: { [key: string]: FilterDef };
|
export let FILTERS: { [key: string]: FilterDef };
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
export let hiddenFilters: string[] = [];
|
export let hiddenFilters: string[] = [];
|
||||||
/** True if a separate search field should be displayed */
|
/** True if a separate search field should be displayed */
|
||||||
export let search = false;
|
export let search = false;
|
||||||
export let view: string;
|
export let view: string | undefined = undefined;
|
||||||
|
|
||||||
let autocomplete: Autocomplete<BaseItem> | undefined;
|
let autocomplete: Autocomplete<BaseItem> | undefined;
|
||||||
let activeFilters: FilterData[] = [];
|
let activeFilters: FilterData[] = [];
|
||||||
|
@ -116,26 +116,24 @@
|
||||||
const key = filter.id;
|
const key = filter.id;
|
||||||
|
|
||||||
let val = null;
|
let val = null;
|
||||||
if (filter.inputType === 0) {
|
if (filter.inputType === InputType.None) {
|
||||||
// Valueless filter (val = true)
|
// Valueless filter (val = true)
|
||||||
val = true;
|
val = true;
|
||||||
} else if (filter.inputType === 1) {
|
} else if (filter.inputType === InputType.FreeText) {
|
||||||
// Text input
|
// Text input
|
||||||
val = fdata.selection?.name;
|
val = fdata.selection?.name;
|
||||||
} else if (filter.inputType === 2 && fdata.selection) {
|
} else if (filter.inputType === InputType.FilterList && fdata.selection) {
|
||||||
// Filter list
|
// Filter list
|
||||||
// @ts-expect-error TODO
|
val = [{ id: fdata.selection.id!, name: fdata.selection.name }];
|
||||||
val = { id: fdata.selection.id, name: fdata.selection.name };
|
} else if (filter.inputType === InputType.Boolean) {
|
||||||
} else if (filter.inputType === 3) {
|
|
||||||
val = Boolean(fdata.selection?.toggle);
|
val = Boolean(fdata.selection?.toggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (val !== null && val !== undefined) {
|
if (val !== null && val !== undefined) {
|
||||||
if (filter.inputType === 2) {
|
if (filter.inputType === 2) {
|
||||||
// @ts-expect-error fd[key] is checked
|
// @ts-expect-error fd[key] is checked
|
||||||
if (Array.isArray(fd[key])) fd[key].push(val);
|
if (Array.isArray(fd[key])) fd[key].push(...val);
|
||||||
// @ts-expect-error fd[key] is checked
|
else fd[key] = val;
|
||||||
else fd[key] = [val];
|
|
||||||
} else {
|
} else {
|
||||||
fd[key] = val;
|
fd[key] = val;
|
||||||
}
|
}
|
||||||
|
@ -223,17 +221,15 @@
|
||||||
partOfFilterbar
|
partOfFilterbar
|
||||||
placeholder="Filter"
|
placeholder="Filter"
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"
|
ariaLabel="Alle Filter entfernen"
|
||||||
aria-label="Alle Filter entfernen"
|
cls="absolute bottom-0 right-0"
|
||||||
|
path={mdiClose}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
activeFilters = [];
|
activeFilters = [];
|
||||||
searchVal = "";
|
searchVal = "";
|
||||||
updateFilter();
|
updateFilter();
|
||||||
}}
|
}} />
|
||||||
>
|
|
||||||
<Icon path={mdiClose} size={1.2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if search}
|
{#if search}
|
||||||
<input
|
<input
|
||||||
|
@ -248,7 +244,9 @@
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if view}
|
||||||
<SavedFilters {view} />
|
<SavedFilters {view} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
.filterbar-outer {
|
.filterbar-outer {
|
||||||
|
|
|
@ -4,8 +4,9 @@
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
|
|
||||||
import Autocomplete from "./Autocomplete.svelte";
|
import Autocomplete from "./Autocomplete.svelte";
|
||||||
import type {
|
import {
|
||||||
BaseItem, FilterData, FilterDef, SelectionOrText,
|
InputType,
|
||||||
|
type BaseItem, type FilterData, type FilterDef, type SelectionOrText,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export let filter: FilterDef;
|
export let filter: FilterDef;
|
||||||
|
@ -36,7 +37,7 @@
|
||||||
autocomplete.open();
|
autocomplete.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOFF = " aus";
|
const TOFF = " aus"; // Toggle name suffix (in case toggleOff.name is unset)
|
||||||
|
|
||||||
$: toggleState = fdata.selection?.toggle !== false;
|
$: toggleState = fdata.selection?.toggle !== false;
|
||||||
$: filterName = toggleState
|
$: filterName = toggleState
|
||||||
|
@ -72,7 +73,7 @@
|
||||||
|
|
||||||
{#if hasInputField}
|
{#if hasInputField}
|
||||||
{#if fdata.editing}
|
{#if fdata.editing}
|
||||||
{#if filter.inputType === 2}
|
{#if filter.inputType === InputType.FilterList}
|
||||||
{@const hids = hiddenIds()}
|
{@const hids = hiddenIds()}
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
bind:this={autocomplete}
|
bind:this={autocomplete}
|
||||||
|
@ -83,8 +84,8 @@
|
||||||
onBackspace={onRemove}
|
onBackspace={onRemove}
|
||||||
{onClose}
|
{onClose}
|
||||||
onSelect={(item) => {
|
onSelect={(item) => {
|
||||||
// Accept the selection if this is a free text field or the user selected a variant
|
// Accept the selection if the user selected a variant
|
||||||
if (filter.inputType !== 2 || item.id) {
|
if (item.id) {
|
||||||
fdata.selection = item;
|
fdata.selection = item;
|
||||||
return { close: true, newValue: "" };
|
return { close: true, newValue: "" };
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="chip btn-transition" {href}>
|
<a class="chip btn-animation" {href}>
|
||||||
<span class="mx-2">
|
<span class="mx-2">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -56,7 +56,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="text-sm h-8 flex items-center">
|
<div class="text-sm h-8 flex items-center">
|
||||||
Gespeicherte Filter:
|
Gespeicherte Filter:
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
mdiAccountMultipleOutline,
|
mdiAccountMultipleOutline,
|
||||||
mdiAccountRemoveOutline,
|
mdiAccountRemoveOutline,
|
||||||
mdiBedKingOutline,
|
mdiBedKingOutline,
|
||||||
|
mdiCalendar,
|
||||||
mdiCheckboxBlankOutline,
|
mdiCheckboxBlankOutline,
|
||||||
mdiCheckboxOutline,
|
mdiCheckboxOutline,
|
||||||
mdiDoctor,
|
mdiDoctor,
|
||||||
|
@ -13,58 +14,75 @@ import {
|
||||||
mdiTag,
|
mdiTag,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
|
|
||||||
|
import { WEEK_LIMIT } from "$lib/shared/constants";
|
||||||
import { trpc } from "$lib/shared/trpc";
|
import { trpc } from "$lib/shared/trpc";
|
||||||
|
import { DateRange, dateToYMD } from "$lib/shared/util";
|
||||||
|
|
||||||
import type { FilterDef } from "./types";
|
import { type FilterDef, InputType, type BaseItem } from "./types";
|
||||||
|
|
||||||
|
export function weekFilterItems(earlierLater: boolean): BaseItem[] {
|
||||||
|
const range = DateRange.thisWeek();
|
||||||
|
|
||||||
|
const res = [];
|
||||||
|
if (earlierLater) res.push({ id: ".." + dateToYMD(range.start!), name: "Früher" });
|
||||||
|
|
||||||
|
for (let i = 0; i < WEEK_LIMIT; i++) {
|
||||||
|
res.push({ id: range.toString(), name: range.format() });
|
||||||
|
range.addDays(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (earlierLater) res.push({ id: dateToYMD(range.start!) + "..", name: "Später" });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
|
export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
|
||||||
category: {
|
category: {
|
||||||
id: "category",
|
id: "category",
|
||||||
name: "Kategorie",
|
name: "Kategorie",
|
||||||
icon: mdiTag,
|
icon: mdiTag,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().category.list.query(),
|
options: async () => trpc().category.list.query(),
|
||||||
},
|
},
|
||||||
author: {
|
author: {
|
||||||
id: "author",
|
id: "author",
|
||||||
name: "Autor",
|
name: "Autor",
|
||||||
icon: mdiAccount,
|
icon: mdiAccount,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().user.getNames.query(),
|
options: async () => trpc().user.getNames.query(),
|
||||||
},
|
},
|
||||||
executor: {
|
executor: {
|
||||||
id: "executor",
|
id: "executor",
|
||||||
name: "Erledigt von",
|
name: "Erledigt von",
|
||||||
icon: mdiDoctor,
|
icon: mdiDoctor,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().user.getNames.query(),
|
options: async () => trpc().user.getNames.query(),
|
||||||
},
|
},
|
||||||
patient: {
|
patient: {
|
||||||
id: "patient",
|
id: "patient",
|
||||||
name: "Patient",
|
name: "Patient",
|
||||||
icon: mdiAccountInjury,
|
icon: mdiAccountInjury,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().patient.getNames.query(),
|
options: async () => trpc().patient.getNames.query(),
|
||||||
},
|
},
|
||||||
station: {
|
station: {
|
||||||
id: "station",
|
id: "station",
|
||||||
name: "Station",
|
name: "Station",
|
||||||
icon: mdiDomain,
|
icon: mdiDomain,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().station.list.query(),
|
options: async () => trpc().station.list.query(),
|
||||||
},
|
},
|
||||||
room: {
|
room: {
|
||||||
id: "room",
|
id: "room",
|
||||||
name: "Zimmer",
|
name: "Zimmer",
|
||||||
icon: mdiBedKingOutline,
|
icon: mdiBedKingOutline,
|
||||||
inputType: 2,
|
inputType: InputType.FilterList,
|
||||||
options: async () => trpc().room.list.query(),
|
options: async () => trpc().room.list.query(),
|
||||||
},
|
},
|
||||||
done: {
|
done: {
|
||||||
id: "done",
|
id: "done",
|
||||||
name: "Erledigt",
|
name: "Erledigt",
|
||||||
icon: mdiCheckboxOutline,
|
icon: mdiCheckboxOutline,
|
||||||
inputType: 3,
|
inputType: InputType.Boolean,
|
||||||
toggleOff: {
|
toggleOff: {
|
||||||
name: "Zu erledigen",
|
name: "Zu erledigen",
|
||||||
icon: mdiCheckboxBlankOutline,
|
icon: mdiCheckboxBlankOutline,
|
||||||
|
@ -74,13 +92,20 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
|
||||||
id: "priority",
|
id: "priority",
|
||||||
name: "Priorität",
|
name: "Priorität",
|
||||||
icon: mdiExclamation,
|
icon: mdiExclamation,
|
||||||
inputType: 0,
|
inputType: InputType.None,
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
id: "search",
|
id: "search",
|
||||||
name: "Beschreibung",
|
name: "Beschreibung",
|
||||||
icon: mdiMagnify,
|
icon: mdiMagnify,
|
||||||
inputType: 1,
|
inputType: InputType.FreeText,
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
id: "date",
|
||||||
|
name: "Woche",
|
||||||
|
icon: mdiCalendar,
|
||||||
|
inputType: InputType.FilterList,
|
||||||
|
options: async () => weekFilterItems(true),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
export enum InputType {
|
||||||
|
None = 0,
|
||||||
|
FreeText = 1,
|
||||||
|
FilterList = 2,
|
||||||
|
Boolean = 3,
|
||||||
|
}
|
||||||
|
|
||||||
export type BaseItem = {
|
export type BaseItem = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -15,8 +22,7 @@ export type FilterDef = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
// 0: No input (value: true); 1: Free text input; 2: Filter list; 3: Boolean switch
|
inputType: InputType;
|
||||||
inputType: 0 | 1 | 2 | 3;
|
|
||||||
toggleOff?: {
|
toggleOff?: {
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
@ -34,6 +40,6 @@ export type FilterQdata = {
|
||||||
[key: string]: string | number | boolean | { id: string | number; name?: string }[];
|
[key: string]: string | number | boolean | { id: string | number; name?: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isFilterValueless(inputType: 0 | 1 | 2 | 3): boolean {
|
export function isFilterValueless(inputType: InputType): boolean {
|
||||||
return inputType === 0 || inputType === 3;
|
return inputType === InputType.None || inputType === InputType.Boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let category: Category;
|
export let category: Category;
|
||||||
export let baseUrl = URL_ENTRIES;
|
export let baseUrl = URL_ENTRIES;
|
||||||
export let href: string | undefined = undefined;
|
export let href: string | undefined = undefined;
|
||||||
|
export let nolink = false;
|
||||||
|
|
||||||
$: textColor = category.color
|
$: textColor = category.color
|
||||||
? colorToHex(getTextColor(hexToColor(category.color)))
|
? colorToHex(getTextColor(hexToColor(category.color)))
|
||||||
|
@ -25,7 +26,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href || nolink}
|
||||||
<a
|
<a
|
||||||
style:color={category.color ? textColor : undefined}
|
style:color={category.color ? textColor : undefined}
|
||||||
style:background-color={category.color}
|
style:background-color={category.color}
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
export let entries: RouterOutput["entry"]["list"];
|
export let entries: RouterOutput["entry"]["list"];
|
||||||
export let baseUrl: string;
|
export let baseUrl: string;
|
||||||
export let patientId: number | null = null;
|
export let patientId: number | null = null;
|
||||||
|
export let view: string | undefined = undefined;
|
||||||
|
|
||||||
function paginationUpdate(pagination: PaginationRequest) {
|
function paginationUpdate(pagination: PaginationRequest) {
|
||||||
updateQuery({
|
updateQuery({
|
||||||
|
@ -57,7 +58,7 @@
|
||||||
filterData={query.filter}
|
filterData={query.filter}
|
||||||
hiddenFilters={patientId !== null ? ["patient"] : []}
|
hiddenFilters={patientId !== null ? ["patient"] : []}
|
||||||
onUpdate={filterUpdate}
|
onUpdate={filterUpdate}
|
||||||
view="plan"
|
{view}
|
||||||
>
|
>
|
||||||
<slot name="filterbar" />
|
<slot name="filterbar" />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
31
src/lib/components/ui/EntryCard.svelte
Normal file
31
src/lib/components/ui/EntryCard.svelte
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RouterOutput } from "$lib/shared/trpc";
|
||||||
|
import { formatDate, formatPatientName } from "$lib/shared/util";
|
||||||
|
|
||||||
|
import CategoryField from "$lib/components/table/CategoryField.svelte";
|
||||||
|
|
||||||
|
import Markdown from "./markdown/Markdown.svelte";
|
||||||
|
|
||||||
|
export let entry: RouterOutput["entry"]["list"]["items"][0];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card2 card-animation" href="/entry/{entry.id}">
|
||||||
|
<div class="row items-center gap-2 text-sm">
|
||||||
|
<span>{formatDate(entry.current_version.date)}</span>
|
||||||
|
{#if entry.current_version.category}
|
||||||
|
<CategoryField category={entry.current_version.category} nolink />
|
||||||
|
{/if}
|
||||||
|
{#if entry.current_version.priority}
|
||||||
|
<div class="badge ellipsis badge-warning">Priorität</div>
|
||||||
|
{/if}
|
||||||
|
<span>{formatPatientName(entry.patient)}</span>
|
||||||
|
|
||||||
|
<span class="ml-auto hidden md:block">von {entry.current_version.author.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="line-clamp-3">
|
||||||
|
<Markdown src={entry.current_version.text} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
|
@ -7,7 +7,7 @@
|
||||||
export let backHref: string | undefined = undefined;
|
export let backHref: string | undefined = undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row">
|
<div class="flex">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if backHref}
|
{#if backHref}
|
||||||
<a class="btn btn-sm btn-circle btn-ghost" href={backHref}>
|
<a class="btn btn-sm btn-circle btn-ghost" href={backHref}>
|
||||||
|
|
24
src/lib/components/ui/IconButton.svelte
Normal file
24
src/lib/components/ui/IconButton.svelte
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
type BtnSize = "sm" | "md";
|
||||||
|
|
||||||
|
export let path: string;
|
||||||
|
export let size: BtnSize = "sm";
|
||||||
|
export let cls = "";
|
||||||
|
export let ariaLabel: undefined | string = undefined;
|
||||||
|
export let ariaPressed: undefined | boolean = undefined;
|
||||||
|
export let tabindex: undefined | number = undefined;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`btn btn-ghost btn-circle ${
|
||||||
|
size === "md" ? "" : "btn-" + size
|
||||||
|
} ${cls}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-pressed={ariaPressed}
|
||||||
|
{tabindex}
|
||||||
|
on:click
|
||||||
|
>
|
||||||
|
<Icon {path} size={size === "md" ? 2 : 1.5} />
|
||||||
|
</button>
|
|
@ -6,6 +6,8 @@
|
||||||
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
import { PAGINATION_LIMIT } from "$lib/shared/constants";
|
||||||
import type { Pagination, PaginationRequest } from "$lib/shared/model";
|
import type { Pagination, PaginationRequest } from "$lib/shared/model";
|
||||||
|
|
||||||
|
import { screenWidthSmall } from "$lib/stores/layout";
|
||||||
|
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
export let paginationData: PaginationRequest | null | undefined;
|
export let paginationData: PaginationRequest | null | undefined;
|
||||||
|
@ -17,18 +19,21 @@
|
||||||
$: nPages = Math.ceil(data.total / limit);
|
$: nPages = Math.ceil(data.total / limit);
|
||||||
let windowBottom: number;
|
let windowBottom: number;
|
||||||
let windowTop: number;
|
let windowTop: number;
|
||||||
$: if (nPages <= 10) {
|
|
||||||
|
$: buttonLim = $screenWidthSmall ? 4 : 10;
|
||||||
|
|
||||||
|
$: if (nPages <= buttonLim) {
|
||||||
windowBottom = 1;
|
windowBottom = 1;
|
||||||
windowTop = nPages;
|
windowTop = nPages;
|
||||||
} else if (thisPage <= 6) {
|
} else if (thisPage <= buttonLim * 0.6) {
|
||||||
windowBottom = 1;
|
windowBottom = 1;
|
||||||
windowTop = 10;
|
windowTop = buttonLim;
|
||||||
} else if (thisPage + 4 >= nPages) {
|
} else if (thisPage + (buttonLim * 0.4) >= nPages) {
|
||||||
windowBottom = nPages - 9;
|
windowBottom = nPages - buttonLim + 1;
|
||||||
windowTop = nPages;
|
windowTop = nPages;
|
||||||
} else {
|
} else {
|
||||||
windowBottom = thisPage - 5;
|
windowBottom = thisPage - Math.floor(buttonLim / 2);
|
||||||
windowTop = thisPage + 4;
|
windowTop = thisPage + Math.ceil(buttonLim / 2) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPaginationRequest(page: number): PaginationRequest | null {
|
function getPaginationRequest(page: number): PaginationRequest | null {
|
||||||
|
|
64
src/lib/components/ui/WeekSelector.svelte
Normal file
64
src/lib/components/ui/WeekSelector.svelte
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { mdiChevronLeft, mdiChevronRight } from "@mdi/js";
|
||||||
|
|
||||||
|
import { DateRange } from "$lib/shared/util";
|
||||||
|
|
||||||
|
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
|
||||||
|
import { weekFilterItems } from "$lib/components/filter/filters";
|
||||||
|
import type { BaseItem } from "$lib/components/filter/types";
|
||||||
|
|
||||||
|
import IconButton from "./IconButton.svelte";
|
||||||
|
|
||||||
|
let editing = false;
|
||||||
|
let autocomplete: Autocomplete<BaseItem> | undefined;
|
||||||
|
|
||||||
|
export let dateRange: DateRange = DateRange.thisWeek();
|
||||||
|
export let onSelect: (value: DateRange) => void = () => {};
|
||||||
|
|
||||||
|
function nextWeek() {
|
||||||
|
dateRange.addDays(7);
|
||||||
|
dateRange = dateRange; // update reactive
|
||||||
|
onSelect(dateRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousWeek() {
|
||||||
|
dateRange.addDays(-7);
|
||||||
|
dateRange = dateRange; // update reactive
|
||||||
|
onSelect(dateRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopEditing() {
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (editing && autocomplete) {
|
||||||
|
autocomplete.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<IconButton path={mdiChevronLeft} on:click={previousWeek} />
|
||||||
|
{#if editing}
|
||||||
|
<Autocomplete
|
||||||
|
bind:this={autocomplete}
|
||||||
|
items={async () => weekFilterItems(false)}
|
||||||
|
onClose={stopEditing}
|
||||||
|
onSelect={(item) => {
|
||||||
|
if (typeof item.id === "string") {
|
||||||
|
const parsed = DateRange.parse(item.id);
|
||||||
|
if (parsed) dateRange = parsed;
|
||||||
|
onSelect(dateRange);
|
||||||
|
}
|
||||||
|
return { close: true, newValue: "" };
|
||||||
|
}}
|
||||||
|
partOfFilterbar
|
||||||
|
selection={{ id: dateRange.toString(), name: dateRange.format() }} />
|
||||||
|
{:else}
|
||||||
|
<button on:click={startEditing}>{dateRange.format()}</button>
|
||||||
|
{/if}
|
||||||
|
<IconButton path={mdiChevronRight} on:click={nextWeek} />
|
||||||
|
</div>
|
|
@ -68,7 +68,7 @@
|
||||||
.carta-theme__default .carta-icon-full {
|
.carta-theme__default .carta-icon-full {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@apply btn-transition;
|
@apply btn-animation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carta-theme__default .carta-icon-full {
|
.carta-theme__default .carta-icon-full {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
PaginationRequest,
|
PaginationRequest,
|
||||||
SortRequest,
|
SortRequest,
|
||||||
} from "$lib/shared/model";
|
} from "$lib/shared/model";
|
||||||
import { dateToYMD } from "$lib/shared/util";
|
import { DateRange, dateToYMD } from "$lib/shared/util";
|
||||||
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
|
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
|
||||||
|
|
||||||
import { prisma } from "$lib/server/prisma";
|
import { prisma } from "$lib/server/prisma";
|
||||||
|
@ -274,6 +274,18 @@ left join stations s on s.id = r.station_id`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter?.date) {
|
||||||
|
filterListToArray(filter.date).forEach((itm) => {
|
||||||
|
const dateRange = DateRange.parse(itm);
|
||||||
|
if (dateRange?.start) {
|
||||||
|
qb.addFilterClause(`ev.date >= ${qb.pvar()}`, dateRange.start);
|
||||||
|
}
|
||||||
|
if (dateRange?.end) {
|
||||||
|
qb.addFilterClause(`ev.date <= ${qb.pvar()}`, dateRange.end);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SORT_FIELDS: { [key: string]: string[] } = {
|
const SORT_FIELDS: { [key: string]: string[] } = {
|
||||||
id: ["e.id"],
|
id: ["e.id"],
|
||||||
patient: ["p.last_name", "p.first_name"],
|
patient: ["p.last_name", "p.first_name"],
|
||||||
|
@ -378,3 +390,23 @@ left join stations s on s.id = r.station_id`,
|
||||||
|
|
||||||
return { items, offset: qb.getOffset(), total };
|
return { items, offset: qb.getOffset(), total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the amount of entries with todo dates before/equal the given date */
|
||||||
|
export async function getNTodo(date: Date): Promise<number> {
|
||||||
|
const result = await prisma.$queryRaw`select count(*) from entries e
|
||||||
|
join entry_versions ev on ev.entry_id = e.id
|
||||||
|
and ev.id = (
|
||||||
|
select
|
||||||
|
id
|
||||||
|
from
|
||||||
|
entry_versions ev2
|
||||||
|
where
|
||||||
|
ev2.entry_id = ev.entry_id
|
||||||
|
order by
|
||||||
|
ev2.created_at desc
|
||||||
|
limit 1)
|
||||||
|
where ev.date <= ${date}`;
|
||||||
|
// @ts-expect-error type checked
|
||||||
|
const count = Number(result[0].count);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
|
@ -152,6 +152,7 @@ export class QueryBuilder {
|
||||||
return `$${this.nP}`;
|
return `$${this.nP}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add a simple filter checking for equality */
|
||||||
addFilter(fname: string, val: unknown | undefined) {
|
addFilter(fname: string, val: unknown | undefined) {
|
||||||
if (val === undefined) return;
|
if (val === undefined) return;
|
||||||
|
|
||||||
|
@ -159,11 +160,13 @@ export class QueryBuilder {
|
||||||
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
this.filterClauses.push(`${fname} = ${this.pvar()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add a SQL filter clause */
|
||||||
addFilterClause(clause: string, ...params: unknown[]) {
|
addFilterClause(clause: string, ...params: unknown[]) {
|
||||||
this.filterClauses.push(clause);
|
this.filterClauses.push(clause);
|
||||||
this.params.push(...params);
|
this.params.push(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add a list filter (value matches any item from the filter list) */
|
||||||
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
|
||||||
if (fl === undefined) return;
|
if (fl === undefined) return;
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ZEntryNew,
|
ZEntryNew,
|
||||||
ZEntryVersionNew,
|
ZEntryVersionNew,
|
||||||
ZEntityId,
|
ZEntityId,
|
||||||
|
fields,
|
||||||
} from "$lib/shared/model/validation";
|
} from "$lib/shared/model/validation";
|
||||||
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
|
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
|
||||||
|
|
||||||
|
@ -16,6 +17,7 @@ import {
|
||||||
getEntryNExecutions,
|
getEntryNExecutions,
|
||||||
getEntryNVersions,
|
getEntryNVersions,
|
||||||
getEntryVersions,
|
getEntryVersions,
|
||||||
|
getNTodo,
|
||||||
newEntry,
|
newEntry,
|
||||||
newEntryExecution,
|
newEntryExecution,
|
||||||
newEntryVersion,
|
newEntryVersion,
|
||||||
|
@ -53,6 +55,9 @@ export const entryRouter = t.router({
|
||||||
const executions = await getEntryExecutions(opts.input);
|
const executions = await getEntryExecutions(opts.input);
|
||||||
return executionsDiff(executions);
|
return executionsDiff(executions);
|
||||||
})),
|
})),
|
||||||
|
nTodo: t.procedure.input(fields.DateString().optional()).query(async (opts) => trpcWrap(
|
||||||
|
async () => getNTodo(opts.input ? new Date(opts.input) : new Date()),
|
||||||
|
)),
|
||||||
create: t.procedure
|
create: t.procedure
|
||||||
.input(ZEntryNew)
|
.input(ZEntryNew)
|
||||||
.mutation(async (opts) => trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))),
|
.mutation(async (opts) => trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))),
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export const PAGINATION_LIMIT = 20;
|
export const PAGINATION_LIMIT = 20;
|
||||||
|
export const WEEK_LIMIT = 8;
|
||||||
|
|
||||||
export const URL_ENTRIES = "/plan";
|
export const URL_ENTRIES = "/plan";
|
||||||
|
export const URL_VISIT = "/visit";
|
||||||
export const URL_PATIENTS = "/patients";
|
export const URL_PATIENTS = "/patients";
|
||||||
|
|
|
@ -26,6 +26,7 @@ export type EntriesFilter = Partial<{
|
||||||
station: FilterList<number>;
|
station: FilterList<number>;
|
||||||
room: FilterList<number>;
|
room: FilterList<number>;
|
||||||
priority: boolean;
|
priority: boolean;
|
||||||
|
date: FilterList<string>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type PatientsFilter = Partial<{
|
export type PatientsFilter = Partial<{
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { DateRange } from "$lib/shared/util";
|
||||||
import { implement } from "$lib/shared/util/zod";
|
import { implement } from "$lib/shared/util/zod";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -36,6 +37,7 @@ export const fields = {
|
||||||
},
|
},
|
||||||
{ message: "Datum muss in der Zukunft liegen" },
|
{ message: "Datum muss in der Zukunft liegen" },
|
||||||
),
|
),
|
||||||
|
DateRange: () => z.string().refine((val) => Boolean(DateRange.parse(val))),
|
||||||
};
|
};
|
||||||
|
|
||||||
const coercedUint = z.coerce.number().int().nonnegative();
|
const coercedUint = z.coerce.number().int().nonnegative();
|
||||||
|
@ -138,6 +140,8 @@ const paginatedQuery = <T extends z.ZodTypeAny>(f: T) => z
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
|
const DateFilterList = z.array(z.object({ id: fields.DateRange(), name: z.string().optional() }));
|
||||||
|
|
||||||
export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
|
export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
|
||||||
.object({
|
.object({
|
||||||
author: ZFilterList,
|
author: ZFilterList,
|
||||||
|
@ -149,6 +153,7 @@ export const ZEntriesFilter = returnDataInSameOrderAsPassed(z
|
||||||
room: ZFilterList,
|
room: ZFilterList,
|
||||||
search: z.string(),
|
search: z.string(),
|
||||||
station: ZFilterList,
|
station: ZFilterList,
|
||||||
|
date: DateFilterList,
|
||||||
})
|
})
|
||||||
.partial());
|
.partial());
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,9 @@ import { expect, it, vi } from "vitest";
|
||||||
import type { EntityQuery } from "$lib/shared/model";
|
import type { EntityQuery } from "$lib/shared/model";
|
||||||
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
||||||
|
|
||||||
import { getQueryUrl, humanDate, parseQueryUrl } from ".";
|
import {
|
||||||
|
getQueryUrl, humanDate, DateRange, parseQueryUrl,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
const MINUTE = 60000;
|
const MINUTE = 60000;
|
||||||
const HOUR = 3_600_000;
|
const HOUR = 3_600_000;
|
||||||
|
@ -53,3 +55,20 @@ it("getQueryUrl", () => {
|
||||||
const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl));
|
const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl));
|
||||||
expect(decoded).toStrictEqual(query);
|
expect(decoded).toStrictEqual(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ s: "", exp: null },
|
||||||
|
{ s: "..", exp: null },
|
||||||
|
{ s: "foo..bar", exp: null },
|
||||||
|
{ s: "2024-04-15", exp: new DateRange(new Date("2024-04-15"), new Date("2024-04-15")) },
|
||||||
|
{ s: "2024-04-13..2024-04-20", exp: new DateRange(new Date("2024-04-13"), new Date("2024-04-20")) },
|
||||||
|
{ s: "2024-04-13..", exp: new DateRange(new Date("2024-04-13"), null) },
|
||||||
|
{ s: "..2024-04-20", exp: new DateRange(null, new Date("2024-04-20")) },
|
||||||
|
])("parse date range $s", ({ s, exp }) => {
|
||||||
|
const res = DateRange.parse(s);
|
||||||
|
expect(res).toStrictEqual(exp);
|
||||||
|
|
||||||
|
if (res && s !== "2024-04-15") {
|
||||||
|
expect(res.toString()).toBe(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -2,11 +2,12 @@ import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import { isRedirect, error } from "@sveltejs/kit";
|
import { isRedirect, error } from "@sveltejs/kit";
|
||||||
import { TRPCClientError } from "@trpc/client";
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
|
||||||
|
|
||||||
import type { EntityQuery } from "$lib/shared/model";
|
import type { EntityQuery, Patient } from "$lib/shared/model";
|
||||||
|
import type { RouterOutput } from "../trpc";
|
||||||
|
|
||||||
const LOCALE = "de-DE";
|
const LOCALE = "de-DE";
|
||||||
|
|
||||||
|
@ -161,3 +162,90 @@ export class Debouncer {
|
||||||
export function sanitizeHtml(s: string): string {
|
export function sanitizeHtml(s: string): string {
|
||||||
return DOMPurify.sanitize(s, { FORBID_TAGS: ["img"] });
|
return DOMPurify.sanitize(s, { FORBID_TAGS: ["img"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DateRange {
|
||||||
|
start: Date | null;
|
||||||
|
|
||||||
|
end: Date | null;
|
||||||
|
|
||||||
|
constructor(start: Date | null, end: Date | null) {
|
||||||
|
if (start === null && end == null) throw Error("this is not a range");
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construct a new DateRange with the given length in days */
|
||||||
|
static withLength(start: Date, nDays: number): DateRange {
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + nDays);
|
||||||
|
return new DateRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a date range of the current calendar week */
|
||||||
|
static thisWeek(): DateRange {
|
||||||
|
const dayStart = new Date();
|
||||||
|
// Correct for timezone
|
||||||
|
dayStart.setMinutes(dayStart.getMinutes() - dayStart.getTimezoneOffset());
|
||||||
|
|
||||||
|
const todayWd = dayStart.getDay();
|
||||||
|
// Day starts at Sunday (0)
|
||||||
|
const daysMinus = todayWd === 0 ? 6 : todayWd - 1;
|
||||||
|
dayStart.setDate(dayStart.getDate() - daysMinus);
|
||||||
|
return DateRange.withLength(dayStart, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a date range from a string
|
||||||
|
*
|
||||||
|
* Accepted formats:
|
||||||
|
* - Single date `2024-04-15` => `2024-04-15..2024-04-15`
|
||||||
|
* - Range with 2 ends: `2024-04-13..2024-04-20`
|
||||||
|
* - Range with 1 end: `2024-04-13..`; `..2024-04-20`
|
||||||
|
*/
|
||||||
|
static parse(s: string): DateRange | null {
|
||||||
|
const parts = s.split("..", 2);
|
||||||
|
const parsed = parts.map((p) => {
|
||||||
|
if (p.length === 0) return null;
|
||||||
|
return new Date(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsed.length === 0
|
||||||
|
// @ts-expect-error Parsed dates can be NaN
|
||||||
|
|| parsed.findIndex(isNaN) !== -1
|
||||||
|
|| parsed.every((e) => e === null)
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
if (parsed.length === 2) {
|
||||||
|
return new DateRange(parsed[0], parsed[1]);
|
||||||
|
} else {
|
||||||
|
return new DateRange(parsed[0], parsed[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shift the range by the given number of days. This modifies the range in-place */
|
||||||
|
addDays(n: number) {
|
||||||
|
this.start?.setDate(this.start.getDate() + n);
|
||||||
|
this.end?.setDate(this.end.getDate() + n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a parsable string representation */
|
||||||
|
toString(): string {
|
||||||
|
let res = "";
|
||||||
|
if (this.start) res += dateToYMD(this.start);
|
||||||
|
res += "..";
|
||||||
|
if (this.end) res += dateToYMD(this.end);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a string representation for display purposes */
|
||||||
|
format(): string {
|
||||||
|
let res = "";
|
||||||
|
if (this.start) res += formatDate(this.start);
|
||||||
|
res += " \u2013 ";
|
||||||
|
if (this.end) res += formatDate(this.end);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPatientName(patient: RouterOutput["patient"]["list"]["items"][0]): string {
|
||||||
|
return `${patient.first_name} ${patient.last_name} (${patient.age})`;
|
||||||
|
}
|
||||||
|
|
5
src/lib/stores/layout.ts
Normal file
5
src/lib/stores/layout.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { derived, writable } from "svelte/store";
|
||||||
|
|
||||||
|
// Width of the main section of the layout
|
||||||
|
export const screenWidth = writable(0);
|
||||||
|
export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500);
|
|
@ -1,5 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
@ -28,12 +31,12 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title">Visite</h2>
|
<h2 class="card-title">Visite</h2>
|
||||||
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
|
||||||
<p>Heute müssen (n) Einträge erledigt werden.</p>
|
<p>Heute müssen {data.nTodo} Einträge erledigt werden.</p>
|
||||||
<div class="card-actions justify-end">
|
<div class="card-actions justify-end">
|
||||||
<a class="btn btn-primary" href="/visit">Visite</a>
|
<a class="btn btn-primary" href="/visit">Visite</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/plan/%7B%22filter%22:%7B%22done%22:%22foo%22%7D%7D">Throw error</a>
|
<!-- <a href="/plan/%7B%22filter%22:%7B%22done%22:%22foo%22%7D%7D">Throw error</a> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
12
src/routes/(app)/+page.ts
Normal file
12
src/routes/(app)/+page.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
import { trpc } from "$lib/shared/trpc";
|
||||||
|
import { loadWrap } from "$lib/shared/util";
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
return loadWrap(async () => {
|
||||||
|
const nTodo = await trpc(event).entry.nTodo.query();
|
||||||
|
|
||||||
|
return { nTodo };
|
||||||
|
});
|
||||||
|
};
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
<Header backHref={basePath} title="Eintrag #{data.entry.id} bearbeiten" />
|
<Header backHref={basePath} title="Eintrag #{data.entry.id} bearbeiten" />
|
||||||
|
|
||||||
<p class="text-sm flex flex-row gap-2">
|
<p class="text-sm flex gap-2">
|
||||||
<span>Erstellt {humanDate(data.entry.created_at, true)}</span>
|
<span>Erstellt {humanDate(data.entry.created_at, true)}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>
|
<span>
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
|
|
||||||
<FormField label="Patient">
|
<FormField label="Patient">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
|
clearBtn
|
||||||
filterFn={(itm) => $form.room_id === null || itm.room_id === $form.room_id}
|
filterFn={(itm) => $form.room_id === null || itm.room_id === $form.room_id}
|
||||||
idInputName="patient_id"
|
idInputName="patient_id"
|
||||||
inputCls="input input-bordered w-full max-w-xs"
|
inputCls="input input-bordered w-full max-w-xs"
|
||||||
|
|
|
@ -12,6 +12,6 @@
|
||||||
<title>Planung</title>
|
<title>Planung</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query}>
|
<FilteredEntryTable baseUrl={URL_ENTRIES} entries={data.entries} query={data.query} view="plan">
|
||||||
<a slot="filterbar" class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a>
|
<a slot="filterbar" class="btn btn-sm btn-primary" href="/entry/new">Neuer Eintrag</a>
|
||||||
</FilteredEntryTable>
|
</FilteredEntryTable>
|
||||||
|
|
|
@ -1,4 +1,70 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
import { URL_VISIT } from "$lib/shared/constants";
|
||||||
|
import type { PaginationRequest } from "$lib/shared/model";
|
||||||
|
import { trpc } from "$lib/shared/trpc";
|
||||||
|
import type { RouterOutput } from "$lib/shared/trpc";
|
||||||
|
import { DateRange, getQueryUrl } from "$lib/shared/util";
|
||||||
|
|
||||||
|
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
|
||||||
|
import EntryCard from "$lib/components/ui/EntryCard.svelte";
|
||||||
|
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
|
||||||
|
import WeekSelector from "$lib/components/ui/WeekSelector.svelte";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
let dateRange: DateRange;
|
||||||
|
let selection: RouterOutput["station"]["get"] | null;
|
||||||
|
|
||||||
|
$: if (data.query.filter?.date) {
|
||||||
|
const date = data.query.filter?.date[0];
|
||||||
|
if (date) {
|
||||||
|
dateRange = DateRange.parse(date.id) ?? DateRange.thisWeek();
|
||||||
|
} else {
|
||||||
|
dateRange = DateRange.thisWeek();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dateRange = DateRange.thisWeek();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (data.query.filter?.station) {
|
||||||
|
const station = data.query.filter?.station[0];
|
||||||
|
if (station) {
|
||||||
|
selection = { id: station.id, name: station.name ?? "" };
|
||||||
|
} else {
|
||||||
|
selection = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginationUpdate(pagination: PaginationRequest) {
|
||||||
|
updateQuery({
|
||||||
|
filter: data.query.filter,
|
||||||
|
pagination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterUpdate() {
|
||||||
|
updateQuery({
|
||||||
|
filter: {
|
||||||
|
done: false,
|
||||||
|
station: selection ? [selection] : undefined,
|
||||||
|
date: dateRange ? [{ id: dateRange.toString(), name: dateRange.format() }] : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuery(q: typeof data.query) {
|
||||||
|
if (browser) {
|
||||||
|
// Update page URL
|
||||||
|
const url = getQueryUrl(q, URL_VISIT);
|
||||||
|
goto(url, { replaceState: true, keepFocus: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
@ -6,3 +72,33 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1 class="heading">Visite</h1>
|
<h1 class="heading">Visite</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between">
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span>Woche:</span>
|
||||||
|
<WeekSelector onSelect={filterUpdate} bind:dateRange />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span>Station:</span>
|
||||||
|
<Autocomplete
|
||||||
|
clearBtn
|
||||||
|
inputCls="input input-sm input-bordered"
|
||||||
|
items={async () => trpc().station.list.query()}
|
||||||
|
onSelect={filterUpdate}
|
||||||
|
onUnselect={filterUpdate}
|
||||||
|
bind:selection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each data.entries.items as entry}
|
||||||
|
<EntryCard {entry} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaginationButtons
|
||||||
|
data={data.entries}
|
||||||
|
onUpdate={paginationUpdate}
|
||||||
|
paginationData={data.query.pagination}
|
||||||
|
/>
|
||||||
|
|
40
src/routes/(app)/visit/+page.ts
Normal file
40
src/routes/(app)/visit/+page.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ZEntriesQuery } from "$lib/shared/model/validation";
|
||||||
|
import { trpc } from "$lib/shared/trpc";
|
||||||
|
import { DateRange, loadWrap, parseQueryUrl } from "$lib/shared/util";
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
return loadWrap(async () => {
|
||||||
|
let query: z.infer<typeof ZEntriesQuery> = {};
|
||||||
|
|
||||||
|
if (event.url.search) {
|
||||||
|
const decoded = parseQueryUrl(event.url.search);
|
||||||
|
query = ZEntriesQuery.parse(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default filter (current week)
|
||||||
|
const drange = DateRange.thisWeek();
|
||||||
|
if (!query.filter) {
|
||||||
|
query.filter = {
|
||||||
|
date: [{ id: drange.toString() }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!query.sort) {
|
||||||
|
query.sort = { field: "date" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await trpc(event).entry.list.query(query);
|
||||||
|
|
||||||
|
// Move prioritized items to the front
|
||||||
|
entries.items.forEach((itm, i) => {
|
||||||
|
if (itm.current_version.priority) {
|
||||||
|
entries.items.unshift(entries.items.splice(i, 1)[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { query, entries };
|
||||||
|
});
|
||||||
|
};
|
|
@ -4,6 +4,7 @@
|
||||||
import { navigating } from "$app/stores";
|
import { navigating } from "$app/stores";
|
||||||
|
|
||||||
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
|
import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
|
||||||
|
import { screenWidth } from "$lib/stores/layout";
|
||||||
|
|
||||||
let loadingBar: LoadingBar | undefined;
|
let loadingBar: LoadingBar | undefined;
|
||||||
|
|
||||||
|
@ -16,6 +17,6 @@
|
||||||
|
|
||||||
<LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" />
|
<LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" />
|
||||||
|
|
||||||
<div class="bg-base-100 text-base-content">
|
<div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
getEntryNExecutions,
|
getEntryNExecutions,
|
||||||
getEntryNVersions,
|
getEntryNVersions,
|
||||||
getEntryVersions,
|
getEntryVersions,
|
||||||
|
getNTodo,
|
||||||
newEntry,
|
newEntry,
|
||||||
newEntryExecution,
|
newEntryExecution,
|
||||||
newEntryVersion,
|
newEntryVersion,
|
||||||
|
@ -269,9 +270,25 @@ test("get entries", async () => {
|
||||||
expect(entriesPrio.total).toBe(1);
|
expect(entriesPrio.total).toBe(1);
|
||||||
expect(entriesPrio.items[0].id).toBe(eId1);
|
expect(entriesPrio.items[0].id).toBe(eId1);
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
const entriesDate = await getEntries({ date: "2024-01-05" });
|
||||||
|
expect(entriesDate.items).length(1);
|
||||||
|
expect(entriesDate.total).toBe(1);
|
||||||
|
expect(entriesDate.items[0].id).toBe(eId2);
|
||||||
|
|
||||||
|
const entriesDateRange = await getEntries({ date: "2024-01-05..2024-01-06" });
|
||||||
|
expect(entriesDateRange.items).length(2);
|
||||||
|
expect(entriesDateRange.total).toBe(2);
|
||||||
|
expect(entriesDateRange.items[0].id).toBe(eId3);
|
||||||
|
expect(entriesDateRange.items[1].id).toBe(eId2);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const entriesSearch = await getEntries({ search: "Blu" }, {});
|
const entriesSearch = await getEntries({ search: "Blu" }, {});
|
||||||
expect(entriesSearch.items).length(1);
|
expect(entriesSearch.items).length(1);
|
||||||
expect(entriesSearch.total).toBe(1);
|
expect(entriesSearch.total).toBe(1);
|
||||||
expect(entriesSearch.items[0].id).toBe(eId1);
|
expect(entriesSearch.items[0].id).toBe(eId1);
|
||||||
|
|
||||||
|
// NTodo
|
||||||
|
const n = await getNTodo(new Date("2024-01-05"));
|
||||||
|
expect(n).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue