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
entry: npm run check
files: \.(js|ts|svelte)$
- id: svelte-prettier
name: prettier
language: system
entry: npx prettier --write --ignore-unknown
types: [text]
- id: svelte-lint
name: eslint
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",
"build": "vite build",
"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:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"lint": "eslint .",
"format": "eslint . --fix",
"test:unit": "vitest",
"test:integration": "vitest --config vitest.config.integration.ts",
"test:integration": "vitest --config vitest.config.integration.js",
"test:e2e": "playwright test"
},
"dependencies": {
"@auth/core": "^0.28.2",
"@auth/core": "^0.30.0",
"@floating-ui/core": "^1.6.0",
"@mdi/js": "^7.4.47",
"@prisma/client": "^5.12.1",
"carta-md": "^4.0.0",
"diff": "^5.2.0",
"isomorphic-dompurify": "^2.6.0",
"marked": "^12.0.1",
"isomorphic-dompurify": "^2.7.0",
"qs": "^6.12.1",
"set-cookie-parser": "^2.6.0",
"svelte-floating-ui": "^1.5.8",
"zod": "^3.22.4",
@ -31,40 +32,47 @@
},
"devDependencies": {
"@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/kit": "^2.5.5",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@sveltejs/kit": "^2.5.6",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@tailwindcss/typography": "^0.5.12",
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@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",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"autoprefixer": "^10.4.19",
"daisyui": "^4.9.0",
"daisyui": "^4.10.2",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-import": "^2.29.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-nesting": "^12.1.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"prisma": "^5.12.1",
"svelte": "^4.2.12",
"svelte": "^4.2.15",
"svelte-check": "^3.6.9",
"sveltekit-superforms": "^2.12.2",
"sveltekit-superforms": "^2.12.5",
"tailwindcss": "^3.4.3",
"trpc-sveltekit": "^3.6.1",
"tslib": "^2.6.2",
"tsx": "^4.7.2",
"typescript": "^5.4.3",
"vite": "^5.2.8",
"vitest": "^1.4.0"
"typescript": "^5.4.5",
"typescript-eslint": "^7.7.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: {
command: "npm run build && npm run preview",
port: 4173,
},
testDir: "tests",
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();

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 {};

View file

@ -3,6 +3,20 @@
@tailwind components;
@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 {
text-align: initial;
}
@ -17,10 +31,6 @@ button {
text-overflow: ellipsis;
}
.border-1 {
border-width: 1px;
}
.v-form-field > input {
@apply input input-bordered w-full max-w-xs;
}
@ -34,7 +44,7 @@ button {
@apply bg-base-200;
@apply rounded-xl;
@apply flex flex-col;
@apply border-solid border-base-content/30 border-[1px];
@apply border border-solid border-base-content/30;
.row {
@apply flex flex-row;
@ -43,7 +53,7 @@ button {
.row,
.rowb {
@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 {

View file

@ -1,8 +1,7 @@
import type { HandleClientError } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client";
const CHECK_CONNECTION =
"Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
const CHECK_CONNECTION = "Die Seite konnte nicht geladen werden, prüfen sie ihre Verbindung";
export const handleError: HandleClientError = async ({ error, message, status }) => {
// 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);
if (
error instanceof TypeError &&
error.message.includes("dynamically imported module")
error instanceof TypeError
&& error.message.includes("dynamically imported module")
) {
// Could not load JS module
message = CHECK_CONNECTION;

View file

@ -2,9 +2,9 @@ import { redirect, type Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { createTRPCHandle } from "trpc-sveltekit";
import { skAuthHandle } from "$lib/server/auth";
import { createContext } from "$lib/server/trpc/context";
import { router } from "$lib/server/trpc/router";
import { skAuthHandle } from "$lib/server/auth";
/**
* Protect the application against unauthorized access.
@ -18,7 +18,7 @@ const authorization: Handle = async ({ event, resolve }) => {
if (!/^\/(login|trpc)/.test(event.url.pathname)) {
if (!event.locals.session) {
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(
skAuthHandle,
authorization,
createTRPCHandle({ router, createContext })
createTRPCHandle({ router, createContext }),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { trpc } from "$lib/shared/trpc";
import {
mdiAccount,
mdiAccountInjury,
@ -13,6 +12,9 @@ import {
mdiMagnify,
mdiTag,
} from "@mdi/js";
import { trpc } from "$lib/shared/trpc";
import type { FilterDef } from "./types";
export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
@ -21,54 +23,42 @@ export const ENTRY_FILTERS: { [key: string]: FilterDef } = {
name: "Kategorie",
icon: mdiTag,
inputType: 2,
options: async () => {
return await trpc().category.list.query();
},
options: async () => trpc().category.list.query(),
},
author: {
id: "author",
name: "Autor",
icon: mdiAccount,
inputType: 2,
options: async () => {
return await trpc().user.getNames.query();
},
options: async () => trpc().user.getNames.query(),
},
executor: {
id: "executor",
name: "Erledigt von",
icon: mdiDoctor,
inputType: 2,
options: async () => {
return await trpc().user.getNames.query();
},
options: async () => trpc().user.getNames.query(),
},
patient: {
id: "patient",
name: "Patient",
icon: mdiAccountInjury,
inputType: 2,
options: async () => {
return await trpc().patient.getNames.query();
},
options: async () => trpc().patient.getNames.query(),
},
station: {
id: "station",
name: "Station",
icon: mdiDomain,
inputType: 2,
options: async () => {
return await trpc().station.list.query();
},
options: async () => trpc().station.list.query(),
},
room: {
id: "room",
name: "Zimmer",
icon: mdiBedKingOutline,
inputType: 2,
options: async () => {
return await trpc().room.list.query();
},
options: async () => trpc().room.list.query(),
},
done: {
id: "done",
@ -100,18 +90,14 @@ export const PATIENT_FILTER: { [key: string]: FilterDef } = {
name: "Station",
icon: mdiDomain,
inputType: 2,
options: async () => {
return await trpc().station.list.query();
},
options: async () => trpc().station.list.query(),
},
room: {
id: "room",
name: "Zimmer",
icon: mdiBedKingOutline,
inputType: 2,
options: async () => {
return await trpc().room.list.query();
},
options: async () => trpc().room.list.query(),
},
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">
import FormField from "$lib/components/ui/FormField.svelte";
import Autocomplete from "$lib/components/filter/Autocomplete.svelte";
import { trpc } from "$lib/shared/trpc";
import type { RouterOutput } from "$lib/shared/trpc";
import { browser } from "$app/environment";
import { defaults, superForm } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
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);
@ -15,8 +18,11 @@
let station = patient?.room?.station;
const { form, errors, constraints, enhance, tainted } = superForm(formData, {
const {
form, errors, constraints, enhance, tainted,
} = superForm(formData, {
validators: schema,
resetForm: patient === null,
});
</script>
@ -28,31 +34,31 @@
{/if}
</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">
<FormField label="Vorname" errors={$errors.first_name}>
<FormField errors={$errors.first_name} label="Vorname">
<input
type="text"
name="first_name"
aria-invalid={Boolean($errors.first_name)}
type="text"
bind:value={$form.first_name}
{...$constraints.last_name}
/>
</FormField>
<FormField label="Nachname" errors={$errors.last_name}>
<FormField errors={$errors.last_name} label="Nachname">
<input
type="text"
name="last_name"
aria-invalid={Boolean($errors.last_name)}
type="text"
bind:value={$form.last_name}
{...$constraints.last_name}
/>
</FormField>
<FormField label="Alter" errors={$errors.age}>
<FormField errors={$errors.age} label="Alter">
<input
type="number"
name="age"
aria-invalid={Boolean($errors.age)}
type="number"
bind:value={$form.age}
{...$constraints.age}
/>
@ -60,13 +66,11 @@
</div>
<div class="flex flex-wrap gap-2">
<FormField label="Zimmer" errors={$errors.room_id}>
<FormField errors={$errors.room_id} label="Zimmer">
<Autocomplete
idInputName="room_id"
inputCls="input input-bordered w-full max-w-xs"
items={async () => {
return await trpc().room.list.query();
}}
selection={patient?.room}
items={async () => trpc().room.list.query()}
onSelect={(item) => {
station = item.station;
$form.room_id = item.id;
@ -74,15 +78,15 @@
onUnselect={() => {
$form.room_id = null;
}}
idInputName="room_id"
selection={patient?.room}
/>
</FormField>
<FormField label="Station">
<input
type="text"
class="input input-bordered w-full max-w-xs"
disabled
type="text"
value={station?.name ?? ""}
/>
</FormField>
@ -91,8 +95,8 @@
<div class="flex flex-wrap gap-2">
<button
class="btn btn-primary max-w-32"
disabled={browser && patient && $tainted === undefined}
type="submit"
disabled={browser && $tainted === undefined}
>
Speichern
</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 baseUrl = URL_ENTRIES;
export let href: string | undefined = undefined;
$: textColor = category.color
? colorToHex(getTextColor(hexToColor(category.color)))
@ -18,17 +19,26 @@
category: [{ id: category.id, name: category.name }],
},
},
baseUrl
baseUrl,
);
e.stopPropagation();
}
</script>
<button
class="badge ellipsis"
class:badge-neutral={!category.color}
style={category.color
? `color: ${textColor}; background-color: #${category.color};`
: undefined}
on:click={onClick}>{category.name}</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-neutral={!category.color}
on:click={onClick}>{category.name}</button
>
{/if}

View file

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

View file

@ -1,17 +1,21 @@
<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 { 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 entries: RouterOutput["entry"]["list"];
export let baseUrl: string;
@ -50,23 +54,23 @@
<FilterBar
FILTERS={ENTRY_FILTERS}
hiddenFilters={patientId !== null ? ["patient"] : []}
filterData={query.filter}
hiddenFilters={patientId !== null ? ["patient"] : []}
onUpdate={filterUpdate}
>
<slot />
</FilterBar>
<EntryTable
{baseUrl}
{entries}
perPatient={patientId !== null}
sortData={query.sort}
{sortUpdate}
perPatient={patientId !== null}
{baseUrl}
/>
<PaginationButtons
paginationData={query.pagination}
data={entries}
onUpdate={paginationUpdate}
paginationData={query.pagination}
/>

View file

@ -1,17 +1,21 @@
<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 { 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 patients: RouterOutput["patient"]["list"];
export let baseUrl: string;
@ -54,10 +58,10 @@
<slot />
</FilterBar>
<PatientTable {patients} sortData={query.sort} {sortUpdate} {baseUrl} />
<PatientTable {baseUrl} {patients} sortData={query.sort} {sortUpdate} />
<PaginationButtons
paginationData={query.pagination}
data={patients}
onUpdate={paginationUpdate}
paginationData={query.pagination}
/>

View file

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

View file

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

View file

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

View file

@ -1,15 +1,22 @@
<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 type { SortRequest } from "$lib/shared/model";
import Icon from "$lib/components/ui/Icon.svelte";
export let key: string;
export let title: string;
export let sortData: SortRequest | undefined = undefined;
export let sortData: SortRequest | undefined;
export let sortUpdate: (sort: SortRequest | undefined) => void = () => {};
// 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() {
if (sorting === 2) {
@ -23,7 +30,7 @@
<th>
<button class:text-primary={sorting > 0} on:click={onClick}>
{#if sorting > 0}
<Icon size={1} path={sorting === 1 ? mdiSortAscending : mdiSortDescending} />
<Icon path={sorting === 1 ? mdiSortAscending : mdiSortDescending} size={1} />
{/if}
{title}
</button>

View file

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

View file

@ -7,5 +7,5 @@
<label class="form-control w-full max-w-xs">
<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>

View file

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

View file

@ -10,7 +10,7 @@
<div class="flex flex-row">
<div class="flex flex-wrap items-center gap-2">
{#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} />
</a>
{/if}

View file

@ -15,10 +15,14 @@
</script>
<svg
viewBox="0 0 24 24"
style="--color: {color}; --size: {size}rem; --rotate: {rotate}deg; --scaleV: {scaleV}; --scaleH:
{scaleH}; --spin-duration: {absSpin}s;"
style:--color={color}
style:--size="{size}rem"
style:--scaleV={scaleV}
style:--spin-duration="{absSpin}s"
style:--scaleH={scaleH}
style:--rotate="{rotate}deg"
class={cls}
viewBox="0 0 24 24"
>
{#if title}<title>{title}</title>{/if}
@ -27,7 +31,7 @@
</g>
</svg>
<style>
<style lang="postcss">
svg {
display: inline;
vertical-align: middle;
@ -36,12 +40,14 @@
height: var(--size);
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 {
transform-origin: center;
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 {
transform-origin: center;
animation: spinReverseFrames linear var(--spin-duration) infinite;

View file

@ -12,7 +12,8 @@
showProgress = true;
showError = false;
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);
}
@ -36,8 +37,8 @@
</script>
<div
class={cls}
style:width={`${navprogress}%`}
class={cls}
class:active={alwaysShown || showProgress}
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>
<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}
{href}><slot /></a
>

View file

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

View file

@ -1,6 +1,7 @@
<!-- Button showing the amount of entry versions -->
<script lang="ts">
import { mdiHistory } from "@mdi/js";
import Icon from "./Icon.svelte";
export let n: number;
@ -8,7 +9,7 @@
</script>
{#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} />
<span>{n}</span>
</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">
import Markdown from "./Markdown.svelte";
import "./carta.pcss";
import { Carta, MarkdownEditor, type Labels } from "carta-md";
import type { InputConstraint } from "sveltekit-superforms";
import { CARTA_CFG } from "./carta";
export let label = "";
export let name = "";
export let value = "";
@ -10,32 +14,34 @@
export let constraints: InputConstraint | undefined = undefined;
export let marginTop = false;
let editMode = true;
const carta = new Carta(CARTA_CFG);
function toggle() {
editMode = !editMode;
}
const LABELS: Labels = {
writeTab: "Bearbeiten",
previewTab: "Vorschau",
iconsLabels: {
heading: "Titel",
bold: "Fett",
italic: "Kursiv",
strikethrough: "Durchgestrichen",
quote: "Zitat",
bulletedList: "Aufzählung",
numberedList: "Nummerierung",
},
};
</script>
<div class="card2" class:mt-4={marginTop}>
<div class="row c-light text-sm items-center justify-between">
<span>{label}</span>
<button type="button" class="label-text" on:click={toggle} tabindex="-1">
{editMode ? "Vorschau" : "Bearbeiten"}
</button>
</div>
<div class="p-2">
{#if editMode}
<textarea
class="textarea w-full h-48"
{name}
aria-invalid={ariaInvalid}
bind:value
{...constraints}
/>
{:else}
<Markdown src={value} />
{/if}
<MarkdownEditor
{carta}
labels={LABELS}
mode="tabs"
textarea={{ name, "aria-invalid": ariaInvalid, ...constraints }}
bind:value />
{#if errors}
<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,
} from "@auth/core";
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 { 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";
export const AUTH_CFG: AuthConfig = {
@ -61,14 +62,14 @@ ISC License
*/
function authjsUrl(event: RequestEvent, authjsEndpoint: string): string {
const url = event.url;
return url.protocol + "//" + url.host + AUTH_BASE_PATH + "/" + authjsEndpoint;
const { url } = event;
return `${url.protocol}//${url.host}${AUTH_BASE_PATH}/${authjsEndpoint}`;
}
export async function makeAuthjsRequest(
event: RequestEvent,
authjsEndpoint: string,
params: Record<string, string>
params: Record<string, string>,
) {
const headers = new Headers(event.request.headers);
headers.set("Content-Type", "application/x-www-form-urlencoded");
@ -112,12 +113,12 @@ export const skAuthHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event;
if (
url.pathname.startsWith(AUTH_BASE_PATH + "/") &&
isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
url.pathname.startsWith(`${AUTH_BASE_PATH}/`)
&& isAuthAction(url.pathname.slice(AUTH_BASE_PATH.length + 1).split("/")[0])
) {
return Auth(request, AUTH_CFG);
} else {
event.locals.session = await auth(event);
}
event.locals.session = await auth(event);
return resolve(event);
};

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import { prisma } from "$lib/server/prisma";
import type {
EntriesFilter,
Entry,
@ -13,6 +12,9 @@ import type {
} from "$lib/shared/model";
import { dateToYMD } from "$lib/shared/util";
import { ErrorConflict, ErrorInvalidInput } from "$lib/shared/util/error";
import { prisma } from "$lib/server/prisma";
import { mapEntry, mapVersion, mapExecution } from "./mapping";
import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util";
@ -86,7 +88,7 @@ export async function newEntryVersion(
author_id: number,
entry_id: number,
version: EntryVersionNew,
old_version_id: number | undefined = undefined
old_version_id: number | undefined = undefined,
): Promise<number> {
return prisma.$transaction(async (tx) => {
const entry = await tx.entry.findUniqueOrThrow({
@ -117,10 +119,10 @@ export async function newEntryVersion(
// Check if there are any updates
if (
cver?.text === updatedVersion.text &&
cver?.date.getTime() === updatedVersion.date.getTime() &&
cver?.category_id === updatedVersion.category_id &&
cver?.priority === updatedVersion.priority
cver?.text === updatedVersion.text
&& cver?.date.getTime() === updatedVersion.date.getTime()
&& cver?.category_id === updatedVersion.category_id
&& cver?.priority === updatedVersion.priority
) {
return cver.id;
}
@ -137,7 +139,7 @@ export async function newEntryExecution(
author_id: number,
entry_id: number,
execution: EntryExecutionNew,
old_execution_id: number | null | undefined = undefined
old_execution_id: number | null | undefined = undefined,
): Promise<number> {
return prisma.$transaction(async (tx) => {
const entry = await tx.entry.findUniqueOrThrow({
@ -153,8 +155,8 @@ export async function newEntryExecution(
// Check if the execution has been updated by someone else
if (
(old_execution_id && (!cex || cex.id !== old_execution_id)) ||
(old_execution_id === null && cex)
(old_execution_id && (!cex || cex.id !== old_execution_id))
|| (old_execution_id === null && cex)
) {
throw new ErrorConflict("old execution id does not match");
}
@ -179,7 +181,7 @@ export async function newEntryExecution(
export async function getEntries(
filter: EntriesFilter = {},
pagination: PaginationRequest = {},
sort: SortRequest = { field: "created_at", asc: false }
sort: SortRequest = { field: "created_at", asc: false },
): Promise<Pagination<Entry>> {
const qb = new QueryBuilder(
`select
@ -240,14 +242,14 @@ left join entry_executions ex on
left join users xau on xau.id=ex.author_id
join patients p on p.id = e.patient_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) {
const query = parseSearchQuery(filter.search);
qb.addFilterClause(
`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);
qb.addFilterClause(
`(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`,
author
author,
);
}
@ -330,51 +332,49 @@ left join stations s on s.id = r.station_id`
])) as [RowItem[], { count: bigint }[]];
const total = Number(countRes[0].count);
const items: Entry[] = res.map((item) => {
return {
id: item.id,
patient: {
id: item.patient_id,
first_name: item.patient_first_name,
last_name: item.patient_last_name,
created_at: item.patient_created_at,
age: item.patient_age,
hidden: item.patient_hidden,
room: item.room_id
? {
id: item.room_id,
name: item.room_name,
station: { id: item.station_id, name: item.station_name },
}
: null,
},
created_at: item.created_at,
current_version: {
id: item.version_id,
text: item.text,
date: dateToYMD(item.date),
category: item.category_id
? {
id: item.category_id,
name: item.category_name,
color: item.category_color,
description: null,
}
: null,
priority: item.priority,
author: { id: item.version_author_id, name: item.version_author_name },
created_at: item.version_created_at,
},
execution: item.execution_id
const items: Entry[] = res.map((item) => ({
id: item.id,
patient: {
id: item.patient_id,
first_name: item.patient_first_name,
last_name: item.patient_last_name,
created_at: item.patient_created_at,
age: item.patient_age,
hidden: item.patient_hidden,
room: item.room_id
? {
id: item.execution_id,
author: { id: item.execution_author_id, name: item.execution_author_name },
text: item.execution_text,
created_at: item.execution_created_at,
}
id: item.room_id,
name: item.room_name,
station: { id: item.station_id, name: item.station_name },
}
: null,
};
});
},
created_at: item.created_at,
current_version: {
id: item.version_id,
text: item.text,
date: dateToYMD(item.date),
category: item.category_id
? {
id: item.category_id,
name: item.category_name,
color: item.category_color,
description: null,
}
: null,
priority: item.priority,
author: { id: item.version_author_id, name: item.version_author_name },
created_at: item.version_created_at,
},
execution: item.execution_id
? {
id: item.execution_id,
author: { id: item.execution_author_id, name: item.execution_author_name },
text: item.execution_text,
created_at: item.execution_created_at,
}
: null,
}));
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 {
Entry,
Patient,
@ -10,16 +21,6 @@ import type {
} from "$lib/shared/model";
import { dateToYMD } from "$lib/shared/util";
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 DbPatientLn = DbPatient & { room: DbRoomLn | null };
@ -44,10 +45,10 @@ export function mapPatient(patient: DbPatientLn): Patient {
age: patient.age,
room: patient.room
? {
id: patient.room.id,
name: patient.room.name,
station: patient.room.station,
}
id: patient.room.id,
name: patient.room.name,
station: patient.room.station,
}
: null,
};
}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { expect, test } from "vitest";
import { QueryBuilder, parseSearchQuery } from "./util";
test("query builder", () => {
@ -9,7 +10,7 @@ test("query builder", () => {
const query = qb.getQuery();
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();
@ -20,7 +21,7 @@ test("query builder", () => {
});
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);
expect(parsed.components).toMatchObject([
{
@ -52,6 +53,6 @@ test("parse search query", () => {
const term = parsed.toTsquery();
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) {
// @ts-expect-error checked if id is present
return fl.map((itm) => itm.id);
} else {
// @ts-expect-error output type checked
return fl;
}
} else {
return [fl];
// @ts-expect-error output type checked
return fl;
}
return [fl];
}
class SearchQueryComponent {
word: string;
typ: QueryComponentType;
negative: boolean;
constructor(word: string, typ: QueryComponentType, negative: boolean) {
@ -49,16 +49,16 @@ class SearchQueryComponent {
multipleParts = parts.length > 1;
tsquery = parts.join(" <-> ");
} else if (this.typ == QueryComponentType.Trailing) {
tsquery = this.quoted() + ":*";
tsquery = `${this.quoted()}:*`;
} else {
tsquery = this.quoted();
}
if (this.negative) {
if (multipleParts) {
tsquery = "!(" + tsquery + ")";
tsquery = `!(${tsquery})`;
} else {
tsquery = "!" + tsquery;
tsquery = `!${tsquery}`;
}
}
@ -94,15 +94,14 @@ export function parseSearchQuery(q: string): SearchQueryComponents {
// Exact
if (m[2]) {
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 (
components.length > 0 &&
components[components.length - 1].typ === QueryComponentType.Normal
components.length > 0
&& components[components.length - 1].typ === QueryComponentType.Normal
) {
components[components.length - 1].typ = QueryComponentType.Trailing;
}
@ -112,12 +111,19 @@ export function parseSearchQuery(q: string): SearchQueryComponents {
export class QueryBuilder {
private selectClause;
private fromClause;
private filterClauses: string[] = [];
private orderClauses: string[] = [];
private params: unknown[] = [];
private nP = 0;
private limit = PAGINATION_LIMIT;
private offset = 0;
constructor(selectClause: string, fromClause: string) {
@ -136,7 +142,7 @@ export class QueryBuilder {
orderByFields(fields: string[], asc: boolean | undefined = undefined) {
const sortDir = asc === false ? " desc" : " asc";
const orderClause = fields.join(sortDir + ", ") + sortDir;
const orderClause = fields.join(`${sortDir}, `) + sortDir;
this.addOrderClause(orderClause);
}
@ -168,7 +174,7 @@ export class QueryBuilder {
getQuery(): string {
const queryParts = [this.selectClause, this.fromClause];
if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and "));
queryParts.push(`where ${this.filterClauses.join(" and ")}`);
}
if (this.orderClauses.length > 0) {
@ -183,7 +189,7 @@ export class QueryBuilder {
getCountQuery(): string {
const queryParts = ["select count(*) as count", this.fromClause];
if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and "));
queryParts.push(`where ${this.filterClauses.join(" and ")}`);
}
return queryParts.join(" ");
}

View file

@ -1,7 +1,8 @@
import { ZUser } from "$lib/shared/model/validation";
import type { RequestEvent } from "@sveltejs/kit";
import { type inferAsyncReturnType, TRPCError } from "@trpc/server";
import { ZUser } from "$lib/shared/model/validation";
// we're not using the event parameter is this example,
// hence the eslint-disable rule
// 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 { TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import { ErrorConflict, ErrorNotFound } from "$lib/shared/util/error";
function handleError(error: unknown): never {
if (error instanceof ZodError) {
@ -18,7 +19,7 @@ function handleError(error: unknown): never {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error.message,
cause: "Prisma error " + error.code,
cause: `Prisma error ${error.code}`,
});
}
} else if (error instanceof ErrorNotFound) {

View file

@ -1,4 +1,5 @@
import { initTRPC } from "@trpc/server";
import type { Context } from "./context";
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 { 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({
greeting: t.procedure.query(async () => {
return `Hello tRPC @ ${new Date().toLocaleTimeString()}`;
}),
greeting: t.procedure.query(
async () => `Hello tRPC @ ${new Date().toLocaleTimeString()}`,
),
testError: t.procedure.query(async () => {
throw new ErrorInvalidInput("here is your error");
}),

View file

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

View file

@ -1,4 +1,3 @@
import { t, trpcWrap } from "..";
import { z } from "zod";
import {
@ -8,6 +7,8 @@ import {
ZEntryNew,
ZEntryVersionNew,
} from "$lib/shared/model/validation";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
import {
getEntries,
getEntry,
@ -19,89 +20,70 @@ import {
newEntryExecution,
newEntryVersion,
} from "$lib/server/query";
import { executionsDiff, versionsDiff } from "$lib/shared/util/diff";
import { t, trpcWrap } from "..";
const ZEntityId = fields.EntityId();
export const entryRouter = t.router({
get: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const [entry, n_versions, n_executions] = await Promise.all([
getEntry(opts.input),
getEntryNVersions(opts.input),
getEntryNExecutions(opts.input),
]);
return { ...entry, n_versions, n_executions };
})
),
get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
const [entry, n_versions, n_executions] = await Promise.all([
getEntry(opts.input),
getEntryNVersions(opts.input),
getEntryNExecutions(opts.input),
]);
return { ...entry, n_versions, n_executions };
})),
list: t.procedure
.input(ZEntriesQuery)
.query(async (opts) =>
trpcWrap(async () =>
getEntries(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {}
)
)
),
.query(async (opts) => trpcWrap(async () => getEntries(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {},
))),
versions: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryVersions(opts.input))),
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const versions = await getEntryVersions(opts.input);
return versionsDiff(versions);
})
),
versionsDiff: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
const versions = await getEntryVersions(opts.input);
return versionsDiff(versions);
})),
executions: t.procedure
.input(ZEntityId)
.query(async (opts) => trpcWrap(async () => getEntryExecutions(opts.input))),
executionsDiff: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const executions = await getEntryExecutions(opts.input);
return executionsDiff(executions);
})
),
executionsDiff: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
const executions = await getEntryExecutions(opts.input);
return executionsDiff(executions);
})),
create: t.procedure
.input(ZEntryNew)
.mutation(async (opts) =>
trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))
),
.mutation(async (opts) => trpcWrap(async () => newEntry(opts.ctx.user.id, opts.input))),
newVersion: t.procedure
.input(
z.object({
id: ZEntityId,
version: ZEntryVersionNew,
old_version_id: ZEntityId.optional(),
})
}),
)
.mutation(async (opts) =>
trpcWrap(async () =>
newEntryVersion(
opts.ctx.user.id,
opts.input.id,
opts.input.version,
opts.input.old_version_id
)
)
),
.mutation(async (opts) => trpcWrap(async () => newEntryVersion(
opts.ctx.user.id,
opts.input.id,
opts.input.version,
opts.input.old_version_id,
))),
newExecution: t.procedure
.input(
z.object({
id: ZEntityId,
execution: ZEntryExecutionNew,
old_execution_id: ZEntityId.nullable().optional(),
})
}),
)
.mutation(async (opts) =>
trpcWrap(async () =>
newEntryExecution(
opts.ctx.user.id,
opts.input.id,
opts.input.execution,
opts.input.old_execution_id
)
)
),
.mutation(async (opts) => trpcWrap(async () => newEntryExecution(
opts.ctx.user.id,
opts.input.id,
opts.input.execution,
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 {
deletePatient,
getPatient,
@ -8,55 +12,46 @@ import {
newPatient,
updatePatient,
} from "$lib/server/query";
import { fields, ZPatientNew, ZPatientsQuery } from "$lib/shared/model/validation";
import { t, trpcWrap } from "..";
import { z } from "zod";
const ZEntityId = fields.EntityId();
export const patientRouter = t.router({
getNames: t.procedure.query(async () => trpcWrap(getPatientNames)),
get: t.procedure.input(ZEntityId).query(async (opts) =>
trpcWrap(async () => {
const [patient, n_entries] = await Promise.all([
getPatient(opts.input),
getPatientNEntries(opts.input),
]);
return { ...patient, n_entries };
})
),
list: t.procedure.input(ZPatientsQuery).query(async (opts) => {
return getPatients(
get: t.procedure.input(ZEntityId).query(async (opts) => trpcWrap(async () => {
const [patient, n_entries] = await Promise.all([
getPatient(opts.input),
getPatientNEntries(opts.input),
]);
return { ...patient, n_entries };
})),
list: t.procedure
.input(ZPatientsQuery)
.query(async (opts) => getPatients(
opts.input.filter ?? {},
opts.input.pagination ?? {},
opts.input.sort ?? {}
);
}),
opts.input.sort ?? {},
)),
create: t.procedure
.input(ZPatientNew)
.mutation(async (opts) => trpcWrap(async () => newPatient(opts.input))),
update: t.procedure
.input(z.object({ id: ZEntityId, patient: ZPatientNew.partial() }))
.mutation(async (opts) =>
trpcWrap(async () => {
await updatePatient(opts.input.id, opts.input.patient);
})
),
delete: t.procedure.input(ZEntityId).mutation(async (opts) =>
trpcWrap(async () => {
await deletePatient(opts.input);
})
),
.mutation(async (opts) => trpcWrap(async () => {
await updatePatient(opts.input.id, opts.input.patient);
})),
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deletePatient(opts.input);
})),
hide: t.procedure
.input(
z.object({
id: ZEntityId,
hidden: z.boolean().default(true),
})
}),
)
.mutation(async (opts) =>
trpcWrap(async () => {
await hidePatient(opts.input.id, opts.input.hidden);
})
),
.mutation(async (opts) => trpcWrap(async () => {
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 { 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();
export const roomRouter = t.router({
@ -10,16 +15,16 @@ export const roomRouter = t.router({
get: t.procedure
.input(ZEntityId)
.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
.input(z.object({ id: ZEntityId, category: ZRoomNew.partial() }))
.mutation(async (opts) =>
trpcWrap(async () => {
await updateRoom(opts.input.id, opts.input.category);
})
),
delete: t.procedure.input(ZEntityId).mutation(async (opts) =>
trpcWrap(async () => {
await deleteRoom(opts.input);
})
),
.input(z.object({ id: ZEntityId, room: ZRoomNew.partial() }))
.mutation(async (opts) => trpcWrap(async () => {
await updateRoom(opts.input.id, opts.input.room);
})),
delete: t.procedure.input(ZEntityId).mutation(async (opts) => trpcWrap(async () => {
await deleteRoom(opts.input);
})),
});

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { hexToColor, colorToHex } from "./colors";
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 };
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 {
@ -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 {
const primaryColor_linear =
primaryColor_sRGB < 0.03928
? primaryColor_sRGB / 12.92
: Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
const primaryColor_linear = primaryColor_sRGB < 0.03928
? primaryColor_sRGB / 12.92
: ((primaryColor_sRGB + 0.055) / 1.055) ** 2.4;
return primaryColor_linear;
}

View file

@ -1,6 +1,9 @@
import { test, expect } from "vitest";
import type { EntryVersion } from "$lib/shared/model";
import { CATEGORIES, U1, U2 } from "$tests/helpers/testdata";
import { versionsDiff } from "./diff";
test("versions diff", () => {
@ -54,9 +57,6 @@ test("versions diff", () => {
priority: true,
text: [
{
count: 38,
added: true,
removed: undefined,
value:
"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,
text: [
{ 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,
value: "-Erreger getestet werden.\n\nHierfür ist das Labor Meier zuständig.",
@ -94,7 +104,12 @@ test("versions diff", () => {
category: undefined,
text: [
{ 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: 14,

View file

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

View file

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

View file

@ -1,11 +1,15 @@
import { expect, test, vi } from "vitest";
import { humanDate } from ".";
import { expect, it, vi } from "vitest";
import type { EntityQuery } from "$lib/shared/model";
import { ZEntriesQuery } from "$lib/shared/model/validation";
import { getQueryUrl, humanDate, parseQueryUrl } from ".";
const MINUTE = 60000;
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
test.each([
it.each([
{ s: 0, txt: "jetzt gerade" },
{ s: -30 * MINUTE, txt: "vor 30 Minuten" },
{ s: -11 * HOUR, txt: "vor 11 Stunden" },
@ -30,3 +34,22 @@ test.each([
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 type { EntityQuery } from "$lib/shared/model";
import { isRedirect, error } from "@sveltejs/kit";
import { TRPCClientError } from "@trpc/client";
import { error } from "@sveltejs/kit";
import qs from "qs";
import { ZodError } from "zod";
import DOMPurify from "isomorphic-dompurify";
import type { EntityQuery } from "$lib/shared/model";
const LOCALE = "de-DE";
@ -23,13 +27,12 @@ export function formatDate(date: Date | string, time = false): string {
hour: "2-digit",
minute: "2-digit",
});
} else {
return dt.toLocaleDateString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
return dt.toLocaleDateString(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
const MS_PER_DAY = 86400000;
@ -46,7 +49,7 @@ export function humanDate(date: Date | string, time = false): string {
const dt = coerceDate(date);
const threshold = 302400000; // 3.5 * 24 * 3_600_000
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);
@ -72,25 +75,30 @@ export function formatBool(val: boolean): string {
return val ? "Ja" : "Nein";
}
export function getQueryUrl(q: EntityQuery, basePath: string): string {
if (Object.values(q).filter((q) => q !== undefined).length === 0) return basePath;
return encodeURI(basePath + "/" + encodeURIComponent(JSON.stringify(q)));
/** Encode an entity query (search and filter) into an URL */
export function getQueryUrl(query: EntityQuery, basePath: string): string {
if (Object.values(query).filter((q) => q !== undefined).length === 0) return basePath;
return basePath + "?" + qs.stringify(query);
}
export function gotoEntityQuery(q: EntityQuery, basePath: string) {
if (window && window.location.pathname.startsWith(basePath + "/")) {
const qstr = decodeURI(window.location.pathname.substring(basePath.length + 1));
if (qstr) {
const oldq: EntityQuery = JSON.parse(qstr);
const nq: EntityQuery = {
filter: { ...oldq.filter, ...q.filter },
sort: q.sort,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function parseQueryUrl(search: string): any {
return qs.parse(search, { ignoreQueryPrefix: true, allowDots: true });
}
export function gotoEntityQuery(query: EntityQuery, basePath: string) {
if (window && window.location.pathname.startsWith(`${basePath}/`)) {
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;
}
}
goto(getQueryUrl(q, basePath));
goto(getQueryUrl(query, basePath));
}
/** Wrap a page load query to handle occuring errors
@ -101,7 +109,9 @@ export async function loadWrap<T>(f: () => Promise<T>) {
try {
return await f();
} catch (e) {
if (e instanceof TRPCClientError) {
if (isRedirect(e)) {
throw e;
} else if (e instanceof TRPCClientError) {
error(e.data?.httpStatus ?? 500, e.message);
} else if (e instanceof ZodError) {
error(400, e.message);
@ -114,7 +124,7 @@ export async function loadWrap<T>(f: () => Promise<T>) {
}
export function baseUrl(url: URL): string {
return url.protocol + "//" + url.host;
return `${url.protocol}//${url.host}`;
}
export function dateToYMD(date: Date): string {
@ -123,7 +133,9 @@ export function dateToYMD(date: Date): string {
export class Debouncer {
private delay: number;
private handler: () => unknown;
private timeout: number | null = null;
constructor(delay: number, handler: () => unknown) {
@ -145,3 +157,7 @@ export class Debouncer {
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;
},
>(
schema: Schema
schema: Schema,
) => z.object(schema),
};
}

View file

@ -1,46 +1,53 @@
<script lang="ts">
import { page } from "$app/stores";
import NavLink from "$lib/components/ui/NavLink.svelte";
import { mdiAccount, mdiHome } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte";
import NavLink from "$lib/components/ui/NavLink.svelte";
</script>
<div
class="sticky top-0 z-30 flex h-12 w-full
justify-center bg-neutral"
justify-center bg-neutral"
>
<nav class="navbar w-full min-h-12">
<div class="flex flex-1">
<NavLink active={$page.route.id === "/(app)"} href="/"
><Icon
path={mdiHome}
cls={$page.route.id === "/(app)" ? "text-primary" : ""}
/></NavLink
<NavLink
active={$page.route.id === "/(app)"}
href="/"
><Icon
cls={$page.route.id === "/(app)" ? "text-primary" : ""}
path={mdiHome}
/></NavLink
>
<NavLink active={$page.route.id === "/(app)/plan/[[query]]"} href="/plan"
>Planung</NavLink
<NavLink
active={$page.route.id === "/(app)/plan"}
href="/plan"
>Planung</NavLink
>
<NavLink active={$page.route.id === "/(app)/visit"} href="/visit">Visite</NavLink>
<NavLink active={$page.route.id === "/(app)/patients/[[query]]"} href="/patients"
>Patienten</NavLink
<NavLink
active={$page.route.id === "/(app)/patients"}
href="/patients"
>Patienten</NavLink
>
</div>
<div class="flex-0">
{#if $page.data.session?.user}
<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} />
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
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="/categories">Kategorien</a></li>
<li><a href="/users">Benutzer</a></li>
<li><a href="/logout">Abmelden</a></li>
</ul>
</div>

View file

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { page } from "$app/stores";
</script>
@ -12,15 +12,14 @@
<p>Sie sind nicht angemeldet</p>
{/if}
<div
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="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-body">
<h2 class="card-title">Planung</h2>
<p>Hier können sie neue Visitenbucheinträge erstellen.</p>
<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>
@ -31,7 +30,7 @@
<p>Hier können sie Visitenbucheinträge abarbeiten.</p>
<p>Heute müssen (n) Einträge erledigt werden.</p>
<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>

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 { SchemaEntryExecution } from "./schema";
import { fail } from "@sveltejs/kit";
import { loadWrap } from "$lib/shared/util";
import { trpc } from "$lib/shared/trpc";
import { ZUrlEntityId } from "$lib/shared/model/validation";
export const actions = {
default: async (event) => {
import { fail } from "@sveltejs/kit";
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);
if (!form.valid) {
return fail(400, { form });
}
await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newExecution.mutate({
id,
old_execution_id: null,
execution: { text: form.data.text },
});
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newExecution.mutate({
id,
old_execution_id: null,
execution: { text: form.data.text },
});
},
} satisfies Actions;
}),
};

View file

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

View file

@ -1,7 +1,8 @@
import type { PageLoad } from "./$types";
import { ZUrlEntityId } from "$lib/shared/model/validation";
import { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import type { PageLoad } from "./$types";
export const load: PageLoad = async (event) => {
const entry = await loadWrap(async () => {

View file

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

View file

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

View file

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

View file

@ -1,28 +1,28 @@
import { superValidate } from "sveltekit-superforms";
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 = {
default: async (event) => {
import { fail, redirect } from "@sveltejs/kit";
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);
if (!form.valid) {
return fail(400, { form });
}
const entryId = await loadWrap(async () => {
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newExecution.mutate({
id,
execution: form.data,
old_execution_id: form.data.old_execution_id,
});
return id;
const id = ZUrlEntityId.parse(event.params.id);
await trpc(event).entry.newExecution.mutate({
id,
execution: form.data,
old_execution_id: form.data.old_execution_id,
});
throw redirect(302, `/entry/${entryId}`);
},
} satisfies Actions;
redirect(302, `/entry/${id}`);
}),
};

View file

@ -1,30 +1,34 @@
<script lang="ts">
import type { PageData } from "./$types";
import MarkdownInput from "$lib/components/ui/MarkdownInput.svelte";
import { SchemaNewExecution } from "./schema";
import { defaults, superForm } from "sveltekit-superforms";
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;
let formData = defaults(SchemaNewExecution);
const formData = defaults(SchemaNewExecution);
const { form, errors, enhance } = superForm(formData, {
validators: SchemaNewExecution,
});
</script>
<EntryBody entry={data.entry} backBtn />
<EntryBody backBtn entry={data.entry} />
<form method="POST" use:enhance>
<MarkdownInput
label="Ausführungstext bearbeiten"
name="text"
ariaInvalid={Boolean($errors.text)}
bind:value={$form.text}
errors={$errors.text}
label="Ausführungstext bearbeiten"
bind:value={$form.text}
>
<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>
</MarkdownInput>
</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 { trpc } from "$lib/shared/trpc";
import { loadWrap } from "$lib/shared/util";
import { superValidate } from "sveltekit-superforms";
import type { PageLoad } from "./$types";
import { SchemaNewExecution } from "./schema";
export const load: PageLoad = async (event) => {
@ -16,7 +19,7 @@ export const load: PageLoad = async (event) => {
old_execution_id: entry.execution?.id,
...entry.execution,
},
SchemaNewExecution
SchemaNewExecution,
);
return { entry, form };

View file

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

View file

@ -1,9 +1,11 @@
<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 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;
$: entryId = $page.params.id;
@ -13,7 +15,7 @@
<title>Eintrag #{entryId} - Erledigt</title>
</svelte:head>
<Header title="Eintrag #{entryId} - Erledigt" backHref="/entry/{entryId}" />
<Header backHref="/entry/{entryId}" title="Eintrag #{entryId} - Erledigt" />
{#each data.versions as version, i}
<div class="card2">

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