diff --git a/package-lock.json b/package-lock.json index 36826d0..a213ce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@auth/sveltekit": "^0.3.7", "@napi-rs/canvas": "^0.1.44", "@prisma/client": "^5.6.0", + "@tanstack/svelte-query": "^5.51.21", "@vercel/blob": "^0.23.3", "@vercel/speed-insights": "^1.0.3", "dotenv": "^16.3.1", @@ -1392,6 +1393,32 @@ "vite": "^4.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.51.21", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.21.tgz", + "integrity": "sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/svelte-query": { + "version": "5.51.21", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.51.21.tgz", + "integrity": "sha512-NaayXSdq6LDxcbtrdo45dliFuXz7tHfDSijDyyGQv5R7bqIaK1OJ2io+mXF75ufIj1WReR1tNb9qGBOjo8/Jqw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.51.21" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-next.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", diff --git a/package.json b/package.json index 5ff21b4..a9d2264 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@auth/sveltekit": "^0.3.7", "@napi-rs/canvas": "^0.1.44", "@prisma/client": "^5.6.0", + "@tanstack/svelte-query": "^5.51.21", "@vercel/blob": "^0.23.3", "@vercel/speed-insights": "^1.0.3", "dotenv": "^16.3.1", diff --git a/src/lib/assets/images/tool-thumbnails/AttendanceThumbnail.svelte b/src/lib/assets/images/tool-thumbnails/AttendanceThumbnail.svelte index 1b3b406..43572a4 100644 --- a/src/lib/assets/images/tool-thumbnails/AttendanceThumbnail.svelte +++ b/src/lib/assets/images/tool-thumbnails/AttendanceThumbnail.svelte @@ -1,37 +1,118 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/src/lib/server/util/permission/localizedPermissions.ts b/src/lib/server/util/permission/localizedPermissions.ts index 9ac145c..3290bab 100644 --- a/src/lib/server/util/permission/localizedPermissions.ts +++ b/src/lib/server/util/permission/localizedPermissions.ts @@ -18,5 +18,10 @@ export const localizedPermissions: LocalizedPermissionTree = { view: "View one's own lab certification", edit: "Edit one's own lab certification" } + }, + attendance: { + '*': 'Full access to attendance', + view: 'View attendance', + edit: 'Edit attendance' } }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1ff8975..d5904d5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,9 +1,19 @@ - - + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + enabled: browser + } + } + }); + - - + + + + + diff --git a/src/routes/api/people/filtered/+server.ts b/src/routes/api/people/filtered/+server.ts new file mode 100644 index 0000000..81d3f55 --- /dev/null +++ b/src/routes/api/people/filtered/+server.ts @@ -0,0 +1,69 @@ +import type { RequestHandler } from './$types'; +import prisma from '$lib/server/util/prisma'; +import { DateTime } from 'luxon'; +import { error } from '@sveltejs/kit'; + +const getFilteredPeople = async (search: string, noAttendanceOnDay: DateTime | null) => { + const people = await prisma.person.findMany({ + select: { + id: true, + name: true + }, + where: { + AND: { + OR: [ + { + name: { + contains: search, + mode: 'insensitive' + } + }, + { + email: { + contains: search, + mode: 'insensitive' + } + } + ], + attendanceLogEntries: noAttendanceOnDay + ? { + none: { + timestamp: { + lte: noAttendanceOnDay.endOf('day').toJSDate(), + gte: noAttendanceOnDay.startOf('day').toJSDate() + } + } + } + : undefined + } + }, + take: 10 + }); + + return people; +}; + +export const GET: RequestHandler = async ({ url }) => { + const { searchParams } = new URL(url); + const search = searchParams.get('search') ?? ''; + const noAttendanceOnDayString = searchParams.get('noAttendanceOnDay'); + + let noAttendanceOnDay: DateTime | null = null; + + if (noAttendanceOnDayString) { + noAttendanceOnDay = DateTime.fromFormat(noAttendanceOnDayString, 'yyyy-MM-dd'); + if (!noAttendanceOnDay.isValid) { + throw error(400, 'Invalid date'); + } + } + + const people = await getFilteredPeople(search, noAttendanceOnDay); + + return new Response(JSON.stringify(people), { + headers: { + 'Content-Type': 'application/json' + } + }); +}; + +export type FilteredPerson = Awaited>[number]; diff --git a/src/routes/tools/attendance/(calendar)/day/+page.ts b/src/routes/tools/attendance/(calendar)/day/+page.ts new file mode 100644 index 0000000..c723f4d --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/+page.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; +import { DateTime } from 'luxon'; + +export const load = () => { + // Redirect to today's date if no date is specified + throw redirect(307, `/tools/attendance/day/${DateTime.now().toFormat('yyyy-MM-dd')}`); +}; diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/+page.server.ts b/src/routes/tools/attendance/(calendar)/day/[date]/+page.server.ts new file mode 100644 index 0000000..e4f908a --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/+page.server.ts @@ -0,0 +1,151 @@ +import { getPersonFromUser } from '$lib/server/util/person/getPersonFromUser.js'; +import prisma from '$lib/server/util/prisma.js'; +import { AttendanceLogEntrySource } from '@prisma/client'; +import { error } from '@sveltejs/kit'; +import { DateTime } from 'luxon'; +import { DailyAttendanceEntry } from './dailyAttendanceEntrySchema'; +import { hasPermission } from '$lib/server/util/permission/hasPermission'; + +export const load = async ({ params }) => { + const date = DateTime.fromFormat(params.date, 'yyyy-MM-dd'); + + const entries = await prisma.attendanceLogEntry.findMany({ + where: { + timestamp: { + gte: date.startOf('day').toJSDate().toISOString(), + lte: date.endOf('day').toJSDate().toISOString() + } + }, + select: { + id: true, + timestamp: true, + personId: true, + enteredBy: { + select: { + name: true + } + }, + person: { + select: { + name: true, + id: true + } + }, + source: true, + includeTime: true + }, + orderBy: { + timestamp: 'asc' + } + }); + + const formatSource = (entry: (typeof entries)[number]) => { + if (entry.source === 'DASHBOARD') { + if (entry.enteredBy !== null) { + return `Recorded by ${entry.enteredBy.name}`; + } else { + return 'Recorded manually'; + } + } else if (entry.source === 'KIOSK') { + if (entry.includeTime) { + return `Checked in at ${DateTime.fromJSDate(entry.timestamp).toFormat('h:mm a')}`; + } else { + return `Checked in at kiosk`; + } + } + + return 'Unknown source'; + }; + + const attendanceLogEntries = entries.map((entry) => ({ + id: entry.id, + timestamp: entry.timestamp.toISOString(), + source: formatSource(entry), + person: entry.person, + enteredBy: entry.enteredBy + })) satisfies DailyAttendanceEntry[]; + + return { + date: date.toFormat('yyyy-MM-dd'), + attendanceLogEntries + }; +}; + +export const actions = { + addAttendance: async (event) => { + const session = await event.locals.getSession(); + if (!session?.user) throw error(500); + + const personAdding = await getPersonFromUser(session.user); + if (!personAdding) throw error(500); + + if (!(await hasPermission(personAdding, 'attendance.edit'))) { + throw error(403, 'You do not have permission to edit attendance'); + } + + const dateString = event.params.date; + const date = DateTime.fromFormat(dateString, 'yyyy-MM-dd'); + if (!date.isValid) throw error(400, 'Invalid date'); + + const data = await event.request.formData(); + + const personId = data.get('person')?.toString(); + if (!personId) throw error(400, 'No person provided'); + + const existingEntry = await prisma.attendanceLogEntry.findFirst({ + where: { + timestamp: date.toJSDate(), + person: { + id: personId + } + } + }); + + if (existingEntry !== null) { + throw error(400, 'Entry already exists'); + } + + await prisma.attendanceLogEntry.create({ + data: { + timestamp: date.toJSDate(), + person: { + connect: { + id: personId + } + }, + source: AttendanceLogEntrySource.DASHBOARD, + enteredBy: { + connect: { + id: personAdding.id + } + } + }, + include: { + person: true, + enteredBy: true + } + }); + }, + deleteAttendance: async (event) => { + const session = await event.locals.getSession(); + if (!session?.user) throw error(500); + + const personDeleting = await getPersonFromUser(session.user); + if (!personDeleting) throw error(500); + + if (!(await hasPermission(personDeleting, 'attendance.edit'))) { + throw error(403, 'You do not have permission to edit attendance'); + } + + const data = await event.request.formData(); + + const entryId = data.get('entry')?.toString(); + if (!entryId) throw error(400, 'No entry provided'); + + await prisma.attendanceLogEntry.delete({ + where: { + id: entryId + } + }); + } +}; diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/+page.svelte b/src/routes/tools/attendance/(calendar)/day/[date]/+page.svelte new file mode 100644 index 0000000..4ccb45c --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/+page.svelte @@ -0,0 +1,131 @@ + + + + +
+ + {#each attendanceLogEntries as entry} + + {/each} +
+ + + + diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/AddAttendanceDialog.svelte b/src/routes/tools/attendance/(calendar)/day/[date]/AddAttendanceDialog.svelte new file mode 100644 index 0000000..db642f6 --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/AddAttendanceDialog.svelte @@ -0,0 +1,92 @@ + + + +
+

Add attendee

+

If somebody attended a meeting on this day, add them here.

+ + +
+ {#if people.length > 0} +
    + {#each people as person (person.id)} + (open = false)} /> + {/each} +
+ {:else if $peopleQuery.isLoading} +
Loading...
+ {:else if $peopleQuery.isError} +
Error loading people
+ {:else if $peopleQuery.data?.length === 0} +
No people found
+ {/if} +
+
+
+ + diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/AttendanceEntry.svelte b/src/routes/tools/attendance/(calendar)/day/[date]/AttendanceEntry.svelte new file mode 100644 index 0000000..b6506dc --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/AttendanceEntry.svelte @@ -0,0 +1,88 @@ + + +
+ Profile picture of {entry.person.name} +
+
{entry.person.name}
+
{entry.source}
+
+ +
{ + return async ({ result, update }) => { + if (result.type === 'success') { + menuOpen = false; + } + + update(); + }; + }} + on:submit={() => (deleteLoading = true)} + > + + + + + Delete + + +
+
+ + diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/PersonCard.svelte b/src/routes/tools/attendance/(calendar)/day/[date]/PersonCard.svelte new file mode 100644 index 0000000..ee24886 --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/PersonCard.svelte @@ -0,0 +1,66 @@ + + +
  • +
    { + return async ({ result, update }) => { + if (result.type === 'success') { + onSuccess(); + } + + update(); + }; + }} + on:submit={() => (loading = true)} + > + + Profile picture of {person.name} +
    {person.name}
    + + + +
    +
  • + + diff --git a/src/routes/tools/attendance/(calendar)/day/[date]/dailyAttendanceEntrySchema.ts b/src/routes/tools/attendance/(calendar)/day/[date]/dailyAttendanceEntrySchema.ts new file mode 100644 index 0000000..c1f7271 --- /dev/null +++ b/src/routes/tools/attendance/(calendar)/day/[date]/dailyAttendanceEntrySchema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const dailyAttendanceEntrySchema = z.object({ + id: z.string(), + timestamp: z.string(), + source: z.string(), + person: z.object({ + id: z.string(), + name: z.string() + }), + enteredBy: z + .object({ + name: z.string() + }) + .or(z.null()) +}); + +export type DailyAttendanceEntry = z.infer; diff --git a/src/routes/tools/attendance/(calendar)/month/[month]/+page.server.ts b/src/routes/tools/attendance/(calendar)/month/[month]/+page.server.ts index ab40afa..4234fe4 100644 --- a/src/routes/tools/attendance/(calendar)/month/[month]/+page.server.ts +++ b/src/routes/tools/attendance/(calendar)/month/[month]/+page.server.ts @@ -49,7 +49,7 @@ export const load = async ({ params }) => { inMonth: DateTime.fromFormat(day, 'yyyy-MM-dd') >= firstDayOfMonth && DateTime.fromFormat(day, 'yyyy-MM-dd') <= lastDayOfMonth, - entryCount: entriesByDay[day]?.length ?? 0 + attendeeCount: entriesByDay[day]?.length ?? 0 })) }; }; diff --git a/src/routes/tools/attendance/(calendar)/month/[month]/+page.svelte b/src/routes/tools/attendance/(calendar)/month/[month]/+page.svelte index e987b4e..3b3f91f 100644 --- a/src/routes/tools/attendance/(calendar)/month/[month]/+page.svelte +++ b/src/routes/tools/attendance/(calendar)/month/[month]/+page.svelte @@ -34,14 +34,19 @@ @@ -88,6 +93,12 @@ flex-direction: column; height: 100px; border-radius: 7px; + text-decoration: none; + transition: background-color 0.1s ease-in-out; + } + + .day:hover { + background-color: var(--light-gray); } .day-number { @@ -98,14 +109,14 @@ border-bottom: 1px solid var(--light-gray-hover); } - .day-entries { + .day-attendees { text-align: center; - padding: 10px; + padding: 12px; font-size: 14px; color: var(--body); } - .day.has-attendees .day-entries { + .day.has-attendees .day-attendees { color: var(--victory-purple); } diff --git a/src/routes/tools/attendance/+layout.server.ts b/src/routes/tools/attendance/+layout.server.ts new file mode 100644 index 0000000..a9d5ca5 --- /dev/null +++ b/src/routes/tools/attendance/+layout.server.ts @@ -0,0 +1,15 @@ +import { hasPermission } from '$lib/server/util/permission/hasPermission'; +import { getPersonFromUser } from '$lib/server/util/person/getPersonFromUser'; +import { error } from '@sveltejs/kit'; + +export const load = async ({ locals }) => { + const session = await locals.getSession(); + if (!session?.user) throw error(500); + + const person = await getPersonFromUser(session.user); + if (!person) throw error(500); + + if (!(await hasPermission(person, 'attendance.view'))) { + throw error(403, 'You do not have permission to view attendance'); + } +}; diff --git a/src/routes/tools/people/[id]/ProfileCardImage.svelte b/src/routes/tools/people/[id]/ProfileCardImage.svelte index 4a4e5d6..c89e6de 100644 --- a/src/routes/tools/people/[id]/ProfileCardImage.svelte +++ b/src/routes/tools/people/[id]/ProfileCardImage.svelte @@ -44,6 +44,7 @@ border-radius: 7px; margin-bottom: 14px; overflow: hidden; + position: relative; } img {