Compare commits

..

11 commits

151 changed files with 4987 additions and 1554 deletions

View file

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,44 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:svelte/recommended",
],
plugins: ["@typescript-eslint", "no-relative-import-paths"],
ignorePatterns: ["*.cjs"],
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
svelteFeatures: {
experimentalGenerics: true,
},
},
},
],
settings: {},
globals: {
$$Generic: "readonly",
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
"no-relative-import-paths/no-relative-import-paths": [
"warn",
{ allowSameFolder: true },
],
"no-console": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
},
};

View file

@ -7,11 +7,6 @@ repos:
pass_filenames: false pass_filenames: false
entry: npm run check entry: npm run check
files: \.(js|ts|svelte)$ files: \.(js|ts|svelte)$
- id: svelte-prettier
name: prettier
language: system
entry: npx prettier --write --ignore-unknown
types: [text]
- id: svelte-lint - id: svelte-lint
name: eslint name: eslint
language: system language: system

View file

@ -1,14 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
/run
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,9 +0,0 @@
{
"useTabs": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 88,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

834
eslint.config.js Normal file
View file

@ -0,0 +1,834 @@
// @ts-expect-error js module
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
// @ts-expect-error js module
import eslintImport from "eslint-plugin-import";
// @ts-expect-error js module
import noRelativeImportPaths from "eslint-plugin-no-relative-import-paths";
import svelte from "eslint-plugin-svelte";
// @ts-expect-error js module
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import svelteParser from "svelte-eslint-parser";
import ts from "typescript-eslint";
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs["flat/recommended"],
// TS-Svelte
{
files: ["*.svelte", "**/*.svelte"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: ts.parser,
svelteFeatures: {
experimentalGenerics: true,
},
},
},
},
// TS options
{
languageOptions: {
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
project: "./tsconfig.json",
extraFileExtensions: [".svelte"],
},
globals: {
...globals.browser,
$$Generic: "readonly",
},
},
},
// No relative import paths
{
plugins: {
"no-relative-import-paths": noRelativeImportPaths,
},
rules: {
"no-relative-import-paths/no-relative-import-paths": [
"warn",
{ allowSameFolder: true, rootDir: "src/lib", prefix: "$lib" },
],
},
},
// Import
{
plugins: {
import: eslintImport,
},
rules: {
"import/order": [
"warn",
{
// Order: SvelteKit builtins, External deps, Internal deps, Components, Siblings
groups: [
"builtin",
"external",
"internal",
"unknown",
["sibling", "parent", "index"],
],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
pathGroups: [
{ pattern: "$app/**", group: "builtin", position: "after" },
{ pattern: "./$types", group: "builtin", position: "after" },
{ pattern: "$lib/components/**", group: "unknown", position: "before" },
{ pattern: "$lib/shared/**", group: "internal", position: "before" },
],
distinctGroup: false,
},
],
},
},
// No unused imports
{
plugins: {
"unused-imports": unusedImports,
},
rules: {
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"unused-imports/no-unused-vars": ["warn", { args: "none" }],
},
},
// JS STYLING
{
plugins: {
"@stylistic": stylistic,
},
rules: {
// enforces line breaks after opening and before closing array brackets
// https://eslint.style/rules/default/array-bracket-newline
"@stylistic/array-bracket-newline": ["warn", "consistent"],
// enforce spacing inside array brackets
// https://eslint.style/rules/default/array-bracket-spacing
"@stylistic/array-bracket-spacing": ["warn", "never"],
// enforces line breaks between array elements
// https://eslint.style/rules/default/array-element-newline
"@stylistic/array-element-newline": ["warn", "consistent"],
// enforces parentheses in arrow functions
// https://eslint.style/rules/default/arrow-parens
"@stylistic/arrow-parens": "warn",
// enforces spacing before/after an arrow function's arrow
// https://eslint.style/rules/default/arrow-spacing
"@stylistic/arrow-spacing": "warn",
// enforce spacing inside single-line blocks
// https://eslint.style/rules/default/block-spacing
"@stylistic/block-spacing": ["warn", "always"],
// enforce one true brace style
// https://eslint.style/rules/default/brace-style
"@stylistic/brace-style": ["warn", "1tbs", { allowSingleLine: true }],
// require trailing commas in multiline object literals
// https://eslint.style/rules/default/comma-dangle
"@stylistic/comma-dangle": [
"warn",
{
arrays: "always-multiline",
objects: "always-multiline",
imports: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
enums: "always-multiline",
generics: "always-multiline",
tuples: "always-multiline",
},
],
// enforce spacing before and after comma
// https://eslint.style/rules/default/comma-spacing
"@stylistic/comma-spacing": ["warn", { before: false, after: true }],
// enforce one true comma style
// https://eslint.style/rules/default/comma-style
"@stylistic/comma-style": [
"warn",
"last",
{
exceptions: {
ArrayExpression: false,
ArrayPattern: false,
ArrowFunctionExpression: false,
CallExpression: false,
FunctionDeclaration: false,
FunctionExpression: false,
ImportDeclaration: false,
ObjectExpression: false,
ObjectPattern: false,
VariableDeclaration: false,
NewExpression: false,
},
},
],
// disallow padding inside computed properties
// https://eslint.style/rules/default/computed-property-spacing
"@stylistic/computed-property-spacing": ["warn", "never"],
// enforce newlines before a dot in a member expression
// https://eslint.style/rules/default/dot-location
"@stylistic/dot-location": ["warn", "property"],
// enforce newline at the end of file, with no multiple empty lines
// https://eslint.style/rules/default/eol-last
"@stylistic/eol-last": ["warn", "always"],
// line breaks between arguments of a function call
// https://eslint.style/rules/default/function-call-argument-newline
"@stylistic/function-call-argument-newline": ["warn", "consistent"],
// enforce spacing between functions and their invocations
// https://eslint.style/rules/default/function-call-spacing
"@stylistic/function-call-spacing": ["warn", "never"],
// require function expressions to have a name
// https://eslint.org/docs/rules/func-names
"func-names": "warn",
// require line breaks inside function parentheses if there are line breaks between parameters
// https://eslint.style/rules/default/function-paren-newline
"@stylistic/function-paren-newline": ["warn", "multiline-arguments"],
// Enforce the location of arrow function bodies with implicit returns
// https://eslint.style/rules/default/implicit-arrow-linebreak
"@stylistic/implicit-arrow-linebreak": ["warn", "beside"],
// this option sets a specific tab width for your code
// https://eslint.style/rules/default/indent
"@stylistic/indent": [
"warn",
2,
{
SwitchCase: 1,
VariableDeclarator: 1,
outerIIFEBody: 1,
// MemberExpression: null,
FunctionDeclaration: {
parameters: 1,
body: 1,
},
FunctionExpression: {
parameters: 1,
body: 1,
},
CallExpression: {
arguments: 1,
},
ArrayExpression: 1,
ObjectExpression: 1,
ImportDeclaration: 1,
flatTernaryExpressions: false,
// list derived from https://github.com/benjamn/ast-types/blob/HEAD/def/jsx.js
ignoredNodes: [
"JSXElement",
"JSXElement > *",
"JSXAttribute",
"JSXIdentifier",
"JSXNamespacedName",
"JSXMemberExpression",
"JSXSpreadAttribute",
"JSXExpressionContainer",
"JSXOpeningElement",
"JSXClosingElement",
"JSXFragment",
"JSXOpeningFragment",
"JSXClosingFragment",
"JSXText",
"JSXEmptyExpression",
"JSXSpreadChild",
],
ignoreComments: false,
},
],
// enforces spacing between keys and values in object literal properties
// https://eslint.style/rules/default/key-spacing
"@stylistic/key-spacing": ["warn", { beforeColon: false, afterColon: true }],
// require a space before & after certain keywords
// https://eslint.style/rules/default/keyword-spacing
"@stylistic/keyword-spacing": [
"warn",
{
before: true,
after: true,
overrides: {
return: { after: true },
throw: { after: true },
case: { after: true },
},
},
],
// disallow mixed 'LF' and 'CRLF' as linebreaks
// https://eslint.style/rules/default/linebreak-style
"@stylistic/linebreak-style": ["warn", "unix"],
// require or disallow an empty line between class members
// https://eslint.style/rules/default/lines-between-class-members
"@stylistic/lines-between-class-members": [
"warn",
"always",
{ exceptAfterSingleLine: false },
],
// require or disallow newlines around directives
// https://eslint.org/docs/rules/lines-around-directive
"lines-around-directive": [
"warn",
{
before: "always",
after: "always",
},
],
// specify the maximum length of a line in your program
// https://eslint.style/rules/default/max-len
"@stylistic/max-len": [
"warn",
100,
2,
{
ignoreUrls: true,
ignoreComments: false,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
// require a capital letter for constructors
"new-cap": [
"warn",
{
newIsCap: true,
newIsCapExceptions: [],
capIsNew: false,
capIsNewExceptions: ["Immutable.Map", "Immutable.Set", "Immutable.List"],
},
],
// disallow the omission of parentheses when invoking a constructor with no arguments
// https://eslint.style/rules/default/new-parens
"@stylistic/new-parens": "warn",
// enforces new line after each method call in the chain to make it
// more readable and easy to maintain
// https://eslint.style/rules/default/newline-per-chained-call
"@stylistic/newline-per-chained-call": ["warn", { ignoreChainWithDepth: 4 }],
// disallow use of the Array constructor
"@typescript-eslint/no-array-constructor": "warn",
// disallow if as the only statement in an else block
// https://eslint.org/docs/rules/no-lonely-if
"no-lonely-if": "warn",
// disallow un-paren'd mixes of different operators
// https://eslint.org/docs/rules/no-mixed-operators
"@stylistic/no-mixed-operators": [
"warn",
{
// the list of arithmetic groups disallows mixing `%` and `**`
// with other arithmetic operators.
groups: [
["%", "**"],
["%", "+"],
["%", "-"],
["%", "*"],
["%", "/"],
["/", "*"],
["&", "|", "<<", ">>", ">>>"],
["==", "!=", "===", "!=="],
["&&", "||"],
],
allowSamePrecedence: false,
},
],
// disallow mixed spaces and tabs for indentation
// https://eslint.style/rules/default/no-mixed-spaces-and-tabs
"@stylistic/no-mixed-spaces-and-tabs": "warn",
// disallow use of chained assignment expressions
// https://eslint.org/docs/rules/no-multi-assign
"no-multi-assign": ["warn"],
// disallow multiple empty lines, only one newline at the end,
// and no new lines at the beginning
// https://eslint.style/rules/default/no-multiple-empty-lines
"@stylistic/no-multiple-empty-lines": ["warn", { max: 1, maxBOF: 0, maxEOF: 0 }],
// disallow nested ternary expressions
"no-nested-ternary": "warn",
// disallow use of the Object constructor
"no-new-object": "warn",
// disallow certain syntax forms
// https://eslint.org/docs/rules/no-restricted-syntax
"no-restricted-syntax": [
"warn",
{
selector: "ForInStatement",
message:
"for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.",
},
{
selector: "LabeledStatement",
message:
"Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.",
},
{
selector: "WithStatement",
message:
"`with` is disallowed in strict mode because it makes code impossible to predict and optimize.",
},
],
// disallow tab characters entirely
// https://eslint.style/rules/default/no-tabs
"@stylistic/no-tabs": "warn",
// disallow trailing whitespace at the end of lines
// https://eslint.style/rules/default/no-trailing-spaces
"svelte/no-trailing-spaces": [
"warn",
{
skipBlankLines: false,
ignoreComments: false,
},
],
// disallow dangling underscores in identifiers
// https://eslint.org/docs/rules/no-underscore-dangle
"no-underscore-dangle": [
"warn",
{
allow: [],
allowAfterThis: false,
allowAfterSuper: false,
enforceInMethodNames: true,
},
],
// disallow the use of Boolean literals in conditional expressions
// also, prefer `a || b` over `a ? a : b`
// https://eslint.org/docs/rules/no-unneeded-ternary
"no-unneeded-ternary": ["warn", { defaultAssignment: false }],
// disallow whitespace before properties
// https://eslint.style/rules/default/no-whitespace-before-property
"@stylistic/no-whitespace-before-property": "warn",
// enforce the location of single-line statements
// https://eslint.style/rules/default/nonblock-statement-body-position
"@stylistic/nonblock-statement-body-position": ["warn", "beside", { overrides: {} }],
// require padding inside curly braces
// https://eslint.style/rules/default/object-curly-spacing
"@stylistic/object-curly-spacing": ["warn", "always"],
// enforce line breaks between braces
// https://eslint.style/rules/default/object-curly-newline
"@stylistic/object-curly-newline": [
"warn",
{
ObjectExpression: { minProperties: 4, multiline: true, consistent: true },
ObjectPattern: { minProperties: 4, multiline: true, consistent: true },
ImportDeclaration: { minProperties: 4, multiline: true, consistent: true },
ExportDeclaration: { minProperties: 4, multiline: true, consistent: true },
},
],
// enforce "same line" or "multiple line" on object properties.
// https://eslint.style/rules/default/object-property-newline
"@stylistic/object-property-newline": [
"warn",
{
allowAllPropertiesOnSameLine: true,
},
],
// require assignment operator shorthand where possible or prohibit it entirely
// https://eslint.org/docs/rules/operator-assignment
"operator-assignment": ["warn", "always"],
// Requires operator at the beginning of the line in multiline statements
// https://eslint.style/rules/default/operator-linebreak
"@stylistic/operator-linebreak": ["warn", "before", { overrides: { "=": "none" } }],
// disallow padding within blocks
// https://eslint.style/rules/default/padded-blocks
"@stylistic/padded-blocks": [
"warn",
{
blocks: "never",
classes: "never",
switches: "never",
},
{
allowSingleLineBlocks: true,
},
],
// Disallow the use of Math.pow in favor of the ** operator
// https://eslint.org/docs/rules/prefer-exponentiation-operator
"prefer-exponentiation-operator": "warn",
// Prefer use of an object spread over Object.assign
// https://eslint.org/docs/rules/prefer-object-spread
"prefer-object-spread": "warn",
// require quotes around object literal property names
// https://eslint.style/rules/default/quote-props.html
"@stylistic/quote-props": [
"warn",
"as-needed",
{ keywords: false, unnecessary: true, numbers: false },
],
// specify whether double or single quotes should be used
// https://eslint.style/rules/default/quotes
"@stylistic/quotes": ["warn", "double", { avoidEscape: true }],
// require or disallow use of semicolons instead of ASI
// https://eslint.style/rules/default/semi
"@stylistic/semi": ["warn", "always"],
// enforce spacing before and after semicolons
// https://eslint.style/rules/default/semi-spacing
"@stylistic/semi-spacing": ["warn", { before: false, after: true }],
// Enforce location of semicolons
// https://eslint.style/rules/default/semi-style
"@stylistic/semi-style": ["warn", "last"],
// require or disallow space before blocks
// https://eslint.style/rules/default/space-before-blocks
"@stylistic/space-before-blocks": "warn",
// require or disallow space before function opening parenthesis
// https://eslint.style/rules/default/space-before-function-paren
"@stylistic/space-before-function-paren": [
"warn",
{
anonymous: "always",
named: "never",
asyncArrow: "always",
},
],
// require or disallow spaces inside parentheses
// https://eslint.style/rules/default/space-in-parens
"@stylistic/space-in-parens": ["warn", "never"],
// require spaces around operators
// https://eslint.style/rules/default/space-infix-ops
"@stylistic/space-infix-ops": "warn",
// Require or disallow spaces before/after unary operators
// https://eslint.style/rules/default/space-unary-ops
"@stylistic/space-unary-ops": [
"warn",
{
words: true,
nonwords: false,
overrides: {},
},
],
// require or disallow a space immediately following the // or /* in a comment
// https://eslint.style/rules/default/spaced-comment
"@stylistic/spaced-comment": [
"warn",
"always",
{
line: {
exceptions: ["-", "+"],
markers: ["=", "!", "/"], // space here to support sprockets directives, slash for TS /// comments
},
block: {
exceptions: ["-", "+"],
markers: ["=", "!", ":", "::"], // space here to support sprockets directives and flow comment types
balanced: true,
},
},
],
// Enforce spacing around colons of switch statements
// https://eslint.style/rules/default/switch-colon-spacing
"@stylistic/switch-colon-spacing": ["warn", { after: true, before: false }],
// Require or disallow spacing between template tags and their literals
// https://eslint.style/rules/default/template-tag-spacing
"@stylistic/template-tag-spacing": ["warn", "never"],
// Disallow multiple spaces
// https://eslint.style/rules/default/no-multi-spaces
"@stylistic/no-multi-spaces": "warn",
},
},
// BEST PRACTICES
{
rules: {
"@typescript-eslint/default-param-last": "warn",
"@typescript-eslint/dot-notation": ["warn", { allowKeywords: true }],
"@typescript-eslint/no-implied-eval": "error",
"@typescript-eslint/no-loop-func": "warn",
"@typescript-eslint/no-redeclare": "error",
"@typescript-eslint/no-unused-expressions": [
"warn",
{
allowShortCircuit: false,
allowTernary: false,
allowTaggedTemplates: false,
},
],
"@typescript-eslint/return-await": "error",
"@typescript-eslint/no-shadow": ["error", { allow: ["i", "j"] }],
"no-shadow-restricted-names": "error",
"@typescript-eslint/no-loss-of-precision": "error",
},
},
// ERRORS
{
rules: {
// Enforce “for” loop update clause moving the counter in the right direction
// https://eslint.org/docs/rules/for-direction
"for-direction": "error",
// Enforces that a return statement is present in property getters
// https://eslint.org/docs/rules/getter-return
"getter-return": ["error", { allowImplicit: true }],
// disallow using an async function as a Promise executor
// https://eslint.org/docs/rules/no-async-promise-executor
"no-async-promise-executor": "error",
// Disallow await inside of loops
// https://eslint.org/docs/rules/no-await-in-loop
"no-await-in-loop": "error",
// Disallow comparisons to negative zero
// https://eslint.org/docs/rules/no-compare-neg-zero
"no-compare-neg-zero": "error",
// disallow assignment in conditional expressions
"no-cond-assign": ["error", "always"],
// disallow use of console
"no-console": "warn",
// Disallows expressions where the operation doesn't affect the value
// https://eslint.org/docs/rules/no-constant-binary-expression
"no-constant-binary-expression": "error",
// disallow use of constant expressions in conditions
"no-constant-condition": "warn",
// disallow control characters in regular expressions
"no-control-regex": "error",
// disallow use of debugger
"no-debugger": "error",
// disallow duplicate arguments in functions
"no-dupe-args": "error",
// Disallow duplicate conditions in if-else-if chains
// https://eslint.org/docs/rules/no-dupe-else-if
"no-dupe-else-if": "error",
// disallow duplicate keys when creating object literals
"no-dupe-keys": "error",
// disallow a duplicate case label.
"no-duplicate-case": "error",
// disallow empty statements
"no-empty": "error",
// disallow the use of empty character classes in regular expressions
"no-empty-character-class": "error",
// disallow assigning to the exception in a catch block
"no-ex-assign": "error",
// disallow double-negation boolean casts in a boolean context
// https://eslint.org/docs/rules/no-extra-boolean-cast
"no-extra-boolean-cast": "error",
// disallow unnecessary parentheses
// https://eslint.org/docs/rules/no-extra-parens
"@typescript-eslint/no-extra-parens": [
"warn",
"all",
{
conditionalAssign: true,
nestedBinaryExpressions: false,
returnAssign: false,
ignoreJSX: "all", // delegate to eslint-plugin-react
enforceForArrowConditionals: false,
},
],
// disallow unnecessary semicolons
"no-extra-semi": "error",
// disallow overwriting functions written as function declarations
"no-func-assign": "error",
// https://eslint.org/docs/rules/no-import-assign
"no-import-assign": "error",
// disallow function or variable declarations in nested blocks
"svelte/no-inner-declarations": "error",
// disallow invalid regular expression strings in the RegExp constructor
"no-invalid-regexp": "error",
// disallow irregular whitespace outside of strings and comments
"no-irregular-whitespace": "error",
// Disallow Number Literals That Lose Precision
// https://eslint.org/docs/rules/no-loss-of-precision
"no-loss-of-precision": "error",
// Disallow characters which are made with multiple code points in character class syntax
// https://eslint.org/docs/rules/no-misleading-character-class
"no-misleading-character-class": "error",
// disallow the use of object properties of the global object (Math and JSON) as functions
"no-obj-calls": "error",
// Disallow new operators with global non-constructor functions
// https://eslint.org/docs/latest/rules/no-new-native-nonconstructor
"no-new-native-nonconstructor": "error",
// Disallow returning values from Promise executor functions
// https://eslint.org/docs/rules/no-promise-executor-return
"no-promise-executor-return": "error",
// disallow use of Object.prototypes builtins directly
// https://eslint.org/docs/rules/no-prototype-builtins
"no-prototype-builtins": "error",
// disallow multiple spaces in a regular expression literal
"no-regex-spaces": "error",
// Disallow returning values from setters
// https://eslint.org/docs/rules/no-setter-return
"no-setter-return": "error",
// disallow sparse arrays
"no-sparse-arrays": "error",
// Disallow template literal placeholder syntax in regular strings
// https://eslint.org/docs/rules/no-template-curly-in-string
"no-template-curly-in-string": "error",
// Avoid code that looks like two expressions but is actually one
// https://eslint.org/docs/rules/no-unexpected-multiline
"no-unexpected-multiline": "error",
// disallow unreachable statements after a return, throw, continue, or break statement
"no-unreachable": "error",
// Disallow loops with a body that allows only one iteration
// https://eslint.org/docs/rules/no-unreachable-loop
"no-unreachable-loop": "error",
// disallow return/throw/break/continue inside finally blocks
// https://eslint.org/docs/rules/no-unsafe-finally
"no-unsafe-finally": "error",
// disallow negating the left operand of relational operators
// https://eslint.org/docs/rules/no-unsafe-negation
"no-unsafe-negation": "error",
// disallow use of optional chaining in contexts where the undefined value is not allowed
// https://eslint.org/docs/rules/no-unsafe-optional-chaining
"no-unsafe-optional-chaining": ["error", { disallowArithmeticOperators: true }],
// Disallow Unused Private Class Members
// https://eslint.org/docs/rules/no-unused-private-class-members
"no-unused-private-class-members": "warn",
// Disallow useless backreferences in regular expressions
// https://eslint.org/docs/rules/no-useless-backreference
"no-useless-backreference": "error",
// disallow comparisons with the value NaN
"use-isnan": "error",
// ensure that the results of typeof are compared against a valid string
// https://eslint.org/docs/rules/valid-typeof
"valid-typeof": ["error", { requireStringLiterals: true }],
},
},
// Svelte errors
{
rules: {
"svelte/block-lang": ["error", { script: "ts", style: "postcss" }],
"svelte/no-target-blank": "error",
"svelte/valid-each-key": "error",
"svelte/require-optimized-style-attribute": "warn",
},
},
// Svelte styling
{
rules: {
"svelte/first-attribute-linebreak": "warn",
"svelte/html-closing-bracket-spacing": "warn",
"svelte/html-quotes": "warn",
"svelte/html-self-closing": "warn",
"svelte/indent": "warn",
"svelte/max-attributes-per-line": [
"warn",
{
multiline: 1,
singleline: 4,
},
],
"svelte/mustache-spacing": "warn",
"svelte/no-extra-reactive-curlies": "warn",
"svelte/no-spaces-around-equal-signs-in-attribute": "warn",
"svelte/prefer-class-directive": "warn",
"svelte/prefer-style-directive": "warn",
"svelte/shorthand-attribute": "warn",
"svelte/shorthand-directive": "warn",
"svelte/sort-attributes": "warn",
},
},
// Ignore
{
ignores: [
"build/",
".svelte-kit/",
"*.config.cjs",
"vite.config.ts.timestamp-*",
".tmp/",
],
},
];

View file

@ -7,23 +7,24 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest --run && vitest --config vitest.config.integration.ts --run", "test": "vitest --run && vitest --config vitest.config.integration.js --run",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "eslint . --fix",
"test:unit": "vitest", "test:unit": "vitest",
"test:integration": "vitest --config vitest.config.integration.ts", "test:integration": "vitest --config vitest.config.integration.js",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.28.2", "@auth/core": "^0.30.0",
"@floating-ui/core": "^1.6.0", "@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.12.1",
"carta-md": "^4.0.0",
"diff": "^5.2.0", "diff": "^5.2.0",
"isomorphic-dompurify": "^2.6.0", "isomorphic-dompurify": "^2.7.0",
"marked": "^12.0.1", "qs": "^6.12.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8", "svelte-floating-ui": "^1.5.8",
"zod": "^3.22.4", "zod": "^3.22.4",
@ -31,40 +32,47 @@
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.42.1", "@playwright/test": "^1.43.1",
"@stylistic/eslint-plugin": "^1.7.2",
"@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.5", "@sveltejs/kit": "^2.5.6",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"@tailwindcss/typography": "^0.5.12", "@tailwindcss/typography": "^0.5.12",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"@types/diff": "^5.0.9", "@types/diff": "^5.0.9",
"@types/node": "^20.12.4", "@types/node": "^20.12.7",
"@types/qs": "^6.9.15",
"@types/set-cookie-parser": "^2.4.7", "@types/set-cookie-parser": "^2.4.7",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"daisyui": "^4.9.0", "daisyui": "^4.10.2",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-no-relative-import-paths": "^1.5.4",
"eslint-plugin-svelte": "^2.37.0",
"eslint-plugin-unused-imports": "^3.1.0",
"globals": "^15.0.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-nesting": "^12.1.1", "postcss-nesting": "^12.1.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"prisma": "^5.12.1", "prisma": "^5.12.1",
"svelte": "^4.2.12", "svelte": "^4.2.15",
"svelte-check": "^3.6.9", "svelte-check": "^3.6.9",
"sveltekit-superforms": "^2.12.2", "sveltekit-superforms": "^2.12.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"trpc-sveltekit": "^3.6.1", "trpc-sveltekit": "^3.6.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"tsx": "^4.7.2", "tsx": "^4.7.2",
"typescript": "^5.4.3", "typescript": "^5.4.5",
"vite": "^5.2.8", "typescript-eslint": "^7.7.0",
"vitest": "^1.4.0" "vite": "^5.2.9",
"vitest": "^1.5.0"
}, },
"type": "module" "type": "module",
"pnpm": {
"patchedDependencies": {
"carta-md@4.0.0": "patches/carta-md@4.0.0.patch"
}
}
} }

View file

@ -0,0 +1,27 @@
# Change "markdown-body" css class to "prose" for Tailwind compatibility
diff --git a/dist/Markdown.svelte b/dist/Markdown.svelte
index 92b29fade303a14539720b9bc389e7a41202b1cf..cbdeede16d7af17a481bcac519aea92b8959803d 100644
--- a/dist/Markdown.svelte
+++ b/dist/Markdown.svelte
@@ -15,7 +15,7 @@ onMount(async () => {
});
</script>
-<div bind:this={elem} class="carta-viewer carta-theme__{theme} markdown-body">
+<div bind:this={elem} class="carta-viewer carta-theme__{theme} prose">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html rendered}
{#if mounted}
diff --git a/dist/internal/components/Renderer.svelte b/dist/internal/components/Renderer.svelte
index 1d2ff1a6937bc490ea1e6eb5c2ef9f3b33e4c326..6a95c154ea4ca7c4d19b20d02b3504fb2b65b7f7 100644
--- a/dist/internal/components/Renderer.svelte
+++ b/dist/internal/components/Renderer.svelte
@@ -17,7 +17,7 @@ onMount(() => carta.$setRenderer(elem));
onMount(() => mounted = true);
</script>
-<div bind:this={elem} on:scroll={handleScroll} class="carta-renderer markdown-body">
+<div bind:this={elem} on:scroll={handleScroll} class="carta-renderer prose">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedHtml}
{#if mounted}

View file

@ -1,12 +1,10 @@
import type { PlaywrightTestConfig } from "@playwright/test"; import { defineConfig } from "@playwright/test";
const config: PlaywrightTestConfig = { export default defineConfig({
webServer: { webServer: {
command: "npm run build && npm run preview", command: "npm run build && npm run preview",
port: 4173, port: 4173,
}, },
testDir: "tests", testDir: "tests",
testMatch: /(.+\.)?(test|spec)\.[jt]s/, testMatch: /(.+\.)?(test|spec)\.[jt]s/,
}; });
export default config;

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,3 @@
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths import generateMockdata from "../tests/helpers/generate-mockdata";
import generateMockdata from "../tests/helpers/generate-mockdata"
await generateMockdata(); await generateMockdata();

36
src/app.d.ts vendored
View file

@ -20,4 +20,40 @@ declare global {
} }
} }
declare module "@gramex/url/encode" {
export default function encode(obj: unknown, settings?: {
listBracket?: boolean;
listIndex?: boolean;
objBracket?: boolean;
sortKeys?: boolean;
drop?: unknown[];
}): string;
}
declare module "@gramex/url/index" {
export { default as encode } from "@gramex/url/encode";
import { default as update } from "@gramex/url/update";
export { update };
export const decode: (url: string, settings?: {
convert?: boolean;
forceList?: boolean;
pruneString?: boolean;
}) => unknown;
}
declare module "@gramex/url/update" {
export default function update(obj: unknown, url: string, settings?: {
convert?: boolean;
forceList?: boolean;
pruneString?: boolean;
pruneObject?: boolean;
pruneArray?: boolean;
}): unknown;
}
declare module "@gramex/url" {
import main = require("@gramex/url/index");
export = main;
}
export {}; export {};

View file

@ -3,6 +3,20 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer components {
.btn-transition {
@apply duration-200 ease-out;
animation: button-pop var(--animation-btn, 0.25s) ease-out;
transition-property: color, background-color, border-color, opacity, box-shadow,
transform;
&:active:hover,
&:active:focus {
animation: button-pop 0s ease-out;
transform: scale(var(--btn-focus-scale, 0.97));
}
}
}
button { button {
text-align: initial; text-align: initial;
} }
@ -17,10 +31,6 @@ button {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.border-1 {
border-width: 1px;
}
.v-form-field > input { .v-form-field > input {
@apply input input-bordered w-full max-w-xs; @apply input input-bordered w-full max-w-xs;
} }
@ -34,7 +44,7 @@ button {
@apply bg-base-200; @apply bg-base-200;
@apply rounded-xl; @apply rounded-xl;
@apply flex flex-col; @apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px]; @apply border border-solid border-base-content/30;
.row { .row {
@apply flex flex-row; @apply flex flex-row;
@ -43,7 +53,7 @@ button {
.row, .row,
.rowb { .rowb {
@apply p-2; @apply p-2;
@apply border-solid border-base-content/30 border-t-[1px]; @apply border-t border-solid border-base-content/30;
} }
.row:first-child { .row:first-child {

View file

@ -1,8 +1,7 @@
import type { HandleClientError } from "@sveltejs/kit"; import type { HandleClientError } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client"; import { TRPCClientError } from "@trpc/client";
const CHECK_CONNECTION = const CHECK_CONNECTION = "Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
"Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
export const handleError: HandleClientError = async ({ error, message, status }) => { export const handleError: HandleClientError = async ({ error, message, status }) => {
// If there are client-side errors, SvelteKit always returns the nondescript // If there are client-side errors, SvelteKit always returns the nondescript
@ -13,8 +12,8 @@ export const handleError: HandleClientError = async ({ error, message, status })
console.error("Client error:", error); console.error("Client error:", error);
if ( if (
error instanceof TypeError && error instanceof TypeError
error.message.includes("dynamically imported module") && error.message.includes("dynamically imported module")
) { ) {
// Could not load JS module // Could not load JS module
message = CHECK_CONNECTION; message = CHECK_CONNECTION;

View file

@ -2,9 +2,9 @@ import { redirect, type Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks"; import { sequence } from "@sveltejs/kit/hooks";
import { createTRPCHandle } from "trpc-sveltekit"; import { createTRPCHandle } from "trpc-sveltekit";
import { skAuthHandle } from "$lib/server/auth";
import { createContext } from "$lib/server/trpc/context"; import { createContext } from "$lib/server/trpc/context";
import { router } from "$lib/server/trpc/router"; import { router } from "$lib/server/trpc/router";
import { skAuthHandle } from "$lib/server/auth";
/** /**
* Protect the application against unauthorized access. * Protect the application against unauthorized access.
@ -18,7 +18,7 @@ const authorization: Handle = async ({ event, resolve }) => {
if (!/^\/(login|trpc)/.test(event.url.pathname)) { if (!/^\/(login|trpc)/.test(event.url.pathname)) {
if (!event.locals.session) { if (!event.locals.session) {
const params = new URLSearchParams({ returnURL: event.url.pathname }); const params = new URLSearchParams({ returnURL: event.url.pathname });
redirect(303, "/login?" + params.toString()); redirect(303, `/login?${params.toString()}`);
} }
} }
@ -29,5 +29,5 @@ const authorization: Handle = async ({ event, resolve }) => {
export const handle = sequence( export const handle = sequence(
skAuthHandle, skAuthHandle,
authorization, authorization,
createTRPCHandle({ router, createContext }) createTRPCHandle({ router, createContext }),
); );

View file

@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { mdiPencil } from "@mdi/js";
import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate, humanDate } from "$lib/shared/util"; import { formatDate, humanDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte";
import PatientCard from "$lib/components/entry/PatientCard.svelte";
import CategoryField from "$lib/components/table/CategoryField.svelte"; import CategoryField from "$lib/components/table/CategoryField.svelte";
import Markdown from "$lib/components/ui/Markdown.svelte"; import UserField from "$lib/components/table/UserField.svelte";
import Header from "$lib/components/ui/Header.svelte"; import Header from "$lib/components/ui/Header.svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { mdiPencil } from "@mdi/js"; import Markdown from "$lib/components/ui/markdown/Markdown.svelte";
import VersionsButton from "$lib/components/ui/VersionsButton.svelte"; import VersionsButton from "$lib/components/ui/VersionsButton.svelte";
import PatientCard from "$lib/components/entry/PatientCard.svelte";
import type { RouterOutput } from "$lib/shared/trpc";
export let entry: RouterOutput["entry"]["get"]; export let entry: RouterOutput["entry"]["get"];
export let withExecution = false; export let withExecution = false;
@ -21,14 +24,14 @@
<title>Eintrag #{entry.id}</title> <title>Eintrag #{entry.id}</title>
</svelte:head> </svelte:head>
<Header title="Eintrag #{entry.id}" backHref={backBtn ? basePath : undefined}> <Header backHref={backBtn ? basePath : undefined} title="Eintrag #{entry.id}">
{#if entry.current_version.category} {#if entry.current_version.category}
<CategoryField category={entry.current_version.category} /> <CategoryField category={entry.current_version.category} />
{/if} {/if}
{#if entry.current_version.priority} {#if entry.current_version.priority}
<div class="badge ellipsis badge-warning">Priorität</div> <div class="badge ellipsis badge-warning">Priorität</div>
{/if} {/if}
<a slot="rightBtn" href="{basePath}/edit" class="btn btn-sm btn-primary ml-auto"> <a slot="rightBtn" class="btn btn-sm btn-primary ml-auto" href="{basePath}/edit">
Bearbeiten Bearbeiten
</a> </a>
</Header> </Header>
@ -50,19 +53,17 @@
Beschreibung Beschreibung
<div> <div>
<VersionsButton href="{basePath}/versions" n={entry.n_versions} /> <VersionsButton href="{basePath}/versions" n={entry.n_versions} />
<a href="{basePath}/edit" class="btn btn-circle btn-sm btn-ghost"> <a class="btn btn-circle btn-sm btn-ghost" href="{basePath}/edit">
<Icon path={mdiPencil} size={1.2} /> <Icon path={mdiPencil} size={1.2} />
</a> </a>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<p class="prose">
<Markdown src={entry.current_version.text} /> <Markdown src={entry.current_version.text} />
</p>
</div> </div>
<div class="rowb c-vlight text-sm"> <div class="rowb c-vlight text-sm">
Zuletzt bearbeitet am {formatDate(entry.current_version.created_at, true)} von Zuletzt bearbeitet am {formatDate(entry.current_version.created_at, true)} von
<UserField user={entry.current_version.author} filterName="author" /> <UserField filterName="author" user={entry.current_version.author} />
</div> </div>
</div> </div>
@ -71,20 +72,18 @@
<div class="row c-light text-sm items-center justify-between"> <div class="row c-light text-sm items-center justify-between">
<p> <p>
Erledigt am {formatDate(entry.execution.created_at, true)} von Erledigt am {formatDate(entry.execution.created_at, true)} von
<UserField user={entry.execution.author} filterName="executor" /> <UserField filterName="executor" user={entry.execution.author} />
</p> </p>
<div> <div>
<VersionsButton href="{basePath}/executions" n={entry.n_executions} /> <VersionsButton href="{basePath}/executions" n={entry.n_executions} />
<a href="{basePath}/editExecution" class="btn btn-circle btn-xs btn-ghost"> <a class="btn btn-circle btn-xs btn-ghost" href="{basePath}/editExecution">
<Icon path={mdiPencil} size={1.2} /> <Icon path={mdiPencil} size={1.2} />
</a> </a>
</div> </div>
</div> </div>
{#if entry.execution?.text} {#if entry.execution?.text}
<div class="row"> <div class="row">
<p class="prose">
<Markdown src={entry.execution?.text} /> <Markdown src={entry.execution?.text} />
</p>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { RouterOutput } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc";
import RoomField from "$lib/components/table/RoomField.svelte"; import RoomField from "$lib/components/table/RoomField.svelte";
export let patient: RouterOutput["patient"]["list"]["items"][0]; export let patient: RouterOutput["patient"]["list"]["items"][0];

View file

@ -7,12 +7,15 @@
*/ */
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { onMount } from "svelte";
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 outclick from "$lib/actions/outclick";
import type { BaseItem } from "./types";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { onMount } from "svelte"; import outclick from "$lib/actions/outclick";
import type { BaseItem } from "./types";
type T = $$Generic<BaseItem>; type T = $$Generic<BaseItem>;
type OnSelectResult = { newValue: string; close: boolean }; type OnSelectResult = { newValue: string; close: boolean };
@ -87,9 +90,9 @@
return false; return false;
} else { } else {
isLoading = true; isLoading = true;
items().then((items) => { items().then((fetchedItems) => {
srcItems = items; srcItems = fetchedItems;
if (cacheKey) cache[cacheKey] = items; if (cacheKey) cache[cacheKey] = fetchedItems;
isLoading = false; isLoading = false;
updateSearch(); updateSearch();
}); });
@ -134,14 +137,12 @@
function updateSearch() { function updateSearch() {
if (loadSrcItems()) { if (loadSrcItems()) {
let searchWord = inputValue().toLowerCase().trim(); const searchWord = inputValue().toLowerCase().trim();
filteredItems = filteredItems = !selection && searchWord.length > 0
!selection && searchWord.length > 0
? srcItems.filter( ? srcItems.filter(
(it) => (it) => !hiddenIds.has(it.id)
!hiddenIds.has(it.id) && && filterFn(it)
filterFn(it) && && it.name.toLowerCase().includes(searchWord),
it.name.toLowerCase().includes(searchWord)
) )
: srcItems.filter((it) => !hiddenIds.has(it.id) && filterFn(it)); : srcItems.filter((it) => !hiddenIds.has(it.id) && filterFn(it));
markSelection(); markSelection();
@ -179,7 +180,7 @@
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
let key = e.key; let { key } = e;
if (key === "Tab" && e.shiftKey) key = "ShiftTab"; if (key === "Tab" && e.shiftKey) key = "ShiftTab";
const fnmap: { [key: string]: () => void } = { const fnmap: { [key: string]: () => void } = {
Tab: () => close, Tab: () => close,
@ -277,13 +278,14 @@
} }
</script> </script>
<div class="flex-grow {cls}" use:outclick on:outclick={close}> <div class="flex-grow {cls}" on:outclick={close} use:outclick>
<input <input
bind:this={inputElm}
class={inputCls} class={inputCls}
class:px-2={padding} class:px-2={padding}
type="text"
{placeholder} {placeholder}
bind:this={inputElm} type="text"
value=""
on:input={onInput} on:input={onInput}
on:click={open} on:click={open}
on:focus={open} on:focus={open}
@ -291,30 +293,31 @@
on:keypress={onKeyPress} on:keypress={onKeyPress}
on:blur={onBlur} on:blur={onBlur}
use:floatingRef use:floatingRef
value=""
/> />
{#if opened && filteredItems.length > 0} {#if opened && filteredItems.length > 0}
<div class="autocomplete-list" use:floatingContent bind:this={listElm}> <div bind:this={listElm} class="autocomplete-list" use:floatingContent>
{#each filteredItems as item, i} {#each filteredItems as item, i}
<div <div
role="option"
class="autocomplete-list-item" class="autocomplete-list-item"
class:selected={i === highlightIndex} class:selected={i === highlightIndex}
aria-selected={i === highlightIndex} aria-selected={i === highlightIndex}
role="option"
tabindex="-1" tabindex="-1"
on:click|preventDefault={() => { on:click|preventDefault={() => {
selectListItem(item, false); selectListItem(item, false);
}} }}
on:keypress={(e) => { on:keypress={(e) => {
e.key == "Enter" && selectListItem(item, true); if (e.key === "Enter") {
selectListItem(item, true);
}
}} }}
on:pointerenter={() => { on:pointerenter={() => {
highlightIndex = i; highlightIndex = i;
}} }}
> >
{#if item.icon} {#if item.icon}
<Icon size={1.2} path={item.icon} /> <Icon path={item.icon} size={1.2} />
{/if} {/if}
{item.name} {item.name}
</div> </div>

View file

@ -1,7 +1,13 @@
<script lang="ts"> <script lang="ts">
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import EntryFilterChip from "./FilterChip.svelte";
import { Debouncer } from "$lib/shared/util";
import Icon from "$lib/components/ui/Icon.svelte";
import Autocomplete from "./Autocomplete.svelte"; import Autocomplete from "./Autocomplete.svelte";
import EntryFilterChip from "./FilterChip.svelte";
import type { import type {
FilterDef, FilterDef,
FilterQdata, FilterQdata,
@ -10,13 +16,11 @@
SelectionOrText, SelectionOrText,
} from "./types"; } from "./types";
import { isFilterValueless } from "./types"; import { isFilterValueless } from "./types";
import Icon from "$lib/components/ui/Icon.svelte";
import { Debouncer } from "$lib/shared/util";
/** Filter definitions */ /** Filter definitions */
export let FILTERS: { [key: string]: FilterDef }; export let FILTERS: { [key: string]: FilterDef };
/** Filter data from the query */ /** Filter data from the query */
export let filterData: FilterQdata | null | undefined = undefined; export let filterData: FilterQdata | null | undefined;
/** Callback when filters are updated */ /** Callback when filters are updated */
export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {}; export let onUpdate: (filterData: FilterQdata | undefined) => void = () => {};
/** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */ /** List of hidden filter IDs, can be specified for prefiltered views (e.g. by patient) */
@ -26,9 +30,9 @@
let autocomplete: Autocomplete<BaseItem> | undefined; let autocomplete: Autocomplete<BaseItem> | undefined;
let activeFilters: FilterData[] = []; let activeFilters: FilterData[] = [];
let cache: { [key: string]: BaseItem[] } = {}; const cache: { [key: string]: BaseItem[] } = {};
let searchVal = ""; let searchVal = "";
let searchDebounce = new Debouncer(400, () => { const searchDebounce = new Debouncer(400, () => {
onUpdate(getFilterQdata()); onUpdate(getFilterQdata());
}); });
@ -38,7 +42,9 @@
if (f.toggleOff) { if (f.toggleOff) {
return [ return [
f, f,
{ id: f.id, name: f.toggleOff.name, icon: f.toggleOff.icon, toggle: false }, {
id: f.id, name: f.toggleOff.name, icon: f.toggleOff.icon, toggle: false,
},
]; ];
} }
return [f]; return [f];
@ -46,9 +52,12 @@
// Filter menu items to be hidden // Filter menu items to be hidden
$: hiddenIds = new Set([ $: hiddenIds = new Set([
...Object.values(FILTERS).flatMap((f) => ...Object.values(FILTERS).flatMap((f) => {
f.inputType === 2 || activeFilters.every((af) => af.id !== f.id) ? [] : [f.id] return f.inputType === 2
), || activeFilters.every((af) => af.id !== f.id)
? []
: [f.id];
}),
...hiddenFilters, ...hiddenFilters,
]); ]);
@ -57,9 +66,9 @@
updateFromQueryData(filterData ?? {}); updateFromQueryData(filterData ?? {});
} }
function updateFromQueryData(filterData: FilterQdata) { function updateFromQueryData(fd: FilterQdata) {
const filters: FilterData[] = []; const filters: FilterData[] = [];
for (const [id, value] of Object.entries(filterData)) { 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
if (hiddenFilters.includes(id) || !FILTERS[id]) continue; if (hiddenFilters.includes(id) || !FILTERS[id]) continue;
// Extract search parameter if a separate search field is used // Extract search parameter if a separate search field is used
@ -74,7 +83,7 @@
}); });
}); });
} else { } else {
let selection: SelectionOrText = {}; const selection: SelectionOrText = {};
if (typeof value === "string") selection.name = value; if (typeof value === "string") selection.name = value;
else if (typeof value === "boolean") selection.toggle = value; else if (typeof value === "boolean") selection.toggle = value;
else selection.id = value; else selection.id = value;
@ -88,13 +97,10 @@
function getHiddenIds(fid: string, fpos: number): Set<string | number> { function getHiddenIds(fid: string, fpos: number): Set<string | number> {
if (FILTERS[fid].inputType === 2) { if (FILTERS[fid].inputType === 2) {
return new Set( return new Set(
activeFilters.flatMap((f, i) => { activeFilters.flatMap((f, i) => (i !== fpos && f.selection?.id ? [f.selection?.id] : [])),
return i !== fpos && f.selection?.id ? [f.selection?.id] : [];
})
); );
} else {
return new Set();
} }
return new Set();
} }
function focusInput() { function focusInput() {
@ -102,22 +108,20 @@
} }
function getFilterQdata(): FilterQdata | undefined { function getFilterQdata(): FilterQdata | undefined {
let fd: FilterQdata = {}; const fd: FilterQdata = {};
activeFilters.forEach((fdata) => { activeFilters.forEach((fdata) => {
const filter = FILTERS[fdata.id]; const filter = FILTERS[fdata.id];
const key = filter.id; const key = filter.id;
let val = null; let val = null;
// Valueless filter (val = true)
if (filter.inputType === 0) { if (filter.inputType === 0) {
// Valueless filter (val = true)
val = true; val = true;
} } else if (filter.inputType === 1) {
// Text input // Text input
else if (filter.inputType === 1) {
val = fdata.selection?.name; val = fdata.selection?.name;
} } else if (filter.inputType === 2 && fdata.selection) {
// Filter list // Filter list
else if (filter.inputType === 2 && fdata.selection) {
// @ts-expect-error TODO // @ts-expect-error TODO
val = { id: fdata.selection.id, name: fdata.selection.name }; val = { id: fdata.selection.id, name: fdata.selection.name };
} else if (filter.inputType === 3) { } else if (filter.inputType === 3) {
@ -137,7 +141,7 @@
}); });
if (searchVal) { if (searchVal) {
fd["search"] = searchVal; fd.search = searchVal;
} }
if (Object.keys(fd).length === 0) return undefined; if (Object.keys(fd).length === 0) return undefined;
@ -163,9 +167,8 @@
} }
function removeFilter(i: number) { function removeFilter(i: number) {
const shouldUpdate = const shouldUpdate = isFilterValueless(FILTERS[activeFilters[i].id].inputType)
isFilterValueless(FILTERS[activeFilters[i].id].inputType) || || activeFilters[i].selection !== null;
activeFilters[i].selection !== null;
activeFilters.splice(i, 1); activeFilters.splice(i, 1);
activeFilters = activeFilters; activeFilters = activeFilters;
if (shouldUpdate) updateFilter(); if (shouldUpdate) updateFilter();
@ -190,10 +193,10 @@
<div class="filterbar-inner input input-sm input-bordered"> <div class="filterbar-inner input input-sm input-bordered">
{#each activeFilters as fdata, i} {#each activeFilters as fdata, i}
<EntryFilterChip <EntryFilterChip
filter={FILTERS[fdata.id]}
{fdata}
hiddenIds={() => getHiddenIds(fdata.id, i)}
{cache} {cache}
{fdata}
filter={FILTERS[fdata.id]}
hiddenIds={() => getHiddenIds(fdata.id, i)}
onRemove={() => removeFilter(i)} onRemove={() => removeFilter(i)}
onSelection={(sel, kb) => { onSelection={(sel, kb) => {
updateFilter(); updateFilter();
@ -204,19 +207,19 @@
<Autocomplete <Autocomplete
bind:this={autocomplete} bind:this={autocomplete}
cls="mr-8" cls="mr-8"
items={filterMenuItems}
{hiddenIds} {hiddenIds}
placeholder="Filter" items={filterMenuItems}
onSelect={(item) => {
const close = addFilter(item);
return { newValue: "", close };
}}
onBackspace={() => { onBackspace={() => {
activeFilters.pop(); activeFilters.pop();
activeFilters = activeFilters; activeFilters = activeFilters;
updateFilter(); updateFilter();
}} }}
onSelect={(item) => {
const close = addFilter(item);
return { newValue: "", close };
}}
partOfFilterbar partOfFilterbar
placeholder="Filter"
/> />
<button <button
class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0" class="btn btn-sm btn-circle btn-ghost absolute bottom-0 right-0"
@ -227,14 +230,14 @@
updateFilter(); updateFilter();
}} }}
> >
<Icon size={1.2} path={mdiClose} /> <Icon path={mdiClose} size={1.2} />
</button> </button>
</div> </div>
{#if search} {#if search}
<input <input
class="input input-sm input-bordered" class="input input-sm input-bordered"
type="text"
placeholder="Suche" placeholder="Suche"
type="text"
bind:value={searchVal} bind:value={searchVal}
on:input={onSearchInput} on:input={onSearchInput}
on:keypress={onSearchKeypress} on:keypress={onSearchKeypress}
@ -250,6 +253,9 @@
.filterbar-inner { .filterbar-inner {
@apply flex flex-wrap flex-grow items-stretch h-min p-0 gap-2 relative; @apply flex flex-wrap flex-grow items-stretch h-min p-0 gap-2 relative;
line-height: 30px;
:global(input) {
height: 32px;
}
} }
</style> </style>

View file

@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import type { BaseItem, FilterData, FilterDef, SelectionOrText } from "./types";
import Autocomplete from "./Autocomplete.svelte"; import Autocomplete from "./Autocomplete.svelte";
import type {
BaseItem, FilterData, FilterDef, SelectionOrText,
} from "./types";
export let filter: FilterDef; export let filter: FilterDef;
export let hiddenIds: () => Set<string | number> = () => new Set(); export let hiddenIds: () => Set<string | number> = () => new Set();
@ -59,7 +63,7 @@ gap-1 pl-1"
}} }}
> >
{#if filterIcon} {#if filterIcon}
<Icon size={1.2} path={filterIcon} /> <Icon path={filterIcon} size={1.2} />
{/if} {/if}
<span class="flex items-center"> <span class="flex items-center">
{filterName + (hasInputField ? ":" : "")} {filterName + (hasInputField ? ":" : "")}
@ -72,33 +76,32 @@ gap-1 pl-1"
{@const hids = hiddenIds()} {@const hids = hiddenIds()}
<Autocomplete <Autocomplete
bind:this={autocomplete} bind:this={autocomplete}
items={filter.options ?? []}
hiddenIds={hids}
{cache} {cache}
cacheKey={filter.id} cacheKey={filter.id}
selection={fdata.selection?.id hiddenIds={hids}
? { id: fdata.selection?.id, name: fdata.selection?.name ?? "" } items={filter.options ?? []}
: null} onBackspace={onRemove}
padding={false} {onClose}
onSelect={(item) => { onSelect={(item) => {
// Accept the selection if this is a free text field or the user selected a variant // Accept the selection if this is a free text field or the user selected a variant
if (filter.inputType !== 2 || item.id) { if (filter.inputType !== 2 || item.id) {
fdata.selection = item; fdata.selection = item;
return { close: true, newValue: "" }; return { close: true, newValue: "" };
} else {
return { close: false, newValue: item.name ?? "" };
} }
return { close: false, newValue: item.name ?? "" };
}} }}
{onClose} padding={false}
onBackspace={onRemove}
partOfFilterbar partOfFilterbar
selection={fdata.selection?.id
? { id: fdata.selection?.id, name: fdata.selection?.name ?? "" }
: null}
/> />
{:else} {:else}
<!-- svelte-ignore a11y-autofocus --> <!-- svelte-ignore a11y-autofocus -->
<input <input
class="bg-transparent" class="bg-transparent"
type="text"
autofocus autofocus
type="text"
value={fdata.selection?.name ?? ""} value={fdata.selection?.name ?? ""}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === "Escape") onClose(true); if (e.key === "Escape") onClose(true);
@ -129,6 +132,6 @@ gap-1 pl-1"
aria-label={`Filter "${filterName}" entfernen"`} aria-label={`Filter "${filterName}" entfernen"`}
on:click={onRemove} on:click={onRemove}
> >
<Icon size={1} path={mdiClose} /> <Icon path={mdiClose} size={1} />
</button> </button>
</div> </div>

View file

@ -1,4 +1,3 @@
import { trpc } from "$lib/shared/trpc";
import { import {
mdiAccount, mdiAccount,
mdiAccountInjury, mdiAccountInjury,
@ -13,6 +12,9 @@ import {
mdiMagnify, mdiMagnify,
mdiTag, mdiTag,
} from "@mdi/js"; } from "@mdi/js";
import { trpc } from "$lib/shared/trpc";
import type { FilterDef } from "./types"; import type { FilterDef } from "./types";
export const ENTRY_FILTERS: { [key: string]: FilterDef } = { export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
@ -21,54 +23,42 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
name: "Kategorie", name: "Kategorie",
icon: mdiTag, icon: mdiTag,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().category.list.query(),
return await trpc().category.list.query();
},
}, },
author: { author: {
id: "author", id: "author",
name: "Autor", name: "Autor",
icon: mdiAccount, icon: mdiAccount,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().user.getNames.query(),
return await trpc().user.getNames.query();
},
}, },
executor: { executor: {
id: "executor", id: "executor",
name: "Erledigt von", name: "Erledigt von",
icon: mdiDoctor, icon: mdiDoctor,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().user.getNames.query(),
return await trpc().user.getNames.query();
},
}, },
patient: { patient: {
id: "patient", id: "patient",
name: "Patient", name: "Patient",
icon: mdiAccountInjury, icon: mdiAccountInjury,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().patient.getNames.query(),
return await trpc().patient.getNames.query();
},
}, },
station: { station: {
id: "station", id: "station",
name: "Station", name: "Station",
icon: mdiDomain, icon: mdiDomain,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().station.list.query(),
return await trpc().station.list.query();
},
}, },
room: { room: {
id: "room", id: "room",
name: "Zimmer", name: "Zimmer",
icon: mdiBedKingOutline, icon: mdiBedKingOutline,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().room.list.query(),
return await trpc().room.list.query();
},
}, },
done: { done: {
id: "done", id: "done",
@ -100,18 +90,14 @@ export const PATIENT_FILTER: { [key: string]: FilterDef } = {
name: "Station", name: "Station",
icon: mdiDomain, icon: mdiDomain,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().station.list.query(),
return await trpc().station.list.query();
},
}, },
room: { room: {
id: "room", id: "room",
name: "Zimmer", name: "Zimmer",
icon: mdiBedKingOutline, icon: mdiBedKingOutline,
inputType: 2, inputType: 2,
options: async () => { options: async () => trpc().room.list.query(),
return await trpc().room.list.query();
},
}, },
hidden: { hidden: {
id: "hidden", id: "hidden",

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { browser } from "$app/environment";
import { defaults, superForm } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import type { Category } from "$lib/shared/model";
import { ZCategoryNew } from "$lib/shared/model/validation";
import FormField from "$lib/components/ui/FormField.svelte";
import Header from "$lib/components/ui/Header.svelte";
const schema = zod(ZCategoryNew);
export let category: Category | null = null;
export let formData = defaults(schema);
const {
form, errors, constraints, enhance, tainted,
} = superForm(formData, {
validators: schema,
resetForm: category === null,
});
</script>
<Header backHref="/categories" title={category ? `Kategorie #${category.id}` : "Neue Kategorie"} />
<form class="flex flex-col gap-4" method="POST" use:enhance>
<FormField errors={$errors.name} label="Name">
<input
name="name"
aria-invalid={Boolean($errors.name)}
type="text"
bind:value={$form.name}
{...$constraints.name}
/>
</FormField>
<FormField errors={$errors.color} label="Farbe">
<input
name="color"
aria-invalid={Boolean($errors.color)}
type="color"
bind:value={$form.color}
{...$constraints.color}
/>
</FormField>
<FormField errors={$errors.description} label="Beschreibung">
<input
name="description"
aria-invalid={Boolean($errors.description)}
type="text"
bind:value={$form.description}
{...$constraints.description}
/>
</FormField>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary max-w-32"
disabled={browser && category && $tainted === undefined}
type="submit"
>
Speichern
</button>
<slot />
</div>
</form>

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import FormField from "$lib/components/ui/FormField.svelte"; import { browser } from "$app/environment";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZPatientNew } from "$lib/shared/model/validation"; import { ZPatientNew } from "$lib/shared/model/validation";
import { browser } from "$app/environment"; import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte";
const schema = zod(ZPatientNew); const schema = zod(ZPatientNew);
@ -15,8 +18,11 @@
let station = patient?.room?.station; let station = patient?.room?.station;
const { form, errors, constraints, enhance, tainted } = superForm(formData, { const {
form, errors, constraints, enhance, tainted,
} = superForm(formData, {
validators: schema, validators: schema,
resetForm: patient === null,
}); });
</script> </script>
@ -28,31 +34,31 @@
{/if} {/if}
</h1> </h1>
<form method="POST" class="flex flex-col gap-4" use:enhance> <form class="flex flex-col gap-4" method="POST" use:enhance>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<FormField label="Vorname" errors={$errors.first_name}> <FormField errors={$errors.first_name} label="Vorname">
<input <input
type="text"
name="first_name" name="first_name"
aria-invalid={Boolean($errors.first_name)} aria-invalid={Boolean($errors.first_name)}
type="text"
bind:value={$form.first_name} bind:value={$form.first_name}
{...$constraints.last_name} {...$constraints.last_name}
/> />
</FormField> </FormField>
<FormField label="Nachname" errors={$errors.last_name}> <FormField errors={$errors.last_name} label="Nachname">
<input <input
type="text"
name="last_name" name="last_name"
aria-invalid={Boolean($errors.last_name)} aria-invalid={Boolean($errors.last_name)}
type="text"
bind:value={$form.last_name} bind:value={$form.last_name}
{...$constraints.last_name} {...$constraints.last_name}
/> />
</FormField> </FormField>
<FormField label="Alter" errors={$errors.age}> <FormField errors={$errors.age} label="Alter">
<input <input
type="number"
name="age" name="age"
aria-invalid={Boolean($errors.age)} aria-invalid={Boolean($errors.age)}
type="number"
bind:value={$form.age} bind:value={$form.age}
{...$constraints.age} {...$constraints.age}
/> />
@ -60,13 +66,11 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<FormField label="Zimmer" errors={$errors.room_id}> <FormField errors={$errors.room_id} label="Zimmer">
<Autocomplete <Autocomplete
idInputName="room_id"
inputCls="input input-bordered w-full max-w-xs" inputCls="input input-bordered w-full max-w-xs"
items={async () => { items={async () => trpc().room.list.query()}
return await trpc().room.list.query();
}}
selection={patient?.room}
onSelect={(item) => { onSelect={(item) => {
station = item.station; station = item.station;
$form.room_id = item.id; $form.room_id = item.id;
@ -74,15 +78,15 @@
onUnselect={() => { onUnselect={() => {
$form.room_id = null; $form.room_id = null;
}} }}
idInputName="room_id" selection={patient?.room}
/> />
</FormField> </FormField>
<FormField label="Station"> <FormField label="Station">
<input <input
type="text"
class="input input-bordered w-full max-w-xs" class="input input-bordered w-full max-w-xs"
disabled disabled
type="text"
value={station?.name ?? ""} value={station?.name ?? ""}
/> />
</FormField> </FormField>
@ -91,8 +95,8 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
class="btn btn-primary max-w-32" class="btn btn-primary max-w-32"
disabled={browser && patient && $tainted === undefined}
type="submit" type="submit"
disabled={browser && $tainted === undefined}
> >
Speichern Speichern
</button> </button>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import { browser } from "$app/environment";
import { defaults, superForm } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { ZRoomNew } from "$lib/shared/model/validation";
import { trpc, type RouterOutput } from "$lib/shared/trpc";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import FormField from "$lib/components/ui/FormField.svelte";
import Header from "$lib/components/ui/Header.svelte";
const schema = zod(ZRoomNew);
export let room: RouterOutput["room"]["get"] | null = null;
export let formData = defaults(schema);
const {
form, errors, constraints, enhance, tainted,
} = superForm(formData, {
validators: schema,
resetForm: room === null,
});
</script>
<Header backHref="/rooms" title={room ? `Zimmer #${room.id}` : "Neues Zimmer"} />
<form class="flex flex-col gap-4" method="POST" use:enhance>
<FormField errors={$errors.name} label="Name">
<input
name="name"
aria-invalid={Boolean($errors.name)}
type="text"
bind:value={$form.name}
{...$constraints.name}
/>
</FormField>
<FormField errors={$errors.station_id} label="Station">
<Autocomplete
idInputName="station_id"
inputCls="input input-bordered w-full max-w-xs"
items={async () => trpc().station.list.query()}
onSelect={(item) => {
$form.station_id = item.id;
}}
onUnselect={() => {
$form.station_id = 0;
}}
selection={room?.station}
/>
</FormField>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary max-w-32"
disabled={browser && room && $tainted === undefined}
type="submit"
>
Speichern
</button>
<slot />
</div>
</form>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { browser } from "$app/environment";
import { defaults, superForm } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import type { Station } from "$lib/shared/model";
import { ZStationNew } from "$lib/shared/model/validation";
import FormField from "$lib/components/ui/FormField.svelte";
import Header from "$lib/components/ui/Header.svelte";
const schema = zod(ZStationNew);
export let station: Station | null = null;
export let formData = defaults(schema);
const {
form, errors, constraints, enhance, tainted,
} = superForm(formData, {
validators: schema,
resetForm: station === null,
});
</script>
<Header backHref="/stations" title={station ? `Station #${station.id}` : "Neue Station"} />
<form class="flex flex-col gap-4" method="POST" use:enhance>
<FormField errors={$errors.name} label="Name">
<input
name="name"
aria-invalid={Boolean($errors.name)}
type="text"
bind:value={$form.name}
{...$constraints.name}
/>
</FormField>
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary max-w-32"
disabled={browser && station && $tainted === undefined}
type="submit"
>
Speichern
</button>
<slot />
</div>
</form>

View file

@ -6,6 +6,7 @@
export let category: Category; export let category: Category;
export let baseUrl = URL_ENTRIES; export let baseUrl = URL_ENTRIES;
export let href: string | undefined = undefined;
$: textColor = category.color $: textColor = category.color
? colorToHex(getTextColor(hexToColor(category.color))) ? colorToHex(getTextColor(hexToColor(category.color)))
@ -18,17 +19,26 @@
category: [{ id: category.id, name: category.name }], category: [{ id: category.id, name: category.name }],
}, },
}, },
baseUrl baseUrl,
); );
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>
<button {#if href}
<a
style:color={category.color ? textColor : undefined}
style:background-color={category.color}
class="badge ellipsis"
class:badge-neutral={!category.color}
{href}>{category.name}</a
>
{:else}
<button
style:color={category.color ? textColor : undefined}
style:background-color={category.color}
class="badge ellipsis" class="badge ellipsis"
class:badge-neutral={!category.color} class:badge-neutral={!category.color}
style={category.color
? `color: ${textColor}; background-color: #${category.color};`
: undefined}
on:click={onClick}>{category.name}</button on:click={onClick}>{category.name}</button
> >
{/if}

View file

@ -5,12 +5,12 @@
import CategoryField from "./CategoryField.svelte"; import CategoryField from "./CategoryField.svelte";
import PatientField from "./PatientField.svelte"; import PatientField from "./PatientField.svelte";
import UserField from "./UserField.svelte";
import RoomField from "./RoomField.svelte"; import RoomField from "./RoomField.svelte";
import SortHeader from "./SortHeader.svelte"; import SortHeader from "./SortHeader.svelte";
import UserField from "./UserField.svelte";
export let entries: RouterOutput["entry"]["list"]; export let entries: RouterOutput["entry"]["list"];
export let sortData: SortRequest | undefined = undefined; export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let perPatient = false; export let perPatient = false;
export let baseUrl: string; export let baseUrl: string;
@ -20,22 +20,22 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} /> <SortHeader key="id" {sortData} {sortUpdate} title="ID" />
{#if !perPatient} {#if !perPatient}
<SortHeader title="Patient" key="patient" {sortData} {sortUpdate} /> <SortHeader key="patient" {sortData} {sortUpdate} title="Patient" />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} /> <SortHeader key="room" {sortData} {sortUpdate} title="Zimmer" />
{/if} {/if}
<SortHeader title="Kategorie" key="category" {sortData} {sortUpdate} /> <SortHeader key="category" {sortData} {sortUpdate} title="Kategorie" />
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} /> <SortHeader key="created_at" {sortData} {sortUpdate} title="Erstellt am" />
<SortHeader title="Aktualisiert am" key="updated_at" {sortData} {sortUpdate} /> <SortHeader key="updated_at" {sortData} {sortUpdate} title="Aktualisiert am" />
<SortHeader title="Zu erledigen am" key="date" {sortData} {sortUpdate} /> <SortHeader key="date" {sortData} {sortUpdate} title="Zu erledigen am" />
<SortHeader title="Autor" key="author" {sortData} {sortUpdate} /> <SortHeader key="author" {sortData} {sortUpdate} title="Autor" />
<SortHeader title="Beschreibung" key="version_text" {sortData} {sortUpdate} /> <SortHeader key="version_text" {sortData} {sortUpdate} title="Beschreibung" />
<SortHeader title="Erledigt" key="executed_at" {sortData} {sortUpdate} /> <SortHeader key="executed_at" {sortData} {sortUpdate} title="Erledigt" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each entries.items as entry} {#each entries.items as entry (entry.id)}
<tr <tr
class="transition-colors hover:bg-neutral-content/10" class="transition-colors hover:bg-neutral-content/10"
class:done={entry.execution} class:done={entry.execution}
@ -44,35 +44,35 @@
<td <td
><a ><a
class="btn btn-xs btn-primary" class="btn btn-xs btn-primary"
href="/entry/{entry.id}" aria-label="Eintrag anzeigen"
aria-label="Eintrag anzeigen">{entry.id}</a href="/entry/{entry.id}">{entry.id}</a
></td ></td
> >
{#if !perPatient} {#if !perPatient}
<td><PatientField patient={entry.patient} {baseUrl} /></td> <td><PatientField {baseUrl} patient={entry.patient} /></td>
<td> <td>
{#if entry.patient.room} {#if entry.patient.room}
<RoomField room={entry.patient.room} {baseUrl} /> <RoomField {baseUrl} room={entry.patient.room} />
{/if} {/if}
</td> </td>
{/if} {/if}
<td> <td>
{#if entry.current_version.category} {#if entry.current_version.category}
<CategoryField category={entry.current_version.category} {baseUrl} /> <CategoryField {baseUrl} category={entry.current_version.category} />
{/if} {/if}
</td> </td>
<td>{formatDate(entry.created_at, true)}</td> <td>{formatDate(entry.created_at, true)}</td>
<td>{formatDate(entry.current_version.created_at, true)}</td> <td>{formatDate(entry.current_version.created_at, true)}</td>
<td>{formatDate(entry.current_version.date)}</td> <td>{formatDate(entry.current_version.date)}</td>
<td><UserField user={entry.current_version.author} {baseUrl} /></td> <td><UserField {baseUrl} user={entry.current_version.author} /></td>
<td><span class="line-clamp-2">{entry.current_version.text}</span></td> <td><span class="line-clamp-2">{entry.current_version.text}</span></td>
<td> <td>
{#if entry.execution} {#if entry.execution}
{formatDate(entry.execution.created_at, true)} {formatDate(entry.execution.created_at, true)}
<UserField <UserField
user={entry.execution.author}
filterName="executor"
{baseUrl} {baseUrl}
filterName="executor"
user={entry.execution.author}
/> />
{/if} {/if}
</td> </td>

View file

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import EntryTable from "$lib/components/table/EntryTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types";
import { type RouterOutput } from "$lib/shared/trpc";
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util";
import type { ZEntriesQuery } from "$lib/shared/model/validation";
import { z } from "zod";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { z } from "zod";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { ZEntriesQuery } from "$lib/shared/model/validation";
import { type RouterOutput } from "$lib/shared/trpc";
import { getQueryUrl } from "$lib/shared/util";
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import { ENTRY_FILTERS } from "$lib/components/filter/filters";
import type { FilterQdata } from "$lib/components/filter/types";
import EntryTable from "$lib/components/table/EntryTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
export let query: z.infer<typeof ZEntriesQuery>; export let query: z.infer<typeof ZEntriesQuery>;
export let entries: RouterOutput["entry"]["list"]; export let entries: RouterOutput["entry"]["list"];
export let baseUrl: string; export let baseUrl: string;
@ -50,23 +54,23 @@
<FilterBar <FilterBar
FILTERS={ENTRY_FILTERS} FILTERS={ENTRY_FILTERS}
hiddenFilters={patientId !== null ? ["patient"] : []}
filterData={query.filter} filterData={query.filter}
hiddenFilters={patientId !== null ? ["patient"] : []}
onUpdate={filterUpdate} onUpdate={filterUpdate}
> >
<slot /> <slot />
</FilterBar> </FilterBar>
<EntryTable <EntryTable
{baseUrl}
{entries} {entries}
perPatient={patientId !== null}
sortData={query.sort} sortData={query.sort}
{sortUpdate} {sortUpdate}
perPatient={patientId !== null}
{baseUrl}
/> />
<PaginationButtons <PaginationButtons
paginationData={query.pagination}
data={entries} data={entries}
onUpdate={paginationUpdate} onUpdate={paginationUpdate}
paginationData={query.pagination}
/> />

View file

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import PatientTable from "$lib/components/table/PatientTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { FilterQdata } from "$lib/components/filter/types";
import { type RouterOutput } from "$lib/shared/trpc";
import { PATIENT_FILTER } from "$lib/components/filter/filters";
import { getQueryUrl } from "$lib/shared/util";
import type { ZPatientsQuery } from "$lib/shared/model/validation";
import { z } from "zod";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { z } from "zod";
import type { PaginationRequest, SortRequest } from "$lib/shared/model";
import type { ZPatientsQuery } from "$lib/shared/model/validation";
import { type RouterOutput } from "$lib/shared/trpc";
import { getQueryUrl } from "$lib/shared/util";
import FilterBar from "$lib/components/filter/FilterBar.svelte";
import { PATIENT_FILTER } from "$lib/components/filter/filters";
import type { FilterQdata } from "$lib/components/filter/types";
import PatientTable from "$lib/components/table/PatientTable.svelte";
import PaginationButtons from "$lib/components/ui/PaginationButtons.svelte";
export let query: z.infer<typeof ZPatientsQuery>; export let query: z.infer<typeof ZPatientsQuery>;
export let patients: RouterOutput["patient"]["list"]; export let patients: RouterOutput["patient"]["list"];
export let baseUrl: string; export let baseUrl: string;
@ -54,10 +58,10 @@
<slot /> <slot />
</FilterBar> </FilterBar>
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} /> <PatientTable {baseUrl} {patients} sortData={query.sort} {sortUpdate} />
<PaginationButtons <PaginationButtons
paginationData={query.pagination}
data={patients} data={patients}
onUpdate={paginationUpdate} onUpdate={paginationUpdate}
paginationData={query.pagination}
/> />

View file

@ -14,12 +14,14 @@
], ],
}, },
}, },
baseUrl baseUrl,
); );
e.stopPropagation(); e.stopPropagation();
} }
</script> </script>
<button class="ellipsis" on:click={onClick} <button
class="ellipsis"
on:click={onClick}
>{`${patient.first_name} ${patient.last_name}`}</button >{`${patient.first_name} ${patient.last_name}`}</button
> >

View file

@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import { mdiClose, mdiFilter } from "@mdi/js";
import { URL_ENTRIES } from "$lib/shared/constants";
import type { SortRequest } from "$lib/shared/model"; import type { SortRequest } from "$lib/shared/model";
import type { RouterOutput } from "$lib/shared/trpc"; import type { RouterOutput } from "$lib/shared/trpc";
import { formatDate, gotoEntityQuery } from "$lib/shared/util"; import { formatDate, gotoEntityQuery } from "$lib/shared/util";
import { mdiClose, mdiFilter } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import RoomField from "./RoomField.svelte"; import RoomField from "./RoomField.svelte";
import SortHeader from "./SortHeader.svelte"; import SortHeader from "./SortHeader.svelte";
import { URL_ENTRIES } from "$lib/shared/constants";
export let patients: RouterOutput["patient"]["list"]; export let patients: RouterOutput["patient"]["list"];
export let sortData: SortRequest | undefined = undefined; export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
export let baseUrl: string; export let baseUrl: string;
</script> </script>
@ -19,17 +21,17 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<SortHeader title="ID" key="id" {sortData} {sortUpdate} /> <SortHeader key="id" {sortData} {sortUpdate} title="ID" />
<SortHeader title="Name" key="name" {sortData} {sortUpdate} /> <SortHeader key="name" {sortData} {sortUpdate} title="Name" />
<SortHeader title="Alter" key="age" {sortData} {sortUpdate} /> <SortHeader key="age" {sortData} {sortUpdate} title="Alter" />
<SortHeader title="Zimmer" key="room" {sortData} {sortUpdate} /> <SortHeader key="room" {sortData} {sortUpdate} title="Zimmer" />
<SortHeader title="Erstellt am" key="created_at" {sortData} {sortUpdate} /> <SortHeader key="created_at" {sortData} {sortUpdate} title="Erstellt am" />
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each patients.items as patient} {#each patients.items as patient (patient.id)}
{@const full_name = patient.first_name + " " + patient.last_name} {@const full_name = `${patient.first_name} ${patient.last_name}`}
<tr <tr
class="transition-colors hover:bg-neutral-content/10" class="transition-colors hover:bg-neutral-content/10"
class:p-hidden={patient.hidden} class:p-hidden={patient.hidden}
@ -37,15 +39,15 @@
<td <td
><a ><a
class="btn btn-xs btn-primary" class="btn btn-xs btn-primary"
href="/patient/{patient.id}" aria-label="Eintrag anzeigen"
aria-label="Eintrag anzeigen">{patient.id}</a href="/patient/{patient.id}">{patient.id}</a
></td ></td
> >
<td>{full_name}</td> <td>{full_name}</td>
<td>{patient.age ?? ""}</td> <td>{patient.age ?? ""}</td>
<td> <td>
{#if patient.room} {#if patient.room}
<RoomField room={patient.room} {baseUrl} /> <RoomField {baseUrl} room={patient.room} />
{/if} {/if}
</td> </td>
<td>{formatDate(patient.created_at, true)}</td> <td>{formatDate(patient.created_at, true)}</td>
@ -55,14 +57,14 @@
on:click={() => { on:click={() => {
gotoEntityQuery( gotoEntityQuery(
{ filter: { patient: [{ id: patient.id, name: full_name }] } }, { filter: { patient: [{ id: patient.id, name: full_name }] } },
URL_ENTRIES URL_ENTRIES,
); );
}} }}
> >
<Icon size={1.2} path={mdiFilter} /> <Icon path={mdiFilter} size={1.2} />
</button> </button>
<button class="btn btn-circle btn-ghost btn-xs inline"> <button class="btn btn-circle btn-ghost btn-xs inline">
<Icon size={1.2} path={mdiClose} /> <Icon path={mdiClose} size={1.2} />
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -13,7 +13,7 @@
room: [{ id: room.id, name: room.name }], room: [{ id: room.id, name: room.name }],
}, },
}, },
baseUrl baseUrl,
); );
e.stopPropagation(); e.stopPropagation();
} }

View file

@ -1,15 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { SortRequest } from "$lib/shared/model";
import Icon from "$lib/components/ui/Icon.svelte";
import { mdiSortAscending, mdiSortDescending } from "@mdi/js"; import { mdiSortAscending, mdiSortDescending } from "@mdi/js";
import type { SortRequest } from "$lib/shared/model";
import Icon from "$lib/components/ui/Icon.svelte";
export let key: string; export let key: string;
export let title: string; export let title: string;
export let sortData: SortRequest | undefined = undefined; export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {}; export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
// 1: asc, 2: desc, 0: not sorted // 1: asc, 2: desc, 0: not sorted
$: sorting = sortData?.field === key ? (sortData.asc !== false ? 1 : 2) : 0; let sorting = 0;
$: if (sortData?.field === key) {
sorting = sortData.asc !== false ? 1 : 2;
} else {
sorting = 0;
}
function onClick() { function onClick() {
if (sorting === 2) { if (sorting === 2) {
@ -23,7 +30,7 @@
<th> <th>
<button class:text-primary={sorting > 0} on:click={onClick}> <button class:text-primary={sorting > 0} on:click={onClick}>
{#if sorting > 0} {#if sorting > 0}
<Icon size={1} path={sorting === 1 ? mdiSortAscending : mdiSortDescending} /> <Icon path={sorting === 1 ? mdiSortAscending : mdiSortDescending} size={1} />
{/if} {/if}
{title} {title}
</button> </button>

View file

@ -8,7 +8,7 @@
export let filterName: string = "author"; export let filterName: string = "author";
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
let 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 ?? "" }];
gotoEntityQuery(query, baseUrl); gotoEntityQuery(query, baseUrl);

View file

@ -7,5 +7,5 @@
<label class="form-control w-full max-w-xs"> <label class="form-control w-full max-w-xs">
<div class="label">{label}</div> <div class="label">{label}</div>
<input {name} {type} class="input input-bordered w-full max-w-xs" {value} /> <input {name} class="input input-bordered w-full max-w-xs" {type} {value} />
</label> </label>

View file

@ -2,7 +2,7 @@
import type { TRPCErrorResponse } from "$lib/shared/model"; import type { TRPCErrorResponse } from "$lib/shared/model";
export let error: TRPCErrorResponse; export let error: TRPCErrorResponse;
export let status: number | undefined = undefined; export let status: number | undefined;
export let withBtn = false; export let withBtn = false;
$: statusCode = status ?? error.data?.httpStatus; $: statusCode = status ?? error.data?.httpStatus;

View file

@ -10,7 +10,7 @@
<div class="flex flex-row"> <div class="flex flex-row">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#if backHref} {#if backHref}
<a href={backHref} class="btn btn-sm btn-circle btn-ghost"> <a class="btn btn-sm btn-circle btn-ghost" href={backHref}>
<Icon path={mdiChevronLeft} size={1.8} /> <Icon path={mdiChevronLeft} size={1.8} />
</a> </a>
{/if} {/if}

View file

@ -15,10 +15,14 @@
</script> </script>
<svg <svg
viewBox="0 0 24 24" style:--color={color}
style="--color: {color}; --size: {size}rem; --rotate: {rotate}deg; --scaleV: {scaleV}; --scaleH: style:--size="{size}rem"
{scaleH}; --spin-duration: {absSpin}s;" style:--scaleV={scaleV}
style:--spin-duration="{absSpin}s"
style:--scaleH={scaleH}
style:--rotate="{rotate}deg"
class={cls} class={cls}
viewBox="0 0 24 24"
> >
{#if title}<title>{title}</title>{/if} {#if title}<title>{title}</title>{/if}
@ -27,7 +31,7 @@
</g> </g>
</svg> </svg>
<style> <style lang="postcss">
svg { svg {
display: inline; display: inline;
vertical-align: middle; vertical-align: middle;
@ -36,12 +40,14 @@
height: var(--size); height: var(--size);
transform: rotate(var(--rotate)) scale(var(--scaleV), var(--scaleH)); transform: rotate(var(--rotate)) scale(var(--scaleV), var(--scaleH));
} }
/* If spin is strictly > 0, class spin is added to g and plays {spinFrames} in {spin-duration} seconds */ /* If spin is strictly > 0,
class spin is added to g and plays {spinFrames} in {spin-duration} seconds */
g.spin { g.spin {
transform-origin: center; transform-origin: center;
animation: spinFrames linear var(--spin-duration) infinite; animation: spinFrames linear var(--spin-duration) infinite;
} }
/* If spin is strictly < 0, class spinReverse is added to g and plays {spinReverseFrames} in {spin-duration} seconds */ /* If spin is strictly < 0,
class spinReverse is added to g and plays {spinReverseFrames} in {spin-duration} seconds */
g.spinReverse { g.spinReverse {
transform-origin: center; transform-origin: center;
animation: spinReverseFrames linear var(--spin-duration) infinite; animation: spinReverseFrames linear var(--spin-duration) infinite;

View file

@ -12,7 +12,8 @@
showProgress = true; showProgress = true;
showError = false; showError = false;
navInterval = setInterval(() => { navInterval = setInterval(() => {
navprogress += navprogress <= 90 ? 2.5 : navprogress < 95 ? 0.1 : 0; if (navprogress <= 90) navprogress += 2.5;
else if (navprogress < 95) navprogress += 0.1;
}, 500); }, 500);
} }
@ -36,8 +37,8 @@
</script> </script>
<div <div
class={cls}
style:width={`${navprogress}%`} style:width={`${navprogress}%`}
class={cls}
class:active={alwaysShown || showProgress} class:active={alwaysShown || showProgress}
class:error={showError} class:error={showError}
/> />

View file

@ -1,13 +0,0 @@
<script lang="ts">
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
export let src: string;
$: doc = DOMPurify.sanitize(marked.parse(src) as string, { FORBID_TAGS: ["img"] });
</script>
<p class="prose">
<!--eslint-disable-next-line svelte/no-at-html-tags-->
{@html doc}
</p>

View file

@ -5,7 +5,8 @@
<div> <div>
<a <a
class="btn btn-sm btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4" class="btn btn-sm btn-ghost drawer-button font-normal
decoration-primary decoration-4 underline-offset-4"
class:underline={active} class:underline={active}
{href}><slot /></a {href}><slot /></a
> >

View file

@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { mdiChevronLeft, mdiChevronRight, mdiPageFirst, mdiPageLast } from "@mdi/js"; import {
mdiChevronLeft, mdiChevronRight, mdiPageFirst, mdiPageLast,
} from "@mdi/js";
import type { Pagination, PaginationRequest } from "$lib/shared/model";
import { PAGINATION_LIMIT } from "$lib/shared/constants"; import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { Pagination, PaginationRequest } from "$lib/shared/model";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
export let paginationData: PaginationRequest | null | undefined = undefined; export let paginationData: PaginationRequest | null | undefined;
export let data: Pagination<unknown>; export let data: Pagination<unknown>;
export let onUpdate: (pagination: PaginationRequest) => void = () => {}; export let onUpdate: (pagination: PaginationRequest) => void = () => {};
@ -31,13 +34,13 @@
function getPaginationRequest(page: number): PaginationRequest | null { function getPaginationRequest(page: number): PaginationRequest | null {
if (page < 1 || page > nPages) return null; if (page < 1 || page > nPages) return null;
let pag = paginationData ? structuredClone(paginationData) : {}; const pag = paginationData ? structuredClone(paginationData) : {};
pag.offset = (page - 1) * limit; pag.offset = (page - 1) * limit;
return pag; return pag;
} }
function pagClick(page: number) { function pagClick(page: number) {
let pag = getPaginationRequest(page); const pag = getPaginationRequest(page);
if (pag) onUpdate(pag); if (pag) onUpdate(pag);
} }
</script> </script>
@ -51,7 +54,9 @@
<button class="join-item btn btn-sm" on:click={() => pagClick(1)}> <button class="join-item btn btn-sm" on:click={() => pagClick(1)}>
<Icon path={mdiPageFirst} /> <Icon path={mdiPageFirst} />
</button> </button>
<button class="join-item btn btn-sm" on:click={() => pagClick(thisPage - 1)} <button
class="join-item btn btn-sm"
on:click={() => pagClick(thisPage - 1)}
><Icon path={mdiChevronLeft} /></button ><Icon path={mdiChevronLeft} /></button
> >
{#each { length: windowTop + 1 - windowBottom } as _, i} {#each { length: windowTop + 1 - windowBottom } as _, i}
@ -62,10 +67,14 @@
on:click={() => pagClick(n)}>{n}</button on:click={() => pagClick(n)}>{n}</button
> >
{/each} {/each}
<button class="join-item btn btn-sm" on:click={() => pagClick(thisPage + 1)} <button
class="join-item btn btn-sm"
on:click={() => pagClick(thisPage + 1)}
><Icon path={mdiChevronRight} /></button ><Icon path={mdiChevronRight} /></button
> >
<button class="join-item btn btn-sm" on:click={() => pagClick(nPages)} <button
class="join-item btn btn-sm"
on:click={() => pagClick(nPages)}
><Icon path={mdiPageLast} /></button ><Icon path={mdiPageLast} /></button
> >
</div> </div>

View file

@ -1,6 +1,7 @@
<!-- Button showing the amount of entry versions --> <!-- Button showing the amount of entry versions -->
<script lang="ts"> <script lang="ts">
import { mdiHistory } from "@mdi/js"; import { mdiHistory } from "@mdi/js";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
export let n: number; export let n: number;
@ -8,7 +9,7 @@
</script> </script>
{#if n > 1} {#if n > 1}
<a {href} class="btn btn-xs btn-primary rounded-full"> <a class="btn btn-xs btn-primary rounded-full" {href}>
<Icon path={mdiHistory} size={1.2} /> <Icon path={mdiHistory} size={1.2} />
<span>{n}</span> <span>{n}</span>
</a> </a>

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { Carta, Markdown } from "carta-md";
import { CARTA_CFG } from "./carta";
export let src: string;
const carta = new Carta(CARTA_CFG);
</script>
<Markdown {carta} value={src} />

View file

@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import Markdown from "./Markdown.svelte"; import "./carta.pcss";
import { Carta, MarkdownEditor, type Labels } from "carta-md";
import type { InputConstraint } from "sveltekit-superforms"; import type { InputConstraint } from "sveltekit-superforms";
import { CARTA_CFG } from "./carta";
export let label = ""; export let label = "";
export let name = ""; export let name = "";
export let value = ""; export let value = "";
@ -10,32 +14,34 @@
export let constraints: InputConstraint | undefined = undefined; export let constraints: InputConstraint | undefined = undefined;
export let marginTop = false; export let marginTop = false;
let editMode = true; const carta = new Carta(CARTA_CFG);
function toggle() { const LABELS: Labels = {
editMode = !editMode; writeTab: "Bearbeiten",
} previewTab: "Vorschau",
iconsLabels: {
heading: "Titel",
bold: "Fett",
italic: "Kursiv",
strikethrough: "Durchgestrichen",
quote: "Zitat",
bulletedList: "Aufzählung",
numberedList: "Nummerierung",
},
};
</script> </script>
<div class="card2" class:mt-4={marginTop}> <div class="card2" class:mt-4={marginTop}>
<div class="row c-light text-sm items-center justify-between"> <div class="row c-light text-sm items-center justify-between">
<span>{label}</span> <span>{label}</span>
<button type="button" class="label-text" on:click={toggle} tabindex="-1">
{editMode ? "Vorschau" : "Bearbeiten"}
</button>
</div> </div>
<div class="p-2"> <div class="p-2">
{#if editMode} <MarkdownEditor
<textarea {carta}
class="textarea w-full h-48" labels={LABELS}
{name} mode="tabs"
aria-invalid={ariaInvalid} textarea={{ name, "aria-invalid": ariaInvalid, ...constraints }}
bind:value bind:value />
{...constraints}
/>
{:else}
<Markdown src={value} />
{/if}
{#if errors} {#if errors}
<div class="label flex-col items-start"> <div class="label flex-col items-start">

View file

@ -0,0 +1,123 @@
.carta-theme__default.carta-editor {
@apply border border-solid border-base-content/30 rounded-xl;
}
/* Box sizings */
.carta-theme__default .carta-toolbar {
@apply border-b border-solid border-base-content/30;
}
.carta-theme__default .carta-wrapper {
@apply px-4;
}
.carta-theme__default .carta-container > * {
@apply my-4;
}
/* Text settings */
.carta-theme__default .carta-input {
@apply caret-base-content text-base;
}
.carta-theme__default .carta-input ::placeholder {
@apply text-base-content;
}
/* Splitter */
.carta-theme__default .mode-split.carta-container::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
@apply bg-base-content/30;
}
.carta-theme__default .mode-split .carta-input {
@apply pr-4;
}
.carta-theme__default .mode-split .carta-renderer {
@apply pl-4;
}
/* Toolbar */
.carta-theme__default .carta-toolbar {
@apply px-3;
}
.carta-theme__default .carta-toolbar-left {
@apply flex items-end;
}
.carta-theme__default button {
@apply text-base-content;
}
/* Markdown input and renderer */
.carta-theme__default .carta-input,
.carta-theme__default .carta-renderer {
height: 300px;
overflow-y: scroll;
}
/* Icons */
.carta-theme__default .carta-icon,
.carta-theme__default .carta-icon-full {
border: 0;
background: transparent;
@apply btn-transition;
}
.carta-theme__default .carta-icon-full {
padding: 6px 4px;
}
.carta-theme__default .carta-icon-full span {
margin-left: 6px;
}
.carta-theme__default .carta-icon:hover,
.carta-theme__default .carta-icon-full:hover {
@apply bg-base-content/20;
}
.carta-input > pre {
background: inherit;
}
.carta-theme__default .carta-icons-menu {
@apply border border-solid border-base-content/30;
padding: 6px;
border-radius: 6px;
min-width: 180px;
}
.carta-theme__default .carta-icons-menu .carta-icon-full {
margin-top: 2px;
}
.carta-theme__default .carta-icons-menu .carta-icon-full:first-child {
margin-top: 0;
}
/* Buttons */
.carta-theme__default .carta-toolbar-left button {
background: none;
border: none;
font-size: 0.9rem;
padding-bottom: 4px;
border-bottom: 2px solid transparent;
margin-right: 12px;
cursor: pointer;
}
.carta-theme__default .carta-toolbar-left button:last-child {
margin-right: 0;
}
.carta-theme__default .carta-toolbar-left button.carta-active {
@apply font-semibold border-primary;
}

View file

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

View file

@ -7,12 +7,13 @@ 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 { prisma } from "$lib/server/prisma";
import { PrismaAdapter } from "$lib/server/authAdapter";
import { env } from "$env/dynamic/private";
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";
import { env } from "$env/dynamic/private";
import { PrismaAdapter } from "$lib/server/authAdapter";
import { prisma } from "$lib/server/prisma";
const AUTH_BASE_PATH: string = "/auth"; const AUTH_BASE_PATH: string = "/auth";
export const AUTH_CFG: AuthConfig = { export const AUTH_CFG: AuthConfig = {
@ -61,14 +62,14 @@ ISC License
*/ */
function authjsUrl(event: RequestEvent, authjsEndpoint: string): string { function authjsUrl(event: RequestEvent, authjsEndpoint: string): string {
const url = event.url; const { url } = event;
return url.protocol + "//" + url.host + AUTH_BASE_PATH + "/" + authjsEndpoint; return `${url.protocol}//${url.host}${AUTH_BASE_PATH}/${authjsEndpoint}`;
} }
export async function makeAuthjsRequest( export async function makeAuthjsRequest(
event: RequestEvent, event: RequestEvent,
authjsEndpoint: string, authjsEndpoint: string,
params: Record<string, string> params: Record<string, string>,
) { ) {
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");
@ -112,12 +113,12 @@ export const skAuthHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event; const { url, request } = event;
if ( if (
url.pathname.startsWith(AUTH_BASE_PATH + "/") && url.pathname.startsWith(`${AUTH_BASE_PATH}/`)
isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0]) && isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
) { ) {
return Auth(request, AUTH_CFG); return Auth(request, AUTH_CFG);
} else {
event.locals.session = await auth(event);
} }
event.locals.session = await auth(event);
return resolve(event); return resolve(event);
}; };

View file

@ -33,19 +33,16 @@ function mapAccount(account: Account): AdapterAccount {
export function PrismaAdapter(p: PrismaClient): Adapter { export function PrismaAdapter(p: PrismaClient): Adapter {
return { return {
createUser: async (data) => createUser: async (data) => mapUser(
mapUser(
await p.user.create({ await p.user.create({
data: { data: {
name: data.name, name: data.name,
email: data.email, email: data.email,
}, },
}) }),
), ),
getUser: async (id) => getUser: async (id) => mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })),
mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })), getUserByEmail: async (email) => mapUserOpt(await p.user.findUnique({ where: { email } })),
getUserByEmail: async (email) =>
mapUserOpt(await p.user.findUnique({ where: { email } })),
async getUserByAccount(provider_providerAccountId) { async getUserByAccount(provider_providerAccountId) {
const account = await p.account.findUnique({ const account = await p.account.findUnique({
where: { provider_providerAccountId }, where: { provider_providerAccountId },
@ -53,12 +50,14 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
}); });
return mapUserOpt(account?.user) ?? null; return mapUserOpt(account?.user) ?? null;
}, },
updateUser: async ({ id, ...data }) => updateUser: async ({ id, ...data }) => mapUser(
mapUser(await p.user.update({ where: { id: Number(id) }, data })), await p.user.update({
deleteUser: async (id) => where: { id: Number(id) },
mapUser(await p.user.delete({ where: { id: Number(id) } })), data,
linkAccount: async (data) => }),
mapAccount( ),
deleteUser: async (id) => mapUser(await p.user.delete({ where: { id: Number(id) } })),
linkAccount: async (data) => mapAccount(
await p.account.create({ await p.account.create({
data: { data: {
user_id: Number(data.userId), user_id: Number(data.userId),
@ -72,10 +71,9 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
scope: data.scope, scope: data.scope,
id_token: data.id_token, id_token: data.id_token,
}, },
}) }),
), ),
unlinkAccount: (provider_providerAccountId) => unlinkAccount: (provider_providerAccountId) => p.account.delete({
p.account.delete({
where: { provider_providerAccountId }, where: { provider_providerAccountId },
}) as unknown as AdapterAccount, }) as unknown as AdapterAccount,
}; };

View file

@ -2,9 +2,8 @@ import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient }; const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = export const prisma = globalForPrisma.prisma
globalForPrisma.prisma || || new PrismaClient({
new PrismaClient({
// log: ["query"], // log: ["query"],
}); });

View file

@ -1,4 +1,5 @@
import type { Category, CategoryNew } from "$lib/shared/model"; import type { Category, CategoryNew } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
export async function newCategory(category: CategoryNew): Promise<number> { export async function newCategory(category: CategoryNew): Promise<number> {

View file

@ -1,4 +1,3 @@
import { prisma } from "$lib/server/prisma";
import type { import type {
EntriesFilter, EntriesFilter,
Entry, Entry,
@ -13,6 +12,9 @@ import type {
} from "$lib/shared/model"; } from "$lib/shared/model";
import { dateToYMD } from "$lib/shared/util"; import { dateToYMD } from "$lib/shared/util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error"; import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma";
import { mapEntry, mapVersion, mapExecution } from "./mapping"; import { mapEntry, mapVersion, mapExecution } from "./mapping";
import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util"; import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util";
@ -86,7 +88,7 @@ export async function newEntryVersion(
author_id: number, author_id: number,
entry_id: number, entry_id: number,
version: EntryVersionNew, version: EntryVersionNew,
old_version_id: number | undefined = undefined old_version_id: number | undefined = undefined,
): Promise<number> { ): Promise<number> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const entry = await tx.entry.findUniqueOrThrow({ const entry = await tx.entry.findUniqueOrThrow({
@ -117,10 +119,10 @@ export async function newEntryVersion(
// Check if there are any updates // Check if there are any updates
if ( if (
cver?.text === updatedVersion.text && cver?.text === updatedVersion.text
cver?.date.getTime() === updatedVersion.date.getTime() && && cver?.date.getTime() === updatedVersion.date.getTime()
cver?.category_id === updatedVersion.category_id && && cver?.category_id === updatedVersion.category_id
cver?.priority === updatedVersion.priority && cver?.priority === updatedVersion.priority
) { ) {
return cver.id; return cver.id;
} }
@ -137,7 +139,7 @@ export async function newEntryExecution(
author_id: number, author_id: number,
entry_id: number, entry_id: number,
execution: EntryExecutionNew, execution: EntryExecutionNew,
old_execution_id: number | null | undefined = undefined old_execution_id: number | null | undefined = undefined,
): Promise<number> { ): Promise<number> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const entry = await tx.entry.findUniqueOrThrow({ const entry = await tx.entry.findUniqueOrThrow({
@ -153,8 +155,8 @@ export async function newEntryExecution(
// Check if the execution has been updated by someone else // Check if the execution has been updated by someone else
if ( if (
(old_execution_id && (!cex || cex.id !== old_execution_id)) || (old_execution_id && (!cex || cex.id !== old_execution_id))
(old_execution_id === null && cex) || (old_execution_id === null && cex)
) { ) {
throw new ErrorConflict("old execution id does not match"); throw new ErrorConflict("old execution id does not match");
} }
@ -179,7 +181,7 @@ export async function newEntryExecution(
export async function getEntries( export async function getEntries(
filter: EntriesFilter = {}, filter: EntriesFilter = {},
pagination: PaginationRequest = {}, pagination: PaginationRequest = {},
sort: SortRequest = { field: "created_at", asc: false } sort: SortRequest = { field: "created_at", asc: false },
): Promise<Pagination<Entry>> { ): Promise<Pagination<Entry>> {
const qb = new QueryBuilder( const qb = new QueryBuilder(
`select `select
@ -240,14 +242,14 @@ left join entry_executions ex on
left join users xau on xau.id=ex.author_id left join users xau on xau.id=ex.author_id
join patients p on p.id = e.patient_id join patients p on p.id = e.patient_id
left join rooms r on r.id = p.room_id left join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id` left join stations s on s.id = r.station_id`,
); );
if (filter?.search && filter.search.length > 0) { if (filter?.search && filter.search.length > 0) {
const query = parseSearchQuery(filter.search); const query = parseSearchQuery(filter.search);
qb.addFilterClause( qb.addFilterClause(
`to_tsquery('german', ${qb.pvar()}) @@ e.tsvec`, `to_tsquery('german', ${qb.pvar()}) @@ e.tsvec`,
query.toTsquery() query.toTsquery(),
); );
} }
@ -268,7 +270,7 @@ left join stations s on s.id = r.station_id`
const author = filterListToArray(filter?.author); const author = filterListToArray(filter?.author);
qb.addFilterClause( qb.addFilterClause(
`(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`, `(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
author author,
); );
} }
@ -330,8 +332,7 @@ left join stations s on s.id = r.station_id`
])) as [RowItem[], { count: bigint }[]]; ])) as [RowItem[], { count: bigint }[]];
const total = Number(countRes[0].count); const total = Number(countRes[0].count);
const items: Entry[] = res.map((item) => { const items: Entry[] = res.map((item) => ({
return {
id: item.id, id: item.id,
patient: { patient: {
id: item.patient_id, id: item.patient_id,
@ -373,8 +374,7 @@ left join stations s on s.id = r.station_id`
created_at: item.execution_created_at, created_at: item.execution_created_at,
} }
: null, : null,
}; }));
});
return { items, offset: qb.getOffset(), total }; return { items, offset: qb.getOffset(), total };
} }

View file

@ -1,3 +1,14 @@
import type {
Patient as DbPatient,
Room as DbRoom,
Station as DbStation,
User as DbUser,
Entry as DbEntry,
EntryVersion as DbEntryVersion,
EntryExecution as DbEntryExecution,
Category as DbCategory,
} from "@prisma/client";
import type { import type {
Entry, Entry,
Patient, Patient,
@ -10,16 +21,6 @@ import type {
} from "$lib/shared/model"; } from "$lib/shared/model";
import { dateToYMD } from "$lib/shared/util"; import { dateToYMD } from "$lib/shared/util";
import { ErrorNotFound } from "$lib/shared/util/error"; import { ErrorNotFound } from "$lib/shared/util/error";
import type {
Patient as DbPatient,
Room as DbRoom,
Station as DbStation,
User as DbUser,
Entry as DbEntry,
EntryVersion as DbEntryVersion,
EntryExecution as DbEntryExecution,
Category as DbCategory,
} from "@prisma/client";
type DbRoomLn = DbRoom & { station: DbStation }; type DbRoomLn = DbRoom & { station: DbStation };
type DbPatientLn = DbPatient & { room: DbRoomLn | null }; type DbPatientLn = DbPatient & { room: DbRoomLn | null };

View file

@ -1,3 +1,5 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { import type {
Patient, Patient,
PatientNew, PatientNew,
@ -7,11 +9,12 @@ import type {
PatientTag, PatientTag,
SortRequest, SortRequest,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { mapPatient } from "./mapping"; import { mapPatient } from "./mapping";
import { QueryBuilder } from "./util"; import { QueryBuilder } from "./util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
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 } });
@ -64,13 +67,11 @@ export async function getPatientNames(): Promise<PatientTag[]> {
where: { hidden: false }, where: { hidden: false },
orderBy: { last_name: "asc" }, orderBy: { last_name: "asc" },
}); });
return patients.map((p) => { return patients.map((p) => ({
return {
id: p.id, id: p.id,
name: p.first_name + " " + p.last_name, name: `${p.first_name} ${p.last_name}`,
room_id: p.room_id, room_id: p.room_id,
}; }));
});
} }
export async function getPatientNEntries(id: number): Promise<number> { export async function getPatientNEntries(id: number): Promise<number> {
@ -80,21 +81,21 @@ export async function getPatientNEntries(id: number): Promise<number> {
export async function getPatients( export async function getPatients(
filter: PatientsFilter = {}, filter: PatientsFilter = {},
pagination: PaginationRequest = {}, pagination: PaginationRequest = {},
sort: SortRequest = { field: "created_at", asc: false } sort: SortRequest = { field: "created_at", asc: false },
): Promise<Pagination<Patient>> { ): Promise<Pagination<Patient>> {
const qb = new QueryBuilder( const qb = new QueryBuilder(
`select p.id, p.first_name, p.last_name, p.created_at, p.age, p.hidden, `select p.id, p.first_name, p.last_name, p.created_at, p.age, p.hidden,
r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`, r.id as room_id, r.name as room_name, s.id as station_id, s.name as station_name`,
`from patients p `from patients p
left join rooms r on r.id = p.room_id left join rooms r on r.id = p.room_id
left join stations s on s.id = r.station_id` left join stations s on s.id = r.station_id`,
); );
if (filter.search && filter.search.length > 0) { if (filter.search && filter.search.length > 0) {
const qvar = qb.pvar(); const qvar = qb.pvar();
qb.addFilterClause( qb.addFilterClause(
`(p.full_name % ${qvar} or p.full_name ilike '%' || ${qvar} || '%')`, `(p.full_name % ${qvar} or p.full_name ilike '%' || ${qvar} || '%')`,
filter.search filter.search,
); );
qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`); qb.addOrderClause(`similarity(p.full_name, ${qvar}) desc`);
} }
@ -144,8 +145,7 @@ export async function getPatients(
])) as [RowItem[], { count: bigint }[]]; ])) as [RowItem[], { count: bigint }[]];
const total = Number(countRes[0].count); const total = Number(countRes[0].count);
const items: Patient[] = res.map((patient) => { const items: Patient[] = res.map((patient) => ({
return {
id: patient.id, id: patient.id,
first_name: patient.first_name, first_name: patient.first_name,
last_name: patient.last_name, last_name: patient.last_name,
@ -162,8 +162,7 @@ export async function getPatients(
}, },
} }
: null, : null,
}; }));
});
return { return {
items, items,

View file

@ -1,5 +1,9 @@
import type { RoomNew, Room, Station, StationNew } from "$lib/shared/model"; import type {
RoomNew, Room, Station, StationNew,
} from "$lib/shared/model";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { mapRoom } from "./mapping"; import { mapRoom } from "./mapping";
export async function newStation(station: StationNew): Promise<number> { export async function newStation(station: StationNew): Promise<number> {
@ -16,7 +20,7 @@ export async function deleteStation(id: number) {
} }
export async function getStation(id: number): Promise<Station> { export async function getStation(id: number): Promise<Station> {
return await prisma.station.findUniqueOrThrow({ where: { id } }); return prisma.station.findUniqueOrThrow({ where: { id } });
} }
export async function getStations(): Promise<Station[]> { export async function getStations(): Promise<Station[]> {

View file

@ -1,12 +1,14 @@
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { import type {
Pagination, Pagination,
PaginationRequest, PaginationRequest,
User, User,
UserTagNameNonnull, UserTagNameNonnull,
} from "$lib/shared/model"; } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma"; import { prisma } from "$lib/server/prisma";
import { mapUser, mapUserTagNameNonnull } from "./mapping"; import { mapUser, mapUserTagNameNonnull } from "./mapping";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
export async function getUser(id: number): Promise<User> { export async function getUser(id: number): Promise<User> {
const user = await prisma.user.findUniqueOrThrow({ where: { id } }); const user = await prisma.user.findUniqueOrThrow({ where: { id } });
@ -14,7 +16,7 @@ export async function getUser(id: number): Promise<User> {
} }
export async function getUsers( export async function getUsers(
pagination: PaginationRequest pagination: PaginationRequest,
): Promise<Pagination<User>> { ): Promise<Pagination<User>> {
const offset = pagination.offset ?? 0; const offset = pagination.offset ?? 0;
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([

View file

@ -1,4 +1,5 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { QueryBuilder, parseSearchQuery } from "./util"; import { QueryBuilder, parseSearchQuery } from "./util";
test("query builder", () => { test("query builder", () => {
@ -9,7 +10,7 @@ test("query builder", () => {
const query = qb.getQuery(); const query = qb.getQuery();
expect(query).toBe( expect(query).toBe(
"select e.id, e.text, e.category from entries e where category = any ($1) and text = $2 limit $3 offset $4" "select e.id, e.text, e.category from entries e where category = any ($1) and text = $2 limit $3 offset $4",
); );
const params = qb.getParams(); const params = qb.getParams();
@ -20,7 +21,7 @@ test("query builder", () => {
}); });
test("parse search query", () => { test("parse search query", () => {
const q = `"hello world" banana -cherry -"b x y" vis`; const q = '"hello world" banana -cherry -"b x y" vis';
const parsed = parseSearchQuery(q); const parsed = parseSearchQuery(q);
expect(parsed.components).toMatchObject([ expect(parsed.components).toMatchObject([
{ {
@ -52,6 +53,6 @@ test("parse search query", () => {
const term = parsed.toTsquery(); const term = parsed.toTsquery();
expect(term).toBe( expect(term).toBe(
`'hello' <-> 'world' & 'banana' & !'cherry' & !('b' <-> 'x' <-> 'y') & 'vis':*` "'hello' <-> 'world' & 'banana' & !'cherry' & !('b' <-> 'x' <-> 'y') & 'vis':*",
); );
}); });

View file

@ -13,18 +13,18 @@ export function filterListToArray<T>(fl: FilterList<T>): T[] {
if (fl[0].id) { if (fl[0].id) {
// @ts-expect-error checked if id is present // @ts-expect-error checked if id is present
return fl.map((itm) => itm.id); return fl.map((itm) => itm.id);
} else { }
// @ts-expect-error output type checked // @ts-expect-error output type checked
return fl; return fl;
} }
} else {
return [fl]; return [fl];
} }
}
class SearchQueryComponent { class SearchQueryComponent {
word: string; word: string;
typ: QueryComponentType; typ: QueryComponentType;
negative: boolean; negative: boolean;
constructor(word: string, typ: QueryComponentType, negative: boolean) { constructor(word: string, typ: QueryComponentType, negative: boolean) {
@ -49,16 +49,16 @@ class SearchQueryComponent {
multipleParts = parts.length > 1; multipleParts = parts.length > 1;
tsquery = parts.join(" <-> "); tsquery = parts.join(" <-> ");
} else if (this.typ == QueryComponentType.Trailing) { } else if (this.typ == QueryComponentType.Trailing) {
tsquery = this.quoted() + ":*"; tsquery = `${this.quoted()}:*`;
} else { } else {
tsquery = this.quoted(); tsquery = this.quoted();
} }
if (this.negative) { if (this.negative) {
if (multipleParts) { if (multipleParts) {
tsquery = "!(" + tsquery + ")"; tsquery = `!(${tsquery})`;
} else { } else {
tsquery = "!" + tsquery; tsquery = `!${tsquery}`;
} }
} }
@ -94,15 +94,14 @@ export function parseSearchQuery(q: string): SearchQueryComponents {
// Exact // Exact
if (m[2]) { if (m[2]) {
return new SearchQueryComponent(m[2], QueryComponentType.Exact, negative); return new SearchQueryComponent(m[2], QueryComponentType.Exact, negative);
} else { }
return new SearchQueryComponent(m[3], QueryComponentType.Normal, negative); return new SearchQueryComponent(m[3], QueryComponentType.Normal, negative);
} },
}
); );
if ( if (
components.length > 0 && components.length > 0
components[components.length - 1].typ === QueryComponentType.Normal && components[components.length - 1].typ === QueryComponentType.Normal
) { ) {
components[components.length - 1].typ = QueryComponentType.Trailing; components[components.length - 1].typ = QueryComponentType.Trailing;
} }
@ -112,12 +111,19 @@ export function parseSearchQuery(q: string): SearchQueryComponents {
export class QueryBuilder { export class QueryBuilder {
private selectClause; private selectClause;
private fromClause; private fromClause;
private filterClauses: string[] = []; private filterClauses: string[] = [];
private orderClauses: string[] = []; private orderClauses: string[] = [];
private params: unknown[] = []; private params: unknown[] = [];
private nP = 0; private nP = 0;
private limit = PAGINATION_LIMIT; private limit = PAGINATION_LIMIT;
private offset = 0; private offset = 0;
constructor(selectClause: string, fromClause: string) { constructor(selectClause: string, fromClause: string) {
@ -136,7 +142,7 @@ export class QueryBuilder {
orderByFields(fields: string[], asc: boolean | undefined = undefined) { orderByFields(fields: string[], asc: boolean | undefined = undefined) {
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);
} }
@ -168,7 +174,7 @@ export class QueryBuilder {
getQuery(): string { getQuery(): string {
const queryParts = [this.selectClause, this.fromClause]; const queryParts = [this.selectClause, this.fromClause];
if (this.filterClauses.length > 0) { if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and ")); queryParts.push(`where ${this.filterClauses.join(" and ")}`);
} }
if (this.orderClauses.length > 0) { if (this.orderClauses.length > 0) {
@ -183,7 +189,7 @@ export class QueryBuilder {
getCountQuery(): string { getCountQuery(): string {
const queryParts = ["select count(*) as count", this.fromClause]; const queryParts = ["select count(*) as count", this.fromClause];
if (this.filterClauses.length > 0) { if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and ")); queryParts.push(`where ${this.filterClauses.join(" and ")}`);
} }
return queryParts.join(" "); return queryParts.join(" ");
} }

View file

@ -1,7 +1,8 @@
import { ZUser } from "$lib/shared/model/validation";
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 { 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

View file

@ -1,7 +1,8 @@
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
import { ZodError } from "zod";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
function handleError(error: unknown): never { function handleError(error: unknown): never {
if (error instanceof ZodError) { if (error instanceof ZodError) {
@ -18,7 +19,7 @@ function handleError(error: unknown): never {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: error.message, message: error.message,
cause: "Prisma error " + error.code, cause: `Prisma error ${error.code}`,
}); });
} }
} else if (error instanceof ErrorNotFound) { } else if (error instanceof ErrorNotFound) {

View file

@ -1,4 +1,5 @@
import { initTRPC } from "@trpc/server"; import { initTRPC } from "@trpc/server";
import type { Context } from "./context"; import type { Context } from "./context";
export { trpcWrap } from "$lib/server/trpc/handleError"; export { trpcWrap } from "$lib/server/trpc/handleError";

View file

@ -1,17 +1,17 @@
import { t } from ".";
import { categoryRouter } from "./routes/category";
import { entryRouter } from "./routes/entry";
import { stationRouter } from "./routes/station";
import { roomRouter } from "./routes/room";
import { patientRouter } from "./routes/patient";
import { userRouter } from "./routes/user";
import { ErrorInvalidInput } from "$lib/shared/util/error"; import { ErrorInvalidInput } from "$lib/shared/util/error";
import { t } from ".";
import { categoryRouter } from "./routes/category";
import { entryRouter } from "./routes/entry";
import { patientRouter } from "./routes/patient";
import { roomRouter } from "./routes/room";
import { stationRouter } from "./routes/station";
import { userRouter } from "./routes/user";
export const router = t.router({ export const router = t.router({
greeting: t.procedure.query(async () => { greeting: t.procedure.query(
return `Hello tRPC @ ${new Date().toLocaleTimeString()}`; async () => `Hello tRPC @ ${new Date().toLocaleTimeString()}`,
}), ),
testError: t.procedure.query(async () => { testError: t.procedure.query(async () => {
throw new ErrorInvalidInput("here is your error"); throw new ErrorInvalidInput("here is your error");
}), }),

View file

@ -1,34 +1,34 @@
import { t, trpcWrap } from "..";
import { z } from "zod"; import { z } from "zod";
import { fields, ZCategoryNew } from "$lib/shared/model/validation";
import { import {
deleteCategory, deleteCategory,
getCategories, getCategories,
getCategory,
newCategory, newCategory,
updateCategory, updateCategory,
} from "$lib/server/query"; } from "$lib/server/query";
import { fields, ZCategoryNew } from "$lib/shared/model/validation";
import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
export const categoryRouter = t.router({ export const categoryRouter = t.router({
list: t.procedure.query(async () => trpcWrap(getCategories)), list: t.procedure.query(async () => trpcWrap(getCategories)),
create: t.procedure.input(ZCategoryNew).mutation(async (opts) => get: t.procedure
trpcWrap(async () => { .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getCategory(opts.input))),
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;
}) })),
),
update: t.procedure update: t.procedure
.input(z.object({ id: ZEntityId, category: ZCategoryNew.partial() })) .input(z.object({ id: ZEntityId, category: ZCategoryNew.partial() }))
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
await updateCategory(opts.input.id, opts.input.category); await updateCategory(opts.input.id, opts.input.category);
}) })),
), 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);
}) })),
),
}); });

View file

@ -1,4 +1,3 @@
import { t, trpcWrap } from "..";
import { z } from "zod"; import { z } from "zod";
import { import {
@ -8,6 +7,8 @@ import {
ZEntryNew, ZEntryNew,
ZEntryVersionNew, ZEntryVersionNew,
} from "$lib/shared/model/validation"; } from "$lib/shared/model/validation";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
import { import {
getEntries, getEntries,
getEntry, getEntry,
@ -19,89 +20,70 @@ import {
newEntryExecution, newEntryExecution,
newEntryVersion, newEntryVersion,
} from "$lib/server/query"; } from "$lib/server/query";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
export const entryRouter = t.router({ export const entryRouter = t.router({
get: t.procedure.input(ZEntityId).query(async (opts) => get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
const [entry, n_versions, n_executions] = await Promise.all([ const [entry, n_versions, n_executions] = await Promise.all([
getEntry(opts.input), getEntry(opts.input),
getEntryNVersions(opts.input), getEntryNVersions(opts.input),
getEntryNExecutions(opts.input), getEntryNExecutions(opts.input),
]); ]);
return { ...entry, n_versions, n_executions }; return { ...entry, n_versions, n_executions };
}) })),
),
list: t.procedure list: t.procedure
.input(ZEntriesQuery) .input(ZEntriesQuery)
.query(async (opts) => .query(async (opts) => trpcWrap(async () => getEntries(
trpcWrap(async () =>
getEntries(
opts.input.filter ?? {}, opts.input.filter ?? {},
opts.input.pagination ?? {}, opts.input.pagination ?? {},
opts.input.sort ?? {} opts.input.sort ?? {},
) ))),
)
),
versions: t.procedure versions: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))), .query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) => versionsDiff: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
const versions = await getEntryVersions(opts.input); const versions = await getEntryVersions(opts.input);
return versionsDiff(versions); return versionsDiff(versions);
}) })),
),
executions: t.procedure executions: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))), .query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
executionsDiff: t.procedure.input(ZEntityId).query(async (opts) => executionsDiff: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
const executions = await getEntryExecutions(opts.input); const executions = await getEntryExecutions(opts.input);
return executionsDiff(executions); return executionsDiff(executions);
}) })),
),
create: t.procedure create: t.procedure
.input(ZEntryNew) .input(ZEntryNew)
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))),
trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))
),
newVersion: t.procedure newVersion: t.procedure
.input( .input(
z.object({ z.object({
id: ZEntityId, id: ZEntityId,
version: ZEntryVersionNew, version: ZEntryVersionNew,
old_version_id: ZEntityId.optional(), old_version_id: ZEntityId.optional(),
}) }),
) )
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => newEntryVersion(
trpcWrap(async () =>
newEntryVersion(
opts.ctx.user.id, opts.ctx.user.id,
opts.input.id, opts.input.id,
opts.input.version, opts.input.version,
opts.input.old_version_id opts.input.old_version_id,
) ))),
)
),
newExecution: t.procedure newExecution: t.procedure
.input( .input(
z.object({ z.object({
id: ZEntityId, id: ZEntityId,
execution: ZEntryExecutionNew, execution: ZEntryExecutionNew,
old_execution_id: ZEntityId.nullable().optional(), old_execution_id: ZEntityId.nullable().optional(),
}) }),
) )
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => newEntryExecution(
trpcWrap(async () =>
newEntryExecution(
opts.ctx.user.id, opts.ctx.user.id,
opts.input.id, opts.input.id,
opts.input.execution, opts.input.execution,
opts.input.old_execution_id opts.input.old_execution_id,
) ))),
)
),
}); });

View file

@ -1,3 +1,7 @@
import { z } from "zod";
import { fields, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
import { import {
deletePatient, deletePatient,
getPatient, getPatient,
@ -8,55 +12,46 @@ import {
newPatient, newPatient,
updatePatient, updatePatient,
} from "$lib/server/query"; } from "$lib/server/query";
import { fields, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
import { z } from "zod";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
export const patientRouter = t.router({ export const patientRouter = t.router({
getNames: t.procedure.query(async () => trpcWrap(getPatientNames)), getNames: t.procedure.query(async () => trpcWrap(getPatientNames)),
get: t.procedure.input(ZEntityId).query(async (opts) => get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
const [patient, n_entries] = await Promise.all([ const [patient, n_entries] = await Promise.all([
getPatient(opts.input), getPatient(opts.input),
getPatientNEntries(opts.input), getPatientNEntries(opts.input),
]); ]);
return { ...patient, n_entries }; return { ...patient, n_entries };
}) })),
), list: t.procedure
list: t.procedure.input(ZPatientsQuery).query(async (opts) => { .input(ZPatientsQuery)
return getPatients( .query(async (opts) => getPatients(
opts.input.filter ?? {}, opts.input.filter ?? {},
opts.input.pagination ?? {}, opts.input.pagination ?? {},
opts.input.sort ?? {} opts.input.sort ?? {},
); )),
}),
create: t.procedure create: t.procedure
.input(ZPatientNew) .input(ZPatientNew)
.mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))), .mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))),
update: t.procedure update: t.procedure
.input(z.object({ id: ZEntityId, patient: ZPatientNew.partial() })) .input(z.object({ id: ZEntityId, patient: ZPatientNew.partial() }))
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
await updatePatient(opts.input.id, opts.input.patient); await updatePatient(opts.input.id, opts.input.patient);
}) })),
), 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( .input(
z.object({ z.object({
id: ZEntityId, id: ZEntityId,
hidden: z.boolean().default(true), hidden: z.boolean().default(true),
}) }),
) )
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => {
trpcWrap(async () => {
await hidePatient(opts.input.id, opts.input.hidden); await hidePatient(opts.input.id, opts.input.hidden);
}) })),
),
}); });

View file

@ -1,8 +1,13 @@
import { deleteRoom, getRoom, getRooms, updateRoom } from "$lib/server/query";
import { fields, ZRoomNew } from "$lib/shared/model/validation";
import { t, trpcWrap } from "..";
import { z } from "zod"; import { z } from "zod";
import { fields, ZRoomNew } from "$lib/shared/model/validation";
import {
deleteRoom, getRoom, getRooms, newRoom, updateRoom,
} from "$lib/server/query";
import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
export const roomRouter = t.router({ export const roomRouter = t.router({
@ -10,16 +15,16 @@ export const roomRouter = t.router({
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getRoom(opts.input))), .query(async (opts) => trpcWrap(async () => getRoom(opts.input))),
create: t.procedure.input(ZRoomNew).mutation(async (opts) => trpcWrap(async () => {
const id = await newRoom(opts.input);
return id;
})),
update: t.procedure update: t.procedure
.input(z.object({ id: ZEntityId, category: ZRoomNew.partial() })) .input(z.object({ id: ZEntityId, room: ZRoomNew.partial() }))
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => {
trpcWrap(async () => { await updateRoom(opts.input.id, opts.input.room);
await updateRoom(opts.input.id, opts.input.category); })),
}) 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);
}) })),
),
}); });

View file

@ -1,12 +1,16 @@
import { z } from "zod";
import { fields, ZStationNew } from "$lib/shared/model/validation";
import { import {
deleteStation, deleteStation,
getStation, getStation,
getStations, getStations,
newStation,
updateStation, updateStation,
} from "$lib/server/query"; } from "$lib/server/query";
import { fields, ZStationNew } from "$lib/shared/model/validation";
import { t, trpcWrap } from ".."; import { t, trpcWrap } from "..";
import { z } from "zod";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
@ -15,16 +19,16 @@ export const stationRouter = t.router({
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getStation(opts.input))), .query(async (opts) => trpcWrap(async () => getStation(opts.input))),
create: t.procedure.input(ZStationNew).mutation(async (opts) => trpcWrap(async () => {
const id = await newStation(opts.input);
return id;
})),
update: t.procedure update: t.procedure
.input(z.object({ id: ZEntityId, category: ZStationNew.partial() })) .input(z.object({ id: ZEntityId, station: ZStationNew.partial() }))
.mutation(async (opts) => .mutation(async (opts) => trpcWrap(async () => {
trpcWrap(async () => { await updateStation(opts.input.id, opts.input.station);
await updateStation(opts.input.id, opts.input.category); })),
}) 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);
}) })),
),
}); });

View file

@ -1,16 +1,17 @@
import { getUser, getUserNames, getUsers } from "$lib/server/query";
import { fields, ZPagination } from "$lib/shared/model/validation";
import { t, trpcWrap } from "..";
import { z } from "zod"; import { z } from "zod";
import { fields, ZPagination } from "$lib/shared/model/validation";
import { getUser, getUserNames, getUsers } from "$lib/server/query";
import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId(); const ZEntityId = fields.EntityId();
export const userRouter = t.router({ export const userRouter = t.router({
list: t.procedure list: t.procedure
.input(z.object({ pagination: ZPagination }).partial()) .input(z.object({ pagination: ZPagination }).partial())
.query(async (opts) => { .query(async (opts) => getUsers(opts.input.pagination ?? {})),
return getUsers(opts.input.pagination ?? {});
}),
getNames: t.procedure.query(getUserNames), getNames: t.procedure.query(getUserNames),
get: t.procedure get: t.procedure
.input(ZEntityId) .input(ZEntityId)

View file

@ -1,4 +1,5 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { fields } from "./validation"; import { fields } from "./validation";
test("date string", () => { test("date string", () => {

View file

@ -1,5 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { implement } from "$lib/shared/util/zod"; import { implement } from "$lib/shared/util/zod";
import type { import type {
CategoryNew, CategoryNew,
EntryExecutionNew, EntryExecutionNew,
@ -9,36 +11,36 @@ import type {
PaginationRequest, PaginationRequest,
PatientNew, PatientNew,
RoomNew, RoomNew,
SortRequest,
StationNew, StationNew,
User, User,
} from "."; } from ".";
// Fields // Fields
export const fields = { export const fields = {
EntityId: () => z.number().int().nonnegative(), EntityId: () => z.number().int().min(1),
NameString: () => z.string().min(1).max(200).trim(), NameString: () => z.string().min(1).max(200).trim(),
TextString: () => z.string().trim(), TextString: () => z.string().trim(),
Age: () => z.number().int().nonnegative().lt(200), Age: () => z.number().int().nonnegative().lt(200),
DateString: () => DateString: () => z
z
.string() .string()
.regex(/^\d{4}-\d{2}-\d{2}$/) .regex(/^\d{4}-\d{2}-\d{2}$/)
// @ts-expect-error check date for NaN is valid // @ts-expect-error check date for NaN is valid
.refine((val) => !isNaN(new Date(val))), .refine((val) => !isNaN(new Date(val))),
DateStringFuture: () => DateStringFuture: () => fields.DateString().refine(
fields.DateString().refine(
(d) => { (d) => {
const inp = new Date(d); const inp = new Date(d);
const now = new Date(); const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return inp >= today; return inp >= today;
}, },
{ message: "Datum muss in der Zukunft liegen" } { message: "Datum muss in der Zukunft liegen" },
), ),
}; };
export const ZUrlEntityId = z.coerce.number().int().nonnegative(); const coercedUint = z.coerce.number().int().nonnegative();
const coercedBool = z.string().toLowerCase().transform((v) => v === "true").or(z.boolean());
export const ZUrlEntityId = coercedUint;
export const ZUser = implement<User>().with({ export const ZUser = implement<User>().with({
id: z.coerce.number().int().nonnegative(), id: z.coerce.number().int().nonnegative(),
@ -58,7 +60,7 @@ export const ZCategoryNew = implement<CategoryNew>().with({
description: fields.TextString().nullable(), description: fields.TextString().nullable(),
color: z color: z
.string() .string()
.regex(/[0-9A-Fa-f]{6}/) .regex(/^#[0-9A-Fa-f]{6}$/)
.toUpperCase() .toUpperCase()
.nullable(), .nullable(),
}); });
@ -92,31 +94,36 @@ export const ZEntryExecutionNew = implement<EntryExecutionNew>().with({
}); });
export const ZPagination = implement<PaginationRequest>().with({ export const ZPagination = implement<PaginationRequest>().with({
limit: fields.EntityId().optional(), limit: coercedUint.optional(),
offset: fields.EntityId().optional(), offset: coercedUint.optional(),
}); });
export const ZSort = implement<SortRequest>().with({ export const ZSort = z.object({
field: z.string().optional(), field: z.string().optional(),
asc: z.boolean().optional(), asc: coercedBool.optional(),
}); });
const ZFilterListEntry = z.object({ const ZFilterListEntry = z.object({
id: fields.EntityId(), id: coercedUint,
name: fields.NameString().optional(), name: fields.NameString().optional(),
}); });
const ZFilterList = z.array(ZFilterListEntry); const ZFilterList = z.array(ZFilterListEntry);
const paginatedQuery = (f: z.ZodTypeAny) => const paginatedQuery = <T extends z.ZodTypeAny>(f: T) => z
z.object({ filter: f, pagination: ZPagination, sort: ZSort }).partial(); .object({
filter: f,
pagination: ZPagination,
sort: ZSort,
})
.partial();
export const ZEntriesFilter = z export const ZEntriesFilter = z
.object({ .object({
author: ZFilterList, author: ZFilterList,
category: ZFilterList, category: ZFilterList,
done: z.boolean(), done: coercedBool,
executor: ZFilterList, executor: ZFilterList,
patient: ZFilterList, patient: ZFilterList,
priority: z.boolean(), priority: coercedBool,
room: ZFilterList, room: ZFilterList,
search: z.string(), search: z.string(),
station: ZFilterList, station: ZFilterList,
@ -130,8 +137,8 @@ export const ZPatientsFilter = z
search: z.string(), search: z.string(),
room: ZFilterList, room: ZFilterList,
station: ZFilterList, station: ZFilterList,
hidden: z.boolean(), hidden: coercedBool,
includeHidden: z.boolean(), includeHidden: z.coerce.boolean(),
}) })
.partial(); .partial();

View file

@ -1,7 +1,8 @@
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import type { Router } from "$lib/server/trpc/router";
import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit"; import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit";
import type { Router } from "$lib/server/trpc/router";
export type RouterInput = inferRouterInputs<Router>; export type RouterInput = inferRouterInputs<Router>;
export type RouterOutput = inferRouterOutputs<Router>; export type RouterOutput = inferRouterOutputs<Router>;

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { hexToColor, colorToHex } from "./colors"; import { hexToColor, colorToHex } from "./colors";
describe.each([ describe.each([

View file

@ -13,7 +13,7 @@ const WHITE: Color = { r: 255, g: 255, b: 255 };
const BLACK: Color = { r: 0, g: 0, b: 0 }; const BLACK: Color = { r: 0, g: 0, b: 0 };
export function colorToHex(c: Color): string { export function colorToHex(c: Color): string {
return "#" + ((1 << 24) | (c.r << 16) | (c.g << 8) | c.b).toString(16).slice(1); return `#${((1 << 24) | (c.r << 16) | (c.g << 8) | c.b).toString(16).slice(1)}`;
} }
export function hexToColor(s: string): Color { export function hexToColor(s: string): Color {
@ -46,10 +46,9 @@ function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit: number): number {
} }
function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB: number): number { function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB: number): number {
const primaryColor_linear = const primaryColor_linear = primaryColor_sRGB < 0.03928
primaryColor_sRGB < 0.03928
? primaryColor_sRGB / 12.92 ? primaryColor_sRGB / 12.92
: Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4); : ((primaryColor_sRGB + 0.055) / 1.055) ** 2.4;
return primaryColor_linear; return primaryColor_linear;
} }

View file

@ -1,6 +1,9 @@
import { test, expect } from "vitest"; import { test, expect } from "vitest";
import type { EntryVersion } from "$lib/shared/model"; import type { EntryVersion } from "$lib/shared/model";
import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata"; import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata";
import { versionsDiff } from "./diff"; import { versionsDiff } from "./diff";
test("versions diff", () => { test("versions diff", () => {
@ -54,9 +57,6 @@ test("versions diff", () => {
priority: true, priority: true,
text: [ text: [
{ {
count: 38,
added: true,
removed: undefined,
value: value:
"10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.", "10ml Blut abnehmen.\n\nDas Blut muss auf Lambda-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
}, },
@ -75,8 +75,18 @@ test("versions diff", () => {
priority: undefined, priority: undefined,
text: [ text: [
{ count: 16, value: "10ml Blut abnehmen.\n\nDas Blut muss auf " }, { count: 16, value: "10ml Blut abnehmen.\n\nDas Blut muss auf " },
{ count: 1, added: undefined, removed: true, value: "Lambda" }, {
{ count: 1, added: true, removed: undefined, value: "XYZ" }, count: 1,
added: undefined,
removed: true,
value: "Lambda",
},
{
count: 1,
added: true,
removed: undefined,
value: "XYZ",
},
{ {
count: 21, count: 21,
value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.", value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
@ -94,7 +104,12 @@ test("versions diff", () => {
category: undefined, category: undefined,
text: [ text: [
{ count: 17, value: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ" }, { count: 17, value: "10ml Blut abnehmen.\n\nDas Blut muss auf XYZ" },
{ count: 2, added: undefined, removed: true, value: "-Erreger" }, {
count: 2,
added: undefined,
removed: true,
value: "-Erreger",
},
{ count: 5, value: " getestet werden." }, { count: 5, value: " getestet werden." },
{ {
count: 14, count: 14,

View file

@ -1,4 +1,5 @@
import { diffWords } from "diff"; import { diffWords } from "diff";
import type { import type {
EntryVersion, EntryVersion,
EntryExecution, EntryExecution,

View file

@ -1,6 +1,6 @@
export class ErrorConflict extends Error { export class ErrorConflict extends Error {
constructor(msg: string) { constructor(msg: string) {
super("conflict: " + msg); super(`conflict: ${msg}`);
Object.setPrototypeOf(this, ErrorConflict.prototype); Object.setPrototypeOf(this, ErrorConflict.prototype);
} }
} }

View file

@ -1,11 +1,15 @@
import { expect, test, vi } from "vitest"; import { expect, it, vi } from "vitest";
import { humanDate } from ".";
import type { EntityQuery } from "$lib/shared/model";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { getQueryUrl, humanDate, parseQueryUrl } from ".";
const MINUTE = 60000; const MINUTE = 60000;
const HOUR = 3_600_000; const HOUR = 3_600_000;
const DAY = 24 * HOUR; const DAY = 24 * HOUR;
test.each([ it.each([
{ s: 0, txt: "jetzt gerade" }, { s: 0, txt: "jetzt gerade" },
{ s: -30 * MINUTE, txt: "vor 30 Minuten" }, { s: -30 * MINUTE, txt: "vor 30 Minuten" },
{ s: -11 * HOUR, txt: "vor 11 Stunden" }, { s: -11 * HOUR, txt: "vor 11 Stunden" },
@ -30,3 +34,22 @@ test.each([
vi.useRealTimers(); vi.useRealTimers();
}); });
it("getQueryUrl", () => {
const query: EntityQuery = {
filter: {
author: [{ id: 2, name: "Max" }],
category: [{ id: 1, name: "Blutabnahme" }, { id: 2, name: "Labortests" }],
done: true,
search: "Hello World",
},
pagination: { limit: 10, offset: 20 },
sort: { field: "room", asc: true },
};
const queryUrl = getQueryUrl(query, "");
expect(queryUrl).toBe("?filter%5Bauthor%5D%5B0%5D%5Bid%5D=2&filter%5Bauthor%5D%5B0%5D%5Bname%5D=Max&filter%5Bcategory%5D%5B0%5D%5Bid%5D=1&filter%5Bcategory%5D%5B0%5D%5Bname%5D=Blutabnahme&filter%5Bcategory%5D%5B1%5D%5Bid%5D=2&filter%5Bcategory%5D%5B1%5D%5Bname%5D=Labortests&filter%5Bdone%5D=true&filter%5Bsearch%5D=Hello%20World&pagination%5Blimit%5D=10&pagination%5Boffset%5D=20&sort%5Bfield%5D=room&sort%5Basc%5D=true");
const decoded = ZEntriesQuery.parse(parseQueryUrl(queryUrl));
expect(decoded).toStrictEqual(query);
});

View file

@ -1,8 +1,12 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { EntityQuery } from "$lib/shared/model";
import { isRedirect, error } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client"; import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit"; import qs from "qs";
import { ZodError } from "zod"; import { ZodError } from "zod";
import DOMPurify from "isomorphic-dompurify";
import type { EntityQuery } from "$lib/shared/model";
const LOCALE = "de-DE"; const LOCALE = "de-DE";
@ -23,14 +27,13 @@ export function formatDate(date: Date | string, time = false): string {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
} else { }
return dt.toLocaleDateString(LOCALE, { return dt.toLocaleDateString(LOCALE, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
}); });
} }
}
const MS_PER_DAY = 86400000; const MS_PER_DAY = 86400000;
@ -46,7 +49,7 @@ export function humanDate(date: Date | string, time = false): string {
const dt = coerceDate(date); const dt = coerceDate(date);
const threshold = 302400000; // 3.5 * 24 * 3_600_000 const threshold = 302400000; // 3.5 * 24 * 3_600_000
const diff = Number(dt) - Number(now); // pos: Future, neg: Past const diff = Number(dt) - Number(now); // pos: Future, neg: Past
if (Math.abs(diff) > threshold) return "am " + formatDate(date, time); if (Math.abs(diff) > threshold) return `am ${formatDate(date, time)}`;
const intl = new Intl.RelativeTimeFormat(LOCALE); const intl = new Intl.RelativeTimeFormat(LOCALE);
@ -72,25 +75,30 @@ export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein"; return val ? "Ja" : "Nein";
} }
export function getQueryUrl(q: EntityQuery, basePath: string): string { /** Encode an entity query (search and filter) into an URL */
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath; export function getQueryUrl(query: EntityQuery, basePath: string): string {
return encodeURI(basePath + "/" + encodeURIComponent(JSON.stringify(q))); if (Object.values(query).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "?" + qs.stringify(query);
} }
export function gotoEntityQuery(q: EntityQuery, basePath: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window && window.location.pathname.startsWith(basePath + "/")) { export function parseQueryUrl(search: string): any {
const qstr = decodeURI(window.location.pathname.substring(basePath.length + 1)); return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true });
if (qstr) { }
const oldq: EntityQuery = JSON.parse(qstr);
const nq: EntityQuery = { export function gotoEntityQuery(query: EntityQuery, basePath: string) {
filter: { ...oldq.filter, ...q.filter }, if (window && window.location.pathname.startsWith(`${basePath}/`)) {
sort: q.sort, if (window.location.search) {
const oldQuery: EntityQuery = parseQueryUrl(window.location.search);
const newQuery: EntityQuery = {
filter: { ...oldQuery.filter, ...query.filter },
sort: query.sort,
}; };
goto(getQueryUrl(nq, basePath)); goto(getQueryUrl(newQuery, basePath));
return; return;
} }
} }
goto(getQueryUrl(q, basePath)); goto(getQueryUrl(query, basePath));
} }
/** Wrap a page load query to handle occuring errors /** Wrap a page load query to handle occuring errors
@ -101,7 +109,9 @@ export async function loadWrap<T>(f: () => Promise<T>) {
try { try {
return await f(); return await f();
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError) { if (isRedirect(e)) {
throw e;
} else if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message); error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) { } else if (e instanceof ZodError) {
error(400, e.message); error(400, e.message);
@ -114,7 +124,7 @@ export async function loadWrap<T>(f: () => Promise<T>) {
} }
export function baseUrl(url: URL): string { export function baseUrl(url: URL): string {
return url.protocol + "//" + url.host; return `${url.protocol}//${url.host}`;
} }
export function dateToYMD(date: Date): string { export function dateToYMD(date: Date): string {
@ -123,7 +133,9 @@ export function dateToYMD(date: Date): string {
export class Debouncer { export class Debouncer {
private delay: number; private delay: number;
private handler: () => unknown; private handler: () => unknown;
private timeout: number | null = null; private timeout: number | null = null;
constructor(delay: number, handler: () => unknown) { constructor(delay: number, handler: () => unknown) {
@ -145,3 +157,7 @@ export class Debouncer {
this.handler(); this.handler();
} }
} }
export function sanitizeHtml(s: string): string {
return DOMPurify.sanitize(s, { FORBID_TAGS: ["img"] });
}

View file

@ -19,7 +19,7 @@ export function implement<Model = never>() {
[unknownKey in Exclude<keyof Schema, keyof Model>]: never; [unknownKey in Exclude<keyof Schema, keyof Model>]: never;
}, },
>( >(
schema: Schema schema: Schema,
) => z.object(schema), ) => z.object(schema),
}; };
} }

View file

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import NavLink from "$lib/components/ui/NavLink.svelte";
import { mdiAccount, mdiHome } from "@mdi/js"; import { mdiAccount, mdiHome } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import NavLink from "$lib/components/ui/NavLink.svelte";
</script> </script>
<div <div
@ -12,35 +13,41 @@
> >
<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">
<NavLink active={$page.route.id === "/(app)"} href="/" <NavLink
active={$page.route.id === "/(app)"}
href="/"
><Icon ><Icon
path={mdiHome}
cls={$page.route.id === "/(app)" ? "text-primary" : ""} cls={$page.route.id === "/(app)" ? "text-primary" : ""}
path={mdiHome}
/></NavLink /></NavLink
> >
<NavLink active={$page.route.id === "/(app)/plan/[[query]]"} href="/plan" <NavLink
active={$page.route.id === "/(app)/plan"}
href="/plan"
>Planung</NavLink >Planung</NavLink
> >
<NavLink active={$page.route.id === "/(app)/visit"} href="/visit">Visite</NavLink> <NavLink active={$page.route.id === "/(app)/visit"} href="/visit">Visite</NavLink>
<NavLink active={$page.route.id === "/(app)/patients/[[query]]"} href="/patients" <NavLink
active={$page.route.id === "/(app)/patients"}
href="/patients"
>Patienten</NavLink >Patienten</NavLink
> >
</div> </div>
<div class="flex-0"> <div class="flex-0">
{#if $page.data.session?.user} {#if $page.data.session?.user}
<div class="dropdown dropdown-hover dropdown-end"> <div class="dropdown dropdown-hover dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-ghost"> <div class="btn btn-sm btn-ghost" role="button" tabindex="0">
<Icon path={mdiAccount} /> <Icon path={mdiAccount} />
<span class="hidden md:inline">{$page.data.session.user?.name}</span> <span class="hidden md:inline">{$page.data.session.user?.name}</span>
</div> </div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex --> <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul <ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
tabindex="0"
> >
<li><a href="/stations">Stationen</a></li>
<li><a href="/rooms">Zimmer</a></li> <li><a href="/rooms">Zimmer</a></li>
<li><a href="/categories">Kategorien</a></li> <li><a href="/categories">Kategorien</a></li>
<li><a href="/users">Benutzer</a></li>
<li><a href="/logout">Abmelden</a></li> <li><a href="/logout">Abmelden</a></li>
</ul> </ul>
</div> </div>

View file

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
</script> </script>
@ -12,15 +12,14 @@
<p>Sie sind nicht angemeldet</p> <p>Sie sind nicht angemeldet</p>
{/if} {/if}
<div <div class="grid grid-flow-row gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
class="grid grid-flow-row gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<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 href="/plan" class="btn btn-primary">Planung</a> <a class="btn btn-primary" href="/plan">Planung</a>
</div> </div>
</div> </div>
</div> </div>
@ -31,7 +30,7 @@
<p>Hier können sie Visitenbucheinträge abarbeiten.</p> <p>Hier können sie Visitenbucheinträge abarbeiten.</p>
<p>Heute müssen (n) Einträge erledigt werden.</p> <p>Heute müssen (n) Einträge erledigt werden.</p>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<a href="/visit" class="btn btn-primary">Visite</a> <a class="btn btn-primary" href="/visit">Visite</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import type { PageData } from "./$types";
import CategoryField from "$lib/components/table/CategoryField.svelte";
import Header from "$lib/components/ui/Header.svelte";
export let data: PageData;
</script>
<svelte:head>
<title>Kategorien</title>
</svelte:head>
<Header title="Kategorien">
<a slot="rightBtn" class="btn btn-sm btn-primary ml-auto" href="/category/new">Neue Kategorie</a>
</Header>
<div class="overflow-x-auto">
<table class="table">
<thead>
<th>Name</th>
<th>Beschreibung</th>
</thead>
<tbody>
{#each data.categories as category (category.id)}
<tr>
<td>
<CategoryField {category} href="/category/{category.id}" />
</td>
<td>
{#if category.description}
{category.description}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View file

@ -0,0 +1,11 @@
import type { PageLoad } from "./$types";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
export const load: PageLoad = async (event) => {
return loadWrap(async () => {
const categories = await trpc(event).category.list.query();
return { categories };
});
};

View file

@ -0,0 +1,35 @@
import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { ZCategoryNew, ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
export const actions: Actions = {
default: async (event) => loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
const formData = await event.request.formData();
const del = formData.get("delete");
if (del) {
await trpc(event).category.delete.mutate(id);
redirect(302, "/categories");
} else {
const form = await superValidate(formData, zod(ZCategoryNew));
if (!form.valid) {
return fail(400, { form });
}
await trpc(event).category.update.mutate({
id,
category: form.data,
});
return { form };
}
}),
};

View file

@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from "./$types";
import CategoryForm from "$lib/components/form/CategoryForm.svelte";
export let data: PageData;
</script>
<svelte:head>
<title>Kategorie: {data.category.name}</title>
</svelte:head>
<CategoryForm category={data.category} formData={data.form}>
<button name="delete" class="btn btn-error" type="submit" value="1">Löschen</button>
</CategoryForm>

View file

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

View file

@ -0,0 +1,22 @@
import type { Actions } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { ZCategoryNew } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
export const actions: Actions = {
default: async (event) => {
const form = await superValidate(event.request, zod(ZCategoryNew));
if (!form.valid) {
return fail(400, { form });
}
const newId = await loadWrap(async () => trpc(event).category.create.mutate(form.data));
throw redirect(302, `/category/${newId}`);
},
};

View file

@ -0,0 +1,6 @@
<script lang="ts">
import CategoryForm from "$lib/components/form/CategoryForm.svelte";
</script>
<CategoryForm />

View file

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

View file

@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
import { SchemaEntryExecution } from "./schema";
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
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 { SchemaEntryExecution } from "./schema";
export let data: PageData; export let data: PageData;
let formData = defaults(SchemaEntryExecution); const formData = defaults(SchemaEntryExecution);
const { form, errors, enhance } = superForm(formData, { const { form, errors, enhance } = superForm(formData, {
validators: SchemaEntryExecution, validators: SchemaEntryExecution,
}); });
@ -18,14 +22,14 @@
{#if !data.entry.execution} {#if !data.entry.execution}
<form method="POST" use:enhance> <form method="POST" use:enhance>
<MarkdownInput <MarkdownInput
label="Eintrag erledigen"
name="text" name="text"
ariaInvalid={Boolean($errors.text)} ariaInvalid={Boolean($errors.text)}
bind:value={$form.text}
errors={$errors.text} errors={$errors.text}
label="Eintrag erledigen"
bind:value={$form.text}
> >
<div class="row c-vlight"> <div class="row c-vlight">
<button type="submit" class="btn btn-sm btn-primary">Erledigt</button> <button class="btn btn-sm btn-primary" type="submit">Erledigt</button>
</div> </div>
</MarkdownInput> </MarkdownInput>
</form> </form>

View file

@ -1,7 +1,8 @@
import type { PageLoad } from "./$types";
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";
import { loadWrap } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
const entry = await loadWrap(async () => { const entry = await loadWrap(async () => {

View file

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

View file

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

View file

@ -1,8 +1,11 @@
import type { PageLoad } from "./$types";
import { superValidate } 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";
import { loadWrap } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import { superValidate } from "sveltekit-superforms";
import type { PageLoad } from "./$types";
import { SchemaNewEntryVersion } from "./schema"; import { SchemaNewEntryVersion } from "./schema";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
@ -16,7 +19,7 @@ export const load: PageLoad = async (event) => {
old_version_id: entry.current_version.id, old_version_id: entry.current_version.id,
...entry.current_version, ...entry.current_version,
}, },
SchemaNewEntryVersion SchemaNewEntryVersion,
); );
return { entry, form }; return { entry, form };

View file

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

View file

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

View file

@ -1,30 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
import { SchemaNewExecution } from "./schema";
import { defaults, superForm } from "sveltekit-superforms"; import { defaults, superForm } from "sveltekit-superforms";
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 { SchemaNewExecution } from "./schema";
export let data: PageData; export let data: PageData;
let formData = defaults(SchemaNewExecution); const formData = defaults(SchemaNewExecution);
const { form, errors, enhance } = superForm(formData, { const { form, errors, enhance } = superForm(formData, {
validators: SchemaNewExecution, validators: SchemaNewExecution,
}); });
</script> </script>
<EntryBody entry={data.entry} backBtn /> <EntryBody backBtn entry={data.entry} />
<form method="POST" use:enhance> <form method="POST" use:enhance>
<MarkdownInput <MarkdownInput
label="Ausführungstext bearbeiten"
name="text" name="text"
ariaInvalid={Boolean($errors.text)} ariaInvalid={Boolean($errors.text)}
bind:value={$form.text}
errors={$errors.text} errors={$errors.text}
label="Ausführungstext bearbeiten"
bind:value={$form.text}
> >
<div class="row c-vlight"> <div class="row c-vlight">
<button type="submit" class="btn btn-sm btn-primary">Speichern</button> <button class="btn btn-sm btn-primary" type="submit">Speichern</button>
</div> </div>
</MarkdownInput> </MarkdownInput>
</form> </form>

View file

@ -1,8 +1,11 @@
import type { PageLoad } from "./$types";
import { superValidate } 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";
import { loadWrap } from "$lib/shared/util"; import { loadWrap } from "$lib/shared/util";
import { superValidate } from "sveltekit-superforms";
import type { PageLoad } from "./$types";
import { SchemaNewExecution } from "./schema"; import { SchemaNewExecution } from "./schema";
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
@ -16,7 +19,7 @@ export const load: PageLoad = async (event) => {
old_execution_id: entry.execution?.id, old_execution_id: entry.execution?.id,
...entry.execution, ...entry.execution,
}, },
SchemaNewExecution SchemaNewExecution,
); );
return { entry, form }; return { entry, form };

View file

@ -1,8 +1,9 @@
import { ZEntryExecutionNew, fields } from "$lib/shared/model/validation";
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { ZEntryExecutionNew, fields } from "$lib/shared/model/validation";
export const SchemaNewExecution = zod( export const SchemaNewExecution = zod(
ZEntryExecutionNew.extend({ ZEntryExecutionNew.extend({
old_execution_id: fields.EntityId().optional(), old_execution_id: fields.EntityId().optional(),
}) }),
); );

View file

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import UserField from "$lib/components/table/UserField.svelte";
import { formatDate } from "$lib/shared/util";
import type { PageData } from "./$types";
import Header from "$lib/components/ui/Header.svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import type { PageData } from "./$types";
import { formatDate } from "$lib/shared/util";
import UserField from "$lib/components/table/UserField.svelte";
import Header from "$lib/components/ui/Header.svelte";
export let data: PageData; export let data: PageData;
$: entryId = $page.params.id; $: entryId = $page.params.id;
@ -13,7 +15,7 @@
<title>Eintrag #{entryId} - Erledigt</title> <title>Eintrag #{entryId} - Erledigt</title>
</svelte:head> </svelte:head>
<Header title="Eintrag #{entryId} - Erledigt" backHref="/entry/{entryId}" /> <Header backHref="/entry/{entryId}" title="Eintrag #{entryId} - Erledigt" />
{#each data.versions as version, i} {#each data.versions as version, i}
<div class="card2"> <div class="card2">

Some files were not shown because too many files have changed in this diff Show more