From 1662dbdbdf5d877f39cf635fd04fcf7357a41d0c Mon Sep 17 00:00:00 2001 From: Topvennie Date: Tue, 6 Aug 2024 21:39:40 +0200 Subject: [PATCH] vinvoor: admin day selector --- vingo/database/models.go | 2 +- vingo/handlers/days.go | 4 +- vinvoor/src/components/LoadingSkeleton.tsx | 6 +- vinvoor/src/overview/checkin/CheckIn.tsx | 4 +- vinvoor/src/scans/Scans.tsx | 2 +- .../{ScansBody.tsx => ScansTableBody.tsx} | 0 vinvoor/src/settings/admin/Admin.tsx | 97 +++-------- vinvoor/src/settings/admin/days/Days.tsx | 63 +++++++ vinvoor/src/settings/admin/days/DaysAdd.tsx | 88 ++++++++++ vinvoor/src/settings/admin/days/DaysTable.tsx | 160 ++++++++++++++++++ .../src/settings/admin/days/DaysTableBody.tsx | 84 +++++++++ .../src/settings/admin/days/DaysTableHead.tsx | 63 +++++++ .../settings/admin/days/DaysTableToolbar.tsx | 126 ++++++++++++++ vinvoor/src/types/days.ts | 28 +++ vinvoor/src/util/fetch.ts | 35 ++-- 15 files changed, 658 insertions(+), 104 deletions(-) rename vinvoor/src/scans/{ScansBody.tsx => ScansTableBody.tsx} (100%) create mode 100644 vinvoor/src/settings/admin/days/Days.tsx create mode 100644 vinvoor/src/settings/admin/days/DaysAdd.tsx create mode 100644 vinvoor/src/settings/admin/days/DaysTable.tsx create mode 100644 vinvoor/src/settings/admin/days/DaysTableBody.tsx create mode 100644 vinvoor/src/settings/admin/days/DaysTableHead.tsx create mode 100644 vinvoor/src/settings/admin/days/DaysTableToolbar.tsx create mode 100644 vinvoor/src/types/days.ts diff --git a/vingo/database/models.go b/vingo/database/models.go index f5d8085..7c49878 100644 --- a/vingo/database/models.go +++ b/vingo/database/models.go @@ -77,5 +77,5 @@ type Season struct { type StreakDay struct { BaseModel - Date time.Time + Date time.Time `json:"date"` } diff --git a/vingo/handlers/days.go b/vingo/handlers/days.go index d0b8a8b..2b0eba8 100644 --- a/vingo/handlers/days.go +++ b/vingo/handlers/days.go @@ -10,8 +10,8 @@ import ( type Days struct{} type DaysBody struct { - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` + StartDate time.Time `json:"startDate"` + EndDate time.Time `json:"endDate"` } func (Days) CreateMultiple(c *fiber.Ctx) error { diff --git a/vinvoor/src/components/LoadingSkeleton.tsx b/vinvoor/src/components/LoadingSkeleton.tsx index 04ff32e..4c20e9a 100644 --- a/vinvoor/src/components/LoadingSkeleton.tsx +++ b/vinvoor/src/components/LoadingSkeleton.tsx @@ -11,9 +11,5 @@ export const LoadingSkeleton: FC = ({ children, ...props }) => { - return loading ? ( - - ) : ( - children - ); + return loading ? : children; }; diff --git a/vinvoor/src/overview/checkin/CheckIn.tsx b/vinvoor/src/overview/checkin/CheckIn.tsx index 667ca64..2cf451d 100644 --- a/vinvoor/src/overview/checkin/CheckIn.tsx +++ b/vinvoor/src/overview/checkin/CheckIn.tsx @@ -21,7 +21,7 @@ export const CheckIn = () => { }} > Checked in - Nice of you to stop by! + Nice of you to stop by ! ) : ( { }} > Not checked in - We miss you! + We miss you ! ); }; diff --git a/vinvoor/src/scans/Scans.tsx b/vinvoor/src/scans/Scans.tsx index ced950f..f4f95a5 100644 --- a/vinvoor/src/scans/Scans.tsx +++ b/vinvoor/src/scans/Scans.tsx @@ -4,7 +4,7 @@ import { LoadingSkeleton } from "../components/LoadingSkeleton"; import { useFetch } from "../hooks/useFetch"; import { Card, convertCardJSON } from "../types/cards"; import { convertScanJSON, Scan } from "../types/scans"; -import { ScansTableBody } from "./ScansBody"; +import { ScansTableBody } from "./ScansTableBody"; import { ScansTableHead } from "./ScansTableHead"; export const Scans = () => { diff --git a/vinvoor/src/scans/ScansBody.tsx b/vinvoor/src/scans/ScansTableBody.tsx similarity index 100% rename from vinvoor/src/scans/ScansBody.tsx rename to vinvoor/src/scans/ScansTableBody.tsx diff --git a/vinvoor/src/settings/admin/Admin.tsx b/vinvoor/src/settings/admin/Admin.tsx index f909c7c..4fb06ba 100644 --- a/vinvoor/src/settings/admin/Admin.tsx +++ b/vinvoor/src/settings/admin/Admin.tsx @@ -1,89 +1,30 @@ -import { Box, Button, Grid, Paper, Stack, Typography } from "@mui/material"; -import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; -import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import dayjs, { Dayjs } from "dayjs"; -import { useSnackbar } from "notistack"; -import { Dispatch, FC, SetStateAction, useState } from "react"; -import { postApi } from "../../util/fetch"; +import { Alert, Grid, Typography } from "@mui/material"; +import { FC } from "react"; +import { Days } from "./days/Days"; export const Admin: FC = () => { - const [startDate, setStartDate] = useState(dayjs()); - const [endDate, setEndDate] = useState(dayjs()); - - const { enqueueSnackbar } = useSnackbar(); - - const handleDateChange = ( - date: Dayjs | null, - setter: Dispatch> - ) => setter(date); - - const handleOnClick = () => { - if (!startDate || !endDate) { - enqueueSnackbar("Please select a start and end date", { - variant: "error", - }); - return; - } - - postApi("admin/days", { - start_date: startDate.toISOString(), - end_date: endDate.toISOString(), - }) - .then(() => - enqueueSnackbar("successfully saved days", { - variant: "success", - }) - ) - .catch((error) => - // This is the admin page so just show the error - enqueueSnackbar(`Failed to save days: ${error}`, { - variant: "error", - }) - ); - }; - return ( - - - - Set days - - - - handleDateChange(newValue, setStartDate) - } - /> - - handleDateChange(newValue, setEndDate) - } - /> - - - - - - - + + + + This page doesn't ask for confirmation when modifying + data ! + + + + + ); diff --git a/vinvoor/src/settings/admin/days/Days.tsx b/vinvoor/src/settings/admin/days/Days.tsx new file mode 100644 index 0000000..e88e586 --- /dev/null +++ b/vinvoor/src/settings/admin/days/Days.tsx @@ -0,0 +1,63 @@ +import { Grid } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { createContext, Dispatch, SetStateAction, useState } from "react"; +import { LoadingSkeleton } from "../../../components/LoadingSkeleton"; +import { useFetch } from "../../../hooks/useFetch"; +import { convertDayJSON, Day } from "../../../types/days"; +import { getApi } from "../../../util/fetch"; +import { DaysAdd } from "./DaysAdd"; +import { DaysTable } from "./DaysTable"; + +interface DayContextProps { + days: readonly Day[]; + setDays: Dispatch>; + reloadDays: () => void; +} + +export const DayContext = createContext({ + days: [], + setDays: () => {}, + reloadDays: () => null, +}); + +export const Days = () => { + const [days, setDays] = useState([]); + const { loading } = useFetch( + "admin/days", + setDays, + convertDayJSON + ); + + const { enqueueSnackbar } = useSnackbar(); + + const reloadDays = () => { + getApi("admin/days", convertDayJSON) + .then((data) => setDays(data)) + // This is the admin page so just show the error + .catch((error) => + enqueueSnackbar(`Error getting all days: ${error}`, { + variant: "error", + }) + ); + }; + + return ( + + + + + + + + + + + + + ); +}; diff --git a/vinvoor/src/settings/admin/days/DaysAdd.tsx b/vinvoor/src/settings/admin/days/DaysAdd.tsx new file mode 100644 index 0000000..ba5dd6b --- /dev/null +++ b/vinvoor/src/settings/admin/days/DaysAdd.tsx @@ -0,0 +1,88 @@ +import { Box, Button, Paper, Stack } from "@mui/material"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import { useSnackbar } from "notistack"; +import { Dispatch, SetStateAction, useContext, useState } from "react"; +import { TypographyG } from "../../../components/TypographyG"; +import { postApi } from "../../../util/fetch"; +import { DayContext } from "./Days"; +export const DaysAdd = () => { + const { reloadDays } = useContext(DayContext); + const [startDate, setStartDate] = useState(dayjs()); + const [endDate, setEndDate] = useState(dayjs()); + + const { enqueueSnackbar } = useSnackbar(); + + const handleDateChange = ( + date: Dayjs | null, + setter: Dispatch> + ) => setter(date); + + const handleOnClick = () => { + if (!startDate || !endDate) { + enqueueSnackbar("Please select a start and end date", { + variant: "error", + }); + return; + } + + postApi("admin/days", { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .then(() => { + enqueueSnackbar("successfully saved days", { + variant: "success", + }); + reloadDays(); + }) + .catch((error) => + // This is the admin page so just show the error + enqueueSnackbar(`Failed to save days: ${error}`, { + variant: "error", + }) + ); + }; + + return ( + + + Add days + + + + handleDateChange(newValue, setStartDate) + } + /> + + handleDateChange(newValue, setEndDate) + } + /> + + + + + + + + ); +}; diff --git a/vinvoor/src/settings/admin/days/DaysTable.tsx b/vinvoor/src/settings/admin/days/DaysTable.tsx new file mode 100644 index 0000000..7fc8dee --- /dev/null +++ b/vinvoor/src/settings/admin/days/DaysTable.tsx @@ -0,0 +1,160 @@ +import { Paper, Stack, Table, TableContainer } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { ChangeEvent, useContext, useEffect, useState } from "react"; +import { TypographyG } from "../../../components/TypographyG"; +import { Day } from "../../../types/days"; +import { deleteAPI } from "../../../util/fetch"; +import { randomInt } from "../../../util/util"; +import { DayContext } from "./Days"; +import { DaysTableBody } from "./DaysTableBody"; +import { DaysTableHead } from "./DaysTableHead"; +import { DaysTableToolbar } from "./DaysTableToolbar"; + +export const DaysTable = () => { + const { days, reloadDays } = useContext(DayContext); + const [rows, setRows] = useState(days); + const [selected, setSelected] = useState([]); + const [deleting, setDeleting] = useState(false); + + const [dateFilter, setDateFilter] = useState< + [Date | undefined, Date | undefined] + >([undefined, undefined]); + const [weekdaysFilter, setWeekdaysFilter] = useState(false); + const [weekendsFilter, setWeekendsFilter] = useState(false); + + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + + const filterDays = (): readonly Day[] => { + let filteredDays = [...days]; + if (dateFilter[0] !== undefined && dateFilter[1] !== undefined) { + filteredDays = filteredDays.filter( + (day) => + day.date.getTime() >= dateFilter[0]!.getTime() && + day.date.getTime() <= dateFilter[1]!.getTime() + ); + } + if (weekdaysFilter) { + filteredDays = filteredDays.filter( + (day) => day.date.getDay() !== 0 && day.date.getDay() !== 6 + ); + } + if (weekendsFilter) { + filteredDays = filteredDays.filter( + (day) => day.date.getDay() === 0 || day.date.getDay() === 6 + ); + } + + return filteredDays; + }; + + const handleDelete = async () => { + setDeleting(true); + const key = randomInt(); + enqueueSnackbar("Deleting...", { + variant: "info", + key: key, + persist: true, + }); + + const promises = selected.map((id) => + deleteAPI(`admin/days/${id}`).catch((error) => + // This is the admin page so just show the error + enqueueSnackbar(`Failed to delete streakday ${id}: ${error}`, { + variant: "error", + }) + ) + ); + + await Promise.all(promises); + + closeSnackbar(key); + enqueueSnackbar( + `Deleted ${selected.length} streakday${ + selected.length > 1 ? "s" : "" + }`, + { + variant: "success", + } + ); + + setSelected([]); + setDeleting(false); + reloadDays(); + }; + + const handleSelect = (id: number) => { + const selectedIndex = selected.indexOf(id); + let newSelected: readonly number[] = []; + + switch (selectedIndex) { + case -1: + newSelected = newSelected.concat(selected, id); + break; + case 0: + newSelected = newSelected.concat(selected.slice(1)); + break; + case selected.length - 1: + newSelected = newSelected.concat(selected.slice(0, -1)); + break; + default: + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + + setSelected(newSelected); + }; + const handleSelectAll = (event: ChangeEvent) => { + if (event.target.checked) setSelected(rows.map((day) => day.id)); + else setSelected([]); + }; + const isSelected = (id: number) => selected.indexOf(id) !== -1; + + useEffect( + () => setRows(filterDays()), + [days, dateFilter, weekdaysFilter, weekendsFilter] + ); + + return ( + + + Edit Days + + + + + + +
+
+
+
+
+ ); +}; diff --git a/vinvoor/src/settings/admin/days/DaysTableBody.tsx b/vinvoor/src/settings/admin/days/DaysTableBody.tsx new file mode 100644 index 0000000..94f41ac --- /dev/null +++ b/vinvoor/src/settings/admin/days/DaysTableBody.tsx @@ -0,0 +1,84 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import { + Checkbox, + IconButton, + TableBody, + TableCell, + TableRow, + Typography, +} from "@mui/material"; +import { useSnackbar } from "notistack"; +import { FC, ReactNode, useContext } from "react"; +import { Day, daysHeadCells } from "../../../types/days"; +import { deleteAPI } from "../../../util/fetch"; +import { DayContext } from "./Days"; + +interface DaysTableBodyProps { + rows: readonly Day[]; + handleSelect: (id: number) => void; + isSelected: (id: number) => boolean; + deleting: boolean; +} + +export const DaysTableBody: FC = ({ + rows, + handleSelect, + isSelected, + deleting, +}) => { + const { days, setDays } = useContext(DayContext); + + const { enqueueSnackbar } = useSnackbar(); + + const handleClick = (id: number) => { + if (isSelected(id)) handleSelect(id); // This will remove it from the selected list + + deleteAPI(`admin/days/${id}`) + .then(() => { + enqueueSnackbar("Deleted streakday", { variant: "success" }); + setDays([...days].filter((day) => day.id !== id)); + }) + .catch((error) => + // This is the admin page so just show the error + enqueueSnackbar(`Failed to delete streakday: ${error}`, { + variant: "error", + }) + ); + }; + + return ( + + {rows.map((day) => ( + + handleSelect(day.id)} + > + + + {daysHeadCells.map((headCell) => ( + + + {headCell.convert + ? headCell.convert(day[headCell.id]) + : (day[headCell.id] as ReactNode)} + + + ))} + + handleClick(day.id)} + > + + + + + ))} + + ); +}; diff --git a/vinvoor/src/settings/admin/days/DaysTableHead.tsx b/vinvoor/src/settings/admin/days/DaysTableHead.tsx new file mode 100644 index 0000000..fbaaef0 --- /dev/null +++ b/vinvoor/src/settings/admin/days/DaysTableHead.tsx @@ -0,0 +1,63 @@ +import { + Box, + Button, + Checkbox, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { ChangeEvent, FC } from "react"; +import { daysHeadCells } from "../../../types/days"; + +interface DaysTableHeadProps { + rowCount: number; + numSelected: number; + onSelectAll: (event: ChangeEvent) => void; + handleDelete: () => void; + deleting: boolean; +} + +export const DaysTableHead: FC = ({ + rowCount, + numSelected, + onSelectAll, + handleDelete, + deleting, +}) => { + return ( + + + + 0 && numSelected < rowCount + } + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAll} + /> + + {daysHeadCells.map((headCell) => ( + + {headCell.label} + + ))} + + + + + + + + ); +}; diff --git a/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx b/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx new file mode 100644 index 0000000..5b9c12f --- /dev/null +++ b/vinvoor/src/settings/admin/days/DaysTableToolbar.tsx @@ -0,0 +1,126 @@ +import { Checkbox, Stack, Typography } from "@mui/material"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import { + ChangeEvent, + Dispatch, + FC, + SetStateAction, + useContext, + useState, +} from "react"; +import { DayContext } from "./Days"; + +interface DaysTableToolbarProps { + dateFilter: [Date | undefined, Date | undefined]; + setDateFilter: Dispatch< + SetStateAction<[Date | undefined, Date | undefined]> + >; + weekdaysFilter: boolean; + setWeekdaysFilter: Dispatch>; + weekendsFilter: boolean; + setWeekendsFilter: Dispatch>; +} + +export const DaysTableToolbar: FC = ({ + dateFilter, + setDateFilter, + weekdaysFilter, + setWeekdaysFilter, + weekendsFilter, + setWeekendsFilter, +}) => { + const { days } = useContext(DayContext); + const [startDate, setStartDate] = useState( + days.length ? dayjs(days[0].date) : dayjs() + ); + const [endDate, setEndDate] = useState( + days.length ? dayjs(days[days.length - 1].date) : dayjs() + ); + + const handleDateChange = ( + date: Dayjs | null, + setter: Dispatch>, + index: number + ) => { + setter(date); + + if (dateFilter[0] !== undefined && dateFilter[1] !== undefined) { + const newDateFilter = [...dateFilter]; + newDateFilter[index] = date?.toDate(); + setDateFilter( + newDateFilter as [Date | undefined, Date | undefined] + ); + } + }; + + const handleClickDate = (event: ChangeEvent) => { + if (event.target.checked) + setDateFilter([startDate?.toDate(), endDate?.toDate()]); + else setDateFilter([undefined, undefined]); + }; + + const handleClickBoolean = ( + event: ChangeEvent, + setter: Dispatch> + ) => setter(event.target.checked); + + return ( + + + + Filter date + + + handleDateChange(newValue, setStartDate, 0) + } + /> + + handleDateChange(newValue, setEndDate, 1) + } + /> + + + + + handleClickBoolean(event, setWeekdaysFilter) + } + /> + Only weekdays + + + + handleClickBoolean(event, setWeekendsFilter) + } + /> + Only weekends + + + ); +}; diff --git a/vinvoor/src/types/days.ts b/vinvoor/src/types/days.ts new file mode 100644 index 0000000..5027310 --- /dev/null +++ b/vinvoor/src/types/days.ts @@ -0,0 +1,28 @@ +import { Base, BaseJSON, TableHeadCell } from "./general"; + +interface DayJSON extends BaseJSON { + date: string; +} + +export interface Day extends Base { + date: Date; +} + +export const convertDayJSON = (daysJSON: DayJSON[]): Day[] => + daysJSON + .map((dayJSON) => ({ + date: new Date(dayJSON.date), + id: dayJSON.id, + createdAt: new Date(dayJSON.createdAt), + })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + +export const daysHeadCells: readonly TableHeadCell[] = [ + { + id: "date", + label: "Date", + align: "left", + padding: "normal", + convert: (value: Date) => value.toDateString(), + }, +]; diff --git a/vinvoor/src/util/fetch.ts b/vinvoor/src/util/fetch.ts index a16ef1e..74daf4c 100644 --- a/vinvoor/src/util/fetch.ts +++ b/vinvoor/src/util/fetch.ts @@ -3,31 +3,38 @@ const URLS: Record = { API: import.meta.env.VITE_API_URL, }; -export const getApi = (endpoint: string, convertData?: (data: any) => T) => { - return _fetch(`${URLS.API}/${endpoint}`, {}, convertData); -}; +export const getApi = (endpoint: string, convertData?: (data: any) => T) => + _fetch(`${URLS.API}/${endpoint}`, {}, convertData); export const postApi = ( endpoint: string, body: Record = {} -) => { - return _fetch(`${URLS.API}/${endpoint}`, { +) => + _fetch(`${URLS.API}/${endpoint}`, { method: "POST", body: JSON.stringify(body), headers: new Headers({ "content-type": "application/json" }), }); -}; export const patchApi = ( endpoint: string, body: Record = {} -) => { - return _fetch(`${URLS.API}/${endpoint}`, { +) => + _fetch(`${URLS.API}/${endpoint}`, { method: "PATCH", body: JSON.stringify(body), headers: new Headers({ "content-type": "application/json" }), }); -}; + +export const deleteAPI = ( + endpoint: string, + body: Record = {} +) => + _fetch(`${URLS.API}/${endpoint}`, { + method: "DELETE", + body: JSON.stringify(body), + headers: new Headers({ "content-type": "application/json" }), + }); interface ResponseNot200Error extends Error { response: Response; @@ -35,16 +42,15 @@ interface ResponseNot200Error extends Error { export const isResponseNot200Error = ( error: any -): error is ResponseNot200Error => { - return (error as ResponseNot200Error).response !== undefined; -}; +): error is ResponseNot200Error => + (error as ResponseNot200Error).response !== undefined; const _fetch = async ( url: string, options: RequestInit = {}, convertData?: (data: any) => T -): Promise => { - return fetch(url, { credentials: "include", ...options }) +): Promise => + fetch(url, { credentials: "include", ...options }) .then((response) => { if (!response.ok) { const error = new Error( @@ -61,4 +67,3 @@ const _fetch = async ( : response.text(); }) .then((data) => (convertData ? convertData(data) : data)); -};