From 362deb2172ada1aaa867e0e0f2e0ffd3c41981cb Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Sun, 5 May 2024 22:54:03 +0100 Subject: [PATCH] Introduce notes on the volunteer pages --- .../volunteers/[volunteer]/VolunteerNotes.tsx | 89 +++++++++++++ .../[team]/volunteers/[volunteer]/page.tsx | 21 ++- app/api/application/updateApplication.ts | 45 ++++++- app/api/auth/settings.ts | 1 + app/api/event/schedule/PublicSchedule.ts | 5 + app/api/event/schedule/getSchedule.ts | 4 + app/lib/Log.ts | 1 + app/lib/LogLoader.ts | 3 + app/lib/UserSettings.ts | 1 + app/lib/database/scheme/UsersEventsTable.ts | 1 + .../volunteers/[volunteer]/VolunteerPage.tsx | 20 ++- ts-sql.scheme.yaml | 120 +++++++++--------- 12 files changed, 243 insertions(+), 68 deletions(-) create mode 100644 app/admin/events/[slug]/[team]/volunteers/[volunteer]/VolunteerNotes.tsx diff --git a/app/admin/events/[slug]/[team]/volunteers/[volunteer]/VolunteerNotes.tsx b/app/admin/events/[slug]/[team]/volunteers/[volunteer]/VolunteerNotes.tsx new file mode 100644 index 00000000..cf788e0e --- /dev/null +++ b/app/admin/events/[slug]/[team]/volunteers/[volunteer]/VolunteerNotes.tsx @@ -0,0 +1,89 @@ +// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. + +'use client'; + +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { FormContainer, TextareaAutosizeElement, type FieldValues } from 'react-hook-form-mui'; + +import { SubmitCollapse } from '@app/admin/components/SubmitCollapse'; +import { callApi } from '@lib/callApi'; + +/** + * Props accepted by the component. + */ +export interface VolunteerNotesProps { + /** + * Slug of the event for which a volunteer is being displayed. + */ + event: string; + + /** + * Slug of the team that the volunteer is part of. + */ + team: string; + + /** + * Information about the volunteer themselves. + */ + volunteer: { + /** + * Unique ID of the volunteer + */ + userId: number; + + /** + * Notes associated with the volunteer and their participation. + */ + registrationNotes?: string; + }; +} + +/** + * The component displays a form in which the volunteer's notes can be read and + * changed. The same information is available in the scheduling app. + */ +export function VolunteerNotes(props: VolunteerNotesProps) { + const { event, team, volunteer } = props; + + const router = useRouter(); + + const [ error, setError ] = useState(); + const [ invalidated, setInvalidated ] = useState(false); + const [ loading, setLoading ] = useState(false); + + const handleChange = useCallback(() => setInvalidated(true), [ /* no deps */ ]); + const handleSubmit = useCallback(async (data: FieldValues) => { + setLoading(true); + setError(undefined); + try { + const response = await callApi('put', '/api/application/:event/:team/:userId', { + event, + team, + userId: volunteer.userId, + + notes: data.notes, + }); + + if (response.success) { + setInvalidated(false); + router.refresh(); + } + } catch (error: any) { + setError(error.message); + } finally { + setLoading(false); + } + }, [ event, router, team, volunteer.userId ]); + + return ( + + + + + ); +} diff --git a/app/admin/events/[slug]/[team]/volunteers/[volunteer]/page.tsx b/app/admin/events/[slug]/[team]/volunteers/[volunteer]/page.tsx index 3f63fa06..a04abbee 100644 --- a/app/admin/events/[slug]/[team]/volunteers/[volunteer]/page.tsx +++ b/app/admin/events/[slug]/[team]/volunteers/[volunteer]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'; +import EditNoteIcon from '@mui/icons-material/EditNote'; import ScheduleIcon from '@mui/icons-material/Schedule'; import type { NextPageParams } from '@lib/NextRouterParams'; @@ -23,13 +24,14 @@ import { ApplicationTrainingPreferences } from './ApplicationTrainingPreferences import { RegistrationStatus } from '@lib/database/Types'; import { VolunteerHeader } from './VolunteerHeader'; import { VolunteerIdentity } from './VolunteerIdentity'; +import { VolunteerNotes } from './VolunteerNotes'; import { VolunteerSchedule } from './VolunteerSchedule'; import { getHotelRoomOptions } from '@app/registration/[slug]/application/hotel/getHotelRoomOptions'; import { getTrainingOptions } from '@app/registration/[slug]/application/training/getTrainingOptions'; import { getPublicEventsForFestival, type EventTimeslotEntry } from '@app/registration/[slug]/application/availability/getPublicEventsForFestival'; import { getShiftsForEvent } from '@app/admin/lib/getShiftsForEvent'; import { readSetting } from '@lib/Settings'; -import { readUserSetting } from '@lib/UserSettings'; +import { readUserSettings } from '@lib/UserSettings'; type RouterParams = NextPageParams<'slug' | 'team' | 'volunteer'>; @@ -69,6 +71,7 @@ export default async function EventVolunteerPage(props: RouterParams) { roleName: tRoles.roleName, registrationDate: dbInstance.dateTimeAsString(tUsersEvents.registrationDate), registrationStatus: tUsersEvents.registrationStatus, + registrationNotes: tUsersEvents.registrationNotes, availabilityEventLimit: tUsersEvents.availabilityEventLimit, availabilityExceptions: tUsersEvents.availabilityExceptions, availabilityTimeslots: tUsersEvents.availabilityTimeslots, @@ -230,8 +233,13 @@ export default async function EventVolunteerPage(props: RouterParams) { // --------------------------------------------------------------------------------------------- const availabilityStep = await readSetting('availability-time-step-minutes'); - const defaultExpanded = - await readUserSetting(user.userId, 'user-admin-volunteers-expand-shifts'); + const settings = await readUserSettings(user.userId, [ + 'user-admin-volunteers-expand-notes', + 'user-admin-volunteers-expand-shifts', + ]); + + const notesExpanded = !!settings['user-admin-volunteers-expand-notes']; + const scheduleExpanded = !!settings['user-admin-volunteers-expand-shifts']; const scheduleSubTitle = `${schedule.length} shift${schedule.length !== 1 ? 's' : ''}`; @@ -240,9 +248,14 @@ export default async function EventVolunteerPage(props: RouterParams) { + } title="Notes" + defaultExpanded={notesExpanded} + setting="user-admin-volunteers-expand-notes"> + + { !!schedule.length && } title="Schedule" - subtitle={scheduleSubTitle} defaultExpanded={defaultExpanded} + subtitle={scheduleSubTitle} defaultExpanded={scheduleExpanded} setting="user-admin-volunteers-expand-shifts"> } diff --git a/app/api/application/updateApplication.ts b/app/api/application/updateApplication.ts index e7739268..3ae7123d 100644 --- a/app/api/application/updateApplication.ts +++ b/app/api/application/updateApplication.ts @@ -70,7 +70,13 @@ export const kUpdateApplicationDefinition = z.object({ }).optional(), //------------------------------------------------------------------------------------------ - // Update type (3): Application status + // Update type (3): Notes + //------------------------------------------------------------------------------------------ + + notes: z.string().optional(), + + //------------------------------------------------------------------------------------------ + // Update type (4): Application status //------------------------------------------------------------------------------------------ status: z.object({ @@ -135,6 +141,7 @@ export async function updateApplication(request: Request, props: ActionProps): P const { eventId, teamId, username } = requestContext; let affectedRows: number = 0; + let skipLog: boolean = false; //---------------------------------------------------------------------------------------------- // Update type (1): Application data @@ -184,7 +191,39 @@ export async function updateApplication(request: Request, props: ActionProps): P } //---------------------------------------------------------------------------------------------- - // Update type (3): Application status + // Update type (3): Application notes + //---------------------------------------------------------------------------------------------- + + if (typeof request.notes === 'string') { + executeAccessCheck(props.authenticationContext, { + check: 'admin-event', + event: request.event, + }); + + affectedRows = await db.update(tUsersEvents) + .set({ + registrationNotes: !!request.notes.length ? request.notes : undefined, + }) + .where(tUsersEvents.userId.equals(request.userId)) + .and(tUsersEvents.eventId.equals(eventId)) + .and(tUsersEvents.teamId.equals(teamId)) + .executeUpdate(); + + skipLog = true; + + await Log({ + type: LogType.EventVolunteerNotes, + severity: LogSeverity.Info, + sourceUser: props.user, + targetUser: request.userId, + data: { + event: requestContext.event, + }, + }); + } + + //---------------------------------------------------------------------------------------------- + // Update type (4): Application status //---------------------------------------------------------------------------------------------- if (request.status) { @@ -260,7 +299,7 @@ export async function updateApplication(request: Request, props: ActionProps): P // --------------------------------------------------------------------------------------------- - if (!!affectedRows) { + if (!!affectedRows && !skipLog) { await Log({ type: LogType.AdminUpdateTeamVolunteer, severity: LogSeverity.Info, diff --git a/app/api/auth/settings.ts b/app/api/auth/settings.ts index a87f4585..20ad1bc4 100644 --- a/app/api/auth/settings.ts +++ b/app/api/auth/settings.ts @@ -59,6 +59,7 @@ export async function settings(request: Request, props: ActionProps): Promise { return `${mutation} a help request from ${display} for ${event}`; }, + [LogType.EventVolunteerNotes]: (source, target, { event }) => { + return `Updated notes for ${target?.name} during ${event}`; + }, [LogType.ExportDataAccess]: (source, target, { event, type }) => { return `Accessed exported ${event} ${type} data`; diff --git a/app/lib/UserSettings.ts b/app/lib/UserSettings.ts index e7858377..52e309bd 100644 --- a/app/lib/UserSettings.ts +++ b/app/lib/UserSettings.ts @@ -22,6 +22,7 @@ export type UserSettingsMap = { 'user-admin-shifts-expand-shifts': boolean; 'user-admin-volunteers-columns-filter': string; 'user-admin-volunteers-columns-hidden': string; + 'user-admin-volunteers-expand-notes': boolean; 'user-admin-volunteers-expand-shifts': boolean; }; diff --git a/app/lib/database/scheme/UsersEventsTable.ts b/app/lib/database/scheme/UsersEventsTable.ts index 1308c93f..f7be1396 100644 --- a/app/lib/database/scheme/UsersEventsTable.ts +++ b/app/lib/database/scheme/UsersEventsTable.ts @@ -27,6 +27,7 @@ export class UsersEventsTable extends Table { roleId = this.column('role_id', 'int'); registrationDate = this.optionalColumnWithDefaultValue('registration_date', 'customLocalDateTime', 'dateTime', TemporalTypeAdapter); registrationStatus = this.columnWithDefaultValue('registration_status', 'enum', 'RegistrationStatus'); + registrationNotes = this.optionalColumnWithDefaultValue('registration_notes', 'string'); shirtFit = this.columnWithDefaultValue('shirt_fit', 'enum', 'ShirtFit'); shirtSize = this.optionalColumnWithDefaultValue('shirt_size', 'enum', 'ShirtSize'); hotelEligible = this.optionalColumnWithDefaultValue('hotel_eligible', 'int'); diff --git a/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx b/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx index e0cfb469..d79264f3 100644 --- a/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx +++ b/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx @@ -8,16 +8,19 @@ import { useCallback, useContext } from 'react'; import { useRouter } from 'next/navigation'; import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; import CardHeader from '@mui/material/CardHeader'; import IconButton from '@mui/material/IconButton'; +import NotesIcon from '@mui/icons-material/Notes'; import PhoneIcon from '@mui/icons-material/Phone'; import Stack from '@mui/material/Stack'; import Tooltip from '@mui/material/Tooltip'; import WhatsAppIcon from '@mui/icons-material/WhatsApp'; import { Alert } from '../../components/Alert'; -import { Avatar } from '@app/components/Avatar'; +import { Avatar } from '@components/Avatar'; +import { Markdown } from '@components/Markdown'; import { ScheduleContext } from '../../ScheduleContext'; import { SetTitle } from '../../components/SetTitle'; import { callApi } from '@lib/callApi'; @@ -120,14 +123,14 @@ export function VolunteerPage(props: VolunteerPageProps) { { !!phoneNumber && - + } { !!whatsAppNumber && - + } @@ -139,8 +142,17 @@ export function VolunteerPage(props: VolunteerPageProps) { } /> - { /* TODO: Notes */ } + { !!volunteer.notes && + + + + + + {volunteer.notes} + + } { /* TODO: Schedule */ } + { /* TODO: Notes editor */ } ); } diff --git a/ts-sql.scheme.yaml b/ts-sql.scheme.yaml index 9f0ca1fe..a96f21bc 100644 --- a/ts-sql.scheme.yaml +++ b/ts-sql.scheme.yaml @@ -1242,6 +1242,11 @@ tables: nullable: false default: "'Registered'" comment: "" + - name: registration_notes + type: text + nullable: true + default: "NULL" + comment: "" - name: shirt_fit type: enum('Regular','Girly') nullable: false @@ -1405,6 +1410,7 @@ tables: `role_id` int(4) unsigned NOT NULL, `registration_date` datetime DEFAULT NULL, `registration_status` enum('Registered','Cancelled','Accepted','Rejected') NOT NULL DEFAULT 'Registered', + `registration_notes` text DEFAULT NULL, `shirt_fit` enum('Regular','Girly') NOT NULL DEFAULT 'Regular', `shirt_size` enum('XS','S','M','L','XL','XXL','3XL','4XL') DEFAULT NULL, `hotel_eligible` tinyint(1) unsigned DEFAULT NULL, @@ -3559,6 +3565,63 @@ tables: PRIMARY KEY (`outbox_twilio_id`), KEY `outbox_result_sid` (`outbox_result_sid`) ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +- name: subscriptions_publications + type: BASE TABLE + comment: "" + columns: + - name: publication_id + type: int(8) unsigned + nullable: false + default: null + extraDef: auto_increment + comment: "" + - name: publication_user_id + type: int(8) unsigned + nullable: true + default: "NULL" + comment: "" + - name: publication_subscription_type + type: enum('Application','Help','Registration','Test') + nullable: false + default: null + comment: "" + - name: publication_subscription_type_id + type: int(8) unsigned + nullable: true + default: "NULL" + comment: "" + - name: publication_created + type: datetime + nullable: false + default: null + comment: "" + indexes: + - name: PRIMARY + def: PRIMARY KEY (publication_id) USING BTREE + table: subscriptions_publications + columns: + - publication_id + comment: "" + constraints: + - name: PRIMARY + type: PRIMARY KEY + def: PRIMARY KEY (publication_id) + table: subscriptions_publications + referencedTable: null + columns: + - publication_id + referencedColumns: [] + comment: "" + triggers: [] + def: |- + CREATE TABLE `subscriptions_publications` ( + `publication_id` int(8) unsigned NOT NULL AUTO_INCREMENT, + `publication_user_id` int(8) unsigned DEFAULT NULL, + `publication_subscription_type` enum('Application','Help','Registration','Test') NOT NULL, + `publication_subscription_type_id` int(8) unsigned DEFAULT NULL, + `publication_created` datetime NOT NULL, + PRIMARY KEY (`publication_id`) + ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - name: subscriptions type: BASE TABLE comment: "" @@ -4836,63 +4899,6 @@ tables: PRIMARY KEY (`vendors_schedule_id`), KEY `vendor_id` (`vendor_id`) ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -- name: subscriptions_publications - type: BASE TABLE - comment: "" - columns: - - name: publication_id - type: int(8) unsigned - nullable: false - default: null - extraDef: auto_increment - comment: "" - - name: publication_user_id - type: int(8) unsigned - nullable: true - default: "NULL" - comment: "" - - name: publication_subscription_type - type: enum('Application','Registration','Test') - nullable: false - default: null - comment: "" - - name: publication_subscription_type_id - type: int(8) unsigned - nullable: true - default: "NULL" - comment: "" - - name: publication_created - type: datetime - nullable: false - default: null - comment: "" - indexes: - - name: PRIMARY - def: PRIMARY KEY (publication_id) USING BTREE - table: subscriptions_publications - columns: - - publication_id - comment: "" - constraints: - - name: PRIMARY - type: PRIMARY KEY - def: PRIMARY KEY (publication_id) - table: subscriptions_publications - referencedTable: null - columns: - - publication_id - referencedColumns: [] - comment: "" - triggers: [] - def: |- - CREATE TABLE `subscriptions_publications` ( - `publication_id` int(8) unsigned NOT NULL AUTO_INCREMENT, - `publication_user_id` int(8) unsigned DEFAULT NULL, - `publication_subscription_type` enum('Application','Registration','Test') NOT NULL, - `publication_subscription_type_id` int(8) unsigned DEFAULT NULL, - `publication_created` datetime NOT NULL, - PRIMARY KEY (`publication_id`) - ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - name: events_sales type: BASE TABLE comment: ""