From c2a0958312ab4bf28177efef19892ed274e7ea56 Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Sun, 3 Mar 2024 21:34:24 +0000 Subject: [PATCH] Introduce colours and categories on the Shifts page --- app/admin/components/Square.tsx | 39 +++++++++++++++ .../[slug]/[team]/shifts/ShiftTable.tsx | 27 +++++++++- .../program/requests/RequestDataTable.tsx | 19 ++----- .../shifts/ShiftCategoriesTable.tsx | 4 ++ app/api/admin/event/shifts/[[...id]]/route.ts | 49 ++++++++++++++++++- app/lib/database/scheme/ShiftsTable.ts | 1 + ts-sql.scheme.yaml | 6 +++ 7 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 app/admin/components/Square.tsx diff --git a/app/admin/components/Square.tsx b/app/admin/components/Square.tsx new file mode 100644 index 00000000..ddbf99ac --- /dev/null +++ b/app/admin/components/Square.tsx @@ -0,0 +1,39 @@ +// 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 Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; + +/** + * Props accepted by the component. + */ +export interface SquareProps { + /** + * Colour, in an HTML-renderable format, to render the square in. + */ + colour: string; + + /** + * Tooltip title to show when hovering over the square. + */ + title: string; +} + +/** + * The component draws a rectangular square with a particular colour, featuring a tooltip + * that explains what the colour is indicating. + */ +export function Square(props: SquareProps) { + return ( + + + + ); +} diff --git a/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx b/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx index 6c5790d7..51ddd434 100644 --- a/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx +++ b/app/admin/events/[slug]/[team]/shifts/ShiftTable.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { default as MuiLink } from '@mui/material/Link'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; +import PaletteIcon from '@mui/icons-material/Palette'; import SentimentSatisfiedAltIcon from '@mui/icons-material/SentimentSatisfiedAlt'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; @@ -14,6 +15,7 @@ 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'; +import { Square } from '@app/admin/components/Square'; /** * Formats the given number of `minutes` to a HH:MM string. @@ -47,7 +49,7 @@ export function ShiftTable(props: ShiftTableProps) { if (!readOnly) { deleteColumn.push({ field: 'id', - headerName: /* empty= */ '', + headerName: /* no header= */ '', sortable: false, width: 50, }); @@ -55,10 +57,31 @@ export function ShiftTable(props: ShiftTableProps) { const columns: RemoteDataTableColumn[] = [ ...deleteColumn, + { + field: 'colour', + headerAlign: 'center', + headerName: /* no header= */ '', + sortable: false, + align: 'center', + width: 50, + + renderHeader: params => + + + , + + renderCell: params => + , + }, + { + field: 'category', + headerName: 'Category', + width: 200, + }, { field: 'name', headerName: 'Shift', - flex: 1, + flex: 2, renderCell: params => diff --git a/app/admin/events/[slug]/program/requests/RequestDataTable.tsx b/app/admin/events/[slug]/program/requests/RequestDataTable.tsx index 10896662..59de913c 100644 --- a/app/admin/events/[slug]/program/requests/RequestDataTable.tsx +++ b/app/admin/events/[slug]/program/requests/RequestDataTable.tsx @@ -15,7 +15,8 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import type { ProgramRequestContext, ProgramRequestRowModel } from '@app/api/admin/program/requests/[[...id]]/route'; -import { type RemoteDataTableColumn, RemoteDataTable } from '@app/admin/components/RemoteDataTable'; +import { RemoteDataTable, type RemoteDataTableColumn } from '@app/admin/components/RemoteDataTable'; +import { Square } from '@app/admin/components/Square'; /** * Information about a particular team shown on the request overview table. @@ -169,20 +170,8 @@ export function RequestDataTable(props: RequestDataTableProps) { align: 'center', width: 50, - renderHeader: params => { - return ( - - - - ); - }, + renderHeader: params => + , renderCell: params => { if (!Object.hasOwn(params.row.shifts, team.id)) { diff --git a/app/admin/volunteers/shifts/ShiftCategoriesTable.tsx b/app/admin/volunteers/shifts/ShiftCategoriesTable.tsx index 3e39462f..4927db63 100644 --- a/app/admin/volunteers/shifts/ShiftCategoriesTable.tsx +++ b/app/admin/volunteers/shifts/ShiftCategoriesTable.tsx @@ -57,6 +57,10 @@ export function ShiftCategoriesTable() { editable: false, sortable: false, width: 50, + + // The default shift category cannot be removed, as it's hardcoded in the source when + // new shifts are being created. It can be freely updated, of course. + isProtected: params => params.value === /* default= */ 1, }, { field: 'name', diff --git a/app/api/admin/event/shifts/[[...id]]/route.ts b/app/api/admin/event/shifts/[[...id]]/route.ts index cfa6196f..a0ec04c7 100644 --- a/app/api/admin/event/shifts/[[...id]]/route.ts +++ b/app/api/admin/event/shifts/[[...id]]/route.ts @@ -7,8 +7,10 @@ import { z } from 'zod'; import { type DataTableEndpoints, createDataTableApi } from '../../../../createDataTableApi'; import { Log, LogSeverity, LogType } from '@lib/Log'; import { Privilege } from '@lib/auth/Privileges'; +import { createColourInterpolator, type ColourInterpolator } from '@lib/ColourInterpolator'; import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; -import db, { tActivities, tEventsTeams, tEvents, tTeams, tSchedule, tShifts } from '@lib/database'; +import db, { tActivities, tEventsTeams, tEvents, tTeams, tSchedule, tShifts, tShiftsCategories } + from '@lib/database'; /** * Row model for a team's shifts. The shifts are fully mutable, even though the create and edit @@ -25,6 +27,16 @@ const kEventShiftRowModel = z.object({ */ name: z.string().optional(), + /** + * The colour that's assigned to this shift, calculated by the server. + */ + colour: z.string(), + + /** + * Name of the category that this shift is part of. + */ + category: z.string(), + /** * Number of hours that volunteers have been scheduled to work on this shift. */ @@ -163,6 +175,9 @@ export const { GET, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftC if (!event || !team) notFound(); + if (sort?.field === 'colour') + throw new Error(`Invalid sorting key provided (v=${sort.field})`); + const dbInstance = db; const activitiesJoin = tActivities.forUseInLeftJoin(); @@ -173,6 +188,8 @@ export const { GET, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftC ${scheduleJoin.scheduleTimeEnd})`; const result = await dbInstance.selectFrom(tShifts) + .innerJoin(tShiftsCategories) + .on(tShiftsCategories.shiftCategoryId.equals(tShifts.shiftCategoryId)) .leftJoin(activitiesJoin) .on(activitiesJoin.activityId.equals(tShifts.shiftActivityId)) .and(activitiesJoin.activityFestivalId.equalsIfValue(event.festivalId)) @@ -184,6 +201,8 @@ export const { GET, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftC .select({ id: tShifts.shiftId, name: tShifts.shiftName, + category: tShiftsCategories.shiftCategoryName, + categoryColour: tShiftsCategories.shiftCategoryColour, hours: dbInstance.sum(shiftDurationFragment), activityId: activitiesJoin.activityId, activityName: activitiesJoin.activityTitle, @@ -193,10 +212,36 @@ export const { GET, PUT } = createDataTableApi(kEventShiftRowModel, kEventShiftC .orderBy(sort?.field ?? 'name', sort?.sort ?? 'asc') .executeSelectMany(); + const categories = new Map; + const categoryCounts = new Map; + + for (const { id, category, categoryColour } of result) { + if (!categories.has(category)) + categories.set(category, createColourInterpolator(categoryColour)); + + const currentCounts = categoryCounts.get(category); + if (!currentCounts) + categoryCounts.set(category, [ 0, 0 ]); + else + categoryCounts.set(category, [ currentCounts[0] + 1, currentCounts[1] + 1 ]); + } + return { success: true, rowCount: result.length, - rows: result, + rows: result.map(shift => { + const [ shiftsInCategory, remaining ] = categoryCounts.get(shift.category)!; + + const colourInterpolator = categories.get(shift.category)!; + const colour = colourInterpolator(remaining / shiftsInCategory); + + categoryCounts.set(shift.category, [ shiftsInCategory, remaining - 1 ]); + + return { + ...shift, + colour, + }; + }), }; }, diff --git a/app/lib/database/scheme/ShiftsTable.ts b/app/lib/database/scheme/ShiftsTable.ts index 4c142e21..aa4f85b5 100644 --- a/app/lib/database/scheme/ShiftsTable.ts +++ b/app/lib/database/scheme/ShiftsTable.ts @@ -14,6 +14,7 @@ export class ShiftsTable extends Table { shiftIdentifier = this.column('shift_identifier', 'string'); eventId = this.column('event_id', 'int'); teamId = this.column('team_id', 'int'); + shiftCategoryId = this.column('shift_category_id', 'int'); shiftName = this.column('shift_name', 'string'); shiftActivityId = this.optionalColumnWithDefaultValue('shift_activity_id', 'int'); shiftAreaId = this.optionalColumnWithDefaultValue('shift_area_id', 'int'); diff --git a/ts-sql.scheme.yaml b/ts-sql.scheme.yaml index 5fa21d08..36fc31a9 100644 --- a/ts-sql.scheme.yaml +++ b/ts-sql.scheme.yaml @@ -630,6 +630,11 @@ tables: nullable: false default: null comment: "" + - name: shift_category_id + type: int(8) unsigned + nullable: false + default: null + comment: "" - name: shift_name type: varchar(64) nullable: false @@ -717,6 +722,7 @@ tables: `shift_identifier` varchar(8) NOT NULL, `event_id` int(4) unsigned NOT NULL, `team_id` int(4) unsigned NOT NULL, + `shift_category_id` int(8) unsigned NOT NULL, `shift_name` varchar(64) NOT NULL, `shift_activity_id` int(4) unsigned DEFAULT NULL, `shift_area_id` int(4) unsigned DEFAULT NULL,