Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Event Team Management page #1

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/data/admin-api/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FimSupabaseClient } from "src/supabaseContext";
import { EventTeamStatus } from "src/data/supabase/events.ts";

export type CreateEventNoteRequest = {
eventId: string,
Expand Down Expand Up @@ -48,4 +49,29 @@ export const updateEventInfo = async (client: FimSupabaseClient, req: UpdateEven
if (!resp.ok) throw new Error(`An error occurred while saving the event: ${resp.statusText}`);
return await resp.json();
});
}

export type UpdateEventTeamRequest = {
eventId: string,
eventTeamId: number,
notes: string | null,
status: EventTeamStatus['id']
};

export const updateEventTeam = async (client: FimSupabaseClient, req: UpdateEventTeamRequest) => {
return fetch(`${import.meta.env.PUBLIC_ADMIN_API_URL}/api/v1/events/${encodeURIComponent(req.eventId)}/teams/${encodeURIComponent(req.eventTeamId)}`, {
method: "PUT",
body: JSON.stringify({
statusId: req.status,
notes: req.notes
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${(await client.auth.getSession()).data.session?.access_token}`
}
}).then(async resp => {
if (resp.status === 401 || resp.status === 403) throw new Error("You do not have permission to perform this action.");
if (!resp.ok) throw new Error(`An error occurred while saving the event: ${resp.statusText}`);
return await resp.json();
});
}
60 changes: 60 additions & 0 deletions src/data/supabase/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export type Event = EventSlim & {
}[]
};

export type EventTeamStatus = {
id: string,
name: string,
ordinal: number
};

export type EventTeam = {
id: number,
teamNumber: number,
notes: string | null,
status: EventTeamStatus['id']
};

export const getEventsForSeason = async (client: FimSupabaseClient, seasonId: number): Promise<EventSlim[]> => {
const { data, error } = await client
.from("events")
Expand Down Expand Up @@ -68,6 +81,44 @@ export const useGetEvent = (eventId: string | null | undefined) => useSupaQuery(
}
});

export const getEventTeams = async (client: FimSupabaseClient, eventId: string): Promise<EventTeam[]> => {
const { data, error } = await client
.from("event_teams")
.select<string, EventTeam>("id,team_number,notes,status_id")
.eq('event_id', eventId);

if (error) throw new Error(error.message);

return data.map(mapDbToEventTeam);
};

export const useGetEventTeams = (eventId: string | null | undefined, options: {enabled?: () => boolean, refetchInterval?: number} = {}) => useSupaQuery({
...options,
queryKey: ["getEventTeams", eventId],
queryFn: async (client) => {
if (eventId === null || eventId === undefined) throw new Error("No event ID provided");
return await getEventTeams(client, eventId);
}
});

export const getEventTeamStatuses = async (client: FimSupabaseClient): Promise<EventTeamStatus[]> => {
const { data, error } = await client
.from("event_team_statuses")
.select<string, EventTeamStatus>("id,name,ordinal")
.order("ordinal", { ascending: true });

if (error) throw new Error(error.message);

return data;
};

export const useGetEventTeamStatuses = () => useSupaQuery({
queryKey: ["eventTeamStatuses"],
queryFn: async (client) => {
return await getEventTeamStatuses(client);
}
});

export const mapDbToEvent = (db: Event): Event => {
return {
id: db.id,
Expand All @@ -91,6 +142,15 @@ export const mapDbToEvent = (db: Event): Event => {
} as Event;
}

export const mapDbToEventTeam = (db: any): EventTeam => {
return {
id: db.id,
notes: db.notes,
status: db.status_id,
teamNumber: db.team_number
};
};

export const getEventQueryKey = (eventId: string) => ['event', eventId] as [string, ...unknown[]];
export const useGetEventQuery = (eventId: string, refetch: boolean = true) => useSupaQuery({
queryKey: getEventQueryKey(eventId),
Expand Down
11 changes: 6 additions & 5 deletions src/pages/equipment/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ const EquipmentList = () => {
if (types.isLoading) return <Loading />;
if (types.isError) return <Alert severity="error">{types.error!.toString()}</Alert>;
if (!types.data) return <>idk</>;


const typeId = search.get('typeId');
return (
<div>
<Tabs value={search.get('typeId')} sx={{ mb: 2 }}>
<Tabs value={typeId} sx={{ mb: 2 }}>
{ types.data.map(t =>
<Tab value={t.id.toString()} label={t.name} component={Link}
to={{ search: searchParamsWithNewValue(search, 'typeId', t.id.toString()).toString() }}
Expand All @@ -30,9 +31,9 @@ const EquipmentList = () => {
</Tabs>

{/* TODO: This id is hardcoded, but we need a way to differentiate the types because the tables will be different */}
{search.get('typeId') === "1" && <AvEquipmentTable />}
{search.get('typeId') === null && <p>Select a type of equipment above to view and manage</p>}
{search.get('typeId') !== null && search.get('typeId') !== "1" && <Alert severity="info">Management of this equipment type is not yet supported</Alert>}
{typeId === "1" && <AvEquipmentTable />}
{typeId === null && <p>Select a type of equipment above to view and manage</p>}
{typeId !== null && typeId !== "1" && <Alert severity="info">Management of this equipment type is not yet supported</Alert>}
</div>
);
};
Expand Down
9 changes: 5 additions & 4 deletions src/pages/events/manage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EventPermission } from "src/data/eventPermission";
import useHasEventPermission from "src/hooks/useHasEventPermission";
//import EventsManageStaff from "src/pages/events/manage/staff.tsx";
import EventsManageMatches from "src/pages/events/manage/matches.tsx";
import EventsManageTeams from "src/pages/events/manage/teams.tsx";

const routes = [{
path: "/overview",
Expand All @@ -22,11 +23,11 @@ const routes = [{
path: "/matches",
label: "Matches",
element: (<EventsManageMatches />)
} , {
path: "/teams",
label: "Teams",
element: (<EventsManageTeams />)
}// , {
// path: "/teams",
// label: "Teams",
// element: (<p>Not yet implemented.</p>)
// }, {
// path: "/staff",
// label: "Staff",
// element: (<EventsManageStaff />),
Expand Down
209 changes: 209 additions & 0 deletions src/pages/events/manage/teams.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { useParams } from "react-router-dom";
import { Loading } from "src/shared/Loading.tsx";
import {
DataGrid,
GridActionsCellItem,
GridColDef,
GridEditInputCell,
GridEditSingleSelectCell,
GridFilterModel,
GridRowId,
GridRowModes,
GridRowModesModel,
GridSkeletonCell,
useGridApiRef
} from "@mui/x-data-grid";
import { Alert, Box, Button, Typography } from "@mui/material";
import { JSXElementConstructor, useMemo, useState } from "react";
import DataTableFilterToolbar from "src/shared/DataTableFilterToolbar.tsx";
import { EventTeam, useGetEventTeams, useGetEventTeamStatuses } from "src/data/supabase/events.ts";
import useHasEventPermission from "src/hooks/useHasEventPermission.ts";
import { GlobalPermission } from "src/data/globalPermission.ts";
import { EventPermission } from "src/data/eventPermission.ts";
import { useSupaMutation } from "src/hooks/useSupaMutation.ts";
import { updateEventTeam, UpdateEventTeamRequest } from "src/data/admin-api/events.ts";
import { Cancel, Edit, Save } from "@mui/icons-material";

const DATA_REFRESH_SEC = 60;

const presetFilters: { label: string, filterModel: GridFilterModel }[] = [
{
label: 'All',
filterModel: { items: [] }
}
];

const EventsManageMatches = () => {
const { id: eventId } = useParams();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const teams = useGetEventTeams(eventId!, {
enabled: () => !Object.values(rowModesModel).some(r => r.mode == GridRowModes.Edit),
refetchInterval: DATA_REFRESH_SEC * 1_000
});
const statuses = useGetEventTeamStatuses();
const canManageTeams = useHasEventPermission(eventId!, [GlobalPermission.Events_Manage], [EventPermission.Event_ManageTeams]);
const updateTeamMutation = useSupaMutation({
mutationFn: (client, req: UpdateEventTeamRequest) => updateEventTeam(client, req)
});
const apiRef = useGridApiRef();

const handleEditClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
};

const handleSaveClick = (id: GridRowId) => () => {
setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
};

const handleCancelClick = (id: GridRowId) => () => {
setRowModesModel({
...rowModesModel,
[id]: { mode: GridRowModes.View, ignoreModifications: true },
});
};

const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
setRowModesModel(newRowModesModel);
};

const columnConfig: GridColDef[] = useMemo<GridColDef<(EventTeam & {isLoading: boolean | undefined})[][number]>[]>(() => ([
{
field: 'teamNumber',
headerName: 'Team',
width: 150
},
{
field: 'status',
headerName: 'Status',
width: 150,
type: 'singleSelect',
valueOptions: statuses.data,
getOptionLabel: (val: any) => val.name,
getOptionValue: (val: any) => val.id,
editable: canManageTeams,
renderEditCell: params => (
params.row.isLoading ? <GridSkeletonCell /> : <GridEditSingleSelectCell {...params} />
)
},
{
field: 'notes',
headerName: 'Notes',
width: 250,
flex: 1,
editable: canManageTeams,
renderEditCell: params => (
params.row.isLoading ? <GridSkeletonCell /> : <GridEditInputCell {...params} />
)
},
{
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 100,
cellClassName: 'actions',
getActions: ({id}) => {
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;

if (isInEditMode) {
return [
<GridActionsCellItem
icon={<Save/>}
label="Save"
sx={{
color: 'primary.main',
}}
onClick={handleSaveClick(id)}
/>,
<GridActionsCellItem
icon={<Cancel />}
label="Cancel"
className="textPrimary"
onClick={handleCancelClick(id)}
color="inherit"
/>,
];
}

return [
<GridActionsCellItem
icon={<Edit />}
label="Edit"
className="textPrimary"
onClick={handleEditClick(id)}
color="inherit"
/>
];
},
},
]), [statuses.data, canManageTeams, rowModesModel]);

const tableToolbar = useMemo((): JSXElementConstructor<any> => {
return () => (
<DataTableFilterToolbar>
<div>
<span style={{textTransform: 'uppercase'}}>Filters:</span>
{presetFilters.map(filter => (
<Button key={filter.label} variant="text" onClick={() => apiRef.current.setFilterModel(filter.filterModel)}>
{filter.label}
</Button>
))}
</div>
</DataTableFilterToolbar>
);
}, [apiRef.current]);

if (teams.isLoading || statuses.isLoading) return (<Loading />);

if (teams.isError) return (<Alert severity="error">Failed to get teams</Alert>);

if (teams.isSuccess) return (
<Box sx={{paddingBottom: 5}}>
<Typography sx={{textAlign: 'center', paddingBottom: 2}}>
{Object.values(rowModesModel).some(r => r.mode == GridRowModes.Edit)
? "Automatic refresh disabled while editing"
: `Data automatically refreshes every ${DATA_REFRESH_SEC} seconds`}
</Typography>
<DataGrid
apiRef={apiRef}
columns={columnConfig}
rows={ teams.data }
slots={{ toolbar: tableToolbar }}
rowModesModel={rowModesModel}
onRowModesModelChange={handleRowModesModelChange}
editMode={"row"}
processRowUpdate={async (newRow, _row, {rowId}) => {
apiRef.current.updateRows([{id: rowId, isLoading: true}]);

await updateTeamMutation.mutateAsync({
eventId: eventId!,
eventTeamId: newRow.id,
notes: newRow.notes !== '' ? newRow.notes : null,
status: newRow.status
});

return {
...newRow,
isLoading: false,
};

}}
onProcessRowUpdateError={(err) => {console.error(err)}}
initialState={{
pagination: {
paginationModel: {
pageSize: 100
}
},
sorting: {
sortModel: [{
field: 'teamNumber',
sort: 'asc'
}]
}
}}
/>
</Box>
);
};

export default EventsManageMatches;