diff --git a/app/admin/components/CollapsableSection.tsx b/app/admin/components/CollapsableSection.tsx new file mode 100644 index 00000000..f53f6755 --- /dev/null +++ b/app/admin/components/CollapsableSection.tsx @@ -0,0 +1,31 @@ +// 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 Collapse from '@mui/material/Collapse'; + +import { Section, type SectionProps } from './Section'; + +/** + * Props accepted by the component, that are directly owned by the component. + */ +export type CollapsableSectionProps = SectionProps & { + /** + * Whether the section should be transitioned in. + */ + in?: boolean; +} + +/** + * The component represents a visually separated section of a page in the + * administration area that, unlike
, can be hidden and shown dynamically. The component + * is designed to be compatible with server-side rendering. + */ +export function CollapsableSection(props: React.PropsWithChildren) { + const { in: transitionedIn, ...sectionProps } = props; + + return ( + +
+ + ); +} diff --git a/app/admin/components/ExcitementIcon.tsx b/app/admin/components/ExcitementIcon.tsx new file mode 100644 index 00000000..7679fb7c --- /dev/null +++ b/app/admin/components/ExcitementIcon.tsx @@ -0,0 +1,58 @@ +// 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 SentimentDissatisfiedIcon from '@mui/icons-material/SentimentDissatisfied'; +import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt'; +import SentimentSatisfiedIcon from '@mui/icons-material/SentimentSatisfied'; +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; +import SentimentVerySatisfiedIcon from '@mui/icons-material/SentimentVerySatisfied'; +import Tooltip from '@mui/material/Tooltip'; + +/** + * Props accepted by the component. + */ +export interface ExcitementIconProps { + /** + * The excitement level, must be between 0 and 1. + */ + excitement: number; +} + +/** + * The component returns an appropriate component visualising the excitement that + * is indicated in the given `props`. + */ +export function ExcitementIcon(props: ExcitementIconProps) { + const { excitement } = props; + if (excitement <= 0.2) { + return ( + + + + ); + } else if (excitement <= 0.4) { + return ( + + + + ); + } else if (excitement <= 0.6) { + return ( + + + + ); + } else if (excitement <= 0.8) { + return ( + + + + ); + } else { + return ( + + + + ); + } +} diff --git a/app/admin/components/SectionHeader.tsx b/app/admin/components/SectionHeader.tsx index e6f6e821..ece02fca 100644 --- a/app/admin/components/SectionHeader.tsx +++ b/app/admin/components/SectionHeader.tsx @@ -1,12 +1,21 @@ // 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 type { Privilege } from '@lib/auth/Privileges'; import Typography, { type TypographyProps } from '@mui/material/Typography'; /** * Props accepted by the component. */ export interface SectionHeaderProps { + // TODO: Action + + /** + * Privilege behind which availability of this section is gated, to inform the volunteer that + * not everyone has access to this information. + */ + privilege?: Privilege; + /** * Title of this section. Required. */ @@ -17,9 +26,6 @@ export interface SectionHeaderProps { */ subtitle?: string; - // TODO: Privilege - // TODO: Action - /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/app/admin/events/[slug]/[team]/faq/[id]/page.tsx b/app/admin/events/[slug]/[team]/faq/[id]/page.tsx index a1b83a28..4ff9f54b 100644 --- a/app/admin/events/[slug]/[team]/faq/[id]/page.tsx +++ b/app/admin/events/[slug]/[team]/faq/[id]/page.tsx @@ -5,7 +5,6 @@ import { notFound } from 'next/navigation'; import type { NextRouterParams } from '@lib/NextRouterParams'; import { ContentEditor } from '@app/admin/content/ContentEditor'; -import { SectionHeader } from '@app/admin/components/SectionHeader'; import { createKnowledgeBaseScope } from '@app/admin/content/ContentScope'; import { generateEventMetadataFn } from '../../../generateEventMetadataFn'; import { verifyAccessAndFetchPageInfo } from '@app/admin/events/verifyAccessAndFetchPageInfo'; @@ -22,9 +21,8 @@ export default async function EventFaqEntryPage(props: NextRouterParams<'slug' | const scope = createKnowledgeBaseScope(event.id); return ( - - - + ); } diff --git a/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx b/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx new file mode 100644 index 00000000..6c5790d7 --- /dev/null +++ b/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx @@ -0,0 +1,140 @@ +// 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 Link from 'next/link'; + +import { default as MuiLink } from '@mui/material/Link'; +import NewReleasesIcon from '@mui/icons-material/NewReleases'; +import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import type { EventShiftContext, EventShiftRowModel } from '@app/api/admin/event/shifts/[[...id]]/route'; +import { ExcitementIcon } from '@app/admin/components/ExcitementIcon'; +import { RemoteDataTable, type RemoteDataTableColumn } from '@app/admin/components/RemoteDataTable'; + +/** + * Formats the given number of `minutes` to a HH:MM string. + */ +function formatMinutes(minutes: number): string { + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes - hours * 60; + + return remainingMinutes ? `${hours}:${('00' + remainingMinutes).substr(-2)}` + : `${hours}`; +} + +/** + * Props accepted by the component. + */ +export type ShiftTableProps = EventShiftContext['context'] & { + /** + * Whether the shift table should be shown in read only mode. + */ + readOnly?: boolean; +}; + +/** + * The component is a Data Table that allows rows to be shown and deleted, which + * displays the shifts that exist for a particular { event, team } pair. + */ +export function ShiftTable(props: ShiftTableProps) { + const { readOnly, ...context } = props; + + const deleteColumn: RemoteDataTableColumn[] = []; + if (!readOnly) { + deleteColumn.push({ + field: 'id', + headerName: /* empty= */ '', + sortable: false, + width: 50, + }); + } + + const columns: RemoteDataTableColumn[] = [ + ...deleteColumn, + { + field: 'name', + headerName: 'Shift', + flex: 1, + + renderCell: params => + + {params.value} + , + }, + { + field: 'hours', + headerName: 'Scheduled', + flex: 1, + + renderCell: params => { + if (!params.value) { + return ( + + … + + ); + } + + return <>{formatMinutes(params.value)} hours; + }, + }, + { + field: 'activityId', + headerAlign: 'center', + headerName: /* empty= */ '', + sortable: false, + align: 'center', + width: 50, + + renderHeader: params => + + + , + + renderCell: params => { + if (!params.value) { + return ( + + + + ); + } + + const href = `../program/activities/${params.row.activityId}`; + return ( + + + + + + ); + } + }, + { + field: 'excitement', + headerAlign: 'center', + headerName: /* empty= */ '', + sortable: false, + align: 'center', + width: 50, + + renderHeader: params => + + + , + + renderCell: params => + , + } + ]; + + return ( + + ); +} diff --git a/app/admin/events/[slug]/[team]/shifts/page.tsx b/app/admin/events/[slug]/[team]/shifts/page.tsx index a06f2185..0d9ca1f4 100644 --- a/app/admin/events/[slug]/[team]/shifts/page.tsx +++ b/app/admin/events/[slug]/[team]/shifts/page.tsx @@ -2,8 +2,11 @@ // Use of this source code is governed by a MIT license that can be found in the LICENSE file. import type { NextRouterParams } from '@lib/NextRouterParams'; +import { CollapsableSection } from '@app/admin/components/CollapsableSection'; +import { Privilege, can } from '@lib/auth/Privileges'; import { Section } from '@app/admin/components/Section'; import { SectionIntroduction } from '@app/admin/components/SectionIntroduction'; +import { ShiftTable } from './ShiftTable'; import { generateEventMetadataFn } from '../../generateEventMetadataFn'; import { verifyAccessAndFetchPageInfo } from '@app/admin/events/verifyAccessAndFetchPageInfo'; @@ -13,17 +16,31 @@ import { verifyAccessAndFetchPageInfo } from '@app/admin/events/verifyAccessAndF * end up out of sync. */ export default async function EventTeamShiftsPage(props: NextRouterParams<'slug' | 'team'>) { - const { event, team } = await verifyAccessAndFetchPageInfo(props.params); + const { event, team, user } = await verifyAccessAndFetchPageInfo(props.params); // TODO: Mutable list with all the shifts that exist during the convention. // TODO: Box with warnings regarding the shifts (e.g. out-of-sync entries). + const readOnly = !can(user, Privilege.EventShiftManagement); + const warnings: any[] = [ ]; + return ( -
- - The shifts tool has not been implemented yet. - -
+ <> +
+ +
+ + + The shifts tool has not been implemented yet. + + + { !readOnly && +
+ + The shifts tool has not been implemented yet. + +
} + ); } diff --git a/app/admin/events/[slug]/program/requests/RequestDataTable.tsx b/app/admin/events/[slug]/program/requests/RequestDataTable.tsx index d9b31919..10896662 100644 --- a/app/admin/events/[slug]/program/requests/RequestDataTable.tsx +++ b/app/admin/events/[slug]/program/requests/RequestDataTable.tsx @@ -152,8 +152,7 @@ export function RequestDataTable(props: RequestDataTableProps) { return params.value; return ( - + ); diff --git a/app/api/admin/event/shifts/[[...id]]/route.ts b/app/api/admin/event/shifts/[[...id]]/route.ts new file mode 100644 index 00000000..12a801b9 --- /dev/null +++ b/app/api/admin/event/shifts/[[...id]]/route.ts @@ -0,0 +1,217 @@ +// 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 DataTableEndpoints, createDataTableApi } from '../../../../createDataTableApi'; +import { Privilege } from '@lib/auth/Privileges'; +import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import db, { tActivities, tEventsTeams, tEvents, tTeams, tSchedule, tShifts } from '@lib/database'; + +/** + * Row model for a team's shifts. The shifts are fully mutable, even though the create and edit + * operations don't happen in a data table. + */ +const kEventShiftRowModel = z.object({ + /** + * Unique ID of the team as it exists in the database. + */ + id: z.number(), + + /** + * Name of the shifts, describing what the volunteer will be doing. + */ + name: z.string().optional(), + + /** + * Number of hours that volunteers have been scheduled to work on this shift. + */ + hours: z.number().optional(), + + /** + * Unique ID of the program activity with which this shift is associated. + */ + activityId: z.number().optional(), + + /** + * Name of the program activity with which this shift is associated. + */ + activityName: z.string().optional(), + + /** + * Excitement of this shift, indicated as a number between 0 and 1. + */ + excitement: z.number(), +}); + +/** + * This API requires the event to be known. + */ +const kEventShiftContext = z.object({ + context: z.object({ + /** + * Unique slug of the event that the request is in scope of. + */ + event: z.string(), + + /** + * Unique slug of the team for whom shifts are being retrieved. + */ + team: z.string(), + }), +}); + +/** + * Export type definitions so that the API can be used in `callApi()`. + */ +export type EventShiftEndpoints = + DataTableEndpoints; + +/** + * Export type definition for the API's Row Model. + */ +export type EventShiftRowModel = z.infer; + +/** + * Export type definition for the API's context. + */ +export type EventShiftContext = z.infer; + +/** + * Validates that the given `context` is correct, and returns an object containing both the event + * and team information loaded from the database. + */ +async function validateContext(context: EventShiftContext['context']) { + const result = await db.selectFrom(tEvents) + .innerJoin(tTeams) + .on(tTeams.teamEnvironment.equals(context.team)) + .innerJoin(tEventsTeams) + .on(tEventsTeams.teamId.equals(tTeams.teamId)) + .and(tEventsTeams.enableTeam.equals(/* true= */ 1)) + .where(tEvents.eventSlug.equals(context.event)) + .select({ + event: { + id: tEvents.eventId, + festivalId: tEvents.eventFestivalId, + name: tEvents.eventShortName, + }, + team: { + id: tTeams.teamId, + name: tTeams.teamName, + }, + }) + .groupBy(tEvents.eventId) + .executeSelectNoneOrOne(); + + return result ?? { event: undefined, team: undefined }; +} + +/** + * This is implemented as a regular DataTable API. The following endpoints are provided by this + * implementation: + * + * GET /api/admin/event/shifts + * DELETE /api/admin/events/shifts/:id + * POST /api/admin/events/shifts + * PUT /api/admin/event/shifts/:id + */ +export const { GET, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftContext, { + async accessCheck({ context }, action, props) { + let privilege: Privilege | undefined; + switch (action) { + case 'get': + case 'list': + break; // no additional privilege necessary + + case 'create': + case 'delete': + case 'update': + privilege = Privilege.EventShiftManagement; + break; + + default: + throw new Error(`Unrecognised action: ${action}`); + } + + executeAccessCheck(props.authenticationContext, { + check: 'admin-event', + event: context.event, + privilege, + }); + }, + + async create({ context, row }) { + const { event, team } = await validateContext(context); + if (!event || !team) + notFound(); + + return { success: false }; + }, + + async delete({ context, id }) { + const { event, team } = await validateContext(context); + if (!event || !team) + notFound(); + + return { success: false }; + }, + + async list({ context, sort }) { + const { event, team } = await validateContext(context); + if (!event || !team) + notFound(); + + const dbInstance = db; + + const activitiesJoin = tActivities.forUseInLeftJoin(); + const scheduleJoin = tSchedule.forUseInLeftJoin(); + + const shiftDurationFragment = dbInstance.fragmentWithType('int', 'required').sql` + TIMESTAMPDIFF(MINUTE, ${scheduleJoin.scheduleTimeStart}, + ${scheduleJoin.scheduleTimeEnd})`; + + const result = await dbInstance.selectFrom(tShifts) + .leftJoin(activitiesJoin) + .on(activitiesJoin.activityId.equals(tShifts.shiftActivityId)) + .and(activitiesJoin.activityFestivalId.equalsIfValue(event.festivalId)) + .and(activitiesJoin.activityDeleted.isNull()) + .leftJoin(scheduleJoin) + .on(scheduleJoin.shiftId.equals(tShifts.shiftId)) + .where(tShifts.eventId.equals(event.id)) + .and(tShifts.teamId.equals(team.id)) + .select({ + id: tShifts.shiftId, + name: tShifts.shiftName, + hours: dbInstance.sum(shiftDurationFragment), + activityId: activitiesJoin.activityId, + activityName: activitiesJoin.activityTitle, + excitement: tShifts.shiftExcitement, + }) + .groupBy(tShifts.shiftId) + .orderBy(sort?.field ?? 'name', sort?.sort ?? 'asc') + .executeSelectMany(); + + return { + success: true, + rowCount: result.length, + rows: result, + }; + }, + + async update({ context, row }, props) { + const { event, team } = await validateContext(context); + if (!event || !team) + notFound(); + + return { success: false }; + }, + + async writeLog({ context, id }, mutation, props) { + const { event, team } = await validateContext(context); + if (!event || !team) + notFound(); + + // TODO + }, +}); diff --git a/app/lib/auth/Privileges.ts b/app/lib/auth/Privileges.ts index 6553a3f5..36357ef3 100644 --- a/app/lib/auth/Privileges.ts +++ b/app/lib/auth/Privileges.ts @@ -7,7 +7,7 @@ import type { User } from './User'; * Enumeration of the privileges that can be assigned to individual users. Do not renumber or change * the order of these entries, instead, mark them as deprecated and add new ones to the bottom. * - * Next setting: 1 << 26 + * Next setting: 1 << 27 */ export enum Privilege { Administrator = 1 << 0, @@ -27,6 +27,7 @@ export enum Privilege { EventRequestOwnership = 1 << 25, EventRetentionManagement = 1 << 21, EventScheduleOverride = 1 << 4, + EventShiftManagement = 1 << 26, EventSupportingTeams = 1 << 22, EventTrainingManagement = 1 << 13, EventVolunteerApplicationOverrides = 1 << 14, @@ -84,6 +85,7 @@ const PrivilegeExpansion: { [key in Privilege]?: Privilege[] } = { Privilege.EventRequestOwnership, Privilege.EventRetentionManagement, Privilege.EventScheduleOverride, + Privilege.EventShiftManagement, Privilege.EventSupportingTeams, Privilege.EventTrainingManagement, Privilege.EventVolunteerApplicationOverrides, @@ -145,6 +147,7 @@ export const PrivilegeGroups: { [key in Privilege]: string } = { [Privilege.EventRequestOwnership]: 'Event access', [Privilege.EventRetentionManagement]: 'Event access', [Privilege.EventScheduleOverride]: 'Event access', + [Privilege.EventShiftManagement]: 'Event access', [Privilege.EventSupportingTeams]: 'Event access', [Privilege.EventTrainingManagement]: 'Event access', [Privilege.EventVolunteerApplicationOverrides]: 'Event access', @@ -180,6 +183,7 @@ export const PrivilegeNames: { [key in Privilege]: string } = { [Privilege.EventRequestOwnership]: 'Manage program requests', [Privilege.EventRetentionManagement]: 'Multi-event retention access', [Privilege.EventScheduleOverride]: 'Always allow access to the volunteer portal', + [Privilege.EventShiftManagement]: 'Manage shifts', [Privilege.EventSupportingTeams]: 'Manage first aid & security', [Privilege.EventTrainingManagement]: 'Manage trainings', [Privilege.EventVolunteerApplicationOverrides]: 'Manage application overrides', diff --git a/app/lib/callApi.ts b/app/lib/callApi.ts index 860bb99c..539c0d4e 100644 --- a/app/lib/callApi.ts +++ b/app/lib/callApi.ts @@ -51,6 +51,7 @@ import type { VolunteerRolesDefinition } from '@app/api/admin/volunteerRoles'; import type { VolunteerTeamsDefinition } from '@app/api/admin/volunteerTeams'; import type { ContentEndpoints } from '@app/api/admin/content/[[...id]]/route'; +import type { EventShiftEndpoints } from '@app/api/admin/event/shifts/[[...id]]/route'; import type { EventTeamEndpoints } from '@app/api/admin/event/teams/[[...id]]/route'; import type { ExportsEndpoints } from '@app/api/admin/exports/[[...id]]/route'; import type { HotelsAssignmentsEndpoints } from '@app/api/admin/hotels/assignments/[[...id]]/route'; @@ -87,6 +88,7 @@ export type ApiEndpoints = { 'get': { '/api/admin/content': ContentEndpoints['list'], '/api/admin/content/:id': ContentEndpoints['get'], + '/api/admin/event/shifts': EventShiftEndpoints['list'], '/api/admin/event/teams': EventTeamEndpoints['list'], '/api/admin/exports': ExportsEndpoints['list'], '/api/admin/hotels/assignments': HotelsAssignmentsEndpoints['list'], @@ -115,6 +117,7 @@ export type ApiEndpoints = { 'post': { '/api/admin/content': ContentEndpoints['create'], '/api/admin/create-event': CreateEventDefinition, + '/api/admin/event/shifts': EventShiftEndpoints['create'], '/api/admin/exports': ExportsEndpoints['create'], '/api/admin/hotels/assignments': HotelsAssignmentsEndpoints['create'], '/api/admin/hotels': HotelsEndpoints['create'], @@ -171,6 +174,7 @@ export type ApiEndpoints = { }, 'delete': { '/api/admin/content/:id': ContentEndpoints['delete'], + '/api/admin/event/shifts/:id': EventShiftEndpoints['delete'], '/api/admin/exports/:id': ExportsEndpoints['delete'], '/api/admin/hotels/assignments/:id': HotelsAssignmentsEndpoints['delete'], '/api/admin/hotels/:id': HotelsEndpoints['delete'], @@ -185,6 +189,7 @@ export type ApiEndpoints = { }, 'put': { '/api/admin/content/:id': ContentEndpoints['update'], + '/api/admin/event/shifts/:id': EventShiftEndpoints['update'], '/api/admin/event/teams/:id': EventTeamEndpoints['update'], '/api/admin/hotels/assignments/:id': HotelsAssignmentsEndpoints['update'], '/api/admin/hotels/:id': HotelsEndpoints['update'],