diff --git a/package.json b/package.json index 800f518..916f998 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "dependencies": { "@auth/core": "^0.18.6", "@auth/sveltekit": "^0.5.3", + "@floating-ui/core": "^1.6.0", "@mdi/js": "^7.4.47", "@prisma/client": "^5.8.1", + "svelte-floating-ui": "^1.5.8", "zod": "^3.22.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23d163d..9af6efd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,18 @@ dependencies: '@auth/sveltekit': specifier: ^0.5.3 version: 0.5.3(@sveltejs/kit@2.5.0)(svelte@4.2.9) + '@floating-ui/core': + specifier: ^1.6.0 + version: 1.6.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 '@prisma/client': specifier: ^5.8.1 version: 5.8.1(prisma@5.8.1) + svelte-floating-ui: + specifier: ^1.5.8 + version: 1.5.8 zod: specifier: ^3.22.4 version: 3.22.4 @@ -417,6 +423,23 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} dev: true + /@floating-ui/core@1.6.0: + resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} + dependencies: + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/dom@1.6.0: + resolution: {integrity: sha512-SZ0BEXzsaaS6THZfZJUcAobbZTD+MvfGM42bxgeg0Tnkp4/an/avqwAXiVLsFtIBZtfsx3Ymvwx0+KnnhdA/9g==} + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/utils@0.2.1: + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -2834,6 +2857,13 @@ packages: svelte: 4.2.9 dev: true + /svelte-floating-ui@1.5.8: + resolution: {integrity: sha512-dVvJhZ2bT+kQDHlE4Lep8t+sgEc0XD96fXLzAi2DDI2bsaegBbClxXVNMma0C2WsG+n9GJSYx292dTvA8CYRtw==} + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/dom': 1.6.0 + dev: false + /svelte-hmr@0.15.3(svelte@4.2.9): resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} engines: {node: ^12.20 || ^14.13.1 || >= 16} diff --git a/prisma/migrations/20240128145410_entry_id_index/migration.sql b/prisma/migrations/20240128145410_entry_id_index/migration.sql new file mode 100644 index 0000000..9d4a176 --- /dev/null +++ b/prisma/migrations/20240128145410_entry_id_index/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "entry_executions_entry_id_idx" ON "entry_executions"("entry_id"); + +-- CreateIndex +CREATE INDEX "entry_versions_entry_id_idx" ON "entry_versions"("entry_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa81260..aebefd9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,8 +80,8 @@ model Patient { full_name String? @default(dbgenerated("((first_name || ' '::text) || last_name)")) @ignore - @@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin) @@map("patients") + @@index([full_name(ops: raw("gin_trgm_ops"))], map: "patients_full_name", type: Gin) } // Entry category (e.g. Blood test, Exams, ...) @@ -128,6 +128,7 @@ model EntryVersion { created_at DateTime @default(now()) @@map("entry_versions") + @@index([entry_id]) } model EntryExecution { @@ -143,4 +144,5 @@ model EntryExecution { created_at DateTime @default(now()) @@map("entry_executions") + @@index([entry_id]) } diff --git a/src/app.d.ts b/src/app.d.ts index 899c7e8..9741a58 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,6 +7,13 @@ declare global { // interface PageData {} // interface Platform {} } + + declare namespace svelteHTML { + // Custom events (https://stackoverflow.com/a/75279911) + interface HTMLAttributes { + "on:outclick"?: CompositionEventHandler; + } + } } export {}; diff --git a/src/app.pcss b/src/app.pcss index 1a7b7cf..d7adec1 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -2,3 +2,9 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.ellipsis { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e0e58d1..2b69631 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -17,9 +17,11 @@ import { router } from "$lib/server/trpc/router"; /** * Protect the application against unauthorized access. * If the user is not logged in, all requests get redirected to the login page + * with the exception of the login page and the TRPC API (which has its own + * auth mechanism) */ const authorization: Handle = async ({ event, resolve }) => { - if (event.url.pathname !== "/login") { + if (!/^\/(login|trpc)/.test(event.url.pathname)) { const session = await event.locals.getSession(); if (!session) { const params = new URLSearchParams({ returnURL: event.url.pathname }); @@ -44,6 +46,25 @@ export const handle = sequence( session: { strategy: "jwt", }, + callbacks: { + // Add user ID to session token + jwt({ token, account, user }) { + if (account) { + token.accessToken = account.access_token; + token.id = user?.id; + } + return token; + }, + session(opt) { + // @ts-expect-error because of union type + if (opt.session.user && opt.token.id) { + // @ts-expect-error because of union type + opt.session.user.id = opt.token.id; + } + + return opt.session; + }, + }, }), authorization, createTRPCHandle({ router, createContext }) diff --git a/src/lib/actions/outclick.ts b/src/lib/actions/outclick.ts new file mode 100644 index 0000000..8811649 --- /dev/null +++ b/src/lib/actions/outclick.ts @@ -0,0 +1,17 @@ +export default function clickOutside(node: Element) { + const handleClick = (event: MouseEvent) => { + const tnode = event.target as Element; + + if (!node.contains(tnode)) { + node.dispatchEvent(new CustomEvent("outclick")); + } + }; + + document.addEventListener("click", handleClick, true); + + return { + destroy() { + document.removeEventListener("click", handleClick, true); + }, + }; +} diff --git a/src/lib/components/filter/Autocomplete.svelte b/src/lib/components/filter/Autocomplete.svelte new file mode 100644 index 0000000..7b55b89 --- /dev/null +++ b/src/lib/components/filter/Autocomplete.svelte @@ -0,0 +1,262 @@ + + +
+ + + {#if opened && filteredItems.length > 0} +
+ {#each filteredItems as item, i} +
selectListItem(item, false)} + on:keypress={(e) => { + e.key == "Enter" && selectListItem(item, true); + }} + on:pointerenter={() => { + highlightIndex = i; + }} + > + {#if item.icon} + + {/if} + {item.name} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/filter/FilterBar.svelte b/src/lib/components/filter/FilterBar.svelte new file mode 100644 index 0000000..feeb41d --- /dev/null +++ b/src/lib/components/filter/FilterBar.svelte @@ -0,0 +1,198 @@ + + +
+
+ {#each activeFilters as fdata, i} + getHiddenIds(fdata.id, i)} + {cache} + onRemove={() => removeFilter(i)} + onSelection={(sel, kb) => { + updateFilter(); + if (kb) focusInput(); + }} + /> + {/each} + { + const close = addFilter(item); + return { newValue: "", close }; + }} + onBackspace={() => { + activeFilters.pop(); + activeFilters = activeFilters; + updateFilter(); + }} + /> + +
+
diff --git a/src/lib/components/filter/FilterChip.svelte b/src/lib/components/filter/FilterChip.svelte new file mode 100644 index 0000000..cdb002d --- /dev/null +++ b/src/lib/components/filter/FilterChip.svelte @@ -0,0 +1,127 @@ + + +
+ + + {#if hasInputField} + {#if fdata.editing} + {#if filter.inputType === 2} + {@const hids = hiddenIds()} + { + // 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 || "" }; + } + }} + {onClose} + onBackspace={onRemove} + /> + {:else} + + { + if (e.key === "Escape") onClose(true); + }} + on:keypress={(e) => { + if (e.key === "Enter") { + // @ts-expect-error Input value is checked + if (e.target?.value) { + // @ts-expect-error Input value is checked + fdata.selection = { id: null, name: e.target.value }; + } + stopEditing(true); + } + }} + /> + {/if} + {:else} + + {/if} + {/if} + +
diff --git a/src/lib/components/filter/filters.ts b/src/lib/components/filter/filters.ts new file mode 100644 index 0000000..aeecefd --- /dev/null +++ b/src/lib/components/filter/filters.ts @@ -0,0 +1,93 @@ +import { trpc } from "$lib/shared/trpc"; +import { + mdiAccount, + mdiAccountInjury, + mdiBedKingOutline, + mdiCheckboxBlankOutline, + mdiCheckboxOutline, + mdiDoctor, + mdiDomain, + mdiExclamation, + mdiMagnify, + mdiTag, +} from "@mdi/js"; +import type { FilterDef } from "./types"; + +export const ENTRY_FILTERS: { [key: string]: FilterDef } = { + category: { + id: "category", + name: "Kategorie", + icon: mdiTag, + inputType: 2, + options: async () => { + return await trpc().category.list.query(); + }, + }, + author: { + id: "author", + name: "Autor", + icon: mdiAccount, + inputType: 2, + options: async () => { + return await trpc().user.getNames.query(); + }, + }, + executor: { + id: "executor", + name: "Erledigt von", + icon: mdiDoctor, + inputType: 2, + options: async () => { + return await trpc().user.getNames.query(); + }, + }, + patient: { + id: "patient", + name: "Patient", + icon: mdiAccountInjury, + inputType: 2, + options: async () => { + return await trpc().patient.getNames.query(); + }, + }, + station: { + id: "station", + name: "Station", + icon: mdiDomain, + inputType: 2, + options: async () => { + return await trpc().station.list.query(); + }, + }, + room: { + id: "room", + name: "Zimmer", + icon: mdiBedKingOutline, + inputType: 2, + options: async () => { + return await trpc().room.list.query(); + }, + }, + done: { + id: "done", + name: "Erledigt", + icon: mdiCheckboxOutline, + inputType: 3, + toggleOff: { + name: "Zu erledigen", + icon: mdiCheckboxBlankOutline, + }, + }, + priority: { + id: "priority", + name: "Priorität", + icon: mdiExclamation, + inputType: 0, + }, + search: { + id: "search", + name: "Beschreibung", + icon: mdiMagnify, + inputType: 1, + }, +}; diff --git a/src/lib/components/filter/types.ts b/src/lib/components/filter/types.ts new file mode 100644 index 0000000..ee41b66 --- /dev/null +++ b/src/lib/components/filter/types.ts @@ -0,0 +1,39 @@ +export type BaseItem = { + id: string | number; + name: string; + icon?: string; + toggle?: boolean; +}; + +export type SelectionOrText = { + id?: string | number; + name?: string; + toggle?: boolean; +}; + +export type FilterDef = { + id: string; + name: string; + icon?: string; + // 0: No input (value: true); 1: Free text input; 2: Filter list; 3: Boolean switch + inputType: 0 | 1 | 2 | 3; + toggleOff?: { + name: string; + icon?: string; + }; + options?: () => Promise; +}; + +export type FilterData = { + id: string; + selection: SelectionOrText | null; + editing: boolean; +}; + +export type FilterQdata = { + [key: string]: string | number | boolean | { id: string | number; name?: string }[]; +}; + +export function isFilterValueless(inputType: 0 | 1 | 2 | 3): boolean { + return inputType === 0 || inputType === 3; +} diff --git a/src/lib/components/table/EntryTable.svelte b/src/lib/components/table/EntryTable.svelte new file mode 100644 index 0000000..c3cf44e --- /dev/null +++ b/src/lib/components/table/EntryTable.svelte @@ -0,0 +1,47 @@ + + +
+ + + + + + + + + + + + + + + {#each entries.items as entry} + + + + + + + + + + + {/each} + +
PatientZimmerKategorieErstellt amZu erledigen amAutorBeschreibungErledigt
{#if entry.patient.room}{/if}{entry.current_version.category?.name}{formatDate(entry.current_version.created_at, true)}{formatDate(entry.current_version.date)}{entry.current_version.author.name}{entry.current_version.text} + {#if entry.execution} + {formatDate(entry.execution.created_at, true)} + {entry.execution.author.name} + {/if} +
+
diff --git a/src/lib/components/table/PatientField.svelte b/src/lib/components/table/PatientField.svelte new file mode 100644 index 0000000..6a6b01b --- /dev/null +++ b/src/lib/components/table/PatientField.svelte @@ -0,0 +1,7 @@ + + +{`${patient.first_name} ${patient.last_name} (${patient.age})`} diff --git a/src/lib/components/table/RoomField.svelte b/src/lib/components/table/RoomField.svelte new file mode 100644 index 0000000..f28e124 --- /dev/null +++ b/src/lib/components/table/RoomField.svelte @@ -0,0 +1,7 @@ + + +{`${room.name} (${room.station.name})`} diff --git a/src/lib/components/ui/NavLink.svelte b/src/lib/components/ui/NavLink.svelte index d678d75..1141da7 100644 --- a/src/lib/components/ui/NavLink.svelte +++ b/src/lib/components/ui/NavLink.svelte @@ -7,7 +7,7 @@
diff --git a/src/lib/components/ui/PaginationButtons.svelte b/src/lib/components/ui/PaginationButtons.svelte new file mode 100644 index 0000000..fab6e4f --- /dev/null +++ b/src/lib/components/ui/PaginationButtons.svelte @@ -0,0 +1,73 @@ + + +
+

+ {data.offset + 1}-{data.offset + data.items.length} von {data.total} +

+ +
+ + + {#each { length: windowTop + 1 - windowBottom } as _, i} + {@const n = windowBottom + i} + + {/each} + + +
+
diff --git a/src/lib/server/authAdapter.ts b/src/lib/server/authAdapter.ts index a0b255f..c33fff9 100644 --- a/src/lib/server/authAdapter.ts +++ b/src/lib/server/authAdapter.ts @@ -43,7 +43,7 @@ export function PrismaAdapter(p: PrismaClient): Adapter { }) ), getUser: async (id) => - mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })), + mapUserOpt(await p.user.findUnique({ where: { id: Number(id) } })), getUserByEmail: async (email) => mapUserOpt(await p.user.findUnique({ where: { email } })), async getUserByAccount(provider_providerAccountId) { @@ -54,14 +54,14 @@ export function PrismaAdapter(p: PrismaClient): Adapter { return mapUserOpt(account?.user) ?? null; }, updateUser: async ({ id, ...data }) => - mapUser(await p.user.update({ where: { id: parseInt(id) }, data })), + mapUser(await p.user.update({ where: { id: Number(id) }, data })), deleteUser: async (id) => - mapUser(await p.user.delete({ where: { id: parseInt(id) } })), + mapUser(await p.user.delete({ where: { id: Number(id) } })), linkAccount: async (data) => mapAccount( await p.account.create({ data: { - user_id: parseInt(data.userId), + user_id: Number(data.userId), type: data.type, provider: data.provider, providerAccountId: data.providerAccountId, diff --git a/src/lib/server/query/entry.ts b/src/lib/server/query/entry.ts index a2a3917..97f9e5c 100644 --- a/src/lib/server/query/entry.ts +++ b/src/lib/server/query/entry.ts @@ -12,7 +12,7 @@ import type { } from "$lib/shared/model"; import { ErrorConflict } from "$lib/shared/util/error"; import { mapEntry, mapVersion, mapExecution } from "./mapping"; -import { QueryBuilder, parseSearchQuery } from "./util"; +import { QueryBuilder, filterListToArray, parseSearchQuery } from "./util"; const USER_SELECT = { select: { id: true, name: true } }; @@ -236,12 +236,9 @@ join stations s on s.id = r.station_id` qb.addFilter("ev.priority", filter?.priority); if (filter?.author) { - let author = filter?.author; - if (!Array.isArray(author)) { - author = [author]; - } + 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)`, + `(${qb.pvar()})::integer[] && (select array_agg(ev2.author_id) from entry_versions ev2 where ev2.entry_id=e.id)`, author ); } diff --git a/src/lib/server/query/mapping.ts b/src/lib/server/query/mapping.ts index 45f5bf2..4b4a963 100644 --- a/src/lib/server/query/mapping.ts +++ b/src/lib/server/query/mapping.ts @@ -6,6 +6,7 @@ import type { Room, EntryVersion, EntryExecution, + UserTagNameNonnull, } from "$lib/shared/model"; import { ErrorNotFound } from "$lib/shared/util/error"; import type { @@ -54,10 +55,14 @@ export function mapUser(user: DbUser): User { return { id: user.id, name: user.name, email: user.email }; } -export function mapUserTag(user: DbUser): UserTag { +export function mapUserTag(user: Omit): UserTag { return { id: user.id, name: user.name }; } +export function mapUserTagNameNonnull(user: Omit): UserTagNameNonnull { + return { id: user.id, name: user.name || "" }; +} + export function mapRoom(room: DbRoomLn): Room { return { id: room.id, name: room.name, station: room.station }; } diff --git a/src/lib/server/query/patient.ts b/src/lib/server/query/patient.ts index d1b9ace..b338f27 100644 --- a/src/lib/server/query/patient.ts +++ b/src/lib/server/query/patient.ts @@ -4,6 +4,7 @@ import type { Pagination, PaginationRequest, PatientsFilter, + PatientTag, } from "$lib/shared/model"; import { prisma } from "$lib/server/prisma"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -51,6 +52,17 @@ export async function getPatient(id: number): Promise { return mapPatient(patient); } +export async function getPatientNames(): Promise { + const patients = await prisma.patient.findMany({ + select: { id: true, first_name: true, last_name: true }, + where: { hidden: false }, + orderBy: { last_name: "asc" }, + }); + return patients.map((p) => { + return { id: p.id, name: p.last_name + ", " + p.first_name }; + }); +} + export async function getPatients( filter: PatientsFilter, pagination: PaginationRequest diff --git a/src/lib/server/query/room.ts b/src/lib/server/query/room.ts index 0a2d5ab..b3f13a4 100644 --- a/src/lib/server/query/room.ts +++ b/src/lib/server/query/room.ts @@ -51,7 +51,7 @@ export async function getRoom(id: number): Promise { export async function getRooms(): Promise { const rooms = await prisma.room.findMany({ include: { station: true }, - orderBy: { name: "asc" }, + orderBy: [{ station: { name: "asc" } }, { name: "asc" }], }); return rooms.map(mapRoom); } diff --git a/src/lib/server/query/user.ts b/src/lib/server/query/user.ts index da7ce65..b873db2 100644 --- a/src/lib/server/query/user.ts +++ b/src/lib/server/query/user.ts @@ -1,6 +1,11 @@ -import type { Pagination, PaginationRequest, User, UserTag } from "$lib/shared/model"; +import type { + Pagination, + PaginationRequest, + User, + UserTagNameNonnull, +} from "$lib/shared/model"; import { prisma } from "$lib/server/prisma"; -import { mapUser, mapUserTag } from "./mapping"; +import { mapUser, mapUserTagNameNonnull } from "./mapping"; import { PAGINATION_LIMIT } from "$lib/shared/constants"; export async function getUser(id: number): Promise { @@ -10,7 +15,7 @@ export async function getUser(id: number): Promise { export async function getUsers( pagination: PaginationRequest -): Promise> { +): Promise> { const offset = pagination.offset || 0; const [users, total] = await Promise.all([ prisma.user.findMany({ @@ -21,8 +26,17 @@ export async function getUsers( prisma.user.count(), ]); return { - items: users.map(mapUserTag), + items: users.map(mapUser), offset, total, }; } + +export async function getUserNames(): Promise { + const users = await prisma.user.findMany({ + select: { id: true, name: true }, + where: { name: { not: null } }, + orderBy: { id: "asc" }, + }); + return users.map(mapUserTagNameNonnull); +} diff --git a/src/lib/server/query/util.test.ts b/src/lib/server/query/util.test.ts index 03489e2..f657562 100644 --- a/src/lib/server/query/util.test.ts +++ b/src/lib/server/query/util.test.ts @@ -9,7 +9,7 @@ test("query builder", () => { const query = qb.getQuery(); expect(query).toBe( - "select e.id, e.text, e.category from entries e where category in $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(); diff --git a/src/lib/server/query/util.ts b/src/lib/server/query/util.ts index 4c841fb..c7dda1d 100644 --- a/src/lib/server/query/util.ts +++ b/src/lib/server/query/util.ts @@ -1,24 +1,27 @@ import { PAGINATION_LIMIT } from "$lib/shared/constants"; import type { FilterList, PaginationRequest } from "$lib/shared/model"; -export function convertFilterList( - fl: FilterList | undefined -): { in: T[] } | T | undefined { - if (!fl) { - return undefined; - } else if (Array.isArray(fl)) { - return { in: fl }; - } else { - return fl; - } -} - enum QueryComponentType { Normal = 1, Exact, Trailing, } +export function filterListToArray(fl: FilterList): T[] { + if (Array.isArray(fl)) { + // @ts-expect-error checked if id is present + 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]; + } +} + class SearchQueryComponent { word: string; typ: QueryComponentType; @@ -149,12 +152,8 @@ export class QueryBuilder { addFilterList(fname: string, fl: FilterList | undefined) { if (fl === undefined) return; - this.params.push(fl); - if (Array.isArray(fl)) { - this.filterClauses.push(`${fname} in ${this.pvar()}`); - } else { - this.filterClauses.push(`${fname} = ${this.pvar()}`); - } + this.filterClauses.push(`${fname} = any (${this.pvar()})`); + this.params.push(filterListToArray(fl)); } getQuery(): string { diff --git a/src/lib/server/trpc/context.ts b/src/lib/server/trpc/context.ts index 09e1a63..9219979 100644 --- a/src/lib/server/trpc/context.ts +++ b/src/lib/server/trpc/context.ts @@ -9,7 +9,7 @@ export async function createContext(event: RequestEvent) { const session = await event.locals.getSession(); if (!session?.user) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "no session" }); + throw new TRPCError({ code: "UNAUTHORIZED", message: "not logged in" }); } const user = ZUser.parse(session?.user); diff --git a/src/lib/server/trpc/router.ts b/src/lib/server/trpc/router.ts index a8ea9e2..ec3ffe5 100644 --- a/src/lib/server/trpc/router.ts +++ b/src/lib/server/trpc/router.ts @@ -2,13 +2,21 @@ 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"; export const router = t.router({ greeting: t.procedure.query(async () => { - return `Hello tRPC v10 @ ${new Date().toLocaleTimeString()}`; + return `Hello tRPC @ ${new Date().toLocaleTimeString()}`; }), category: categoryRouter, entry: entryRouter, + station: stationRouter, + room: roomRouter, + patient: patientRouter, + user: userRouter, }); export type Router = typeof router; diff --git a/src/lib/server/trpc/routes/patient.ts b/src/lib/server/trpc/routes/patient.ts new file mode 100644 index 0000000..a148076 --- /dev/null +++ b/src/lib/server/trpc/routes/patient.ts @@ -0,0 +1,13 @@ +import { getPatientNames, getPatients } from "$lib/server/query"; +import { ZPagination, ZPatientsFilter } from "$lib/shared/model/validation"; +import { t } from ".."; +import { z } from "zod"; + +export const patientRouter = t.router({ + getNames: t.procedure.query(getPatientNames), + list: t.procedure + .input(z.object({ filter: ZPatientsFilter, pagination: ZPagination }).partial()) + .query(async (opts) => { + return getPatients(opts.input.filter || {}, opts.input.pagination || {}); + }), +}); diff --git a/src/lib/server/trpc/routes/room.ts b/src/lib/server/trpc/routes/room.ts new file mode 100644 index 0000000..145cb43 --- /dev/null +++ b/src/lib/server/trpc/routes/room.ts @@ -0,0 +1,7 @@ +import { getRooms } from "$lib/server/query"; +import { t } from ".."; +// import { z } from "zod"; + +export const roomRouter = t.router({ + list: t.procedure.query(getRooms), +}); diff --git a/src/lib/server/trpc/routes/station.ts b/src/lib/server/trpc/routes/station.ts new file mode 100644 index 0000000..b4cbc9f --- /dev/null +++ b/src/lib/server/trpc/routes/station.ts @@ -0,0 +1,7 @@ +import { getStations } from "$lib/server/query"; +import { t } from ".."; +// import { z } from "zod"; + +export const stationRouter = t.router({ + list: t.procedure.query(getStations), +}); diff --git a/src/lib/server/trpc/routes/user.ts b/src/lib/server/trpc/routes/user.ts new file mode 100644 index 0000000..fae8b89 --- /dev/null +++ b/src/lib/server/trpc/routes/user.ts @@ -0,0 +1,13 @@ +import { getUserNames, getUsers } from "$lib/server/query"; +import { ZPagination } from "$lib/shared/model/validation"; +import { t } from ".."; +import { z } from "zod"; + +export const userRouter = t.router({ + list: t.procedure + .input(z.object({ pagination: ZPagination }).partial()) + .query(async (opts) => { + return getUsers(opts.input.pagination || {}); + }), + getNames: t.procedure.query(getUserNames), +}); diff --git a/src/lib/shared/model/model.ts b/src/lib/shared/model/model.ts index 9761060..b9b7726 100644 --- a/src/lib/shared/model/model.ts +++ b/src/lib/shared/model/model.ts @@ -30,6 +30,11 @@ export type UserTag = { name: Option; }; +export type UserTagNameNonnull = { + id: number; + name: string; +}; + export type Station = { id: number; name: string; @@ -67,6 +72,11 @@ export type Patient = { created_at: Date; }; +export type PatientTag = { + id: number; + name: string; +}; + export type PatientNew = { first_name: string; last_name: string; diff --git a/src/lib/shared/model/requests.ts b/src/lib/shared/model/requests.ts index 96c2e5f..7f909d9 100644 --- a/src/lib/shared/model/requests.ts +++ b/src/lib/shared/model/requests.ts @@ -1,9 +1,9 @@ export type PaginationRequest = Partial<{ - limit: number; - offset: number; + limit: number | undefined; + offset: number | undefined; }>; -export type FilterList = T | T[]; +export type FilterList = T | T[] | { id: T }[]; export type EntriesFilter = Partial<{ search: string; diff --git a/src/lib/shared/model/validation.ts b/src/lib/shared/model/validation.ts index bada4c8..1cdd726 100644 --- a/src/lib/shared/model/validation.ts +++ b/src/lib/shared/model/validation.ts @@ -18,7 +18,7 @@ const ZNameString = z.string().min(1).max(200).trim(); const ZTextString = z.string().trim(); export const ZUser = implement().with({ - id: ZEntityId, + id: z.coerce.number().int().nonnegative(), name: z.string().nullable(), email: z.string().nullable(), }); @@ -73,12 +73,10 @@ export const ZPagination = implement().with({ offset: ZEntityId.optional(), }); -// const ZFilterList = z -// .string() -// .regex(/^\d+(;\d+)*$/) -// .transform((s) => s.split(";").map(Number)) -// .optional(); -const ZFilterList = z.array(ZEntityId).or(ZEntityId); +const ZFilterListEntry = z.object({ id: ZEntityId, name: ZNameString.optional() }); +const ZFilterList = z.array(ZFilterListEntry); +const paginatedQuery = (f: z.ZodTypeAny) => + z.object({ filter: f, pagination: ZPagination }).partial(); export const ZEntriesFilter = z .object({ @@ -94,7 +92,9 @@ export const ZEntriesFilter = z }) .partial(); -export const ZPatientsFilterUrl = z +export const ZEntriesQuery = paginatedQuery(ZEntriesFilter); + +export const ZPatientsFilter = z .object({ search: z.string(), room: ZFilterList, @@ -102,3 +102,5 @@ export const ZPatientsFilterUrl = z hidden: z.boolean(), }) .partial(); + +export const ZPatientsQuery = paginatedQuery(ZPatientsFilter); diff --git a/src/lib/shared/trpc.ts b/src/lib/shared/trpc.ts index cde6393..b331cf9 100644 --- a/src/lib/shared/trpc.ts +++ b/src/lib/shared/trpc.ts @@ -1,6 +1,10 @@ +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import type { Router } from "$lib/server/trpc/router"; import { createTRPCClient, type TRPCClientInit } from "trpc-sveltekit"; +export type RouterInput = inferRouterInputs; +export type RouterOutput = inferRouterOutputs; + let browserClient: ReturnType>; /** Get a new tRPC client diff --git a/src/lib/shared/util/index.ts b/src/lib/shared/util/index.ts new file mode 100644 index 0000000..731bf14 --- /dev/null +++ b/src/lib/shared/util/index.ts @@ -0,0 +1,21 @@ +export function formatDate(date: Date | string, time = false): string { + let dt = date; + if (!(dt instanceof Date)) { + dt = new Date(dt); + } + if (time) { + return dt.toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } else { + return dt.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index b10ceca..3d542a0 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -8,10 +8,10 @@
-
-
+
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index a8bd2a5..e81153d 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -2,30 +2,37 @@ import { page } from "$app/stores"; -
-

SvelteKit Auth Example

+ + Visitenbuch + + +{#if $page.data.session?.user} +

Hallo, {$page.data.session.user.name}

+{:else} +

Sie sind nicht angemeldet

+{/if} + +
+
+
+

Planung

+

Hier können sie neue Visitenbucheinträge erstellen.

+
+ Planung +
+
+
+ +
+
+

Visite

+

Hier können sie Visitenbucheinträge abarbeiten.

+

Heute müssen (n) Einträge erledigt werden.

+
+ Visite +
+
+
- -

- {#if $page.data.session} - {#if $page.data.session.user?.image} - - {/if} - - Signed in as
- {$page.data.session.user?.name ?? "User"} -
- {:else} - You are not signed in - {/if} -

- -

- - {JSON.stringify($page.data.session)} - -

-

{new Date().toUTCString()}

diff --git a/src/routes/(app)/plan/+page.svelte b/src/routes/(app)/plan/+page.svelte index 05a9630..db97e38 100644 --- a/src/routes/(app)/plan/+page.svelte +++ b/src/routes/(app)/plan/+page.svelte @@ -1,10 +1,67 @@ -

Planung

+ + Visitenbuch - Planung + -

{data.greeting}

-

{JSON.stringify(data.categories)}

+ + +{#if loading} +

Loading...

+{/if} + + + + diff --git a/src/routes/(app)/plan/+page.ts b/src/routes/(app)/plan/+page.ts index d2ee5a6..a3028c4 100644 --- a/src/routes/(app)/plan/+page.ts +++ b/src/routes/(app)/plan/+page.ts @@ -1,11 +1,18 @@ +import { z } from "zod"; + +import { ZEntriesQuery } from "$lib/shared/model/validation"; import { trpc } from "$lib/shared/trpc"; import type { PageLoad } from "./$types"; export const load: PageLoad = async (event) => { - const [greeting, categories] = await Promise.all([ - trpc(event).greeting.query(), - trpc(event).category.list.query(), - ]); + const q = event.url.searchParams.get("q"); + let query: z.infer = {}; - return { greeting, categories }; + if (q) { + query = ZEntriesQuery.parse(JSON.parse(q)); + } + + const entries = await trpc(event).entry.list.query(query); + + return { query, entries }; }; diff --git a/src/routes/(app)/visit/+page.svelte b/src/routes/(app)/visit/+page.svelte index a91baca..8c9961a 100644 --- a/src/routes/(app)/visit/+page.svelte +++ b/src/routes/(app)/visit/+page.svelte @@ -1,4 +1,8 @@ + + Visitenbuch - Visite + +

Visite

diff --git a/tests/integration/query/entry.ts b/tests/integration/query/entry.ts index d6cca4b..03815e0 100644 --- a/tests/integration/query/entry.ts +++ b/tests/integration/query/entry.ts @@ -204,9 +204,11 @@ test("get entries", async () => { // Filter by category const entriesCategory = await getEntries({ category: 3 }, {}); + const entriesCategory2 = await getEntries({ category: [3] }, {}); expect(entriesCategory.items).length(1); expect(entriesCategory.total).toBe(1); expect(entriesCategory.items[0].id).toBe(eId1); + expect(entriesCategory2).toStrictEqual(entriesCategory); // Filter by author const entriesAuthor = await getEntries({ author: 2 }, {}); diff --git a/tests/integration/query/user.ts b/tests/integration/query/user.ts index 300def0..01458b0 100644 --- a/tests/integration/query/user.ts +++ b/tests/integration/query/user.ts @@ -17,10 +17,12 @@ test("get users", async () => { { id: 1, name: "Sven Schulz", + email: "sven.schulz@example.com", }, { id: 2, name: "Sabrina Loewe", + email: "sabrina.loewe@example.com", }, ], offset: 0, diff --git a/tsconfig.json b/tsconfig.json index 794b95b..9fea023 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,8 @@ "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true - } + "strict": true, + }, // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes