diff --git a/app/(pages)/(main)/board/menu/category.tsx b/app/(pages)/(main)/board/menu/category.tsx new file mode 100644 index 0000000..4a570ad --- /dev/null +++ b/app/(pages)/(main)/board/menu/category.tsx @@ -0,0 +1,245 @@ +// This file contains react components for individual categories, and relevant utility functions. + +import { useEffect, useState } from "react"; +import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material"; +import { MenuCategory } from "@prisma/client"; + +import { NewProduct, Product } from "@/app/(pages)/(main)/board/menu/product"; +import { styled } from "@mui/system"; +import CircularProgress from "@mui/material/CircularProgress"; +import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; +import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; + + +// Updates a given category. +function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial): Promise { + + return fetch("/api/v2/escape/menu/categories", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) // set menu_products to undefined, because trying to PATCH the category with products makes Prisma angry + }); +} + +// Creates a given category. +function createCategory(category: MenuCategoryCreate): Promise { + return fetch("/api/v2/escape/menu/categories", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(category) + }); +} + +function deleteCategory(categoryId: number): Promise { + return fetch(`/api/v2/escape/menu/categories/${ categoryId }`, { + method: "DELETE", + }); +} + +// a with larger text. Equivalent font-size to

+const LargeTextField = styled(TextField)({ + "& .MuiOutlinedInput-input": { + fontSize: "3.75rem", + } +}) + +export function Category( + props: { + category: MenuCategoryWithProducts, + onUpdate: () => void // called when the category has been updated in the database. + } +) { + + let [categoryName, setCategoryName] = useState(props.category.name); + + // validate category name. Category name cannot be empty. + const categoryNameInvalid: boolean = categoryName.trim() === ""; + + // these are used to disable the UPDATE button when user has not inputted anything yet. + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + // is used for the spinner inside the UPDATE button + let [isUpdating, setIsUpdating] = useState(false); + + + let [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + let [isDeleting, setIsDeleting] = useState(false); + + + useEffect(() => { + // useEffect is always run once at the start. + if (isFirst) { + setIsFirst(false); + return; + } + + // this happens when categoryName has been updated for the first time. + setHasBeenUpdated(true); + }, [categoryName]); + + return ( + + + + + { + setCategoryName(e.target.value) + } } + + required + label="Category Name" + placeholder="Category Name" + error={ categoryNameInvalid } + helperText={ categoryNameInvalid ? "Name must not be empty" : "" } + + > + + + + + + + + + + + + + + + + Name + + + Price + + + Volume (cL) + + +
+
+ + { + props.category.menu_products.map((item) => ( + + ) + ) + } + + +
+ + setDeleteDialogOpen(false) } + onDelete={ () => { + setIsDeleting(true); + deleteCategory(props.category.id).then(() => { + props.onUpdate(); + }); + } } + showSpinner={ isDeleting } + title="Delete category?" + > + Are you sure you want to delete this category? + +
+ ) +} + +export function NewCategory(props: { onUpdate: () => void }) { + let [categoryName, setCategoryName] = useState(""); + let [isCreating, setIsCreating] = useState(false); + + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [categoryName]); + + + const invalid = categoryName.trim() === ""; + + // called when user clicks the CREATE button + const createNewCategory = () => { + setIsCreating(true); + createCategory( + {name: categoryName} + ).then(() => { + setCategoryName(""); + setIsCreating(false); + setIsFirst(true); + setHasBeenUpdated(false); + props.onUpdate(); + }); + }; + + return ( + + New Category + + setCategoryName(e.target.value) } + style={ {fontSize: "3.75rem"} } + + required + error={ invalid && hasBeenUpdated} + helperText={ invalid && hasBeenUpdated ? "Name must not be empty" : "" } + > + + + + + + ) +} diff --git a/app/(pages)/(main)/board/menu/page.tsx b/app/(pages)/(main)/board/menu/page.tsx new file mode 100644 index 0000000..3beed0a --- /dev/null +++ b/app/(pages)/(main)/board/menu/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Stack } from "@mui/material"; +import { useEffect, useState } from "react"; +import authWrapper from "@/app/middleware/authWrapper"; +import { Category, NewCategory } from "@/app/(pages)/(main)/board/menu/category"; +import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; + +// require login +export default authWrapper(MenuEditPage, "admin"); + +function MenuEditPage() { + const [menuCategories, setMenuCategories] = useState([]); + + useEffect(() => { + refetchMenu(); + }, []); + + const refetchMenu = () => fetchMenu().then(menu => setMenuCategories(menu)); + + return ( + + { + menuCategories.map((item) => { + return ( + + ); + }) + } + + + + ) +} + +async function fetchMenu(): Promise { + const menu = await fetch("/api/v2/escape/menu"); + return await menu.json(); +} + diff --git a/app/(pages)/(main)/board/menu/product.tsx b/app/(pages)/(main)/board/menu/product.tsx new file mode 100644 index 0000000..5585454 --- /dev/null +++ b/app/(pages)/(main)/board/menu/product.tsx @@ -0,0 +1,351 @@ +import { MenuProduct } from "@prisma/client"; +import { useEffect, useState } from "react"; +import { Button, Checkbox, FormControlLabel, Grid, TextField, Tooltip } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; +import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog"; +import { MenuProductCreate } from "@/app/api/utils/types/MenuProductTypes"; + +function updateProduct(product: MenuProduct, newAttributes: Partial): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({...product, ...newAttributes}) + }); +} + +function createProduct(product: MenuProductCreate): Promise { + return fetch("/api/v2/escape/menu/products", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(product) + }); +} + +function deleteProduct(productId: number): Promise { + return fetch(`/api/v2/escape/menu/products/${ productId }`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + } + }); +} + +export function Product(props: { product: MenuProduct, onUpdate: () => void }) { + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + let [isFirst, setIsFirst] = useState(true); + + let [isUpdating, setIsUpdating] = useState(false); + + let [valid, setValid] = useState(false); + let [newProduct, setNewProduct] = useState({ + product: props.product, + valid: { + name: true, + price: true, + volume: true, + allValid: true, + } + }); + + let [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + let [isDeleting, setIsDeleting] = useState(false); + + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [newProduct]); + + return ( + + <> + { /* shared inputs */ } + { + setValid(value.valid.allValid); + setNewProduct(value); + } }> + + + + + + + + + + setDeleteDialogOpen(false) } + onDelete={ () => { + setIsDeleting(true); + deleteProduct(props.product.id).then(() => { + setIsDeleting(false); + setDeleteDialogOpen(false); + + props.onUpdate(); + }); + } } + showSpinner={ isDeleting } + title={ "Delete product?" } + > + Are you sure you want to delete this product? + + + + ) +} + +type ProductInputs = { + name: string, + price: number, + volume: number, + glutenfree: boolean, + hidden: boolean, +}; + +type ProductInputsState = { + product: ProductInputs; + valid: { + name: boolean; + price: boolean; + volume: boolean; + allValid: boolean; + }; +} + +// Due to the fact that NewProduct and Product share the same inputs, they are extracted here. +// This component *does not* keep track of any state. +function ProductInputs( + props: { + + state: ProductInputsState, + onUpdate: (state: ProductInputsState) => void, + + validateInputs?: boolean + } +) { + const validateInputs = props.validateInputs ?? true; + const product = props.state.product; + + function isValid(product: ProductInputs): ProductInputsState["valid"] { + const name = product.name.trim() !== ""; + const price = product.price > 0; + const volume = product.volume > 0; + return { + name, + price, + volume, + allValid: name && price && volume, + } + } + + + return ( + <> + + { // all the inputs here are essentially the same. + const newProduct = {...product, name: e.target.value}; // create new product. + props.onUpdate( // propagate upwards. + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + + label="Product Name" + placeholder="Product Name" + + + error={ !props.state.valid.name && validateInputs } + helperText={ !props.state.valid.name && validateInputs ? "Name must be set" : "" } + > + + + + + { + const newProduct = {...product, price: Number(e.target.value)}; + props.onUpdate( + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + + placeholder="Product Price" + label="Product Price" + error={ !props.state.valid.price && validateInputs } + helperText={ !props.state.valid.price && validateInputs ? "Price must be greater than 0" : "" } + > + + + + { + const newProduct = {...product, volume: Number(e.target.value)}; + props.onUpdate( + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + + label="Volume (cL)" + placeholder="Volume (cL)" + + error={ !props.state.valid.volume && validateInputs } + helperText={ !props.state.valid.volume && validateInputs ? "Volume must be greater than 0" : "" } + > + + + + { + const newProduct = {...product, glutenfree: e.target.checked}; + props.onUpdate( + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + /> + } + label={ "Gluten-free" } + /> + + + + + { + const newProduct = {...product, hidden: e.target.checked}; + props.onUpdate( + { + valid: isValid(newProduct), + product: newProduct + } + ); + } } + /> + } + label="Hidden" + /> + + + + + ) +} + +export function NewProduct(props: { onUpdate: () => void, categoryId: number | null }) { + const initialState: ProductInputsState = { + product: { + name: "", + price: 0, + volume: 0, + glutenfree: false, + hidden: false, + }, + valid: { + name: false, + volume: false, + allValid: false, + price: false + }, + }; + + let [newProduct, setNewProduct] = useState(initialState); + + let [isFirst, setIsFirst] = useState(true); + let [hasBeenUpdated, setHasBeenUpdated] = useState(false); + + let [isCreating, setIsCreating] = useState(false); + + + useEffect(() => { + if (isFirst) { + setIsFirst(false); + return; + } + + setHasBeenUpdated(true); + }, [newProduct]); + + + return ( + <> + { + setNewProduct(value); + + } }/> + + + + + + + ); +} \ No newline at end of file diff --git a/app/(pages)/(main)/board/page.js b/app/(pages)/(main)/board/page.js index cee4831..1635549 100644 --- a/app/(pages)/(main)/board/page.js +++ b/app/(pages)/(main)/board/page.js @@ -1,29 +1,34 @@ - -"use client" +"use client"; import { PageHeader } from "@/app/components/sanity/PageBuilder"; -import { Box } from "@mui/material"; +import { Box, Link, Stack } from "@mui/material"; import { useEffect, useState } from "react"; import Forcegraph from "@/app/components/RecruitmentGraph" export default function BoardPage() { - const [data, setData] = useState({ nodes: [], edges: [] }); - - useEffect(() => { - fetch(`/api/v2/recruitGraph`) - .then(res => res.json()) - .then(data => { - setData({ nodes: data.nodes, edges: data.edges}) - }) - - }, []); - - return ( - - - - - - ); + const [data, setData] = useState({nodes: [], edges: []}); + + useEffect(() => { + fetch(`/api/v2/recruitGraph`) + .then(res => res.json()) + .then(data => { + setData({nodes: data.nodes, edges: data.edges}) + }) + + }, []); + + return ( + + + + + + Menu editor + + + + + + ); } \ No newline at end of file diff --git a/app/(pages)/(main)/escape/menu/layout.ts b/app/(pages)/(main)/escape/menu/layout.ts new file mode 100644 index 0000000..9f9a080 --- /dev/null +++ b/app/(pages)/(main)/escape/menu/layout.ts @@ -0,0 +1,8 @@ +export const metadata = { + title: "Escape Menu", + description: "Escape Bar Menu", +}; + +export default function Layout({children}) { + return children; +} \ No newline at end of file diff --git a/app/(pages)/(main)/escape/menu/page.tsx b/app/(pages)/(main)/escape/menu/page.tsx new file mode 100644 index 0000000..42e37e0 --- /dev/null +++ b/app/(pages)/(main)/escape/menu/page.tsx @@ -0,0 +1,87 @@ +import { Divider, Grid, Stack, Typography } from "@mui/material"; +import { MenuProduct } from "@prisma/client"; +import prisma from "@/prisma/prismaClient"; +import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes"; + + +export default async function EscapeMenu() { + const menu: MenuCategoryWithProducts[] = await prisma.menuCategory.findMany({ + include: { + menu_products: { + where: { + hidden: false + } + } + } + }); + + + return ( + + + Menu + + + { menu.map((item) => + + + + + + ) } + + ) +} + +// Individual menu category. +function Category(props: { + category: MenuCategoryWithProducts, +}) { + const category = props.category; + + return ( + + { category.name } + + + + { + category.menu_products.map((item) => ( + + )) + } + + + ) + +} + +// Individual menu product. +function Product( + props: { + product: MenuProduct + } +) { + + const product = props.product; + + return ( + <> + + + { product.name } + + { product.glutenfree ? (Gluten-free) : <> } + + + + { product.volume } CL + + + { product.price },- + + + ) + +} \ No newline at end of file diff --git a/app/(pages)/(main)/layout.js b/app/(pages)/(main)/layout.js index fd1b0c7..081b3bf 100644 --- a/app/(pages)/(main)/layout.js +++ b/app/(pages)/(main)/layout.js @@ -3,7 +3,7 @@ import "./../../globals.css"; -import { EmojiPeople, Groups, Home } from "@mui/icons-material"; +import { EmojiPeople, Groups, Home, MenuBook } from "@mui/icons-material"; import { Box, CssBaseline, @@ -26,10 +26,11 @@ const NavItems = [ name: "Volunteering", icon: , }, + {id: "escape/menu", path: "escape/menu", name: "Menu", icon: } ]; export default function AppLayout({ children }) { - + const pathname = usePathname(); return ( @@ -61,7 +62,7 @@ export default function AppLayout({ children }) { }} > - {children} diff --git a/app/(pages)/(main)/volunteering/page.js b/app/(pages)/(main)/volunteering/page.js index 8032ff6..805cc61 100644 --- a/app/(pages)/(main)/volunteering/page.js +++ b/app/(pages)/(main)/volunteering/page.js @@ -56,20 +56,20 @@ function VolunteeringPage(params) { const [vouchersEarned, setVouchersEarned] = useState(0); const [vouchersUsed, setVouchersUsed] = useState(0); - + useEffect(() => { sanityFetch(setPages); }, []); - - + + useEffect(() => { - + fetch("/api/v2/semester") .then(res => res.json()) .then(data => { setSemester(data.semester) }); - + fetch("/api/v2/semesterVolunteerInfo") .then(res => res.json()) .then(data => { @@ -80,9 +80,9 @@ function VolunteeringPage(params) { setVouchersUsed(data.vouchersUsed) }) - + }, []) - + // Semester-based data return ( @@ -111,14 +111,14 @@ function VolunteeringPage(params) { }) : <>} - + ); } function createNavigation(semester, paidMemberships, vouchersEarned, vouchersUsed, numVolunteers, volunteerHours) { - - const buttonGroup1 = createButtons(BUTTON_CONTENT_1); + + const buttonGroup1 = createButtons(BUTTON_CONTENT_1); return ( @@ -172,7 +172,7 @@ function createNavigation(semester, paidMemberships, vouchersEarned, vouchersUse } function createButtons(content) { - + const gridItems = content.map((e) => { return ( @@ -194,7 +194,7 @@ function createButtons(content) { ); }); - + return ( {gridItems} diff --git a/app/api/utils/types/MenuCategoryTypes.ts b/app/api/utils/types/MenuCategoryTypes.ts new file mode 100644 index 0000000..6a2b96b --- /dev/null +++ b/app/api/utils/types/MenuCategoryTypes.ts @@ -0,0 +1,9 @@ +import { Prisma } from "@prisma/client"; +import MenuCategoryDefaultArgs = Prisma.MenuCategoryDefaultArgs; + +// see https://www.prisma.io/docs/orm/prisma-client/type-safety/operating-against-partial-structures-of-model-types +// for an explanation over these two lines. Note: this code is using `satisfies` instead of `Prisma.validator` (also see https://www.prisma.io/blog/satisfies-operator-ur8ys8ccq7zb). +const menuCategoryWithProductsArgs = {include: {menu_products: true}} satisfies MenuCategoryDefaultArgs; +export type MenuCategoryWithProducts = Prisma.MenuCategoryGetPayload + +export type MenuCategoryCreate = Prisma.MenuCategoryCreateArgs["data"] diff --git a/app/api/utils/types/MenuProductTypes.ts b/app/api/utils/types/MenuProductTypes.ts new file mode 100644 index 0000000..f1451b8 --- /dev/null +++ b/app/api/utils/types/MenuProductTypes.ts @@ -0,0 +1,3 @@ +import { Prisma } from "@prisma/client"; + +export type MenuProductCreate = Prisma.MenuProductCreateArgs["data"] \ No newline at end of file diff --git a/app/api/v2/escape/menu/categories/[categoryId]/route.ts b/app/api/v2/escape/menu/categories/[categoryId]/route.ts new file mode 100644 index 0000000..6531029 --- /dev/null +++ b/app/api/v2/escape/menu/categories/[categoryId]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; +import prisma from "@/prisma/prismaClient"; +import { authOptions } from "@/app/api/utils/authOptions"; + +export async function DELETE( + _req: NextRequest, + context: { params: Promise<{ categoryId: string }> } +) { + const params = await context.params; + + let session = await getServerSession(authOptions); + let auth = new Auth(session, params) + .requireRoles(["admin"]) + .requireParams(["categoryId"]); + + if (auth.failed) return auth.response; + + let categoryId = Number(params.categoryId); + // verify productId is an integer + if (isNaN(categoryId) || !categoryId) { + return auth.verify(NextResponse.json({error: "categoryId must be an integer"}, {status: 400})); + } + + try { + await prisma.$transaction(async transaction => { + await transaction.menuProduct.deleteMany({ + where: { + category_id: categoryId + } + }); + + await transaction.menuCategory.delete( + { + where: { + id: categoryId, + }, + } + ); + }); + + return auth.verify(NextResponse.json({})) + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} \ No newline at end of file diff --git a/app/api/v2/escape/menu/categories/route.ts b/app/api/v2/escape/menu/categories/route.ts new file mode 100644 index 0000000..59e8650 --- /dev/null +++ b/app/api/v2/escape/menu/categories/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { MenuCategory } from "@prisma/client"; +import prismaClient from "@/prisma/prismaClient"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; +import { authOptions } from "@/app/api/utils/authOptions"; +import { MenuCategoryCreate } from "@/app/api/utils/types/MenuCategoryTypes"; + + +// Modify a category. Does not allow modifying products inside the category. +export async function PATCH( + req: NextRequest +) { + const category: MenuCategory = await req.json(); + + const session = await getServerSession(authOptions); + const auth: Auth = new Auth(session, category) + .requireRoles(["admin"]) + .requireParams(["id"]); // only id is strictly required + + if (auth.failed) return auth.response; + + try { + + const newProduct = await prismaClient.menuCategory.update({ + where: { + id: category.id + }, + data: category + }); + + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} + + +// Create a new category. +export async function POST( + req: NextRequest +) { + const category: MenuCategoryCreate = await req.json(); + + const session = await getServerSession(authOptions); + const auth = new Auth(session, category) + .requireRoles(["admin"]) + .requireParams(["name"]); + + if (auth.failed) return auth.response; + + try { + const newCategory = await prismaClient.menuCategory.create({ + data: category + }); + + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify(newCategory), {status: 201})); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} diff --git a/app/api/v2/escape/menu/products/[productId]/route.ts b/app/api/v2/escape/menu/products/[productId]/route.ts new file mode 100644 index 0000000..f2d7b76 --- /dev/null +++ b/app/api/v2/escape/menu/products/[productId]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/prisma/prismaClient"; +import { Prisma } from "@prisma/client"; +import { Auth } from "@/app/api/utils/auth"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/utils/authOptions"; + + +// delete a product with the given id +export async function DELETE( + _req: NextRequest, + context: { params: Promise<{ productId: string }> } +): Promise { + let params = await context.params; + + const session = await getServerSession(authOptions); + const auth = new Auth(session, params) + .requireRoles(["admin"]) + .requireParams(["productId"]); + + const productId = Number(params.productId); + + if (auth.failed) return auth.response; + + // verify productId is an integer + if (isNaN(productId) || !productId) { + return auth.verify(NextResponse.json({error: "productId must be an integer"}, {status: 400})); + } + + try { + await prisma.menuProduct.delete({ + where: { + id: productId, + } + }); + } catch (error) { + // error code P2015 means no product with the provided id exists + // see https://www.prisma.io/docs/orm/reference/error-reference#p2015 + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2015") { + return auth.verify(NextResponse.json({error: "Not found"}, {status: 404})); + } else { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ error }`}, + {status: 500} + )); + } + } + + return auth.verify(NextResponse.json(JSON.stringify({}))); +} \ No newline at end of file diff --git a/app/api/v2/escape/menu/products/route.ts b/app/api/v2/escape/menu/products/route.ts new file mode 100644 index 0000000..f76f0f9 --- /dev/null +++ b/app/api/v2/escape/menu/products/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import prisma from "@/prisma/prismaClient"; +import prismaClient from "@/prisma/prismaClient"; +import { MenuProduct } from "@prisma/client"; +import { authOptions } from "@/app/api/utils/authOptions"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; +import { MenuProductCreate } from "@/app/api/utils/types/MenuProductTypes"; + + +// Modify a product. Modifies the product based on the id in the request body. +export async function PATCH( + req: NextRequest +) { + const product: MenuProduct = await req.json(); + + const session = await getServerSession(authOptions); + const auth = new Auth(session, product) + .requireRoles(["admin"]) + .requireParams(["id"]); + + if (auth.failed) return auth.response; + + try { + const newProduct = await prismaClient.menuProduct.update({ + where: { + id: product.id + }, + data: product + }); + + return auth.verify(NextResponse.json(JSON.stringify(newProduct))); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} + + +// Create new product. +export async function POST( + req: NextRequest +) { + const product: MenuProductCreate = await req.json(); + + const session = await getServerSession(authOptions); + const auth = new Auth(session, product) + .requireRoles(["admin"]) + .requireParams(["name", "hidden", "price", "volume", "glutenfree", "category_id", "priceVolunteer"]); + + if (auth.failed) return auth.response; + + try { + await prisma.menuProduct.create({ + data: product + }); + + // 201 Created + return auth.verify(NextResponse.json(JSON.stringify({}), {status: 201})); + } catch (e) { + return auth.verify(NextResponse.json( + {error: `something went wrong: ${ e }`}, + {status: 500} + )); + } +} + diff --git a/app/api/v2/escape/menu/route.ts b/app/api/v2/escape/menu/route.ts new file mode 100644 index 0000000..20619cd --- /dev/null +++ b/app/api/v2/escape/menu/route.ts @@ -0,0 +1,20 @@ +import prisma from "@/prisma/prismaClient"; +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { Auth } from "@/app/api/utils/auth"; + +// Gets the whole menu +export async function GET(): Promise { + const session = getServerSession(); + const auth = new Auth(session); + + let menuCategories = await prisma.menuCategory.findMany( + { + include: { + menu_products: true + } + } + ); + + return auth.verify(NextResponse.json(menuCategories)); +} \ No newline at end of file diff --git a/app/components/input/DeletionConfirmationDialog.tsx b/app/components/input/DeletionConfirmationDialog.tsx new file mode 100644 index 0000000..c67dfe0 --- /dev/null +++ b/app/components/input/DeletionConfirmationDialog.tsx @@ -0,0 +1,42 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; +import { ReactNode } from "react"; + +export type DeletionConfirmationDialogProps = { + open: boolean, + onClose: () => void; // called when "Cancel" is clicked, or the dialog is closed in any other way. + onDelete: () => void; // called when "Delete" is clicked. + showSpinner: boolean; // when true, shows a spinner instead of "Delete" in the button. + + title: ReactNode; // Title of the dialog + children: ReactNode; // content +} + +export function DeletionConfirmationDialog( + props: DeletionConfirmationDialogProps +) { + + return ( + + + { props.title } + + + + { props.children } + + + + + + + + ) +} \ No newline at end of file diff --git a/app/components/layout/AppBar.js b/app/components/layout/AppBar.js index 27a47ba..afae106 100644 --- a/app/components/layout/AppBar.js +++ b/app/components/layout/AppBar.js @@ -110,7 +110,7 @@ function NavElementLargeScreen(item, index, currentPath) { key={`link_nav${index}_itemtext`} sx={{ color: - currentPath == `/${item.path}` + currentPath === `/${item.path}` ? cybTheme.palette.primary.main : cybTheme.palette.text.primary, }} @@ -157,13 +157,15 @@ function NavElementSmallScreen(item, index, iconProps, currentPath) { key={`link_snav${index}_itemtext`} sx={{ color: - currentPath == `/${item.path}` + currentPath === `/${item.path}` ? cybTheme.palette.primary.main : cybTheme.palette.text.primary, + textAlign: "center" }} + primary={ - {item.name == "About CYB" ? "About" : item.name} + {item.name === "About CYB" ? "About" : item.name} } /> diff --git a/prisma/prismaClient.js b/prisma/prismaClient.js deleted file mode 100644 index 8d0292f..0000000 --- a/prisma/prismaClient.js +++ /dev/null @@ -1,16 +0,0 @@ - -import { PrismaClient } from "@prisma/client"; - -const prisma = globalThis.prisma ?? new PrismaClient(); - -export default prisma; - -if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; - -// const bruh = new PrismaClient() - -// bruh.workGroup.create({ -// data: { -// name -// } -// }) \ No newline at end of file diff --git a/prisma/prismaClient.ts b/prisma/prismaClient.ts new file mode 100644 index 0000000..f055c17 --- /dev/null +++ b/prisma/prismaClient.ts @@ -0,0 +1,8 @@ + +import { PrismaClient } from "@prisma/client"; + +const prisma: PrismaClient = globalThis.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma; + +export default prisma; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 474f1bf..2307f8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,14 +244,14 @@ model ActivateToken { } model Voucher { - id String @id @default(cuid()) + id String @id @default(cuid()) usedAt DateTime? workLogEntryId String - loggedDate DateTime @db.Timestamp(0) - expirationDate DateTime @db.Timestamp(0) + loggedDate DateTime @db.Timestamp(0) + expirationDate DateTime @db.Timestamp(0) userId String - workLogEntry WorkLog @relation(fields: [workLogEntryId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + workLogEntry WorkLog @relation(fields: [workLogEntryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([workLogEntryId], map: "Voucher_workLogEntryId_fkey") @@index([userId], map: "Voucher_userId_fkey") @@ -369,15 +369,22 @@ model ShiftCafe { @@index([shiftManager], map: "ShiftCafe_shiftWorker_User_idx") } +model MenuCategory { + id Int @id @default(autoincrement()) + name String @db.VarChar(45) + menu_products MenuProduct[] @relation("MenuProduct_category") +} + model MenuProduct { - id Int @id - name String @db.VarChar(45) - volume Float @db.Float + id Int @id @default(autoincrement()) + name String @db.VarChar(45) + volume Float @db.Float price Int priceVolunteer Int - glutenfree Int @db.TinyInt - active Int? @default(1) @db.TinyInt - category String? @db.VarChar(45) + glutenfree Boolean + hidden Boolean @default(false) + category_id Int + category MenuCategory? @relation("MenuProduct_category", fields: [category_id], references: [id]) } model old_users {