diff --git a/app/api/application/updateApplication.ts b/app/api/application/updateApplication.ts index 3ae7123d..86aebafb 100644 --- a/app/api/application/updateApplication.ts +++ b/app/api/application/updateApplication.ts @@ -218,6 +218,7 @@ export async function updateApplication(request: Request, props: ActionProps): P targetUser: request.userId, data: { event: requestContext.event, + notes: request.notes, }, }); } diff --git a/app/api/event/schedule/PublicSchedule.ts b/app/api/event/schedule/PublicSchedule.ts index c1886b67..0e15b561 100644 --- a/app/api/event/schedule/PublicSchedule.ts +++ b/app/api/event/schedule/PublicSchedule.ts @@ -90,6 +90,11 @@ export const kPublicSchedule = z.strictObject({ */ enableKnowledgeBaseSearch: z.boolean(), + /** + * Whether the volunteer is able to edit notes of other volunteers. + */ + enableNotesEditor: z.boolean(), + /** * Amount of fuzziness to apply to the search results. While this allows minor compensation * for typos, a high value could lead to less relevant results being presented to the user. diff --git a/app/api/event/schedule/getSchedule.ts b/app/api/event/schedule/getSchedule.ts index 9fb91463..3d6fecd4 100644 --- a/app/api/event/schedule/getSchedule.ts +++ b/app/api/event/schedule/getSchedule.ts @@ -389,6 +389,7 @@ export async function getSchedule(request: Request, props: ActionProps): Promise if (!event || !event.festivalId) notFound(); + // TODO: Should `isLeader` also contain senior volunteers w/o the event administrator bit? let isLeader: boolean = can(props.user, Privilege.EventAdministrator); let team: string | undefined; @@ -425,6 +426,7 @@ export async function getSchedule(request: Request, props: ActionProps): Promise enableHelpRequests: can(props.user, Privilege.EventHelpRequests), enableKnowledgeBase: settings['schedule-knowledge-base'] ?? false, enableKnowledgeBaseSearch: settings['schedule-knowledge-base-search'] ?? false, + enableNotesEditor: isLeader, searchResultFuzziness: settings['schedule-search-candidate-fuzziness'] ?? 0.04, searchResultLimit: settings['schedule-search-result-limit'] ?? 5, searchResultMinimumScore: settings['schedule-search-candidate-minimum-score'] ?? 0.37, diff --git a/app/api/event/schedule/notes/route.ts b/app/api/event/schedule/notes/route.ts new file mode 100644 index 00000000..c2051121 --- /dev/null +++ b/app/api/event/schedule/notes/route.ts @@ -0,0 +1,14 @@ +// 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. + +import { NextRequest } from 'next/server'; +import { executeAction } from '../../../Action'; + +import { updateNotes, kUpdateNotesDefinition } from '../updateNotes'; + +/** + * The /api/event/schedule/notes endpoint can be used to update the notes of a particular volunteer. + */ +export async function PUT(request: NextRequest): Promise { + return executeAction(request, kUpdateNotesDefinition, updateNotes); +} diff --git a/app/api/event/schedule/updateNotes.ts b/app/api/event/schedule/updateNotes.ts new file mode 100644 index 00000000..d2202eea --- /dev/null +++ b/app/api/event/schedule/updateNotes.ts @@ -0,0 +1,86 @@ +// 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. + +import { notFound } from 'next/navigation'; +import { z } from 'zod'; + +import type { ActionProps } from '../../Action'; +import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types'; +import { LogSeverity, RegistrationStatus } from '@lib/database/Types'; +import { Log, LogType } from '@lib/Log'; +import { getEventBySlug } from '@lib/EventLoader'; +import db, { tUsersEvents } from '@lib/database'; + +/** + * Interface definition for the Schedule API, exposed through /api/event/schedule/notes + */ +export const kUpdateNotesDefinition = z.object({ + request: z.object({ + /** + * Unique slug of the event with which the notes should be associated. + */ + event: z.string(), + + /** + * Unique ID of the user whose notes should be updated. + */ + userId: z.number(), + + /** + * The notes as they should be stored in the database. May be empty. + */ + notes: z.string(), + }), + response: z.strictObject({ + /** + * Whether the notes was updated successfully. + */ + success: z.boolean(), + + /** + * Error message when something went wrong. Will be presented to the user. + */ + error: z.string().optional(), + }), +}); + +export type UpdateNotesDefinition = ApiDefinition; + +type Request = ApiRequest; +type Response = ApiResponse; + +/** + * API through which the notes associated with a volunteer can be updated. + */ +export async function updateNotes(request: Request, props: ActionProps): Promise { + if (!props.user || !props.authenticationContext.user) + notFound(); + + const event = await getEventBySlug(request.event); + if (!event) + notFound(); + + const affectedRows = await db.update(tUsersEvents) + .set({ + registrationNotes: !!request.notes.length ? request.notes : undefined + }) + .where(tUsersEvents.eventId.equals(event.id)) + .and(tUsersEvents.userId.equals(request.userId)) + .and(tUsersEvents.registrationStatus.equals(RegistrationStatus.Accepted)) + .executeUpdate(); + + if (!!affectedRows) { + await Log({ + type: LogType.EventVolunteerNotes, + severity: LogSeverity.Info, + sourceUser: props.user, + targetUser: request.userId, + data: { + event: event.shortName, + notes: request.notes, + }, + }); + } + + return { success: !!affectedRows }; +} diff --git a/app/lib/callApi.ts b/app/lib/callApi.ts index eedc4ea5..40630214 100644 --- a/app/lib/callApi.ts +++ b/app/lib/callApi.ts @@ -46,6 +46,7 @@ import type { UpdateAvatarDefinition } from '@app/api/auth/updateAvatar'; import type { UpdateEventDefinition } from '@app/api/admin/updateEvent'; import type { UpdateHelpRequestDefinition } from '@app/api/event/schedule/updateHelpRequest'; import type { UpdateIntegrationDefinition } from '@app/api/admin/updateIntegration'; +import type { UpdateNotesDefinition } from '@app/api/event/schedule/updateNotes'; import type { UpdatePermissionsDefinition } from '@app/api/admin/updatePermissions'; import type { UpdatePublicationDefinition } from '@app/api/admin/updatePublication'; import type { UpdateScheduleEntryDefinition } from '@app/api/admin/event/schedule/updateScheduleEntry'; @@ -252,6 +253,7 @@ export type ApiEndpoints = { '/api/ai/settings': UpdateAiSettingsDefinition, '/api/application/:event/:team/:userId': UpdateApplicationDefinition, '/api/event/schedule/help-request': UpdateHelpRequestDefinition, + '/api/event/schedule/notes': UpdateNotesDefinition, '/api/nardo/:id': NardoEndpoints['update'], }, }; diff --git a/app/schedule/[event]/components/NotesEditorDialog.tsx b/app/schedule/[event]/components/NotesEditorDialog.tsx new file mode 100644 index 00000000..5e6bc4fe --- /dev/null +++ b/app/schedule/[event]/components/NotesEditorDialog.tsx @@ -0,0 +1,111 @@ +// 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, useEffect, useState, type ChangeEvent } from 'react'; + +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import LoadingButton from '@mui/lab/LoadingButton'; +import TextField from '@mui/material/TextField'; + +/** + * Props accepted by the component. + */ +export interface NotesEditorDialogProps { + /** + * To be called when the notes dialog has been closed. + */ + onClose?: () => void; + + /** + * To be called when the updates notes are to be submitted. When the return value is truthy, + * the dialog will be closed, whereas an error will be shown in case of a failure. + */ + onSubmit?: (notes: string) => Promise; + + /** + * The notes that are stored for the context at the moment. + */ + notes?: string; + + /** + * Whether the dialog should be presented to the user. + */ + open?: boolean; +} + +/** + * The component displays a dialog, when opened, in which the notes for a given + * event or volunteer can be changed. Markdown is supported in updated notes. + */ +export default function NotesEditorDialog(props: NotesEditorDialogProps) { + const { onClose, onSubmit, open } = props; + + const [ error, setError ] = useState(false); + const [ loading, setLoading ] = useState(false); + + const [ notes, setNotes ] = useState(props.notes || ''); + + useEffect(() => setNotes(props.notes || ''), [ props.notes ]); + + const handleClose = useCallback(() => { + setTimeout(() => { + setError(false); + setNotes(props.notes || ''); + }, 350); + + if (!!onClose) + onClose(); + + }, [ onClose, props.notes, setNotes ]); + + const handleUpdateNotes = useCallback((event: ChangeEvent) => { + setNotes(event.target.value); + + }, [ /* no deps */ ]); + + const handleSubmit = useCallback(async () => { + setError(false); + setLoading(true); + try { + if (!!await onSubmit?.(notes)) + handleClose(); + else + setError(true); + } catch (error: any) { + setError(true); + } finally { + setLoading(false); + } + }, [ handleClose, notes, onSubmit ]); + + return ( + + + What should we keep in mind? + + + + + + The notes could not be saved. Try again later? + + + + + + + Save + + + + ); +} diff --git a/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx b/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx index d79264f3..d94d99fd 100644 --- a/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx +++ b/app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx @@ -4,13 +4,15 @@ 'use client'; import Link from 'next/link'; -import { useCallback, useContext } from 'react'; +import dynamic from 'next/dynamic'; +import { useCallback, useContext, useState } 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 EditNoteIcon from '@mui/icons-material/EditNote'; import IconButton from '@mui/material/IconButton'; import NotesIcon from '@mui/icons-material/Notes'; import PhoneIcon from '@mui/icons-material/Phone'; @@ -25,6 +27,10 @@ import { ScheduleContext } from '../../ScheduleContext'; import { SetTitle } from '../../components/SetTitle'; import { callApi } from '@lib/callApi'; +const NotesEditorDialog = dynamic(() => import('../../components/NotesEditorDialog'), { + ssr: false, +}); + /** * Props accepted by the component. */ @@ -87,7 +93,30 @@ export function VolunteerPage(props: VolunteerPageProps) { // --------------------------------------------------------------------------------------------- // Notes management: // --------------------------------------------------------------------------------------------- - // TODO + + const [ noteEditorOpen, setNoteEditorOpen ] = useState(false); + + const handleCloseNotes = useCallback(() => setNoteEditorOpen(false), [ /* no deps */ ]); + const handleOpenNotes = useCallback(() => setNoteEditorOpen(true), [ /* no deps */ ]); + + const handleSubmitNotes = useCallback(async (notes: string) => { + if (!schedule) + return false; // unable to update notes when the event is not known + + const response = await callApi('put', '/api/event/schedule/notes', { + event: schedule.slug, + userId: parseInt(props.userId, /* radix= */ 10), + notes, + }); + + if (!!response.success) { + refresh?.(); + router.refresh(); + } + + return response.success; + + }, [ props.userId, refresh, router, schedule ]); // --------------------------------------------------------------------------------------------- @@ -120,6 +149,12 @@ export function VolunteerPage(props: VolunteerPageProps) { sx={{ '& .MuiCardHeader-action': { alignSelf: 'center' } }} action={ + { !!schedule.config.enableNotesEditor && + + + + + } { !!phoneNumber && @@ -152,7 +187,9 @@ export function VolunteerPage(props: VolunteerPageProps) { } { /* TODO: Schedule */ } - { /* TODO: Notes editor */ } + { !!schedule.config.enableNotesEditor && + } ); }