Compare commits

...

10 commits

100 changed files with 1416 additions and 464 deletions

41
CHANGELOG.md Normal file
View file

@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.2.0](https://code.thetadev.de/HSA/Visitenbuch/compare/v0.1.0..v0.2.0) - 2024-05-06
### 🚀 Features
- Add toast error messages - ([80c6243](https://code.thetadev.de/HSA/Visitenbuch/commit/80c6243e2b1fe1c8c92b57809c094219be666065))
- Improve week selector, select all TODO items by default - ([21f145f](https://code.thetadev.de/HSA/Visitenbuch/commit/21f145f5f08e4f44beecd3d373dfc69e5fc94f23))
- Use global store for saved filters, add default filters - ([f36ae71](https://code.thetadev.de/HSA/Visitenbuch/commit/f36ae71d32ebcfc5054dca9c6907ffc1554f39e3))
- Hide rooms/stations/categories - ([2cb8dce](https://code.thetadev.de/HSA/Visitenbuch/commit/2cb8dce51a880d46af3a77ee9cbdfce680b88755))
- Add about page, licenses - ([5230ee3](https://code.thetadev.de/HSA/Visitenbuch/commit/5230ee375c7b25e577dc2ae045309d8cd54cfdf4))
### 🐛 Bug Fixes
- Date timezone issue, refactor utils - ([efb0e24](https://code.thetadev.de/HSA/Visitenbuch/commit/efb0e246127489d618ca6e3ea50f10dd1140a954))
- Normalize line endings - ([799dbc4](https://code.thetadev.de/HSA/Visitenbuch/commit/799dbc4097b5cf72e813e67b6ca6ccfe6ab5f08b))
- Light/dark theme - ([8630b6a](https://code.thetadev.de/HSA/Visitenbuch/commit/8630b6a32d850e93ff1b426a92f68400f7b2f666))
- Not using UTC dates for parsing date ranges in backend - ([519ae01](https://code.thetadev.de/HSA/Visitenbuch/commit/519ae01eeec294f7522c5ad9bcf8f2b6b44539fb))
### 🚜 Refactor
- Added return types - ([e6302f3](https://code.thetadev.de/HSA/Visitenbuch/commit/e6302f380b903e3ab3f4ef6b5c6e1483d2939027))
### ⚙️ Miscellaneous Tasks
- Update dependencies - ([e3f7341](https://code.thetadev.de/HSA/Visitenbuch/commit/e3f7341a0e575a9b0cb922c9b697c21f6f6875c6))
- Add git-cliff - ([1b96a46](https://code.thetadev.de/HSA/Visitenbuch/commit/1b96a46dcf2b34318bed5fc383a839a6d7cfd25e))
### Build
- Remove docker multistage build, add entrypoint - ([be52e70](https://code.thetadev.de/HSA/Visitenbuch/commit/be52e70e8d5abed676c9594f2de0e749b7d2da08))
## [v0.1.0](https://code.thetadev.de/HSA/Visitenbuch/commits/tag/v0.1.0) - 2024-05-06
Initial release
<!-- generated by git-cliff -->

100
cliff.toml Normal file
View file

@ -0,0 +1,100 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% set repo_url = "https://code.thetadev.de/HSA/Visitenbuch" %}\
{% if version %}\
{%set vname = version | split(pat="/") | last %}
{%if previous.version %}\
## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\
{% else %}\
## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\
{% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% if previous.version %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }} - \
([{{ commit.id | truncate(length=7, end="") }}]({{ repo_url }}/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}\
{% else %}
Initial release
{% endif %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ message = "^ci", skip = true },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
# tag_pattern = "v[0-9].*"
# regex for skipping tags
# skip_tags = ""
# regex for ignoring tags
# ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
# limit_commits = 42

View file

@ -493,7 +493,7 @@ export default [
// specify whether double or single quotes should be used // specify whether double or single quotes should be used
// https://eslint.style/rules/default/quotes // https://eslint.style/rules/default/quotes
"@stylistic/quotes": ["warn", "double", { avoidEscape: true }], "@stylistic/quotes": ["warn", "double", { avoidEscape: true, allowTemplateLiterals: true }],
// require or disallow use of semicolons instead of ASI // require or disallow use of semicolons instead of ASI
// https://eslint.style/rules/default/semi // https://eslint.style/rules/default/semi

View file

@ -1,6 +1,6 @@
{ {
"name": "visitenbuch", "name": "visitenbuch",
"version": "0.0.1", "version": "0.2.0",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -45,6 +45,7 @@
"@types/node": "^20.12.8", "@types/node": "^20.12.8",
"@types/qs": "^6.9.15", "@types/qs": "^6.9.15",
"@types/set-cookie-parser": "^2.4.7", "@types/set-cookie-parser": "^2.4.7",
"@zerodevx/svelte-toast": "^0.9.5",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"daisyui": "^4.10.5", "daisyui": "^4.10.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@ -57,6 +58,7 @@
"globals": "^15.1.0", "globals": "^15.1.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-nesting": "^12.1.2", "postcss-nesting": "^12.1.2",
"rollup-license-plugin": "^3.0.0",
"svelte": "^4.2.15", "svelte": "^4.2.15",
"svelte-check": "^3.7.1", "svelte-check": "^3.7.1",
"sveltekit-superforms": "^2.13.0", "sveltekit-superforms": "^2.13.0",

View file

@ -90,6 +90,9 @@ devDependencies:
'@types/set-cookie-parser': '@types/set-cookie-parser':
specifier: ^2.4.7 specifier: ^2.4.7
version: 2.4.7 version: 2.4.7
'@zerodevx/svelte-toast':
specifier: ^0.9.5
version: 0.9.5(svelte@4.2.15)
autoprefixer: autoprefixer:
specifier: ^10.4.19 specifier: ^10.4.19
version: 10.4.19(postcss@8.4.38) version: 10.4.19(postcss@8.4.38)
@ -126,6 +129,9 @@ devDependencies:
postcss-nesting: postcss-nesting:
specifier: ^12.1.2 specifier: ^12.1.2
version: 12.1.2(postcss@8.4.38) version: 12.1.2(postcss@8.4.38)
rollup-license-plugin:
specifier: ^3.0.0
version: 3.0.0
svelte: svelte:
specifier: ^4.2.15 specifier: ^4.2.15
version: 4.2.15 version: 4.2.15
@ -1448,6 +1454,14 @@ packages:
pretty-format: 29.7.0 pretty-format: 29.7.0
dev: true dev: true
/@zerodevx/svelte-toast@0.9.5(svelte@4.2.15):
resolution: {integrity: sha512-JLeB/oRdJfT+dz9A5bgd3Z7TuQnBQbeUtXrGIrNWMGqWbabpepBF2KxtWVhL2qtxpRqhae2f6NAOzH7xs4jUSw==}
peerDependencies:
svelte: ^3.57.0 || ^4.0.0
dependencies:
svelte: 4.2.15
dev: true
/acorn-jsx@5.3.2(acorn@8.11.3): /acorn-jsx@5.3.2(acorn@8.11.3):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@ -1923,6 +1937,11 @@ packages:
- postcss - postcss
dev: true dev: true
/data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
dev: true
/data-urls@5.0.0: /data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2584,6 +2603,14 @@ packages:
reusify: 1.0.4 reusify: 1.0.4
dev: true dev: true
/fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
dev: true
/file-entry-cache@6.0.1: /file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@ -2642,6 +2669,13 @@ packages:
mime-types: 2.1.35 mime-types: 2.1.35
dev: false dev: false
/formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
dependencies:
fetch-blob: 3.2.0
dev: true
/fraction.js@4.3.7: /fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
dev: true dev: true
@ -2697,6 +2731,11 @@ packages:
has-symbols: 1.0.3 has-symbols: 1.0.3
hasown: 2.0.2 hasown: 2.0.2
/get-npm-tarball-url@2.1.0:
resolution: {integrity: sha512-ro+DiMu5DXgRBabqXupW38h7WPZ9+Ad8UjwhvsmmN8w1sU7ab0nzAXvVZ4kqYg57OrqomRtJvepX5/xvFKNtjA==}
engines: {node: '>=12.17'}
dev: true
/get-stream@8.0.1: /get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'} engines: {node: '>=16'}
@ -3938,6 +3977,20 @@ packages:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true dev: true
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
dev: true
/node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
dev: true
/node-releases@2.0.14: /node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
dev: true dev: true
@ -4528,6 +4581,15 @@ packages:
glob: 7.2.3 glob: 7.2.3
dev: true dev: true
/rollup-license-plugin@3.0.0:
resolution: {integrity: sha512-dcTdmq+vgqNrSq8Q7uXWp+49u8Mq4gDGeUUI92/4+STMYDyJmO+H+WFLq8DGLWECZ8ckUYAzlfbOyfmbHBE68w==}
engines: {node: '>=18.0.0'}
dependencies:
get-npm-tarball-url: 2.1.0
node-fetch: 3.3.2
spdx-expression-validate: 2.0.0
dev: true
/rollup@4.17.2: /rollup@4.17.2:
resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==} resolution: {integrity: sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -4729,6 +4791,27 @@ packages:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
dev: false dev: false
/spdx-exceptions@2.5.0:
resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==}
dev: true
/spdx-expression-parse@3.0.1:
resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
dependencies:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.17
dev: true
/spdx-expression-validate@2.0.0:
resolution: {integrity: sha512-b3wydZLM+Tc6CFvaRDBOF9d76oGIHNCLYFeHbftFXUWjnfZWganmDmvtM5sm1cRwJc/VDBMLyGGrsLFd1vOxbg==}
dependencies:
spdx-expression-parse: 3.0.1
dev: true
/spdx-license-ids@3.0.17:
resolution: {integrity: sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==}
dev: true
/stackback@0.0.2: /stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
dev: true dev: true
@ -5571,6 +5654,11 @@ packages:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
dev: false dev: false
/web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
dev: true
/webidl-conversions@7.0.0: /webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'} engines: {node: '>=12'}

View file

@ -0,0 +1,26 @@
-- DropForeignKey
ALTER TABLE "entry_versions" DROP CONSTRAINT "entry_versions_category_id_fkey";
-- DropForeignKey
ALTER TABLE "patients" DROP CONSTRAINT "patients_room_id_fkey";
-- DropForeignKey
ALTER TABLE "rooms" DROP CONSTRAINT "rooms_station_id_fkey";
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "rooms" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "stations" ADD COLUMN "hidden" BOOLEAN NOT NULL DEFAULT false;
-- AddForeignKey
ALTER TABLE "rooms" ADD CONSTRAINT "rooms_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "stations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "patients" ADD CONSTRAINT "patients_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rooms"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "entry_versions" ADD CONSTRAINT "entry_versions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -52,6 +52,7 @@ model Station {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
Room Room[] Room Room[]
hidden Boolean @default(false)
@@map("stations") @@map("stations")
} }
@ -60,9 +61,10 @@ model Station {
model Room { model Room {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
station Station @relation(fields: [station_id], references: [id], onDelete: Cascade) station Station @relation(fields: [station_id], references: [id], onDelete: Restrict)
station_id Int station_id Int
Patient Patient[] Patient Patient[]
hidden Boolean @default(false)
@@map("rooms") @@map("rooms")
} }
@ -72,7 +74,7 @@ model Patient {
first_name String first_name String
last_name String last_name String
age Int? age Int?
room Room? @relation(fields: [room_id], references: [id], onDelete: SetNull) room Room? @relation(fields: [room_id], references: [id], onDelete: Restrict)
room_id Int? room_id Int?
Entry Entry[] Entry Entry[]
hidden Boolean @default(false) hidden Boolean @default(false)
@ -92,6 +94,7 @@ model Category {
color String? color String?
description String? description String?
EntryVersion EntryVersion[] EntryVersion EntryVersion[]
hidden Boolean @default(false)
@@map("categories") @@map("categories")
} }
@ -120,7 +123,7 @@ model EntryVersion {
text String text String
date DateTime @db.Date date DateTime @db.Date
category Category? @relation(fields: [category_id], references: [id], onDelete: SetNull) category Category? @relation(fields: [category_id], references: [id], onDelete: Restrict)
category_id Int? category_id Int?
priority Boolean priority Boolean

27
release.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -e
CHANGELOG="CHANGELOG.md"
VERSION=$(jq -r '.version' package.json)
TAG="v${VERSION}"
echo "Releasing $TAG:"
if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi
CLIFF_ARGS="--tag '${TAG}' --unreleased"
echo "git-cliff $CLIFF_ARGS"
if [ -f "$CHANGELOG" ]; then
eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'"
else
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
fi
editor "$CHANGELOG"
git add "$CHANGELOG"
git commit -m "chore(release): release v$VERSION"
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG"
echo "🚀 Run 'git push origin $TAG' to publish"

5
src/app.d.ts vendored
View file

@ -6,7 +6,7 @@ declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
session: Session; session: Session | null;
} }
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}
@ -18,6 +18,9 @@ declare global {
"on:outclick"?: CompositionEventHandler<T>; "on:outclick"?: CompositionEventHandler<T>;
} }
} }
declare const __VERSION__: string;
declare const __LASTMOD__: string;
} }
export {}; export {};

View file

@ -15,6 +15,32 @@
} }
} }
:root {
--toastBackground: oklch(var(--b3));
--toastColor: oklch(var(--bc));
--toastLeftBorder: oklch(var(--p));
--toastBarBackground: oklch(var(--bc) / 0.4);
--toastContainerTop: 4rem;
--toastContainerRight: 1rem;
--toastBarHeight: 3px;
}
.toast-error {
--toastLeftBorder: oklch(var(--er));
}
._toastItem {
border-left: solid 6px var(--toastLeftBorder) !important;
}
._toastMsg {
white-space: pre-wrap;
}
.badge {
border: none;
}
button { button {
text-align: initial; text-align: initial;
} }

View file

@ -1,5 +1,7 @@
export default function clickOutside(node: Element) { import type { ActionReturn } from "svelte/action";
const handleClick = (event: MouseEvent) => {
export default function clickOutside(node: Element): ActionReturn {
const handleClick = (event: MouseEvent): void => {
const tnode = event.target as Element; const tnode = event.target as Element;
if (!node.contains(tnode)) { if (!node.contains(tnode)) {

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
@ -6,6 +7,8 @@
import { createFloatingActions } from "svelte-floating-ui"; import { createFloatingActions } from "svelte-floating-ui";
import { shift } from "svelte-floating-ui/dom"; import { shift } from "svelte-floating-ui/dom";
import { toastError } from "$lib/shared/util/toast";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import outclick from "$lib/actions/outclick"; import outclick from "$lib/actions/outclick";
@ -29,7 +32,7 @@
/** Set of item IDs that should be hidden from the list */ /** Set of item IDs that should be hidden from the list */
export let hiddenIds: Set<string | number> = new Set(); export let hiddenIds: Set<string | number> = new Set();
/** Object to cache fetched items in */ /** Object to cache fetched items in */
export let cache: { [key: string]: T[] } = {}; export let cache: Record<string, T[]> = {};
/** Key in cache object under which fetched items are stored */ /** Key in cache object under which fetched items are stored */
export let cacheKey: string | undefined = undefined; export let cacheKey: string | undefined = undefined;
/** Input field placeholder */ /** Input field placeholder */
@ -56,9 +59,9 @@
/** Selection callback. Returns the new input value after selection */ /** Selection callback. Returns the new input value after selection */
export let onSelect: (item: T, kb: boolean) => OnSelectResult | void = () => {}; export let onSelect: (item: T, kb: boolean) => OnSelectResult | void = () => {};
export let onUnselect = () => {}; export let onUnselect = (): void => {};
export let onClose = (kb: boolean) => {}; export let onClose = (kb: boolean): void => {};
export let onBackspace = () => {}; export let onBackspace = (): void => {};
let opened = false; let opened = false;
let highlightIndex = 0; let highlightIndex = 0;
@ -78,7 +81,7 @@
return ""; return "";
} }
function setInputValue(v: string) { function setInputValue(v: string): void {
if (inputElm) inputElm.value = v; if (inputElm) inputElm.value = v;
} }
@ -98,14 +101,14 @@
if (cacheKey) cache[cacheKey] = fetchedItems; if (cacheKey) cache[cacheKey] = fetchedItems;
isLoading = false; isLoading = false;
updateSearch(); updateSearch();
}); }).catch((e) => toastError("Konnte Items nicht laden:\n" + e));
return false; return false;
} }
} }
return true; return true;
} }
function markSelection() { function markSelection(): void {
if (selection) { if (selection) {
if (selection.id) { if (selection.id) {
const i = srcItems.findIndex((itm) => itm.id === selection?.id); const i = srcItems.findIndex((itm) => itm.id === selection?.id);
@ -123,7 +126,7 @@
highlight(); highlight();
} }
function clearSelection() { function clearSelection(): void {
selection = null; selection = null;
onUnselect(); onUnselect();
setInputValue(""); setInputValue("");
@ -131,14 +134,14 @@
updateSearch(); updateSearch();
} }
function onInput() { function onInput(): void {
selection = null; selection = null;
onUnselect(); onUnselect();
opened = true; opened = true;
updateSearch(); updateSearch();
} }
function updateSearch() { function updateSearch(): void {
if (loadSrcItems()) { if (loadSrcItems()) {
const searchWord = inputValue().toLowerCase().trim(); const searchWord = inputValue().toLowerCase().trim();
filteredItems = !selection && searchWord.length > 0 filteredItems = !selection && searchWord.length > 0
@ -152,7 +155,7 @@
} }
} }
export function open() { export function open(): void {
if (!opened) { if (!opened) {
updateSearch(); updateSearch();
} }
@ -160,14 +163,14 @@
if (inputElm) inputElm.focus(); if (inputElm) inputElm.focus();
} }
export function close(kb = true) { export function close(kb = true): void {
if (opened) { if (opened) {
onClose(kb); onClose(kb);
} }
opened = false; opened = false;
} }
function selectListItem(item: T | undefined, kb: boolean) { function selectListItem(item: T | undefined, kb: boolean): void {
if (item) { if (item) {
selection = item; selection = item;
const selRes = onSelect(item, kb); const selRes = onSelect(item, kb);
@ -182,10 +185,10 @@
} }
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent): void {
let { key } = e; let { key } = e;
if (key === "Tab" && e.shiftKey) key = "ShiftTab"; if (key === "Tab" && e.shiftKey) key = "ShiftTab";
const fnmap: { [key: string]: () => void } = { const fnmap: Record<string, () => void> = {
Tab: () => close, Tab: () => close,
ShiftTab: () => close, ShiftTab: () => close,
ArrowDown: () => { ArrowDown: () => {
@ -223,7 +226,7 @@
} }
} }
function onKeyPress(e: KeyboardEvent) { function onKeyPress(e: KeyboardEvent): void {
if (e.key === "Enter") { if (e.key === "Enter") {
if (opened) { if (opened) {
selectItem(); selectItem();
@ -232,7 +235,7 @@
} }
} }
function onBlur() { function onBlur(): void {
if (!selection) { if (!selection) {
if (!noAutoselect1 && filteredItems.length === 1) { if (!noAutoselect1 && filteredItems.length === 1) {
selectListItem(filteredItems[0], true); selectListItem(filteredItems[0], true);
@ -242,7 +245,7 @@
} }
} }
function highlight() { function highlight(): void {
if (browser && opened) { if (browser && opened) {
window.setTimeout(() => { window.setTimeout(() => {
const query = ".selected"; const query = ".selected";
@ -261,7 +264,7 @@
} }
} }
function selectItem() { function selectItem(): void {
const listItem = filteredItems[highlightIndex]; const listItem = filteredItems[highlightIndex];
selectListItem(listItem, true); selectListItem(listItem, true);
} }
@ -348,7 +351,7 @@
top: 0px; top: 0px;
max-height: calc(15 * (1rem + 10px) + 15px); max-height: calc(15 * (1rem + 10px) + 15px);
user-select: none; user-select: none;
@apply bg-neutral text-neutral-content rounded-btn p-2; @apply bg-base-100 text-base-content rounded-btn p-2 border border-base-content/30;
} }
.autocomplete-list:empty { .autocomplete-list:empty {

View file

@ -19,7 +19,7 @@
import { InputType, isFilterValueless } from "./types"; import { InputType, isFilterValueless } from "./types";
/** Filter definitions */ /** Filter definitions */
export let FILTERS: { [key: string]: FilterDef }; export let FILTERS: Record<string, FilterDef>;
/** Filter data from the query */ /** Filter data from the query */
export let filterData: FilterQdata | null | undefined; export let filterData: FilterQdata | null | undefined;
/** Callback when filters are updated */ /** Callback when filters are updated */
@ -32,7 +32,7 @@
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = []; let activeFilters: FilterData[] = [];
const cache: { [key: string]: BaseItem[] } = {}; const cache: Record<string, BaseItem[]> = {};
let searchVal = ""; let searchVal = "";
const searchDebounce = new Debouncer(400, () => { const searchDebounce = new Debouncer(400, () => {
onUpdate(getFilterQdata()); onUpdate(getFilterQdata());
@ -68,7 +68,7 @@
updateFromQueryData(filterData ?? {}); updateFromQueryData(filterData ?? {});
} }
function updateFromQueryData(fd: FilterQdata) { function updateFromQueryData(fd: FilterQdata): void {
const filters: FilterData[] = []; const filters: FilterData[] = [];
for (const [id, value] of Object.entries(fd)) { for (const [id, value] of Object.entries(fd)) {
// If filter is hidden or undefined, dont display it // If filter is hidden or undefined, dont display it
@ -105,7 +105,7 @@
return new Set(); return new Set();
} }
function focusInput() { function focusInput(): void {
if (autocomplete) autocomplete.open(); if (autocomplete) autocomplete.open();
} }
@ -166,7 +166,7 @@
return !valueless; return !valueless;
} }
function removeFilter(i: number) { function removeFilter(i: number): void {
const shouldUpdate = isFilterValueless(FILTERS[activeFilters[i].id].inputType) const shouldUpdate = isFilterValueless(FILTERS[activeFilters[i].id].inputType)
|| activeFilters[i].selection !== null; || activeFilters[i].selection !== null;
activeFilters.splice(i, 1); activeFilters.splice(i, 1);
@ -174,15 +174,15 @@
if (shouldUpdate) updateFilter(); if (shouldUpdate) updateFilter();
} }
function updateFilter() { function updateFilter(): void {
onUpdate(getFilterQdata()); onUpdate(getFilterQdata());
} }
function onSearchInput(e: Event) { function onSearchInput(e: Event): void {
searchDebounce.trigger(); searchDebounce.trigger();
} }
function onSearchKeypress(e: KeyboardEvent) { function onSearchKeypress(e: KeyboardEvent): void {
if (e.key === "Enter") { if (e.key === "Enter") {
searchDebounce.now(); searchDebounce.now();
} }

View file

@ -11,24 +11,24 @@
export let filter: FilterDef; export let filter: FilterDef;
export let hiddenIds: () => Set<string | number> = () => new Set(); export let hiddenIds: () => Set<string | number> = () => new Set();
export let cache: { [key: string]: BaseItem[] } = {}; export let cache: Record<string, BaseItem[]> = {};
export let fdata: FilterData; export let fdata: FilterData;
export let onRemove = () => {}; export let onRemove = (): void => {};
export let onSelection = (selection: SelectionOrText, kb: boolean) => {}; export let onSelection = (selection: SelectionOrText, kb: boolean): void => {};
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete<BaseItem> | undefined;
function startEditing() { function startEditing(): void {
fdata.editing = true; fdata.editing = true;
} }
function stopEditing(kb = false) { function stopEditing(kb = false): void {
fdata.editing = false; fdata.editing = false;
if (fdata.selection) onSelection(fdata.selection, kb); if (fdata.selection) onSelection(fdata.selection, kb);
} }
function onClose(kb = false) { function onClose(kb = false): void {
if (fdata.selection) stopEditing(kb); if (fdata.selection) stopEditing(kb);
else onRemove(); else onRemove();
} }

View file

@ -4,15 +4,15 @@
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
export let href = ""; export let href = "";
export let onSave = () => {}; export let onSave = (): void => {};
export let onRemove = () => {}; export let onRemove = (): void => {};
function onSaveInt(e: MouseEvent) { function onSaveInt(e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
onSave(); onSave();
} }
function onRemoveInt(e: MouseEvent) { function onRemoveInt(e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
onRemove(); onRemove();
} }

View file

@ -1,58 +1,79 @@
<!-- Bar of saved filter chips --> <!-- Bar of saved filter chips -->
<script lang="ts"> <script lang="ts">
import { mdiPlus } from "@mdi/js"; import { mdiPlus } from "@mdi/js";
import { onMount } from "svelte";
import type { SavedFilter } from "$lib/shared/model"; import type { SavedFilter } from "$lib/shared/model";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { isDefaultFilter } from "$lib/shared/util";
import { toastError, toastInfo } from "$lib/shared/util/toast";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import LoadingIcon from "$lib/components/ui/LoadingIcon.svelte"; import { savedFilters } from "$lib/stores";
import Chip from "./SavedFilterChip.svelte"; import Chip from "./SavedFilterChip.svelte";
export let view: string; export let view: string;
let filters: SavedFilter[] | null = null; $: filters = $savedFilters[view] ?? [];
onMount(() => {
trpc().savedFilter.get.query(view).then((res) => {
filters = res;
});
});
function getQuery(): string { function getQuery(): string {
return window.location.search.substring(1); return window.location.search.substring(1);
} }
function create() { function updateSavedFilter(filter: SavedFilter): void {
savedFilters.update((v) => {
if (!v[view]) v[view] = [];
const ix = v[view].findIndex((f) => f.id === filter.id);
if (ix === -1) {
if (isDefaultFilter(filter.name)) v[view].unshift(filter);
else v[view].push(filter);
} else {
v[view][ix] = filter;
}
return v;
});
}
function create(): void {
const query = getQuery(); const query = getQuery();
if (query.length === 0) return; if (query.length === 0) {
toastInfo("Filter leer");
return;
}
const name = prompt("Name"); const name = prompt("Name");
if (!name) return; if (!name) return;
trpc().savedFilter.create.mutate({ name, query, view }).then((id) => { trpc().savedFilter.create.mutate({ name, query, view }).then((id) => {
filters?.push({ id, name, query }); toastInfo("Filter erstellt");
filters = filters; // force reactive update updateSavedFilter({ id, name, query });
}); }).catch(toastError);
} }
function update(ix: number) { function update(ix: number): void {
const f = filters![ix]; const f = filters[ix];
const query = getQuery(); const query = getQuery();
if (query.length === 0) return; if (query.length === 0) {
toastInfo("Filter leer");
trpc().savedFilter.update.mutate({ id: f.id, query }); return;
f.query = query;
filters = filters; // force reactive update
} }
function remove(ix: number) { trpc().savedFilter.update.mutate({ id: f.id, query }).then(() => {
const f = filters![ix]; f.query = query;
trpc().savedFilter.delete.mutate(f.id); toastInfo("Filter aktualisiert");
filters!.splice(ix, 1); updateSavedFilter(f);
filters = filters; }).catch(toastError);
}
function remove(ix: number): void {
const f = filters[ix];
trpc().savedFilter.delete.mutate(f.id).then(() => {
toastInfo("Filter gelöscht");
savedFilters.update((v) => {
v[view].splice(ix, 1);
return v;
});
}).catch(toastError);
} }
</script> </script>
@ -61,7 +82,6 @@
Gespeicherte Filter: Gespeicherte Filter:
</div> </div>
{#if filters}
{#each filters as filter, i (filter.id)} {#each filters as filter, i (filter.id)}
<Chip <Chip
href={"?" + filter.query} href={"?" + filter.query}
@ -71,9 +91,6 @@
{filter.name} {filter.name}
</Chip> </Chip>
{/each} {/each}
{:else}
<LoadingIcon />
{/if}
<button class="btn btn-sm btn-primary pl-1" on:click={create}> <button class="btn btn-sm btn-primary pl-1" on:click={create}>
<Icon path={mdiPlus} /> <Icon path={mdiPlus} />

View file

@ -16,26 +16,28 @@ import {
import { WEEK_LIMIT } from "$lib/shared/constants"; 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 { DateRange } from "$lib/shared/util";
import { type FilterDef, InputType, type BaseItem } from "./types"; import { type FilterDef, InputType, type BaseItem } from "./types";
export function weekFilterItems(earlierLater: boolean): BaseItem[] { export function weekFilterItems(): BaseItem[] {
const range = DateRange.thisWeek(); const range = DateRange.thisWeek();
const res = []; const res: BaseItem[] = [];
if (earlierLater) res.push({ id: ".." + dateToYMD(range.start!), name: "Früher" }); const addRange = (r: DateRange): void => {
res.push({ id: r.toString(), name: r.format() });
};
addRange(new DateRange(null, range.start));
for (let i = 0; i < WEEK_LIMIT; i++) { for (let i = 0; i < WEEK_LIMIT; i++) {
res.push({ id: range.toString(), name: range.format() }); addRange(range);
range.addDays(7); range.addDays(7);
} }
addRange(new DateRange(range.start, null));
if (earlierLater) res.push({ id: dateToYMD(range.start!) + "..", name: "Später" });
return res; return res;
} }
export const ENTRY_FILTERS: { [key: string]: FilterDef } = { export const ENTRY_FILTERS: Record<string, FilterDef> = {
category: { category: {
id: "category", id: "category",
name: "Kategorie", name: "Kategorie",
@ -105,11 +107,11 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
name: "Woche", name: "Woche",
icon: mdiCalendar, icon: mdiCalendar,
inputType: InputType.FilterList, inputType: InputType.FilterList,
options: async () => weekFilterItems(true), options: async () => weekFilterItems(),
}, },
}; };
export const PATIENT_FILTER: { [key: string]: FilterDef } = { export const PATIENT_FILTER: Record<string, FilterDef> = {
station: { station: {
id: "station", id: "station",
name: "Station", name: "Station",

View file

@ -36,9 +36,10 @@ export type FilterData = {
editing: boolean; editing: boolean;
}; };
export type FilterQdata = { export type FilterQdata = Record<
[key: string]: string | number | boolean | { id: string | number; name?: string }[]; string,
}; string | number | boolean | { id: string | number; name?: string }[]
>;
export function isFilterValueless(inputType: InputType): boolean { export function isFilterValueless(inputType: InputType): boolean {
return inputType === InputType.None || inputType === InputType.Boolean; return inputType === InputType.None || inputType === InputType.Boolean;

View file

@ -7,6 +7,7 @@
import type { Category } from "$lib/shared/model"; import type { Category } from "$lib/shared/model";
import { ZCategoryNew } from "$lib/shared/model/validation"; import { ZCategoryNew } from "$lib/shared/model/validation";
import { superformConfig } from "$lib/shared/util";
import FormField from "$lib/components/ui/FormField.svelte"; import FormField from "$lib/components/ui/FormField.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
@ -21,6 +22,7 @@
} = superForm(formData, { } = superForm(formData, {
validators: schema, validators: schema,
resetForm: category === null, resetForm: category === null,
...superformConfig("Kategorie"),
}); });
</script> </script>

View file

@ -0,0 +1,18 @@
<script lang="ts">
export let hidden: boolean;
export let hasEntries: boolean;
</script>
{#if hidden}
<button
name="hide"
class="btn btn-primary"
type="submit"
value="0"
>Einblenden</button
>
{:else if hasEntries}
<button name="hide" class="btn" type="submit" value="1">Ausblenden</button>
{:else}
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button>
{/if}

View file

@ -7,6 +7,7 @@
import { ZPatientNew } from "$lib/shared/model/validation"; import { ZPatientNew } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc";
import { superformConfig } from "$lib/shared/util";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte"; import FormField from "$lib/components/ui/FormField.svelte";
@ -23,6 +24,7 @@
} = superForm(formData, { } = superForm(formData, {
validators: schema, validators: schema,
resetForm: patient === null, resetForm: patient === null,
...superformConfig("Patient"),
}); });
</script> </script>

View file

@ -7,6 +7,7 @@
import { ZRoomNew } from "$lib/shared/model/validation"; import { ZRoomNew } from "$lib/shared/model/validation";
import { trpc, type RouterOutput } from "$lib/shared/trpc"; import { trpc, type RouterOutput } from "$lib/shared/trpc";
import { superformConfig } from "$lib/shared/util";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte"; import FormField from "$lib/components/ui/FormField.svelte";
@ -22,6 +23,7 @@
} = superForm(formData, { } = superForm(formData, {
validators: schema, validators: schema,
resetForm: room === null, resetForm: room === null,
...superformConfig("Zimmer"),
}); });
</script> </script>

View file

@ -7,6 +7,7 @@
import type { Station } from "$lib/shared/model"; import type { Station } from "$lib/shared/model";
import { ZStationNew } from "$lib/shared/model/validation"; import { ZStationNew } from "$lib/shared/model/validation";
import { superformConfig } from "$lib/shared/util";
import FormField from "$lib/components/ui/FormField.svelte"; import FormField from "$lib/components/ui/FormField.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
@ -21,6 +22,7 @@
} = superForm(formData, { } = superForm(formData, {
validators: schema, validators: schema,
resetForm: station === null, resetForm: station === null,
...superformConfig("Station"),
}); });
</script> </script>

View file

@ -13,7 +13,7 @@
? colorToHex(getTextColor(hexToColor(category.color))) ? colorToHex(getTextColor(hexToColor(category.color)))
: null; : null;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent): void {
gotoEntityQuery( gotoEntityQuery(
{ {
filter: { filter: {

View file

@ -22,7 +22,7 @@
export let patientId: number | null = null; export let patientId: number | null = null;
export let view: string | undefined = undefined; export let view: string | undefined = undefined;
function paginationUpdate(pagination: PaginationRequest) { function paginationUpdate(pagination: PaginationRequest): void {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
pagination, pagination,
@ -30,11 +30,11 @@
}); });
} }
function filterUpdate(filter: FilterQdata | undefined) { function filterUpdate(filter: FilterQdata | undefined): void {
updateQuery({ filter, sort: query.sort }); updateQuery({ filter, sort: query.sort });
} }
function sortUpdate(sort: SortRequest | undefined) { function sortUpdate(sort: SortRequest | undefined): void {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
pagination: query.pagination, pagination: query.pagination,
@ -42,7 +42,7 @@
}); });
} }
function updateQuery(q: typeof query) { function updateQuery(q: typeof query): void {
if (browser) { if (browser) {
if (patientId !== null && q.filter?.patient) delete q.filter.patient; if (patientId !== null && q.filter?.patient) delete q.filter.patient;

View file

@ -20,7 +20,7 @@
export let patients: RouterOutput["patient"]["list"]; export let patients: RouterOutput["patient"]["list"];
export let baseUrl: string; export let baseUrl: string;
function paginationUpdate(pagination: PaginationRequest) { function paginationUpdate(pagination: PaginationRequest): void {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
pagination, pagination,
@ -28,11 +28,11 @@
}); });
} }
function filterUpdate(filter: FilterQdata | undefined) { function filterUpdate(filter: FilterQdata | undefined): void {
updateQuery({ filter, sort: query.sort }); updateQuery({ filter, sort: query.sort });
} }
function sortUpdate(sort: SortRequest | undefined) { function sortUpdate(sort: SortRequest | undefined): void {
updateQuery({ updateQuery({
filter: query.filter, filter: query.filter,
pagination: query.pagination, pagination: query.pagination,
@ -40,7 +40,7 @@
}); });
} }
function updateQuery(q: typeof query) { function updateQuery(q: typeof query): void {
if (browser) { if (browser) {
// Update page URL // Update page URL
const url = getQueryUrl(q, baseUrl); const url = getQueryUrl(q, baseUrl);

View file

@ -5,7 +5,7 @@
export let patient: RouterOutput["patient"]["list"]["items"][0]; export let patient: RouterOutput["patient"]["list"]["items"][0];
export let baseUrl: string; export let baseUrl: string;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent): void {
gotoEntityQuery( gotoEntityQuery(
{ {
filter: { filter: {

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { mdiClose, mdiFilter } from "@mdi/js"; import { mdiFilter } from "@mdi/js";
import { URL_ENTRIES } from "$lib/shared/constants"; import { URL_ENTRIES } from "$lib/shared/constants";
import type { SortRequest } from "$lib/shared/model"; import type { SortRequest } from "$lib/shared/model";
@ -63,9 +63,6 @@
> >
<Icon path={mdiFilter} size={1.2} /> <Icon path={mdiFilter} size={1.2} />
</button> </button>
<button class="btn btn-circle btn-ghost btn-xs inline">
<Icon path={mdiClose} size={1.2} />
</button>
</td> </td>
</tr> </tr>
{/each} {/each}

View file

@ -6,7 +6,7 @@
export let room: Room; export let room: Room;
export let baseUrl = URL_ENTRIES; export let baseUrl = URL_ENTRIES;
function onClick(e: MouseEvent) { function onClick(e: MouseEvent): void {
gotoEntityQuery( gotoEntityQuery(
{ {
filter: { filter: {

View file

@ -18,7 +18,7 @@
sorting = 0; sorting = 0;
} }
function onClick() { function onClick(): void {
if (sorting === 2) { if (sorting === 2) {
sortUpdate(undefined); sortUpdate(undefined);
} else { } else {

View file

@ -7,7 +7,7 @@
export let baseUrl = URL_ENTRIES; export let baseUrl = URL_ENTRIES;
export let filterName: string = "author"; export let filterName: string = "author";
function onClick(e: MouseEvent) { function onClick(e: MouseEvent): void {
const query: EntityQuery = { filter: {} }; const query: EntityQuery = { filter: {} };
// @ts-expect-error filterName is checked // @ts-expect-error filterName is checked
query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }]; query.filter[filterName] = [{ id: user.id, name: user.name ?? "" }];

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { mdiEye, mdiEyeOff } from "@mdi/js";
import Icon from "./Icon.svelte";
export let hidden: boolean;
export let baseUrl: string;
</script>
<a class="btn btn-sm" href={baseUrl + (hidden ? "" : "?hidden")}>
<Icon path={hidden ? mdiEye : mdiEyeOff} />
</a>

View file

@ -7,7 +7,7 @@
let showProgress = false; let showProgress = false;
let showError = false; let showError = false;
export function start() { export function start(): void {
navprogress = 5; navprogress = 5;
showProgress = true; showProgress = true;
showError = false; showError = false;
@ -17,7 +17,7 @@
}, 500); }, 500);
} }
export function reset() { export function reset(): void {
clearInterval(navInterval); clearInterval(navInterval);
navprogress = 100; navprogress = 100;
@ -30,7 +30,7 @@
}, 500); }, 500);
} }
export function error() { export function error(): void {
showError = true; showError = true;
reset(); reset();
} }

View file

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
export let active: boolean; export let active: boolean;
export let href: string; export let href: string;
export let ddn = false;
const ddnClass = "decoration-primary decoration-4 underline-offset-4";
</script> </script>
<div>
<a <a
class="btn btn-sm btn-ghost drawer-button font-normal class={ddn ? ddnClass : "btn btn-sm btn-ghost drawer-button font-normal " + ddnClass}
decoration-primary decoration-4 underline-offset-4"
class:underline={active} class:underline={active}
{href}><slot /></a {href}><slot /></a
> >
</div>

View file

@ -6,7 +6,7 @@
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 { screenWidthSmall } from "$lib/stores";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
@ -44,7 +44,7 @@
return pag; return pag;
} }
function pagClick(page: number) { function pagClick(page: number): void {
const pag = getPaginationRequest(page); const pag = getPaginationRequest(page);
if (pag) onUpdate(pag); if (pag) onUpdate(pag);
} }

View file

@ -12,26 +12,38 @@
let editing = false; let editing = false;
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete<BaseItem> | undefined;
export let dateRange: DateRange = DateRange.thisWeek(); export let dateRange: DateRange = new DateRange(null, DateRange.thisWeek().end);
export let onSelect: (value: DateRange) => void = () => {}; export let onSelect: (value: DateRange) => void = () => {};
function nextWeek() { function addDays(n: number): void {
dateRange.addDays(7); if (dateRange.start === null) {
dateRange.start = new Date(dateRange.end!);
dateRange.start.setDate(dateRange.start.getDate() - 6);
} else if (dateRange.end === null) {
dateRange.end = new Date(dateRange.start!);
dateRange.end.setDate(dateRange.end.getDate() + 6);
} else {
dateRange.addDays(n);
}
}
function nextWeek(): void {
addDays(7);
dateRange = dateRange; // update reactive dateRange = dateRange; // update reactive
onSelect(dateRange); onSelect(dateRange);
} }
function previousWeek() { function previousWeek(): void {
dateRange.addDays(-7); addDays(-7);
dateRange = dateRange; // update reactive dateRange = dateRange; // update reactive
onSelect(dateRange); onSelect(dateRange);
} }
function startEditing() { function startEditing(): void {
editing = true; editing = true;
} }
function stopEditing() { function stopEditing(): void {
editing = false; editing = false;
} }
@ -45,7 +57,7 @@
{#if editing} {#if editing}
<Autocomplete <Autocomplete
bind:this={autocomplete} bind:this={autocomplete}
items={async () => weekFilterItems(false)} items={async () => weekFilterItems()}
onClose={stopEditing} onClose={stopEditing}
onSelect={(item) => { onSelect={(item) => {
if (typeof item.id === "string") { if (typeof item.id === "string") {

View file

@ -121,3 +121,9 @@
.carta-theme__default .carta-toolbar-left button.carta-active { .carta-theme__default .carta-toolbar-left button.carta-active {
@apply font-semibold border-primary; @apply font-semibold border-primary;
} }
@media (prefers-color-scheme: dark) {
.shiki, .shiki span {
color: var(--shiki-dark) !important;
}
}

View file

@ -3,7 +3,6 @@ import type { Options } from "carta-md";
import { sanitizeHtml } from "$lib/shared/util"; import { sanitizeHtml } from "$lib/shared/util";
export const CARTA_CFG: Options = { export const CARTA_CFG: Options = {
theme: "github-dark",
sanitizer: sanitizeHtml, sanitizer: sanitizeHtml,
disableIcons: ["taskList"], disableIcons: ["taskList"],
}; };

View file

@ -7,6 +7,7 @@ import {
type AuthConfig, type AuthConfig,
} from "@auth/core"; } from "@auth/core";
import Keycloak from "@auth/core/providers/keycloak"; import Keycloak from "@auth/core/providers/keycloak";
import type { Session } from "@auth/core/types";
import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit"; import { redirect, type Handle, type RequestEvent } from "@sveltejs/kit";
import { parse } from "set-cookie-parser"; import { parse } from "set-cookie-parser";
@ -70,7 +71,7 @@ export async function makeAuthjsRequest(
event: RequestEvent, event: RequestEvent,
authjsEndpoint: string, authjsEndpoint: string,
params: Record<string, string>, params: Record<string, string>,
) { ): Promise<never> {
const headers = new Headers(event.request.headers); const headers = new Headers(event.request.headers);
headers.set("Content-Type", "application/x-www-form-urlencoded"); headers.set("Content-Type", "application/x-www-form-urlencoded");
@ -87,7 +88,7 @@ export async function makeAuthjsRequest(
return redirect(302, res.redirect ?? ""); return redirect(302, res.redirect ?? "");
} }
export async function auth(event: RequestEvent) { export async function auth(event: RequestEvent): Promise<Session | null> {
const { request: req } = event; const { request: req } = event;
setEnvDefaults(env, AUTH_CFG); setEnvDefaults(env, AUTH_CFG);

View file

@ -1,7 +1,13 @@
import type { Category, CategoryNew } from "$lib/shared/model"; import type { Category, CategoryDetail, CategoryNew } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { handleDeleteConflict } from "./util";
const SELECT = {
id: true, name: true, description: true, color: true,
};
export async function newCategory(category: CategoryNew): Promise<number> { export async function newCategory(category: CategoryNew): Promise<number> {
const created = await prisma.category.create({ const created = await prisma.category.create({
data: category, data: category,
@ -10,18 +16,26 @@ export async function newCategory(category: CategoryNew): Promise<number> {
return created.id; return created.id;
} }
export async function updateCategory(id: number, category: Partial<CategoryNew>) { export async function updateCategory(id: number, category: Partial<CategoryNew>): Promise<void> {
await prisma.category.update({ where: { id }, data: category }); await prisma.category.update({ where: { id }, data: category });
} }
export async function deleteCategory(id: number) { export async function deleteCategory(id: number): Promise<void> {
await prisma.category.delete({ where: { id } }); await handleDeleteConflict(prisma.category.delete({ where: { id } }), "category with entries");
} }
export async function getCategory(id: number): Promise<Category> { export async function hideCategory(id: number, hidden: boolean): Promise<void> {
await prisma.category.update({ where: { id }, data: { hidden } });
}
export async function getCategory(id: number): Promise<CategoryDetail> {
return prisma.category.findUniqueOrThrow({ where: { id } }); return prisma.category.findUniqueOrThrow({ where: { id } });
} }
export async function getCategories(): Promise<Category[]> { export async function getCategories(hidden = false): Promise<Category[]> {
return prisma.category.findMany({ orderBy: { id: "asc" } }); return prisma.category.findMany({ select: SELECT, where: { hidden }, orderBy: { id: "asc" } });
}
export async function getCategoryNEntries(id: number): Promise<number> {
return prisma.entry.count({ where: { EntryVersion: { some: { category_id: id } } } });
} }

View file

@ -276,7 +276,7 @@ left join stations s on s.id = r.station_id`,
if (filter?.date) { if (filter?.date) {
filterListToArray(filter.date).forEach((itm) => { filterListToArray(filter.date).forEach((itm) => {
const dateRange = DateRange.parse(itm); const dateRange = DateRange.parse(itm, true);
if (dateRange?.start) { if (dateRange?.start) {
qb.addFilterClause(`ev.date >= ${qb.pvar()}`, dateRange.start); qb.addFilterClause(`ev.date >= ${qb.pvar()}`, dateRange.start);
} }
@ -286,7 +286,7 @@ left join stations s on s.id = r.station_id`,
}); });
} }
const SORT_FIELDS: { [key: string]: string[] } = { const SORT_FIELDS: Record<string, string[]> = {
id: ["e.id"], id: ["e.id"],
patient: ["p.last_name", "p.first_name"], patient: ["p.last_name", "p.first_name"],
room: ["s.name", "r.name"], room: ["s.name", "r.name"],
@ -405,7 +405,19 @@ export async function getNTodo(date: Date): Promise<number> {
order by order by
ev2.created_at desc ev2.created_at desc
limit 1) limit 1)
where ev.date <= ${date}`; left join entry_executions ex on
ex.entry_id = e.id
and ex.id = (
select
id
from
entry_executions ex2
where
ex2.entry_id = ex.entry_id
order by
ex2.created_at desc
limit 1)
where ev.date <= ${date} and ex.id is null`;
// @ts-expect-error type checked // @ts-expect-error type checked
const count = Number(result[0].count); const count = Number(result[0].count);
return count; return count;

View file

@ -3,3 +3,4 @@ export * from "./category";
export * from "./patient"; export * from "./patient";
export * from "./user"; export * from "./user";
export * from "./room"; export * from "./room";
export * from "./station";

View file

@ -14,7 +14,6 @@ import type {
Patient, Patient,
User, User,
UserTag, UserTag,
Room,
EntryVersion, EntryVersion,
EntryExecution, EntryExecution,
UserTagNameNonnull, UserTagNameNonnull,
@ -65,10 +64,6 @@ export function mapUserTagNameNonnull(user: Omit<DbUser, "email">): UserTagNameN
return { id: user.id, name: user.name || "" }; return { id: user.id, name: user.name || "" };
} }
export function mapRoom(room: DbRoomLn): Room {
return { id: room.id, name: room.name, station: room.station };
}
export function mapVersion(version: DbEntryVersionLn): EntryVersion { export function mapVersion(version: DbEntryVersionLn): EntryVersion {
return { return {
id: version.id, id: version.id,

View file

@ -1,5 +1,3 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { import type {
Patient, Patient,
PatientNew, PatientNew,
@ -9,12 +7,12 @@ import type {
PatientTag, PatientTag,
SortRequest, SortRequest,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error"; import { ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { mapPatient } from "./mapping"; import { mapPatient } from "./mapping";
import { QueryBuilder } from "./util"; import { QueryBuilder, handleDeleteConflict } from "./util";
export async function newPatient(patient: PatientNew): Promise<number> { export async function newPatient(patient: PatientNew): Promise<number> {
const created = await prisma.patient.create({ data: patient, select: { id: true } }); const created = await prisma.patient.create({ data: patient, select: { id: true } });
@ -22,27 +20,17 @@ export async function newPatient(patient: PatientNew): Promise<number> {
} }
/** Update a patient */ /** Update a patient */
export async function updatePatient(id: number, patient: Partial<PatientNew>) { export async function updatePatient(id: number, patient: Partial<PatientNew>): Promise<void> {
await prisma.patient.update({ where: { id }, data: patient }); await prisma.patient.update({ where: { id }, data: patient });
} }
/** Delete a patient (Note: this only works if the patient is not associated with any entries) */ /** Delete a patient (Note: this only works if the patient is not associated with any entries) */
export async function deletePatient(id: number) { export async function deletePatient(id: number): Promise<void> {
try { await handleDeleteConflict(prisma.patient.delete({ where: { id } }), "patient with entries");
await prisma.patient.delete({ where: { id } });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
// Foreign key constraint failed
if (error.code === "P2003") {
throw new ErrorConflict("cannot delete patient with entries");
}
}
throw error;
}
} }
/** Hide/show a patient */ /** Hide/show a patient */
export async function hidePatient(id: number, hidden: boolean) { export async function hidePatient(id: number, hidden: boolean): Promise<void> {
await prisma.patient.update({ where: { id }, data: { hidden } }); await prisma.patient.update({ where: { id }, data: { hidden } });
} }
@ -109,7 +97,7 @@ export async function getPatients(
qb.addFilterList("r.id", filter.room); qb.addFilterList("r.id", filter.room);
qb.addFilterList("s.id", filter.station); qb.addFilterList("s.id", filter.station);
const SORT_FIELDS: { [key: string]: string[] } = { const SORT_FIELDS: Record<string, string[]> = {
id: ["p.id"], id: ["p.id"],
name: ["p.last_name", "p.first_name"], name: ["p.last_name", "p.first_name"],
first_name: ["p.first_name"], first_name: ["p.first_name"],

View file

@ -1,61 +1,43 @@
import type { import type {
RoomNew, Room, Station, StationNew, RoomNew, Room,
RoomDetail,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { mapRoom } from "./mapping"; import { handleDeleteConflict } from "./util";
export async function newStation(station: StationNew): Promise<number> { const SELECT = { id: true, name: true, station: { select: { id: true, name: true } } };
const created = await prisma.station.create({ data: station, select: { id: true } });
return created.id;
}
export async function updateStation(id: number, station: Partial<StationNew>) {
await prisma.station.update({ where: { id }, data: station });
}
export async function deleteStation(id: number) {
await prisma.station.delete({ where: { id } });
}
export async function getStation(id: number): Promise<Station> {
return prisma.station.findUniqueOrThrow({ where: { id } });
}
export async function getStations(): Promise<Station[]> {
return prisma.station.findMany({ orderBy: { id: "asc" } });
}
export async function newRoom(room: RoomNew): Promise<number> { export async function newRoom(room: RoomNew): Promise<number> {
const created = await prisma.room.create({ data: room, select: { id: true } }); const created = await prisma.room.create({ data: room, select: { id: true } });
return created.id; return created.id;
} }
export async function updateRoom(id: number, room: Partial<RoomNew>) { export async function updateRoom(id: number, room: Partial<RoomNew>): Promise<void> {
await prisma.room.update({ where: { id }, data: room }); await prisma.room.update({ where: { id }, data: room });
} }
export async function deleteRoom(id: number) { export async function deleteRoom(id: number): Promise<void> {
await prisma.room.delete({ where: { id } }); await handleDeleteConflict(prisma.room.delete({ where: { id } }), "room with patients");
} }
export async function getRoom(id: number): Promise<Room> { export async function hideRoom(id: number, hidden: boolean): Promise<void> {
const room = await prisma.room.findUniqueOrThrow({ await prisma.room.update({ where: { id }, data: { hidden } });
where: { id },
include: { station: true },
});
return {
id: room.id,
name: room.name,
station: room.station,
};
} }
export async function getRooms(): Promise<Room[]> { export async function getRoom(id: number): Promise<RoomDetail> {
const rooms = await prisma.room.findMany({ return prisma.room.findUniqueOrThrow({ select: { ...SELECT, hidden: true }, where: { id } });
include: { station: true }, }
export async function getRooms(hidden = false): Promise<Room[]> {
return prisma.room.findMany({
select: SELECT,
where: { hidden },
orderBy: [{ station: { name: "asc" } }, { name: "asc" }], orderBy: [{ station: { name: "asc" } }, { name: "asc" }],
}); });
return rooms.map(mapRoom); }
export async function getRoomNPatients(id: number): Promise<number> {
return prisma.patient.count({ where: { room_id: id } });
} }

View file

@ -1,7 +1,11 @@
import { DEFAULT_FILTER_NAME } from "$lib/shared/constants";
import type { SavedFilter, SavedFilterNew } from "$lib/shared/model"; import type { SavedFilter, SavedFilterNew } from "$lib/shared/model";
import { isDefaultFilter } from "$lib/shared/util";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
const SELECT = { id: true, name: true, query: true };
export async function newSavedFilter(filter: SavedFilterNew, user_id: number): Promise<number> { export async function newSavedFilter(filter: SavedFilterNew, user_id: number): Promise<number> {
const created = await prisma.savedFilter.create({ const created = await prisma.savedFilter.create({
data: { data: {
@ -12,17 +16,49 @@ export async function newSavedFilter(filter: SavedFilterNew, user_id: number): P
return created.id; return created.id;
} }
export async function updateSavedFilter(id: number, query: string, user_id: number) { export async function updateSavedFilter(id: number, query: string, user_id: number): Promise<void> {
await prisma.savedFilter.update({ where: { id, user_id }, data: { query } }); await prisma.savedFilter.update({ where: { id, user_id }, data: { query } });
} }
export async function deleteSavedFilter(id: number, user_id: number) { export async function deleteSavedFilter(id: number, user_id: number): Promise<void> {
await prisma.savedFilter.delete({ where: { id, user_id } }); await prisma.savedFilter.delete({ where: { id, user_id } });
} }
export async function getSavedFilters(user_id: number, view: string): Promise<SavedFilter[]> { export async function getSavedFilters(user_id: number, view: string): Promise<SavedFilter[]> {
return prisma.savedFilter.findMany({ const filters = await prisma.savedFilter.findMany({
select: { id: true, name: true, query: true }, select: SELECT,
where: { user_id, view }, where: { user_id, view },
}); });
// Move default filter to the top
const dix = filters.findIndex((f) => f.name.toLowerCase() === DEFAULT_FILTER_NAME);
if (dix !== -1) filters.unshift(filters.splice(dix, 1)[0]);
return filters;
}
export async function getDefaultFilter(user_id: number, view: string): Promise<SavedFilter | null> {
return prisma.savedFilter.findFirst({
select: SELECT,
where: { user_id, view, name: { mode: "insensitive", equals: DEFAULT_FILTER_NAME } },
});
}
export async function getAllFilters(user_id: number): Promise<Record<string, SavedFilter[]>> {
const filters = await prisma.savedFilter.findMany({
select: {
id: true, name: true, query: true, view: true,
},
where: { user_id },
});
const grouped: Record<string, SavedFilter[]> = {};
for (const filter of filters) {
if (!grouped[filter.view]) grouped[filter.view] = [];
const f = { id: filter.id, name: filter.name, query: filter.query };
// Place default filter at the first position
if (isDefaultFilter(f.name)) grouped[filter.view].unshift(f);
else grouped[filter.view].push(f);
}
return grouped;
} }

View file

@ -0,0 +1,40 @@
import type { Station, StationDetail, StationNew } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma";
import { handleDeleteConflict } from "./util";
const SELECT = { id: true, name: true };
export async function newStation(station: StationNew): Promise<number> {
const created = await prisma.station.create({ data: station, select: { id: true } });
return created.id;
}
export async function updateStation(id: number, station: Partial<StationNew>): Promise<void> {
await prisma.station.update({ where: { id }, data: station });
}
export async function deleteStation(id: number): Promise<void> {
await handleDeleteConflict(prisma.station.delete({ where: { id } }), "station with rooms");
}
export async function hideStation(id: number, hidden: boolean): Promise<void> {
await prisma.station.update({ where: { id }, data: { hidden } });
}
export async function getStation(id: number): Promise<StationDetail> {
return prisma.station.findUniqueOrThrow({ where: { id } });
}
export async function getStations(hidden = false): Promise<Station[]> {
return prisma.station.findMany({
select: SELECT,
where: { hidden },
orderBy: { id: "asc" },
});
}
export async function getStationNRooms(id: number): Promise<number> {
return prisma.room.count({ where: { station_id: id } });
}

View file

@ -1,5 +1,8 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { PAGINATION_LIMIT } from "$lib/shared/constants"; import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { FilterList, PaginationRequest } from "$lib/shared/model"; import type { FilterList, PaginationRequest } from "$lib/shared/model";
import { ErrorConflict } from "$lib/shared/util/error";
enum QueryComponentType { enum QueryComponentType {
Normal = 1, Normal = 1,
@ -20,6 +23,20 @@ export function filterListToArray<T>(fl: FilterList<T>): T[] {
return [fl]; return [fl];
} }
export async function handleDeleteConflict(act: Promise<unknown>, msg: string): Promise<void> {
try {
await act;
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
// Foreign key constraint failed
if (error.code === "P2003") {
throw new ErrorConflict(`cannot delete ${msg}; hide instead`);
}
}
throw error;
}
}
class SearchQueryComponent { class SearchQueryComponent {
word: string; word: string;
@ -131,16 +148,16 @@ export class QueryBuilder {
this.fromClause = fromClause; this.fromClause = fromClause;
} }
setPagination(pag: PaginationRequest) { setPagination(pag: PaginationRequest): void {
if (pag.limit) this.limit = pag.limit; if (pag.limit) this.limit = pag.limit;
if (pag.offset) this.offset = pag.offset; if (pag.offset) this.offset = pag.offset;
} }
addOrderClause(orderClause: string) { addOrderClause(orderClause: string): void {
this.orderClauses.push(orderClause); this.orderClauses.push(orderClause);
} }
orderByFields(fields: string[], asc: boolean | undefined = undefined) { orderByFields(fields: string[], asc: boolean | undefined = undefined): void {
const sortDir = asc === false ? " desc" : " asc"; const sortDir = asc === false ? " desc" : " asc";
const orderClause = fields.join(`${sortDir}, `) + sortDir; const orderClause = fields.join(`${sortDir}, `) + sortDir;
this.addOrderClause(orderClause); this.addOrderClause(orderClause);
@ -153,7 +170,7 @@ export class QueryBuilder {
} }
/** Add a simple filter checking for equality */ /** Add a simple filter checking for equality */
addFilter(fname: string, val: unknown | undefined) { addFilter(fname: string, val: unknown | undefined): void {
if (val === undefined) return; if (val === undefined) return;
this.params.push(val); this.params.push(val);
@ -161,13 +178,13 @@ export class QueryBuilder {
} }
/** Add a SQL filter clause */ /** Add a SQL filter clause */
addFilterClause(clause: string, ...params: unknown[]) { addFilterClause(clause: string, ...params: unknown[]): void {
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) */ /** 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): void {
if (fl === undefined) return; if (fl === undefined) return;
this.filterClauses.push(`${fname} = any (${this.pvar()})`); this.filterClauses.push(`${fname} = any (${this.pvar()})`);

View file

@ -1,12 +1,13 @@
import type { RequestEvent } from "@sveltejs/kit"; import type { RequestEvent } from "@sveltejs/kit";
import { type inferAsyncReturnType, TRPCError } from "@trpc/server"; import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
import type { User } from "$lib/shared/model";
import { ZUser } from "$lib/shared/model/validation"; import { ZUser } from "$lib/shared/model/validation";
// we're not using the event parameter is this example, // we're not using the event parameter is this example,
// hence the eslint-disable rule // hence the eslint-disable rule
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function createContext(event: RequestEvent) { export async function createContext(event: RequestEvent): Promise<{ user: User }> {
if (!event.locals.session?.user) { if (!event.locals.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" }); throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" });
} }

View file

@ -1,11 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import { ZEntityId, ZCategoryNew } from "$lib/shared/model/validation"; import {
ZEntityId, ZCategoryNew, ZHide, ZListHidden,
} from "$lib/shared/model/validation";
import { import {
deleteCategory, deleteCategory,
getCategories, getCategories,
getCategory, getCategory,
getCategoryNEntries,
hideCategory,
newCategory, newCategory,
updateCategory, updateCategory,
} from "$lib/server/query"; } from "$lib/server/query";
@ -13,10 +17,18 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
export const categoryRouter = t.router({ export const categoryRouter = t.router({
list: t.procedure.query(async () => trpcWrap(getCategories)), list: t.procedure.input(ZListHidden).query(async (opts) => trpcWrap(
async () => getCategories(opts.input?.hidden),
)),
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getCategory(opts.input))), .query(async (opts) => trpcWrap(async () => {
const [category, n_entries] = await Promise.all([
getCategory(opts.input),
getCategoryNEntries(opts.input),
]);
return { ...category, n_entries };
})),
create: t.procedure.input(ZCategoryNew).mutation(async (opts) => trpcWrap(async () => { create: t.procedure.input(ZCategoryNew).mutation(async (opts) => trpcWrap(async () => {
const id = await newCategory(opts.input); const id = await newCategory(opts.input);
return id; return id;
@ -29,4 +41,7 @@ export const categoryRouter = t.router({
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deleteCategory(opts.input); await deleteCategory(opts.input);
})), })),
hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => {
await hideCategory(opts.input.id, opts.input.hidden);
})),
}); });

View file

@ -1,6 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { ZEntityId, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation"; import {
ZEntityId, ZHide, ZPatientNew, ZPatientsQuery,
} from "$lib/shared/model/validation";
import { import {
deletePatient, deletePatient,
@ -42,14 +44,7 @@ export const patientRouter = t.router({
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deletePatient(opts.input); await deletePatient(opts.input);
})), })),
hide: t.procedure hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => {
.input(
z.object({
id: ZEntityId,
hidden: z.boolean().default(true),
}),
)
.mutation(async (opts) => trpcWrap(async () => {
await hidePatient(opts.input.id, opts.input.hidden); await hidePatient(opts.input.id, opts.input.hidden);
})), })),
}); });

View file

@ -1,18 +1,28 @@
import { z } from "zod"; import { z } from "zod";
import { ZEntityId, ZRoomNew } from "$lib/shared/model/validation"; import {
ZEntityId, ZHide, ZListHidden, ZRoomNew,
} from "$lib/shared/model/validation";
import { import {
deleteRoom, getRoom, getRooms, newRoom, updateRoom, deleteRoom, getRoom, getRoomNPatients, getRooms, hideRoom, newRoom, updateRoom,
} from "$lib/server/query"; } from "$lib/server/query";
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
export const roomRouter = t.router({ export const roomRouter = t.router({
list: t.procedure.query(async (opts) => trpcWrap(getRooms)), list: t.procedure.input(ZListHidden).query(async (opts) => trpcWrap(
async () => getRooms(opts.input?.hidden),
)),
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getRoom(opts.input))), .query(async (opts) => trpcWrap(async () => {
const [room, n_patients] = await Promise.all([
getRoom(opts.input),
getRoomNPatients(opts.input),
]);
return { ...room, n_patients };
})),
create: t.procedure.input(ZRoomNew).mutation(async (opts) => trpcWrap(async () => { create: t.procedure.input(ZRoomNew).mutation(async (opts) => trpcWrap(async () => {
const id = await newRoom(opts.input); const id = await newRoom(opts.input);
return id; return id;
@ -25,4 +35,7 @@ export const roomRouter = t.router({
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deleteRoom(opts.input); await deleteRoom(opts.input);
})), })),
hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => {
await hideRoom(opts.input.id, opts.input.hidden);
})),
}); });

View file

@ -3,7 +3,12 @@ import {
} from "$lib/shared/model/validation"; } from "$lib/shared/model/validation";
import { import {
deleteSavedFilter, getSavedFilters, newSavedFilter, updateSavedFilter, deleteSavedFilter,
getAllFilters,
getDefaultFilter,
getSavedFilters,
newSavedFilter,
updateSavedFilter,
} from "$lib/server/query/savedFilter"; } from "$lib/server/query/savedFilter";
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
@ -12,6 +17,12 @@ export const savedFilterRouter = t.router({
get: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap( get: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap(
async () => getSavedFilters(opts.ctx.user.id, opts.input), async () => getSavedFilters(opts.ctx.user.id, opts.input),
)), )),
getDefault: t.procedure.input(fields.NameString()).query(async (opts) => trpcWrap(
async () => getDefaultFilter(opts.ctx.user.id, opts.input),
)),
getAll: t.procedure.query(async (opts) => trpcWrap(
async () => getAllFilters(opts.ctx.user.id),
)),
create: t.procedure.input(ZSavedFilterNew).mutation(async (opts) => trpcWrap( create: t.procedure.input(ZSavedFilterNew).mutation(async (opts) => trpcWrap(
async () => newSavedFilter(opts.input, opts.ctx.user.id), async () => newSavedFilter(opts.input, opts.ctx.user.id),
)), )),

View file

@ -1,11 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import { ZEntityId, ZStationNew } from "$lib/shared/model/validation"; import {
ZEntityId, ZHide, ZListHidden, ZStationNew,
} from "$lib/shared/model/validation";
import { import {
deleteStation, deleteStation,
getStation, getStation,
getStationNRooms,
getStations, getStations,
hideStation,
newStation, newStation,
updateStation, updateStation,
} from "$lib/server/query"; } from "$lib/server/query";
@ -13,10 +17,16 @@ import {
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
export const stationRouter = t.router({ export const stationRouter = t.router({
list: t.procedure.query(getStations), list: t.procedure.input(ZListHidden).query(async (opts) => getStations(opts.input?.hidden)),
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getStation(opts.input))), .query(async (opts) => trpcWrap(async () => {
const [station, n_rooms] = await Promise.all([
getStation(opts.input),
getStationNRooms(opts.input),
]);
return { ...station, n_rooms };
})),
create: t.procedure.input(ZStationNew).mutation(async (opts) => trpcWrap(async () => { create: t.procedure.input(ZStationNew).mutation(async (opts) => trpcWrap(async () => {
const id = await newStation(opts.input); const id = await newStation(opts.input);
return id; return id;
@ -29,4 +39,7 @@ export const stationRouter = t.router({
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => { delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deleteStation(opts.input); await deleteStation(opts.input);
})), })),
hide: t.procedure.input(ZHide).mutation(async (opts) => trpcWrap(async () => {
await hideStation(opts.input.id, opts.input.hidden);
})),
}); });

View file

@ -4,3 +4,5 @@ export const WEEK_LIMIT = 8;
export const URL_ENTRIES = "/plan"; export const URL_ENTRIES = "/plan";
export const URL_VISIT = "/visit"; export const URL_VISIT = "/visit";
export const URL_PATIENTS = "/patients"; export const URL_PATIENTS = "/patients";
export const DEFAULT_FILTER_NAME = "default";

View file

@ -35,31 +35,40 @@ export type UserTagNameNonnull = {
name: string; name: string;
}; };
export type Station = { export type StationDetail = {
id: number; id: number;
name: string; name: string;
hidden: boolean;
}; };
export type Station = Omit<StationDetail, "hidden">;
export type StationNew = Omit<Station, "id">; export type StationNew = Omit<Station, "id">;
export type Room = { export type RoomDetail = {
id: number; id: number;
name: string; name: string;
station: Station; station: Station;
hidden: boolean;
}; };
export type Room = Omit<RoomDetail, "hidden">;
export type RoomNew = { export type RoomNew = {
name: string; name: string;
station_id: number; station_id: number;
}; };
export type Category = { export type CategoryDetail = {
id: number; id: number;
name: string; name: string;
color: Option<string>; color: Option<string>;
description: Option<string>; description: Option<string>;
hidden: boolean;
}; };
export type Category = Omit<CategoryDetail, "hidden">;
export type CategoryNew = Omit<Category, "id">; export type CategoryNew = Omit<Category, "id">;
export type Patient = { export type Patient = {

View file

@ -45,7 +45,7 @@ const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z
function returnDataInSameOrderAsPassed<Schema extends z.ZodObject<z.ZodRawShape>>( function returnDataInSameOrderAsPassed<Schema extends z.ZodObject<z.ZodRawShape>>(
schema: Schema, schema: Schema,
) { ): z.ZodEffects<z.ZodAny, z.TypeOf<Schema> | undefined> {
return z.any().transform((value, ctx) => { return z.any().transform((value, ctx) => {
const parsed = schema.safeParse(value); const parsed = schema.safeParse(value);
if (parsed.success) { if (parsed.success) {
@ -181,3 +181,10 @@ export const ZSavedFilterUpdate = z.object({
id: ZEntityId, id: ZEntityId,
query: z.string(), query: z.string(),
}); });
export const ZHide = z.object({
id: ZEntityId,
hidden: z.boolean().default(true),
});
export const ZListHidden = z.object({ hidden: z.boolean().optional() }).optional();

View file

@ -109,9 +109,6 @@ export class DateRange {
/** Create a date range of the current calendar week */ /** Create a date range of the current calendar week */
static thisWeek(): DateRange { static thisWeek(): DateRange {
const dayStart = new Date(); const dayStart = new Date();
// Correct for timezone
dayStart.setMinutes(dayStart.getMinutes() - dayStart.getTimezoneOffset());
const todayWd = dayStart.getDay(); const todayWd = dayStart.getDay();
// Day starts at Sunday (0) // Day starts at Sunday (0)
const daysMinus = todayWd === 0 ? 6 : todayWd - 1; const daysMinus = todayWd === 0 ? 6 : todayWd - 1;
@ -126,11 +123,11 @@ export class DateRange {
* - Range with 2 ends: `2024-04-13..2024-04-20` * - Range with 2 ends: `2024-04-13..2024-04-20`
* - Range with 1 end: `2024-04-13..`; `..2024-04-20` * - Range with 1 end: `2024-04-13..`; `..2024-04-20`
*/ */
static parse(s: string): DateRange | null { static parse(s: string, utc = false): DateRange | null {
const parts = s.split("..", 2); const parts = s.split("..", 2);
const parsed = parts.map((p) => { const parsed = parts.map((p) => {
if (p.length === 0) return null; if (p.length === 0) return null;
return dateFromYMD(p); return utc ? new Date(p) : dateFromYMD(p);
}); });
if (parsed.length === 0 if (parsed.length === 0
@ -147,7 +144,7 @@ export class DateRange {
} }
/** Shift the range by the given number of days. This modifies the range in-place */ /** Shift the range by the given number of days. This modifies the range in-place */
addDays(n: number) { addDays(n: number): void {
this.start?.setDate(this.start.getDate() + n); this.start?.setDate(this.start.getDate() + n);
this.end?.setDate(this.end.getDate() + n); this.end?.setDate(this.end.getDate() + n);
} }
@ -163,10 +160,8 @@ export class DateRange {
/** Return a string representation for display purposes */ /** Return a string representation for display purposes */
format(): string { format(): string {
let res = ""; if (this.start === null) return "bis " + formatDate(this.end!);
if (this.start) res += formatDate(this.start); if (this.end === null) return "ab " + formatDate(this.start);
res += " \u2013 "; return formatDate(this.start) + " \u2013 " + formatDate(this.end);
if (this.end) res += formatDate(this.end);
return res;
} }
} }

View file

@ -0,0 +1,8 @@
import { toast } from "@zerodevx/svelte-toast";
export function toastInfo(msg: string): void {
toast.push({ msg });
}
export function toastError(msg: string): void {
toast.push({ msg, classes: ["toast-error"] });
}

View file

@ -4,10 +4,15 @@ import { isRedirect, error } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client"; import { TRPCClientError } from "@trpc/client";
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import qs from "qs"; import qs from "qs";
import type { FormOptions } from "sveltekit-superforms";
import { ZodError } from "zod"; import { ZodError } from "zod";
import type { EntityQuery } from "$lib/shared/model"; import { DEFAULT_FILTER_NAME, URL_VISIT } from "$lib/shared/constants";
import type { RouterOutput } from "$lib/shared/trpc"; import type { EntityQuery, SavedFilter } from "$lib/shared/model";
import { type RouterOutput } from "$lib/shared/trpc";
import { DateRange } from "./date";
import { toastError, toastInfo } from "./toast";
export function formatBool(val: boolean): string { export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein"; return val ? "Ja" : "Nein";
@ -24,7 +29,7 @@ export function parseQueryUrl(search: string): any {
return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true }); return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true });
} }
export function gotoEntityQuery(query: EntityQuery, basePath: string) { export function gotoEntityQuery(query: EntityQuery, basePath: string): void {
if (window && window.location.pathname.startsWith(`${basePath}/`)) { if (window && window.location.pathname.startsWith(`${basePath}/`)) {
if (window.location.search) { if (window.location.search) {
const oldQuery: EntityQuery = parseQueryUrl(window.location.search); const oldQuery: EntityQuery = parseQueryUrl(window.location.search);
@ -43,7 +48,7 @@ export function gotoEntityQuery(query: EntityQuery, basePath: string) {
* *
* Converts TRPC errors to SvelteKit ones * Converts TRPC errors to SvelteKit ones
*/ */
export async function loadWrap<T>(f: () => Promise<T>) { export async function loadWrap<T>(f: () => Promise<T>): Promise<T> {
try { try {
return await f(); return await f();
} catch (e) { } catch (e) {
@ -77,16 +82,16 @@ export class Debouncer {
this.handler = handler; this.handler = handler;
} }
clear() { clear(): void {
if (this.timeout) window.clearTimeout(this.timeout); if (this.timeout) window.clearTimeout(this.timeout);
} }
trigger() { trigger(): void {
this.clear(); this.clear();
this.timeout = window.setTimeout(this.handler, this.delay); this.timeout = window.setTimeout(this.handler, this.delay);
} }
now() { now(): void {
this.clear(); this.clear();
this.handler(); this.handler();
} }
@ -107,3 +112,45 @@ export function divFloor(a: number, b: number): number {
export function normalizeLineEndings(s: string): string { export function normalizeLineEndings(s: string): string {
return s.replaceAll("\r\n", "\n"); return s.replaceAll("\r\n", "\n");
} }
export function superformConfig(entity?: string): Pick<FormOptions, "onError" | "onResult"> {
return {
onError: ({ result }) => {
toastError(result.error.message);
},
onResult: ({ result }) => {
if (result.type === "success") {
if (result.data?.form && typeof result.data.form.message === "string") {
toastInfo(result.data.form.message);
} else if (entity) {
toastInfo(entity + " aktualisiert");
} else {
toastInfo("OK");
}
}
},
};
}
export function isDefaultFilter(name: string): boolean {
return name.toLowerCase() === DEFAULT_FILTER_NAME;
}
export function defaultFilterUrl(
savedFilters: Record<string, SavedFilter[]>,
view: string,
): string {
let df = undefined;
const filters = savedFilters[view];
if (filters) df = filters.find((f) => isDefaultFilter(f.name));
return "/" + view + (df ? "?" + df.query : "");
}
export function defaultVisitUrl(): string {
return getQueryUrl({
filter: {
done: false,
date: [{ id: new DateRange(null, DateRange.thisWeek().end).toString() }],
},
}, URL_VISIT);
}

9
src/lib/stores/index.ts Normal file
View file

@ -0,0 +1,9 @@
import { derived, writable, type Writable } from "svelte/store";
import type { SavedFilter } from "$lib/shared/model";
// Width of the main section of the layout
export const screenWidth = writable(0);
export const screenWidthSmall = derived(screenWidth, ($mainWidth) => $mainWidth < 500);
export const savedFilters: Writable<Record<string, SavedFilter[]>> = writable({});

View file

@ -1,5 +0,0 @@
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);

View file

@ -1,15 +1,23 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import type { LayoutData } from "./$types";
import { mdiAccount, mdiHome } from "@mdi/js"; import { mdiAccount, mdiHome } from "@mdi/js";
import { defaultFilterUrl, defaultVisitUrl } from "$lib/shared/util";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import NavLink from "$lib/components/ui/NavLink.svelte"; import NavLink from "$lib/components/ui/NavLink.svelte";
import { savedFilters } from "$lib/stores";
export let data: LayoutData;
$: savedFilters.set(data.savedFilters);
</script> </script>
<div <div
class="sticky top-0 z-30 flex h-12 w-full class="sticky top-0 z-30 flex h-12 w-full
justify-center bg-neutral" justify-center bg-neutral text-neutral-content"
> >
<nav class="navbar w-full min-h-12"> <nav class="navbar w-full min-h-12">
<div class="flex flex-1"> <div class="flex flex-1">
@ -23,13 +31,13 @@
> >
<NavLink <NavLink
active={$page.route.id === "/(app)/plan"} active={$page.route.id === "/(app)/plan"}
href="/plan" href={defaultFilterUrl($savedFilters, "plan")}
>Planung</NavLink >Planung</NavLink
> >
<NavLink active={$page.route.id === "/(app)/visit"} href="/visit">Visite</NavLink> <NavLink active={$page.route.id === "/(app)/visit"} href={defaultVisitUrl()}>Visite</NavLink>
<NavLink <NavLink
active={$page.route.id === "/(app)/patients"} active={$page.route.id === "/(app)/patients"}
href="/patients" href={defaultFilterUrl($savedFilters, "patients")}
>Patienten</NavLink >Patienten</NavLink
> >
</div> </div>
@ -42,12 +50,14 @@
</div> </div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul <ul
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52" class="dropdown-content bg-base-100 text-base-content
z-[1] menu p-2 shadow border border-base-content/30 rounded-btn w-52"
tabindex="0" tabindex="0"
> >
<li><a href="/stations">Stationen</a></li> <li><NavLink active={$page.route.id === "/(app)/stations"} ddn href="/stations">Stationen</NavLink></li>
<li><a href="/rooms">Zimmer</a></li> <li><NavLink active={$page.route.id === "/(app)/rooms"} ddn href="/rooms">Zimmer</NavLink></li>
<li><a href="/categories">Kategorien</a></li> <li><NavLink active={$page.route.id === "/(app)/categories"} ddn href="/categories">Kategorien</NavLink></li>
<li><NavLink active={$page.route.id === "/(app)/about"} ddn href="/about">Info</NavLink></li>
<li><a href="/logout">Abmelden</a></li> <li><a href="/logout">Abmelden</a></li>
</ul> </ul>
</div> </div>

View file

@ -0,0 +1,9 @@
import type { LayoutLoad } from "./$types";
import { trpc } from "$lib/shared/trpc";
export const load: LayoutLoad = async (event) => {
const savedFilters = await trpc(event).savedFilter.getAll.query();
return { savedFilters };
};

View file

@ -2,6 +2,10 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { defaultFilterUrl } from "$lib/shared/util";
import { savedFilters } from "$lib/stores";
export let data: PageData; export let data: PageData;
</script> </script>
@ -22,7 +26,7 @@
<h2 class="card-title">Planung</h2> <h2 class="card-title">Planung</h2>
<p>Hier können sie neue Visitenbucheinträge erstellen.</p> <p>Hier können sie neue Visitenbucheinträge erstellen.</p>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<a class="btn btn-primary" href="/plan">Planung</a> <a class="btn btn-primary" href={defaultFilterUrl($savedFilters, "plan")}>Planung</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
// eslint-disable-next-line no-undef
const version = __VERSION__;
// eslint-disable-next-line no-undef
const lastmod = __LASTMOD__;
</script>
<div class="flex flex-col items-center">
<h1 class="heading">Visitenbuch</h1>
<p>Version: {version}</p>
<p>Letzte Änderung: {lastmod}</p>
<p><a href="/oss-licenses.html">Open-Source-Lizenzen</a></p>
</div>

View file

@ -3,6 +3,7 @@
import CategoryField from "$lib/components/table/CategoryField.svelte"; import CategoryField from "$lib/components/table/CategoryField.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import HiddenToggle from "$lib/components/ui/HiddenToggle.svelte";
export let data: PageData; export let data: PageData;
</script> </script>
@ -11,8 +12,11 @@
<title>Kategorien</title> <title>Kategorien</title>
</svelte:head> </svelte:head>
<Header title="Kategorien"> <Header title={"Kategorien" + (data.hidden ? " (ausgeblendet)" : "")}>
<a slot="rightBtn" class="btn btn-sm btn-primary ml-auto" href="/category/new">Neue Kategorie</a> <div slot="rightBtn" class="flex gap-2 ml-auto">
<HiddenToggle baseUrl="/categories" hidden={data.hidden} />
<a class="btn btn-sm btn-primary" href="/category/new">Neue Kategorie</a>
</div>
</Header> </Header>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View file

@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
return loadWrap(async () => { return loadWrap(async () => {
const categories = await trpc(event).category.list.query(); const hidden = event.url.searchParams.get("hidden") !== null;
return { categories }; const categories = await trpc(event).category.list.query({ hidden });
return { categories, hidden };
}); });
}; };

View file

@ -1,7 +1,7 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms"; import { message, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZCategoryNew, ZUrlEntityId } from "$lib/shared/model/validation"; import { ZCategoryNew, ZUrlEntityId } from "$lib/shared/model/validation";
@ -12,14 +12,18 @@ export const actions: Actions = {
default: async (event) => loadWrap(async () => { default: async (event) => loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
const formData = await event.request.formData(); const formData = await event.request.formData();
const form = await superValidate(formData, zod(ZCategoryNew));
const hide = formData.get("hide");
const del = formData.get("delete"); const del = formData.get("delete");
if (del) { if (hide) {
const hidden = Boolean(parseInt(hide.toString()));
await trpc(event).category.hide.mutate({ id, hidden });
return message(form, "Kategorie " + (hidden ? "ausgeblendet" : "eingeblendet"));
} else if (del) {
await trpc(event).category.delete.mutate(id); await trpc(event).category.delete.mutate(id);
redirect(302, "/categories"); redirect(302, "/categories");
} else { } else {
const form = await superValidate(formData, zod(ZCategoryNew));
if (!form.valid) { if (!form.valid) {
return fail(400, { form }); return fail(400, { form });
} }
@ -28,8 +32,7 @@ export const actions: Actions = {
id, id,
category: form.data, category: form.data,
}); });
return { form };
} }
return { form };
}), }),
}; };

View file

@ -2,6 +2,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import CategoryForm from "$lib/components/form/CategoryForm.svelte"; import CategoryForm from "$lib/components/form/CategoryForm.svelte";
import HideDelete from "$lib/components/form/HideDelete.svelte";
export let data: PageData; export let data: PageData;
</script> </script>
@ -11,5 +12,5 @@
</svelte:head> </svelte:head>
<CategoryForm category={data.category} formData={data.form}> <CategoryForm category={data.category} formData={data.form}>
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button> <HideDelete hasEntries={data.category.n_entries > 0} hidden={data.category.hidden} />
</CategoryForm> </CategoryForm>

View file

@ -1,7 +1,7 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit"; import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms"; import { superValidate, message } from "sveltekit-superforms";
import { ZUrlEntityId } from "$lib/shared/model/validation"; import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
@ -22,5 +22,6 @@ export const actions: Actions = {
old_execution_id: null, old_execution_id: null,
execution: { text: form.data.text }, execution: { text: form.data.text },
}); });
return message(form, "Eintrag erledigt");
}), }),
}; };

View file

@ -4,6 +4,8 @@
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
import { superformConfig } from "$lib/shared/util";
import EntryBody from "$lib/components/entry/EntryBody.svelte"; import EntryBody from "$lib/components/entry/EntryBody.svelte";
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
@ -12,8 +14,11 @@
export let data: PageData; export let data: PageData;
const formData = defaults(SchemaEntryExecution); const formData = defaults(SchemaEntryExecution);
const { form, errors, enhance } = superForm(formData, { const {
form, errors, enhance,
} = superForm(formData, {
validators: SchemaEntryExecution, validators: SchemaEntryExecution,
...superformConfig("Eintrag"),
}); });
</script> </script>

View file

@ -6,6 +6,7 @@
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { formatDate, humanDate } from "$lib/shared/util"; import { formatDate, humanDate } from "$lib/shared/util";
import { superformConfig } from "$lib/shared/util";
import PatientCard from "$lib/components/entry/PatientCard.svelte"; import PatientCard from "$lib/components/entry/PatientCard.svelte";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
@ -24,6 +25,7 @@
form, errors, constraints, enhance, tainted, form, errors, constraints, enhance, tainted,
} = superForm(data.form, { } = superForm(data.form, {
validators: SchemaNewEntryVersion, validators: SchemaNewEntryVersion,
...superformConfig("Eintrag"),
}); });
</script> </script>

View file

@ -4,6 +4,8 @@
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
import { superformConfig } from "$lib/shared/util";
import EntryBody from "$lib/components/entry/EntryBody.svelte"; import EntryBody from "$lib/components/entry/EntryBody.svelte";
import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte"; import MarkdownInput from "$lib/components/ui/markdown/MarkdownInput.svelte";
@ -14,6 +16,7 @@
const formData = defaults(SchemaNewExecution); const formData = defaults(SchemaNewExecution);
const { form, errors, enhance } = superForm(formData, { const { form, errors, enhance } = superForm(formData, {
validators: SchemaNewExecution, validators: SchemaNewExecution,
...superformConfig("Eintrag"),
}); });
</script> </script>

View file

@ -2,6 +2,8 @@
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { superformConfig } from "$lib/shared/util";
import { toastError } from "$lib/shared/util/toast";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte"; import FormField from "$lib/components/ui/FormField.svelte";
@ -14,6 +16,7 @@
form, errors, constraints, enhance, form, errors, constraints, enhance,
} = superForm(formData, { } = superForm(formData, {
validators: SchemaNewEntryWithPatient, validators: SchemaNewEntryWithPatient,
...superformConfig("Eintrag"),
}); });
</script> </script>
@ -56,7 +59,7 @@
$form.patient_first_name = p.first_name; $form.patient_first_name = p.first_name;
$form.patient_last_name = p.last_name; $form.patient_last_name = p.last_name;
$form.patient_age = p.age; $form.patient_age = p.age;
}); }).catch((e) => toastError("Konnte Patient nicht laden:\n" + e));
return { newValue: item.name ?? "", close: true }; return { newValue: item.name ?? "", close: true };
}} }}
onUnselect={() => { onUnselect={() => {

View file

@ -1,7 +1,7 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms"; import { message, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation"; import { ZPatientNew, ZUrlEntityId } from "$lib/shared/model/validation";
@ -12,20 +12,18 @@ export const actions: Actions = {
default: async (event) => loadWrap(async () => { default: async (event) => loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
const formData = await event.request.formData(); const formData = await event.request.formData();
const form = await superValidate(formData, zod(ZPatientNew));
const hide = formData.get("hide"); const hide = formData.get("hide");
const del = formData.get("delete"); const del = formData.get("delete");
if (hide) { if (hide) {
await trpc(event).patient.hide.mutate({ const hidden = Boolean(parseInt(hide.toString()));
id, await trpc(event).patient.hide.mutate({ id, hidden });
hidden: Boolean(parseInt(hide.toString())), return message(form, "Patient " + (hidden ? "ausgeblendet" : "eingeblendet"));
});
} else if (del) { } else if (del) {
await trpc(event).patient.delete.mutate(id); await trpc(event).patient.delete.mutate(id);
redirect(302, "/patients"); redirect(302, "/patients");
} else { } else {
const form = await superValidate(formData, zod(ZPatientNew));
if (!form.valid) { if (!form.valid) {
return fail(400, { form }); return fail(400, { form });
} }
@ -34,8 +32,7 @@ export const actions: Actions = {
id, id,
patient: form.data, patient: form.data,
}); });
return { form };
} }
return { form };
}), }),
}; };

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import HideDelete from "$lib/components/form/HideDelete.svelte";
import PatientForm from "$lib/components/form/PatientForm.svelte"; import PatientForm from "$lib/components/form/PatientForm.svelte";
import FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte"; import FilteredEntryTable from "$lib/components/table/FilteredEntryTable.svelte";
@ -14,19 +15,7 @@
</svelte:head> </svelte:head>
<PatientForm formData={data.form} patient={data.patient}> <PatientForm formData={data.form} patient={data.patient}>
{#if data.patient.hidden} <HideDelete hasEntries={data.patient.n_entries > 0} hidden={data.patient.hidden} />
<button
name="hide"
class="btn btn-primary"
type="submit"
value="0"
>Einblenden</button
>
{:else if hasEntries}
<button name="hide" class="btn" type="submit" value="1">Ausblenden</button>
{:else}
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button>
{/if}
</PatientForm> </PatientForm>
{#if hasEntries} {#if hasEntries}

View file

@ -1,7 +1,7 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms"; import { message, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZRoomNew, ZUrlEntityId } from "$lib/shared/model/validation"; import { ZRoomNew, ZUrlEntityId } from "$lib/shared/model/validation";
@ -12,14 +12,18 @@ export const actions: Actions = {
default: async (event) => loadWrap(async () => { default: async (event) => loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
const formData = await event.request.formData(); const formData = await event.request.formData();
const form = await superValidate(formData, zod(ZRoomNew));
const hide = formData.get("hide");
const del = formData.get("delete"); const del = formData.get("delete");
if (del) { if (hide) {
const hidden = Boolean(parseInt(hide.toString()));
await trpc(event).room.hide.mutate({ id, hidden });
return message(form, "Zimmer " + (hidden ? "ausgeblendet" : "eingeblendet"));
} else if (del) {
await trpc(event).room.delete.mutate(id); await trpc(event).room.delete.mutate(id);
redirect(302, "/rooms"); redirect(302, "/rooms");
} else { } else {
const form = await superValidate(formData, zod(ZRoomNew));
if (!form.valid) { if (!form.valid) {
return fail(400, { form }); return fail(400, { form });
} }
@ -28,8 +32,7 @@ export const actions: Actions = {
id, id,
room: form.data, room: form.data,
}); });
return { form };
} }
return { form };
}), }),
}; };

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import HideDelete from "$lib/components/form/HideDelete.svelte";
import RoomForm from "$lib/components/form/RoomForm.svelte"; import RoomForm from "$lib/components/form/RoomForm.svelte";
export let data: PageData; export let data: PageData;
@ -11,5 +12,5 @@
</svelte:head> </svelte:head>
<RoomForm formData={data.form} room={data.room}> <RoomForm formData={data.form} room={data.room}>
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button> <HideDelete hasEntries={data.room.n_patients > 0} hidden={data.room.hidden} />
</RoomForm> </RoomForm>

View file

@ -2,6 +2,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import HiddenToggle from "$lib/components/ui/HiddenToggle.svelte";
export let data: PageData; export let data: PageData;
</script> </script>
@ -10,8 +11,11 @@
<title>Zimmer</title> <title>Zimmer</title>
</svelte:head> </svelte:head>
<Header title="Zimmer"> <Header title={"Zimmer" + (data.hidden ? " (ausgeblendet)" : "")}>
<a slot="rightBtn" class="btn btn-sm btn-primary ml-auto" href="/room/new">Neues Zimmer</a> <div slot="rightBtn" class="flex gap-2 ml-auto">
<HiddenToggle baseUrl="/rooms" hidden={data.hidden} />
<a class="btn btn-sm btn-primary ml-auto" href="/room/new">Neues Zimmer</a>
</div>
</Header> </Header>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View file

@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
return loadWrap(async () => { return loadWrap(async () => {
const rooms = await trpc(event).room.list.query(); const hidden = event.url.searchParams.get("hidden") !== null;
return { rooms }; const rooms = await trpc(event).room.list.query({ hidden });
return { rooms, hidden };
}); });
}; };

View file

@ -1,7 +1,7 @@
import type { Actions } from "./$types"; import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms"; import { message, superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZStationNew, ZUrlEntityId } from "$lib/shared/model/validation"; import { ZStationNew, ZUrlEntityId } from "$lib/shared/model/validation";
@ -12,14 +12,18 @@ export const actions: Actions = {
default: async (event) => loadWrap(async () => { default: async (event) => loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id); const id = ZUrlEntityId.parse(event.params.id);
const formData = await event.request.formData(); const formData = await event.request.formData();
const form = await superValidate(formData, zod(ZStationNew));
const hide = formData.get("hide");
const del = formData.get("delete"); const del = formData.get("delete");
if (del) { if (hide) {
const hidden = Boolean(parseInt(hide.toString()));
await trpc(event).station.hide.mutate({ id, hidden });
return message(form, "Station " + (hidden ? "ausgeblendet" : "eingeblendet"));
} else if (del) {
await trpc(event).station.delete.mutate(id); await trpc(event).station.delete.mutate(id);
redirect(302, "/stations"); redirect(302, "/stations");
} else { } else {
const form = await superValidate(formData, zod(ZStationNew));
if (!form.valid) { if (!form.valid) {
return fail(400, { form }); return fail(400, { form });
} }
@ -28,8 +32,7 @@ export const actions: Actions = {
id, id,
station: form.data, station: form.data,
}); });
return { form };
} }
return { form };
}), }),
}; };

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import HideDelete from "$lib/components/form/HideDelete.svelte";
import StationForm from "$lib/components/form/StationForm.svelte"; import StationForm from "$lib/components/form/StationForm.svelte";
export let data: PageData; export let data: PageData;
@ -11,5 +12,5 @@
</svelte:head> </svelte:head>
<StationForm formData={data.form} station={data.station}> <StationForm formData={data.form} station={data.station}>
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button> <HideDelete hasEntries={data.station.n_rooms > 0} hidden={data.station.hidden} />
</StationForm> </StationForm>

View file

@ -2,6 +2,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import HiddenToggle from "$lib/components/ui/HiddenToggle.svelte";
export let data: PageData; export let data: PageData;
</script> </script>
@ -10,8 +11,11 @@
<title>Stationen</title> <title>Stationen</title>
</svelte:head> </svelte:head>
<Header title="Stationen"> <Header title={"Stationen" + (data.hidden ? " (ausgeblendet)" : "")}>
<a slot="rightBtn" class="btn btn-sm btn-primary ml-auto" href="/station/new">Neue Station</a> <div slot="rightBtn" class="flex gap-2 ml-auto">
<HiddenToggle baseUrl="/stations" hidden={data.hidden} />
<a class="btn btn-sm btn-primary ml-auto" href="/station/new">Neue Station</a>
</div>
</Header> </Header>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View file

@ -5,7 +5,8 @@ import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
return loadWrap(async () => { return loadWrap(async () => {
const stations = await trpc(event).station.list.query(); const hidden = event.url.searchParams.get("hidden") !== null;
return { stations }; const stations = await trpc(event).station.list.query({ hidden });
return { stations, hidden };
}); });
}; };

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { toast } from "@zerodevx/svelte-toast";
</script>
<div>
<button class="btn" on:click={() => toast.push({ msg: "Hello" })}>Ok</button>
<button class="btn" on:click={() => toast.push({ msg: "Error", classes: ["toast-error"] })}>Error</button>
</div>

View file

@ -4,9 +4,8 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { URL_VISIT } from "$lib/shared/constants"; import { URL_VISIT } from "$lib/shared/constants";
import type { PaginationRequest } from "$lib/shared/model"; import type { PaginationRequest, Station } from "$lib/shared/model";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import { DateRange, getQueryUrl } from "$lib/shared/util"; import { DateRange, getQueryUrl } from "$lib/shared/util";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte"; import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
@ -17,7 +16,7 @@
export let data: PageData; export let data: PageData;
let dateRange: DateRange; let dateRange: DateRange;
let selection: RouterOutput["station"]["get"] | null; let selection: Station | null;
$: if (data.query.filter?.date) { $: if (data.query.filter?.date) {
const date = data.query.filter?.date[0]; const date = data.query.filter?.date[0];
@ -41,14 +40,14 @@
selection = null; selection = null;
} }
function paginationUpdate(pagination: PaginationRequest) { function paginationUpdate(pagination: PaginationRequest): void {
updateQuery({ updateQuery({
filter: data.query.filter, filter: data.query.filter,
pagination, pagination,
}); });
} }
function filterUpdate() { function filterUpdate(): void {
updateQuery({ updateQuery({
filter: { filter: {
done: false, done: false,
@ -58,7 +57,7 @@
}); });
} }
function updateQuery(q: typeof data.query) { function updateQuery(q: typeof data.query): void {
if (browser) { if (browser) {
// Update page URL // Update page URL
const url = getQueryUrl(q, URL_VISIT); const url = getQueryUrl(q, URL_VISIT);

View file

@ -3,12 +3,9 @@ import type { PageLoad } from "./$types";
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { URL_VISIT } from "$lib/shared/constants";
import { ZEntriesQuery } from "$lib/shared/model/validation"; import { ZEntriesQuery } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc"; import { trpc } from "$lib/shared/trpc";
import { import { defaultVisitUrl, loadWrap, parseQueryUrl } from "$lib/shared/util";
DateRange, getQueryUrl, loadWrap, parseQueryUrl,
} from "$lib/shared/util";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
return loadWrap(async () => { return loadWrap(async () => {
@ -20,13 +17,7 @@ export const load: PageLoad = async (event) => {
} }
if (!query.filter) { if (!query.filter) {
const url = getQueryUrl({ redirect(302, defaultVisitUrl());
filter: {
done: false,
date: [{ id: DateRange.thisWeek().toString() }],
},
}, URL_VISIT);
redirect(302, url);
} }
// Sort entries by date // Sort entries by date

View file

@ -3,8 +3,10 @@
import { navigating } from "$app/stores"; import { navigating } from "$app/stores";
import { SvelteToast } from "@zerodevx/svelte-toast";
import LoadingBar from "$lib/components/ui/LoadingBar.svelte"; import LoadingBar from "$lib/components/ui/LoadingBar.svelte";
import { screenWidth } from "$lib/stores/layout"; import { screenWidth } from "$lib/stores";
let loadingBar: LoadingBar | undefined; let loadingBar: LoadingBar | undefined;
@ -13,9 +15,12 @@
} else { } else {
loadingBar?.reset(); loadingBar?.reset();
} }
const options = { pausable: true };
</script> </script>
<LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" /> <LoadingBar bind:this={loadingBar} cls="fixed top-0 left-0 z-50" />
<SvelteToast {options} />
<div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}> <div class="bg-base-100 text-base-content" bind:clientWidth={$screenWidth}>
<slot /> <slot />

View file

@ -10,7 +10,7 @@ import { makeAuthjsRequest } from "$lib/server/auth";
*/ */
const COOKIE_NAME = "autoLoginTs"; const COOKIE_NAME = "autoLoginTs";
async function doLogin(event: RequestEvent) { async function doLogin(event: RequestEvent): Promise<never> {
const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url); const callbackUrl = event.url.searchParams.get("returnURL") ?? baseUrl(event.url);
return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl }); return makeAuthjsRequest(event, "signin/keycloak", { callbackUrl });

View file

@ -1,4 +1,7 @@
/** @type {import('tailwindcss').Config}*/ /** @type {import('tailwindcss').Config}*/
const themes = require("daisyui/src/theming/themes");
const config = { const config = {
content: [ content: [
"./src/**/*.{html,js,svelte,ts}", "./src/**/*.{html,js,svelte,ts}",
@ -9,6 +12,15 @@ const config = {
daisyui: { daisyui: {
logs: false, logs: false,
themes: [
{
light: {
...themes["light"],
"primary": "#799dff",
}
},
"dark"
],
}, },
safelist: ["prose"], safelist: ["prose"],
}; };

View file

@ -1,9 +1,12 @@
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { CATEGORIES, STATIONS, USERS } from "./testdata"; import {
CATEGORIES, ROOMS, STATIONS, USERS,
} from "./testdata";
export default async () => { export default async () => {
await prisma.$transaction([ await prisma.$transaction([
prisma.savedFilter.deleteMany(),
prisma.entryExecution.deleteMany(), prisma.entryExecution.deleteMany(),
prisma.entryVersion.deleteMany(), prisma.entryVersion.deleteMany(),
prisma.entry.deleteMany(), prisma.entry.deleteMany(),
@ -17,11 +20,9 @@ export default async () => {
prisma.category.createMany({ data: CATEGORIES }), prisma.category.createMany({ data: CATEGORIES }),
prisma.station.createMany({ data: STATIONS }), prisma.station.createMany({ data: STATIONS }),
prisma.room.createMany({ prisma.room.createMany({
data: [ data: ROOMS.map((v) => {
{ id: 1, name: "R1.1", station_id: 1 }, return { id: v.id, name: v.name, station_id: v.station.id };
{ id: 2, name: "R1.2", station_id: 1 }, }),
{ id: 3, name: "R2.1", station_id: 2 },
],
}), }),
prisma.patient.createMany({ prisma.patient.createMany({
data: [ data: [
@ -55,7 +56,7 @@ export default async () => {
prisma.$executeRawUnsafe( prisma.$executeRawUnsafe(
`alter sequence stations_id_seq restart with ${STATIONS.length + 1}`, `alter sequence stations_id_seq restart with ${STATIONS.length + 1}`,
), ),
prisma.$executeRawUnsafe("alter sequence rooms_id_seq restart with 4"), prisma.$executeRawUnsafe(`alter sequence rooms_id_seq restart with ${ROOMS.length + 1}`),
prisma.$executeRawUnsafe("alter sequence patients_id_seq restart with 4"), prisma.$executeRawUnsafe("alter sequence patients_id_seq restart with 4"),
prisma.$executeRawUnsafe("alter sequence entry_executions_id_seq restart with 1"), prisma.$executeRawUnsafe("alter sequence entry_executions_id_seq restart with 1"),
prisma.$executeRawUnsafe("alter sequence entry_versions_id_seq restart with 1"), prisma.$executeRawUnsafe("alter sequence entry_versions_id_seq restart with 1"),

View file

@ -1,9 +1,33 @@
import type { Category, Station } from "$lib/shared/model"; import type { Category, Station, Room } from "$lib/shared/model";
export const S1: Station = { id: 1, name: "S1" }; export const S1: Station = { id: 1, name: "S1" };
export const S2: Station = { id: 2, name: "S2" }; export const S2: Station = { id: 2, name: "S2" };
export const S3: Station = { id: 3, name: "S3_tmp" };
export const STATIONS: Station[] = [S1, S2]; export const STATIONS: Station[] = [S1, S2, S3];
export const ROOMS: Room[] = [
{
id: 1,
name: "R1.1",
station: S1,
},
{
id: 2,
name: "R1.2",
station: S1,
},
{
id: 3,
name: "R2.1",
station: S2,
},
{
id: 4,
name: "Rtmp",
station: S2,
},
];
export const CATEGORIES: Category[] = [ export const CATEGORIES: Category[] = [
{ {

View file

@ -1,6 +1,8 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { getCategories, getCategory, newCategory } from "$lib/server/query"; import {
deleteCategory, getCategories, getCategory, hideCategory, newCategory,
} from "$lib/server/query";
import { CATEGORIES } from "$tests/helpers/testdata"; import { CATEGORIES } from "$tests/helpers/testdata";
test("create category", async () => { test("create category", async () => {
@ -21,3 +23,19 @@ test("get categories", async () => {
const categories = await getCategories(); const categories = await getCategories();
expect(categories).toStrictEqual(CATEGORIES); expect(categories).toStrictEqual(CATEGORIES);
}); });
test("delete categories", async () => {
await deleteCategory(6);
expect(getCategory(6)).rejects.toThrowError("No Category found");
});
test("hide category", async () => {
await hideCategory(1, true);
const cs1 = await getCategories();
const exp = [...CATEGORIES];
exp.splice(0, 1);
expect(cs1).toStrictEqual(exp);
await hideCategory(1, false);
const cs2 = await getCategories();
expect(cs2).toStrictEqual(CATEGORIES);
});

View file

@ -3,12 +3,14 @@ import { expect, test } from "vitest";
import { ErrorConflict } from "$lib/shared/util/error"; import { ErrorConflict } from "$lib/shared/util/error";
import { import {
getCategoryNEntries,
getEntries, getEntries,
getEntry, getEntry,
getEntryNExecutions, getEntryNExecutions,
getEntryNVersions, getEntryNVersions,
getEntryVersions, getEntryVersions,
getNTodo, getNTodo,
getPatientNEntries,
newEntry, newEntry,
newEntryExecution, newEntryExecution,
newEntryVersion, newEntryVersion,
@ -21,6 +23,46 @@ const TEST_VERSION = {
priority: false, priority: false,
}; };
async function insertTestEntries() {
// Create some entries
const eId1 = await newEntry(1, {
patient_id: 1,
version: TEST_VERSION,
});
const eId2 = await newEntry(1, {
patient_id: 2,
version: {
text: "Carrot cake jelly-o bonbon toffee chocolate.",
date: "2024-01-05",
priority: false,
category_id: null,
},
});
const eId3 = await newEntry(1, {
patient_id: 1,
version: {
text: "Cheesecake danish donut oat cake caramels.",
date: "2024-01-06",
priority: false,
category_id: null,
},
});
// Update an entry
await newEntryVersion(2, eId1, {
category_id: 3,
text: `${TEST_VERSION.text}\n\n> Hello World`,
date: "2024-01-01",
priority: true,
});
// Execute entries
await newEntryExecution(1, eId2, { text: "Some execution txt" });
await newEntryExecution(2, eId3, { text: "More execution txt" });
return { eId1, eId2, eId3 };
}
test("create entry", async () => { test("create entry", async () => {
const eId = await newEntry(1, { const eId = await newEntry(1, {
patient_id: 1, patient_id: 1,
@ -163,46 +205,6 @@ test("create entry execution (wrong old xid)", async () => {
expect(async () => newEntryExecution(1, eId, { text: "x2" }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match")); expect(async () => newEntryExecution(1, eId, { text: "x2" }, x1 + 1)).rejects.toThrowError(new ErrorConflict("old execution id does not match"));
}); });
async function insertTestEntries() {
// Create some entries
const eId1 = await newEntry(1, {
patient_id: 1,
version: TEST_VERSION,
});
const eId2 = await newEntry(1, {
patient_id: 2,
version: {
text: "Carrot cake jelly-o bonbon toffee chocolate.",
date: "2024-01-05",
priority: false,
category_id: null,
},
});
const eId3 = await newEntry(1, {
patient_id: 1,
version: {
text: "Cheesecake danish donut oat cake caramels.",
date: "2024-01-06",
priority: false,
category_id: null,
},
});
// Update an entry
await newEntryVersion(2, eId1, {
category_id: 3,
text: `${TEST_VERSION.text}\n\n> Hello World`,
date: "2024-01-01",
priority: true,
});
// Execute entries
await newEntryExecution(1, eId2, { text: "Some execution txt" });
await newEntryExecution(2, eId3, { text: "More execution txt" });
return { eId1, eId2, eId3 };
}
test("get entries", async () => { test("get entries", async () => {
const { eId1, eId2, eId3 } = await insertTestEntries(); const { eId1, eId2, eId3 } = await insertTestEntries();
const entries = await getEntries({}, {}); const entries = await getEntries({}, {});
@ -290,5 +292,15 @@ test("get entries", async () => {
// NTodo // NTodo
const n = await getNTodo(new Date("2024-01-05")); const n = await getNTodo(new Date("2024-01-05"));
expect(n).toBe(2); expect(n).toBe(1);
});
test("get patient n entries", async () => {
await insertTestEntries();
expect(await getPatientNEntries(1)).toBe(2);
});
test("get category n entries", async () => {
await insertTestEntries();
expect(await getCategoryNEntries(3)).toBe(1);
}); });

View file

@ -64,9 +64,7 @@ test("delete patient (restricted)", async () => {
}, },
}); });
expect(async () => deletePatient(pId)).rejects.toThrowError( expect(async () => deletePatient(pId)).rejects.toThrowError();
"cannot delete patient with entries",
);
}); });
test("hide patient", async () => { test("hide patient", async () => {

View file

@ -1,42 +1,20 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import type { Room, Station } from "$lib/shared/model"; import type { RoomDetail } from "$lib/shared/model";
import { import {
deleteStation, deleteRoom,
getRoom, getRoom,
getRoomNPatients,
getRooms, getRooms,
getStation, getStation,
getStations, hideRoom,
newRoom, newRoom,
newStation,
updateStation, updateStation,
} from "$lib/server/query"; } from "$lib/server/query";
import { S1, S2 } from "$tests/helpers/testdata"; import {
ROOMS, S1,
test("create station", async () => { } from "$tests/helpers/testdata";
const sId = await newStation({ name: "S3" });
const station = await getStation(sId);
expect(station).toStrictEqual({ id: sId, name: "S3" } satisfies Station);
});
test("update station", async () => {
const name = "HelloStation";
await updateStation(S1.id, { name });
const station = await getStation(S1.id);
expect(station.id).toBe(S1.id);
expect(station.name).toBe(name);
});
test("delete station", async () => {
await deleteStation(S1.id);
expect(async () => getStation(S1.id)).rejects.toThrowError("No Station found");
});
test("get stations", async () => {
const stations = await getStations();
expect(stations).toStrictEqual([S1, S2]);
});
test("create room", async () => { test("create room", async () => {
const rId = await newRoom({ name: "A1", station_id: 1 }); const rId = await newRoom({ name: "A1", station_id: 1 });
@ -45,7 +23,8 @@ test("create room", async () => {
id: rId, id: rId,
name: "A1", name: "A1",
station: S1, station: S1,
} satisfies Room); hidden: false,
} satisfies RoomDetail);
}); });
test("update room", async () => { test("update room", async () => {
@ -57,27 +36,26 @@ test("update room", async () => {
}); });
test("delete room", async () => { test("delete room", async () => {
await deleteStation(S1.id); await deleteRoom(ROOMS[3].id);
expect(async () => getStation(S1.id)).rejects.toThrowError("No Station found"); expect(async () => getRoom(ROOMS[3].id)).rejects.toThrowError("No Room found");
});
test("hide room", async () => {
await hideRoom(ROOMS[0].id, true);
const rs1 = await getRooms();
const exp = [...ROOMS];
exp.splice(0, 1);
expect(rs1).toStrictEqual(exp);
await hideRoom(ROOMS[0].id, false);
const rs2 = await getRooms();
expect(rs2).toStrictEqual(ROOMS);
}); });
test("get rooms", async () => { test("get rooms", async () => {
const rooms = await getRooms(); const rooms = await getRooms();
expect(rooms).toStrictEqual([ expect(rooms).toStrictEqual(ROOMS);
{ });
id: 1,
name: "R1.1", test("get room n patients", async () => {
station: S1, expect(await getRoomNPatients(ROOMS[0].id)).toBe(1);
},
{
id: 2,
name: "R1.2",
station: S1,
},
{
id: 3,
name: "R2.1",
station: S2,
},
] satisfies Room[]);
}); });

View file

@ -0,0 +1,129 @@
import { expect, test } from "vitest";
import {
deleteSavedFilter,
getAllFilters,
getDefaultFilter, getSavedFilters, newSavedFilter, updateSavedFilter,
} from "$lib/server/query/savedFilter";
const VIEW = "plan";
async function createFilters() {
const fA1 = await newSavedFilter({
name: "Todo",
query: "filter[done]=false",
view: VIEW,
}, 1);
const fA2 = await newSavedFilter({
name: "Done",
query: "filter[done]=true",
view: VIEW,
}, 1);
const fADefault = await newSavedFilter({
name: "Default",
query: "filter[done]=true&filter[station][0][id]=1&filter[station][0][name]=S1",
view: VIEW,
}, 1);
const fADefaultP = await newSavedFilter({
name: "Default",
query: "filter[station][0][id]=1&filter[station][0][name]=S1",
view: "patients",
}, 1);
const fAOther = await newSavedFilter({
name: "Done",
query: "filter[done]=true",
view: "other",
}, 1);
const fB1 = await newSavedFilter({
name: "Prio",
query: "filter[priority]=true",
view: VIEW,
}, 2);
return {
fA1, fA2, fADefault, fB1, fADefaultP, fAOther,
};
}
const U1_FILTERS = (ids: Awaited<ReturnType<typeof createFilters>>) => [
// Default filter must be ordered on top
{
id: ids.fADefault,
name: "Default",
query: "filter[done]=true&filter[station][0][id]=1&filter[station][0][name]=S1",
},
{
id: ids.fA1,
name: "Todo",
query: "filter[done]=false",
},
{
id: ids.fA2,
name: "Done",
query: "filter[done]=true",
},
];
test("get filters", async () => {
const ids = await createFilters();
const u1Filters = await getSavedFilters(1, VIEW);
expect(u1Filters).toStrictEqual(U1_FILTERS(ids));
const u2Filters = await getSavedFilters(2, VIEW);
expect(u2Filters).toStrictEqual([
{
id: ids.fB1,
name: "Prio",
query: "filter[priority]=true",
},
]);
});
test("get default filter", async () => {
const ids = await createFilters();
const filter = await getDefaultFilter(1, VIEW);
expect(filter).toStrictEqual(U1_FILTERS(ids)[0]);
});
test("update filter", async () => {
const q = "filter[done]=true";
const ids = await createFilters();
await updateSavedFilter(ids.fADefault, q, 1);
const filter = await getDefaultFilter(1, VIEW);
expect(filter?.query).toBe(q);
});
test("update filter not found", async () => {
expect(updateSavedFilter(1, "Hello World", 1)).rejects.toThrowError();
});
test("delete filter", async () => {
const ids = await createFilters();
await deleteSavedFilter(ids.fADefault, 1);
const filter = await getDefaultFilter(1, VIEW);
expect(filter).toBe(null);
});
test("get all filters", async () => {
const ids = await createFilters();
const filters = await getAllFilters(1);
expect(filters).toStrictEqual({
other: [
{
id: ids.fAOther,
name: "Done",
query: "filter[done]=true",
},
],
patients: [
{
id: ids.fADefaultP,
name: "Default",
query: "filter[station][0][id]=1&filter[station][0][name]=S1",
},
],
plan: U1_FILTERS(ids),
});
});

View file

@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import type { StationDetail } from "$lib/shared/model";
import {
deleteStation, getStation, getStationNRooms, getStations, hideStation, newStation, updateStation,
} from "$lib/server/query";
import {
STATIONS, S1, S2, S3,
} from "$tests/helpers/testdata";
test("create station", async () => {
const sId = await newStation({ name: "S3" });
const station = await getStation(sId);
expect(station).toStrictEqual({ id: sId, name: "S3", hidden: false } satisfies StationDetail);
});
test("update station", async () => {
const name = "HelloStation";
await updateStation(S1.id, { name });
const station = await getStation(S1.id);
expect(station.id).toBe(S1.id);
expect(station.name).toBe(name);
});
test("delete station", async () => {
await deleteStation(S3.id);
expect(async () => getStation(S3.id)).rejects.toThrowError("No Station found");
});
test("hide station", async () => {
await hideStation(S1.id, true);
const cs1 = await getStations();
expect(cs1).toStrictEqual([S2, S3]);
await hideStation(S1.id, false);
const cs2 = await getStations();
expect(cs2).toStrictEqual(STATIONS);
});
test("get stations", async () => {
const stations = await getStations();
expect(stations).toStrictEqual(STATIONS);
});
test("get station n rooms", async () => {
expect(await getStationNRooms(1)).toBe(2);
});

View file

@ -1,9 +1,77 @@
import { exec } from "child_process";
import { promisify } from "util";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { createViteLicensePlugin } from "rollup-license-plugin";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
// Get current tag/commit and last commit date from git
const pexec = promisify(exec);
let [version, lastmod] = (
await Promise.allSettled([
pexec("git describe --tags || git rev-parse --short HEAD"),
pexec('git log -1 --format=%cd --date=format:"%Y-%m-%d %H:%M"'),
])
).map((v) => {
if (v.status !== "fulfilled") throw Error("could not get version info from git");
return JSON.stringify(v.value.stdout.trim());
});
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [
sveltekit(),
createViteLicensePlugin({
additionalFiles: {
"oss-licenses.html": (packages) => {
let res = `<html>
<head>
<title>Visitenbuch - Lizenzen</title>
</head>
<body>
<h1>Open-Source-Lizenzen</h1>
<a href="./oss-licenses.json">JSON-formatted license list</a>
`;
for (const p of packages) {
// @ts-expect-error repo not present in type definition
let rp = p.repository;
// @ts-expect-error author not present in type definition
let aut = p.author;
if (typeof aut === "object") {
aut = aut.name;
}
let repoUrl = null;
if (typeof rp === "string") {
if (rp.startsWith("http")) repoUrl = rp;
else if (rp.startsWith("git+http")) repoUrl = rp.substring(4);
else if (rp.startsWith("git://")) repoUrl = "https://" + rp.substring(6);
else if (rp.startsWith("github:")) repoUrl = "https://github.com/" + rp.substring(7);
else if (rp.match(/^[\w-]+\/[\w-]+$/)) repoUrl = "https://github.com/" + rp;
}
res += `<div class="package">\n`;
res += `<h3><a href="https://www.npmjs.com/package/${p.name}" target="_blank" rel="noopener noreferrer">${p.name}</a></h3>\n`;
res += `<table>\n`;
res += `<tr><td>Version:</td><td>${p.version}</td></tr>\n`;
if (aut) res += `<tr><td>Author:</td><td>${aut}</td></tr>\n`;
res += `<tr><td>License:</td><td>${p.license}</td></tr>\n`;
if (repoUrl) res += `<tr><td>Repository:</td><td><a href="${repoUrl}" target="_blank" rel="noopener noreferrer">${repoUrl}</a></td></tr>\n`;
else if (rp) res += `<tr><td>Repository:</td><td>${rp}</td></tr>\n`;
res += `</table>\n`;
res += "</div>";
}
res += "</body>\n</html>\n";
return res;
},
},
}),
],
test: { test: {
include: ["src/**/*.{test,spec}.{js,ts}"], include: ["src/**/*.{test,spec}.{js,ts}"],
}, },
define: {
__VERSION__: version,
__LASTMOD__: lastmod,
},
}); });