From bc1ac5379a52b9fd7780cc92ea182062568c284c Mon Sep 17 00:00:00 2001 From: Oliver Levay Date: Tue, 22 Aug 2023 13:48:40 +0200 Subject: [PATCH] :sparkles: Make more admin for the webshop --- backend/services/core/graphql.schema.json | 16 + ...20230810103128_fix_webshop_release_date.ts | 11 +- ...230810130719_make-product-price-numeric.ts | 5 +- .../core/src/datasources/WebshopAPI.ts | 29 +- .../services/core/src/schemas/webshop.graphql | 1 + .../shared/converters/webshopConverters.ts | 1 + backend/services/core/src/types/graphql.ts | 2 + .../core/tests/products/productsFunctions.ts | 3 +- frontend/api/products.graphql | 106 ++++- frontend/components/Webshop/CreateProduct.tsx | 138 ------ .../Webshop/ManageInventoryItem.tsx | 91 ++++ frontend/components/Webshop/ManageProduct.tsx | 110 +++++ .../Webshop/ManageProductInventory.tsx | 62 +++ frontend/components/Webshop/Product.tsx | 95 ++++- frontend/components/Webshop/ProductEditor.tsx | 135 ++++++ frontend/generated/graphql.tsx | 396 +++++++++++++++++- frontend/pages/webshop/index.tsx | 17 + frontend/pages/webshop/product/[id]/edit.tsx | 46 ++ .../pages/webshop/product/[id]/manage.tsx | 20 + frontend/pages/webshop/product/create.tsx | 33 +- frontend/providers/AddFoodPreferencePopup.tsx | 15 +- 21 files changed, 1128 insertions(+), 204 deletions(-) delete mode 100644 frontend/components/Webshop/CreateProduct.tsx create mode 100644 frontend/components/Webshop/ManageInventoryItem.tsx create mode 100644 frontend/components/Webshop/ManageProduct.tsx create mode 100644 frontend/components/Webshop/ManageProductInventory.tsx create mode 100644 frontend/components/Webshop/ProductEditor.tsx create mode 100644 frontend/pages/webshop/product/[id]/edit.tsx create mode 100644 frontend/pages/webshop/product/[id]/manage.tsx diff --git a/backend/services/core/graphql.schema.json b/backend/services/core/graphql.schema.json index 4a0769303..ee3271be2 100644 --- a/backend/services/core/graphql.schema.json +++ b/backend/services/core/graphql.schema.json @@ -9324,6 +9324,22 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "releaseDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/backend/services/core/migrations/20230810103128_fix_webshop_release_date.ts b/backend/services/core/migrations/20230810103128_fix_webshop_release_date.ts index bc046f44b..00362e2fe 100644 --- a/backend/services/core/migrations/20230810103128_fix_webshop_release_date.ts +++ b/backend/services/core/migrations/20230810103128_fix_webshop_release_date.ts @@ -1,22 +1,19 @@ -import { Knex } from "knex"; - +import { Knex } from 'knex'; export async function up(knex: Knex): Promise { await knex.schema.alterTable('product_inventory', (table) => { table.dropColumn('release_date'); }); await knex.schema.alterTable('product', (table) => { - table.timestamp('release_date').notNullable(); - }) + table.timestamp('release_date').notNullable().defaultTo(knex.fn.now()); + }); } - export async function down(knex: Knex): Promise { await knex.schema.alterTable('product_inventory', (table) => { table.timestamp('release_date').defaultTo(knex.fn.now()); }); await knex.schema.alterTable('product', (table) => { table.dropColumn('release_date'); - }) + }); } - diff --git a/backend/services/core/migrations/20230810130719_make-product-price-numeric.ts b/backend/services/core/migrations/20230810130719_make-product-price-numeric.ts index 038116bcb..8350dfd7f 100644 --- a/backend/services/core/migrations/20230810130719_make-product-price-numeric.ts +++ b/backend/services/core/migrations/20230810130719_make-product-price-numeric.ts @@ -1,5 +1,4 @@ -import { Knex } from "knex"; - +import { Knex } from 'knex'; export async function up(knex: Knex): Promise { await knex.schema.alterTable('product', (table) => { @@ -7,10 +6,8 @@ export async function up(knex: Knex): Promise { }); } - export async function down(knex: Knex): Promise { await knex.schema.alterTable('product', (table) => { table.integer('price').notNullable().alter(); }); } - diff --git a/backend/services/core/src/datasources/WebshopAPI.ts b/backend/services/core/src/datasources/WebshopAPI.ts index d9205d410..ab6c2f007 100644 --- a/backend/services/core/src/datasources/WebshopAPI.ts +++ b/backend/services/core/src/datasources/WebshopAPI.ts @@ -193,7 +193,8 @@ export default class WebshopAPI extends dbUtils.KnexDataSource { addInventory(ctx: context.UserContext, inventoryInput: gql.CreateInventoryInput): Promise> { return this.withAccess('webshop:create', ctx, async () => { - const product = await this.knex(TABLE.PRODUCT).where({ id: inventoryInput.productId }).first(); + const product = await this.knex(TABLE.PRODUCT) + .where({ id: inventoryInput.productId }).first(); if (!product) throw new Error('Product not found'); const newInventory = await this.knex(TABLE.PRODUCT_INVENTORY).insert({ product_id: inventoryInput.productId, @@ -201,7 +202,7 @@ export default class WebshopAPI extends dbUtils.KnexDataSource { quantity: inventoryInput.quantity, product_discount_id: inventoryInput.discountId, }); - if(!newInventory) throw new Error('Failed to create inventory'); + if (!newInventory) throw new Error('Failed to create inventory'); return this.getProductById(ctx, inventoryInput.productId); }); } @@ -209,24 +210,28 @@ export default class WebshopAPI extends dbUtils.KnexDataSource { updateInventory(ctx: context.UserContext, inventoryInput: gql.UpdateInventoryInput): Promise> { return this.withAccess('webshop:create', ctx, async () => { - const inventory = await this.knex(TABLE.PRODUCT_INVENTORY).where({ id: inventoryInput.inventoryId }).first(); + const inventory = await this.knex(TABLE.PRODUCT_INVENTORY) + .where({ id: inventoryInput.inventoryId }).first(); if (!inventory) throw new Error('Inventory not found'); - await this.knex(TABLE.PRODUCT_INVENTORY).where({ id: inventoryInput.inventoryId }).update({ - variant: inventoryInput.variant, - quantity: inventoryInput.quantity, - product_discount_id: inventoryInput.discountId, - }); + await this.knex(TABLE.PRODUCT_INVENTORY) + .where({ id: inventoryInput.inventoryId }).update({ + variant: inventoryInput.variant, + quantity: inventoryInput.quantity, + product_discount_id: inventoryInput.discountId, + }); return this.getProductById(ctx, inventory.product_id); }); } deleteInventory(ctx: context.UserContext, inventoryId: UUID): Promise { return this.withAccess('webshop:create', ctx, async () => { - const inventory = await this.knex(TABLE.PRODUCT_INVENTORY).where({ id: inventoryId }).first(); + const inventory = await this.knex(TABLE.PRODUCT_INVENTORY) + .where({ id: inventoryId }).first(); if (!inventory) throw new Error('Inventory not found'); - await this.knex(TABLE.PRODUCT_INVENTORY).where({ id: inventoryId }).update({ - deleted_at: new Date(), - }); + await this.knex(TABLE.PRODUCT_INVENTORY) + .where({ id: inventoryId }).update({ + deleted_at: new Date(), + }); return true; }); } diff --git a/backend/services/core/src/schemas/webshop.graphql b/backend/services/core/src/schemas/webshop.graphql index 07360ffc9..76d2dc4cc 100644 --- a/backend/services/core/src/schemas/webshop.graphql +++ b/backend/services/core/src/schemas/webshop.graphql @@ -61,6 +61,7 @@ type Product { imageUrl: String! inventory: [ProductInventory]! category: ProductCategory + releaseDate: Date! } input CreateProductInput { diff --git a/backend/services/core/src/shared/converters/webshopConverters.ts b/backend/services/core/src/shared/converters/webshopConverters.ts index ec5876852..36bcf1301 100644 --- a/backend/services/core/src/shared/converters/webshopConverters.ts +++ b/backend/services/core/src/shared/converters/webshopConverters.ts @@ -29,6 +29,7 @@ export const convertProduct = ( price: product.price, imageUrl: product.image_url, maxPerUser: product.max_per_user, + releaseDate: product.release_date, category, inventory, }); diff --git a/backend/services/core/src/types/graphql.ts b/backend/services/core/src/types/graphql.ts index 3003ba9b8..4bbe9b8fa 100644 --- a/backend/services/core/src/types/graphql.ts +++ b/backend/services/core/src/types/graphql.ts @@ -1187,6 +1187,7 @@ export type Product = { maxPerUser: Scalars['Int']['output']; name: Scalars['String']['output']; price: Scalars['Float']['output']; + releaseDate: Scalars['Date']['output']; }; export type ProductCategory = { @@ -2822,6 +2823,7 @@ export type ProductResolvers; name?: Resolver; price?: Resolver; + releaseDate?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }>; diff --git a/backend/services/core/tests/products/productsFunctions.ts b/backend/services/core/tests/products/productsFunctions.ts index d1fc94299..aea988ded 100644 --- a/backend/services/core/tests/products/productsFunctions.ts +++ b/backend/services/core/tests/products/productsFunctions.ts @@ -1,4 +1,4 @@ -import { Product, ProductCategory, CreateProductInput, CreateInventoryInput } from '~/src/types/graphql'; +import { Product, ProductCategory, CreateProductInput } from '~/src/types/graphql'; export const hej = ''; @@ -20,5 +20,6 @@ export function expectedProduct( name: category.name, description: category.description, }, + releaseDate: new Date(), }; } diff --git a/frontend/api/products.graphql b/frontend/api/products.graphql index 323ca0480..41893cff6 100644 --- a/frontend/api/products.graphql +++ b/frontend/api/products.graphql @@ -6,6 +6,29 @@ query Products($categoryId: UUID) { price maxPerUser imageUrl + releaseDate + inventory { + id + variant + quantity + } + category { + id + name + description + } + } +} + +query Product($id: UUID!) { + product(id: $id) { + id + name + description + price + maxPerUser + imageUrl + releaseDate inventory { id variant @@ -27,7 +50,7 @@ query ProductCategories { } } -mutation CreateProduct($input: ProductInput!) { +mutation CreateProduct($input: CreateProductInput!) { webshop { createProduct(input: $input) { id @@ -48,4 +71,85 @@ mutation CreateProduct($input: ProductInput!) { } } } +} + +mutation UpdateProduct($input: UpdateProductInput!) { + webshop { + updateProduct(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + +mutation CreateInventory($input: CreateInventoryInput!) { + webshop { + addInventory(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + +mutation UpdateInventory($input: UpdateInventoryInput!) { + webshop { + updateInventory(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + +mutation DeleteInventory($inventoryId: UUID!) { + webshop { + deleteInventory(inventoryId: $inventoryId) + } +} + +mutation DeleteProduct($productId: UUID!) { + webshop { + deleteProduct(productId: $productId) + } } \ No newline at end of file diff --git a/frontend/components/Webshop/CreateProduct.tsx b/frontend/components/Webshop/CreateProduct.tsx deleted file mode 100644 index 1d606743c..000000000 --- a/frontend/components/Webshop/CreateProduct.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { - FormControl, - InputLabel, - MenuItem, - Select, - Stack, - TextField, -} from '@mui/material'; -import { useState } from 'react'; -import { - useCreateProductMutation, - useProductCategoriesQuery, -} from '~/generated/graphql'; -import LoadingButton from '../LoadingButton'; -import { useSnackbar } from '~/providers/SnackbarProvider'; -import handleApolloError from '~/functions/handleApolloError'; -import { useTranslation } from 'next-i18next'; -import { useApiAccess } from '~/providers/ApiAccessProvider'; - -export default function CreateProduct() { - const { data: categoriesData } = useProductCategoriesQuery(); - const categories = categoriesData?.productCategories || []; - const [categoryId, setCategoryId] = useState(''); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [imageUrl, setImageUrl] = useState(''); - const [maxPerUser, setMaxPerUser] = useState(''); - const [price, setPrice] = useState(''); - const [quantity, setQuantity] = useState(''); - const { t } = useTranslation(); - const [createProduct] = useCreateProductMutation({ - variables: { - input: { - categoryId, - name, - description, - imageUrl, - maxPerUser: Number(maxPerUser), - price: Number(price), - quantity: Number(quantity), - variants: [], - }, - }, - onCompleted: () => { - showMessage('Product created', 'success'); - }, - onError: (error) => { - handleApolloError(error, showMessage, t, 'Error creating product'); - }, - }); - const { showMessage } = useSnackbar(); - const { hasAccess } = useApiAccess(); - if (!hasAccess('webshop:create')) { - return ( - -

Create New Product

-

{t('no_permission_page')}

-
- ); - } - return ( - -

Create New Product

- setName(e.target.value)} - /> - setDescription(e.target.value)} - multiline - /> - - Category - - - setImageUrl(e.target.value)} - /> - { - setMaxPerUser(e.target.value); - }} - /> - { - setPrice(e.target.value); - }} - /> - { - setQuantity(e.target.value); - }} - /> - { - await createProduct(); - }} - > - Create product - -
- ); -} diff --git a/frontend/components/Webshop/ManageInventoryItem.tsx b/frontend/components/Webshop/ManageInventoryItem.tsx new file mode 100644 index 000000000..f933270ed --- /dev/null +++ b/frontend/components/Webshop/ManageInventoryItem.tsx @@ -0,0 +1,91 @@ +import { Button, Stack, TextField } from '@mui/material'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import handleApolloError from '~/functions/handleApolloError'; +import { ProductQuery, useDeleteInventoryMutation, useUpdateInventoryMutation } from '~/generated/graphql'; +import { useSnackbar } from '~/providers/SnackbarProvider'; + +export default function ManageInventoryItem({ + inventoryItem, +}: { + inventoryItem: ProductQuery['product']['inventory'][number] +}) { + const [variant, setVariant] = useState(inventoryItem.variant); + const [quantity, setQuantity] = useState(inventoryItem.quantity.toString()); + const { t } = useTranslation(); + const { showMessage } = useSnackbar(); + const [updateInventory] = useUpdateInventoryMutation({ + onCompleted: () => { + showMessage('Product inventory updated', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error creating product'); + }, + }); + + const [deleteInventory] = useDeleteInventoryMutation({ + onCompleted: () => { + showMessage('Product inventory deleted', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error deleting product inventory'); + }, + }); + return ( + + { + setVariant(e.target.value); + }} + /> + { + setQuantity(e.target.value); + }} + /> + + + + + + ); +} diff --git a/frontend/components/Webshop/ManageProduct.tsx b/frontend/components/Webshop/ManageProduct.tsx new file mode 100644 index 000000000..7aee99c1b --- /dev/null +++ b/frontend/components/Webshop/ManageProduct.tsx @@ -0,0 +1,110 @@ +import { + Button, Stack, Typography, +} from '@mui/material'; +import { useTranslation } from 'next-i18next'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import handleApolloError from '~/functions/handleApolloError'; +import { + useDeleteProductMutation, + useProductQuery, +} from '~/generated/graphql'; +import { useSnackbar } from '~/providers/SnackbarProvider'; + +export default function ManageProduct({ + id, +}: { + id: string; +}) { + const router = useRouter(); + const { t } = useTranslation(); + const { showMessage } = useSnackbar(); + const { data } = useProductQuery({ + variables: { + id, + }, + }); + const [deleteProduct] = useDeleteProductMutation({ + onCompleted: () => { + showMessage('Product inventory deleted', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error deleting product inventory'); + }, + }); + const product = data?.product; + + return ( + +

Manage product

+ {data?.product && ( + + + Name: + {' '} + {product.name} + + + Description: + {' '} + {product.description} + + + Category: + {' '} + {product.category?.name} + + + Image URL: + {' '} + {product.imageUrl} + + + Max per user: + {' '} + {product.maxPerUser} + + + Price: + {' '} + {product.price} + + + Release date: + {' '} + {product.releaseDate} + + + + + + + + + )} +
+ ); +} diff --git a/frontend/components/Webshop/ManageProductInventory.tsx b/frontend/components/Webshop/ManageProductInventory.tsx new file mode 100644 index 000000000..b30006c7e --- /dev/null +++ b/frontend/components/Webshop/ManageProductInventory.tsx @@ -0,0 +1,62 @@ +import { Button, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'next-i18next'; +import { useCreateInventoryMutation, useProductQuery } from '~/generated/graphql'; +import ManageInventoryItem from './ManageInventoryItem'; +import { useSnackbar } from '~/providers/SnackbarProvider'; +import handleApolloError from '~/functions/handleApolloError'; + +export default function ManageProductInventory({ + id, +}: { + id: string +}) { + const { data, refetch } = useProductQuery({ + variables: { + id, + }, + }); + const inventory = data?.product?.inventory; + const { t } = useTranslation(); + const { showMessage } = useSnackbar(); + const [createInventory] = useCreateInventoryMutation({ + variables: { + input: { + productId: id, + quantity: 0, + }, + }, + onCompleted: async () => { + await refetch(); + showMessage('Product inventory added', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error creating product'); + }, + }); + return ( + + + { + inventory?.length === 0 + && There is no inventory information about this product + } + + { + inventory?.map((inventoryItem) => ( + + )) + } + + + ); +} diff --git a/frontend/components/Webshop/Product.tsx b/frontend/components/Webshop/Product.tsx index b8cad3462..a86581ec2 100644 --- a/frontend/components/Webshop/Product.tsx +++ b/frontend/components/Webshop/Product.tsx @@ -15,12 +15,14 @@ import { import AddShoppingCartIcon from '@mui/icons-material/AddShoppingCart'; import { useTranslation } from 'next-i18next'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; import { MyCartQuery, ProductsQuery, useAddToMyCartMutation, useMyCartQuery, useProductsQuery, } from '~/generated/graphql'; import handleApolloError from '~/functions/handleApolloError'; import { useSnackbar } from '~/providers/SnackbarProvider'; +import { useApiAccess } from '~/providers/ApiAccessProvider'; function getQuantityInMyCart(productId: string, myCart?: MyCartQuery['myCart']) { if (!myCart) return 0; @@ -30,10 +32,31 @@ function getQuantityInMyCart(productId: string, myCart?: MyCartQuery['myCart']) return quantities.reduce((a, b) => a + b, 0); } +// time diff in milliseconds +const timeDiff = (date1: Date, date2: Date) => { + const diff = date2.getTime() - date1.getTime(); + return diff; +}; + +// milliseconds to days, hours, minutes and seconds +const msToTime = (duration: number) => { + const seconds = Math.floor((duration / 1000) % 60); + const minutes = Math.floor((duration / (1000 * 60)) % 60); + const hours = Math.floor((duration / (1000 * 60 * 60)) % 24); + const days = Math.floor((duration / (1000 * 60 * 60 * 24))); + let output = ''; + if (duration > (1000 * 60 * 60 * 24)) { output += `${days}d `; } + if (duration > (1000 * 60 * 60)) { output += `${hours}h `; } + if (duration > (1000 * 60)) { output += `${minutes}m `; } + output += `${seconds}s`; + return output; +}; + export default function Product({ product }: { product: ProductsQuery['products'][number] }) { const { t } = useTranslation(); const { showMessage } = useSnackbar(); const [selectedVariant, setSelectedVariant] = useState(product.inventory[0]); + const [timeLeft, setTimeLeft] = useState(1); const { refetch: refetchMyCart, data } = useMyCartQuery(); const quantityInMyCart = getQuantityInMyCart(product.id, data?.myCart); const { refetch: refetchProducts } = useProductsQuery( @@ -49,8 +72,26 @@ export default function Product({ product }: { product: ProductsQuery['products' setSelectedVariant(product.inventory .find((p) => p.id === selectedVariant.id) || product.inventory[0]); } + + let interval; + if (new Date(product.releaseDate) > new Date()) { + setTimeLeft(timeDiff(new Date(), new Date(product.releaseDate))); + // update timeleft every second + interval = setInterval(() => { + setTimeLeft(timeDiff(new Date(), new Date(product.releaseDate))); + }, 1000); + const msRemaining = timeDiff(new Date(product.releaseDate), new Date()); + setTimeout(() => { + setTimeLeft(0); + }, msRemaining); + } else { + setTimeLeft(0); + } + return () => clearInterval(interval); }, [product]); + const { hasAccess } = useApiAccess(); + return ( )} - + + )} + + { + timeLeft <= 0 && ( + + }).then(() => { + refetchMyCart(); + refetchProducts(); + }); + })} + > + + {t('webshop:add_to_cart')} + + ) + } + { + timeLeft > 0 && ( + + Biljetter släpps om: + {' '} + {msToTime(timeLeft)} + + ) + } diff --git a/frontend/components/Webshop/ProductEditor.tsx b/frontend/components/Webshop/ProductEditor.tsx new file mode 100644 index 000000000..0bf6d84bb --- /dev/null +++ b/frontend/components/Webshop/ProductEditor.tsx @@ -0,0 +1,135 @@ +import { + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, +} from '@mui/material'; +import { useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { DateTime } from 'luxon'; +import Link from 'next/link'; +import { + CreateProductInput, + ProductQuery, + useProductCategoriesQuery, +} from '~/generated/graphql'; +import LoadingButton from '../LoadingButton'; +import { useApiAccess } from '~/providers/ApiAccessProvider'; +import DateTimePicker from '../DateTimePicker'; + +export default function ProductEditor({ + existingProduct, + onFinish, +}: { + existingProduct?: ProductQuery['product']; + onFinish: (input: CreateProductInput) => Promise; +}) { + const { data: categoriesData } = useProductCategoriesQuery(); + const categories = categoriesData?.productCategories || []; + const [categoryId, setCategoryId] = useState(existingProduct?.category?.id || ''); + const [name, setName] = useState(existingProduct?.name || ''); + const [description, setDescription] = useState(existingProduct?.description || ''); + const [imageUrl, setImageUrl] = useState(existingProduct?.imageUrl || ''); + const [maxPerUser, setMaxPerUser] = useState(existingProduct?.maxPerUser?.toString() || ''); + const [price, setPrice] = useState(existingProduct?.price?.toString() || ''); + const [releaseDate, setReleaseDate] = useState(DateTime.now()); + const { t } = useTranslation(); + const { hasAccess } = useApiAccess(); + if (!hasAccess('webshop:create')) { + return ( + + {existingProduct &&

Edit Product

} + {!existingProduct &&

Create New Product

} +

{t('no_permission_page')}

+
+ ); + } + return ( + + {existingProduct &&

Edit Product

} + {!existingProduct &&

Create New Product

} + setName(e.target.value)} + /> + setDescription(e.target.value)} + multiline + /> + + Category + + + setImageUrl(e.target.value)} + /> + { + setMaxPerUser(e.target.value); + }} + /> + { + setPrice(e.target.value); + }} + /> + + + { + await onFinish({ + name, + description, + categoryId, + imageUrl, + maxPerUser: Number(maxPerUser), + price: Number(price), + releaseDate, + + }); + }} + > + {existingProduct && 'Save Product'} + {!existingProduct && 'Create New Product'} + + + + + +
+ ); +} diff --git a/frontend/generated/graphql.tsx b/frontend/generated/graphql.tsx index 030efd719..e393f727d 100644 --- a/frontend/generated/graphql.tsx +++ b/frontend/generated/graphql.tsx @@ -490,6 +490,13 @@ export type CreateGoverningDocument = { url: Scalars['String']; }; +export type CreateInventoryInput = { + discountId?: InputMaybe; + productId: Scalars['UUID']; + quantity: Scalars['Int']; + variant?: InputMaybe; +}; + export type CreateMailAlias = { email: Scalars['String']; position_id: Scalars['String']; @@ -528,6 +535,16 @@ export type CreatePosition = { name: Scalars['String']; }; +export type CreateProductInput = { + categoryId: Scalars['UUID']; + description: Scalars['String']; + imageUrl: Scalars['String']; + maxPerUser: Scalars['Int']; + name: Scalars['String']; + price: Scalars['Float']; + releaseDate: Scalars['Date']; +}; + export type CreateSpecialReceiver = { alias: Scalars['String']; targetEmail: Scalars['String']; @@ -1168,6 +1185,7 @@ export type Product = { maxPerUser: Scalars['Int']; name: Scalars['String']; price: Scalars['Float']; + releaseDate: Scalars['Date']; }; export type ProductCategory = { @@ -1177,18 +1195,6 @@ export type ProductCategory = { name: Scalars['String']; }; -export type ProductInput = { - categoryId: Scalars['UUID']; - description: Scalars['String']; - discountId?: InputMaybe; - imageUrl: Scalars['String']; - maxPerUser: Scalars['Int']; - name: Scalars['String']; - price: Scalars['Float']; - quantity: Scalars['Int']; - variants: Array; -}; - export type ProductInventory = { __typename?: 'ProductInventory'; discount?: Maybe; @@ -1679,6 +1685,13 @@ export type UpdateGoverningDocument = { url?: InputMaybe; }; +export type UpdateInventoryInput = { + discountId?: InputMaybe; + inventoryId: Scalars['UUID']; + quantity: Scalars['Int']; + variant?: InputMaybe; +}; + export type UpdateMandate = { end_date?: InputMaybe; member_id?: InputMaybe; @@ -1710,6 +1723,17 @@ export type UpdatePosition = { nameEn?: InputMaybe; }; +export type UpdateProductInput = { + categoryId?: InputMaybe; + description?: InputMaybe; + imageUrl?: InputMaybe; + maxPerUser?: InputMaybe; + name?: InputMaybe; + price?: InputMaybe; + productId: Scalars['UUID']; + releaseDate?: InputMaybe; +}; + export type UpdateTag = { color?: InputMaybe; isDefault?: InputMaybe; @@ -1744,14 +1768,24 @@ export type UserInventoryItem = { export type WebshopMutations = { __typename?: 'WebshopMutations'; + addInventory?: Maybe; addToMyCart?: Maybe; consumeItem?: Maybe; createProduct?: Maybe; + deleteInventory?: Maybe; + deleteProduct?: Maybe; freeCheckout?: Maybe; initiatePayment?: Maybe; removeFromMyCart?: Maybe; removeMyCart?: Maybe; + updateInventory?: Maybe; updatePaymentStatus?: Maybe; + updateProduct?: Maybe; +}; + + +export type WebshopMutationsAddInventoryArgs = { + input: CreateInventoryInput; }; @@ -1767,7 +1801,17 @@ export type WebshopMutationsConsumeItemArgs = { export type WebshopMutationsCreateProductArgs = { - input: ProductInput; + input: CreateProductInput; +}; + + +export type WebshopMutationsDeleteInventoryArgs = { + inventoryId: Scalars['UUID']; +}; + + +export type WebshopMutationsDeleteProductArgs = { + productId: Scalars['UUID']; }; @@ -1782,11 +1826,21 @@ export type WebshopMutationsRemoveFromMyCartArgs = { }; +export type WebshopMutationsUpdateInventoryArgs = { + input: UpdateInventoryInput; +}; + + export type WebshopMutationsUpdatePaymentStatusArgs = { paymentId: Scalars['String']; status: PaymentStatus; }; + +export type WebshopMutationsUpdateProductArgs = { + input: UpdateProductInput; +}; + export type _Entity = AccessPolicy | AdminSetting | Alert | Api | Article | ArticleRequest | Bookable | BookableCategory | BookingRequest | Committee | Door | Event | FastMandate | FileData | MailAlias | MailAliasPolicy | Mandate | Markdown | Member | Ping | Position | Tag | Token; export type _Service = { @@ -2758,7 +2812,14 @@ export type ProductsQueryVariables = Exact<{ }>; -export type ProductsQuery = { __typename?: 'Query', products: Array<{ __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null> }; +export type ProductsQuery = { __typename?: 'Query', products: Array<{ __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, releaseDate: any, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null> }; + +export type ProductQueryVariables = Exact<{ + id: Scalars['UUID']; +}>; + + +export type ProductQuery = { __typename?: 'Query', product?: { __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, releaseDate: any, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null }; export type ProductCategoriesQueryVariables = Exact<{ [key: string]: never; }>; @@ -2766,12 +2827,47 @@ export type ProductCategoriesQueryVariables = Exact<{ [key: string]: never; }>; export type ProductCategoriesQuery = { __typename?: 'Query', productCategories: Array<{ __typename?: 'ProductCategory', id: any, name: string, description: string } | null> }; export type CreateProductMutationVariables = Exact<{ - input: ProductInput; + input: CreateProductInput; }>; export type CreateProductMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', createProduct?: { __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null } | null }; +export type UpdateProductMutationVariables = Exact<{ + input: UpdateProductInput; +}>; + + +export type UpdateProductMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', updateProduct?: { __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null } | null }; + +export type CreateInventoryMutationVariables = Exact<{ + input: CreateInventoryInput; +}>; + + +export type CreateInventoryMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', addInventory?: { __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null } | null }; + +export type UpdateInventoryMutationVariables = Exact<{ + input: UpdateInventoryInput; +}>; + + +export type UpdateInventoryMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', updateInventory?: { __typename?: 'Product', id: any, name: string, description: string, price: number, maxPerUser: number, imageUrl: string, inventory: Array<{ __typename?: 'ProductInventory', id: any, variant?: string | null, quantity: number } | null>, category?: { __typename?: 'ProductCategory', id: any, name: string, description: string } | null } | null } | null }; + +export type DeleteInventoryMutationVariables = Exact<{ + inventoryId: Scalars['UUID']; +}>; + + +export type DeleteInventoryMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', deleteInventory?: boolean | null } | null }; + +export type DeleteProductMutationVariables = Exact<{ + productId: Scalars['UUID']; +}>; + + +export type DeleteProductMutation = { __typename?: 'Mutation', webshop?: { __typename?: 'WebshopMutations', deleteProduct?: boolean | null } | null }; + export type SongsQueryVariables = Exact<{ [key: string]: never; }>; @@ -8432,6 +8528,7 @@ export const ProductsDocument = gql` price maxPerUser imageUrl + releaseDate inventory { id variant @@ -8473,6 +8570,57 @@ export function useProductsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions

; export type ProductsLazyQueryHookResult = ReturnType; export type ProductsQueryResult = Apollo.QueryResult; +export const ProductDocument = gql` + query Product($id: UUID!) { + product(id: $id) { + id + name + description + price + maxPerUser + imageUrl + releaseDate + inventory { + id + variant + quantity + } + category { + id + name + description + } + } +} + `; + +/** + * __useProductQuery__ + * + * To run a query within a React component, call `useProductQuery` and pass it any options that fit your needs. + * When your component renders, `useProductQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useProductQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useProductQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ProductDocument, options); + } +export function useProductLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ProductDocument, options); + } +export type ProductQueryHookResult = ReturnType; +export type ProductLazyQueryHookResult = ReturnType; +export type ProductQueryResult = Apollo.QueryResult; export const ProductCategoriesDocument = gql` query ProductCategories { productCategories { @@ -8510,7 +8658,7 @@ export type ProductCategoriesQueryHookResult = ReturnType; export type ProductCategoriesQueryResult = Apollo.QueryResult; export const CreateProductDocument = gql` - mutation CreateProduct($input: ProductInput!) { + mutation CreateProduct($input: CreateProductInput!) { webshop { createProduct(input: $input) { id @@ -8559,6 +8707,222 @@ export function useCreateProductMutation(baseOptions?: Apollo.MutationHookOption export type CreateProductMutationHookResult = ReturnType; export type CreateProductMutationResult = Apollo.MutationResult; export type CreateProductMutationOptions = Apollo.BaseMutationOptions; +export const UpdateProductDocument = gql` + mutation UpdateProduct($input: UpdateProductInput!) { + webshop { + updateProduct(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + `; +export type UpdateProductMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateProductMutation__ + * + * To run a mutation, you first call `useUpdateProductMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateProductMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateProductMutation, { data, loading, error }] = useUpdateProductMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateProductMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateProductDocument, options); + } +export type UpdateProductMutationHookResult = ReturnType; +export type UpdateProductMutationResult = Apollo.MutationResult; +export type UpdateProductMutationOptions = Apollo.BaseMutationOptions; +export const CreateInventoryDocument = gql` + mutation CreateInventory($input: CreateInventoryInput!) { + webshop { + addInventory(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + `; +export type CreateInventoryMutationFn = Apollo.MutationFunction; + +/** + * __useCreateInventoryMutation__ + * + * To run a mutation, you first call `useCreateInventoryMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateInventoryMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createInventoryMutation, { data, loading, error }] = useCreateInventoryMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateInventoryMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateInventoryDocument, options); + } +export type CreateInventoryMutationHookResult = ReturnType; +export type CreateInventoryMutationResult = Apollo.MutationResult; +export type CreateInventoryMutationOptions = Apollo.BaseMutationOptions; +export const UpdateInventoryDocument = gql` + mutation UpdateInventory($input: UpdateInventoryInput!) { + webshop { + updateInventory(input: $input) { + id + name + description + price + maxPerUser + imageUrl + inventory { + id + variant + quantity + } + category { + id + name + description + } + } + } +} + `; +export type UpdateInventoryMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateInventoryMutation__ + * + * To run a mutation, you first call `useUpdateInventoryMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateInventoryMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateInventoryMutation, { data, loading, error }] = useUpdateInventoryMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateInventoryMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateInventoryDocument, options); + } +export type UpdateInventoryMutationHookResult = ReturnType; +export type UpdateInventoryMutationResult = Apollo.MutationResult; +export type UpdateInventoryMutationOptions = Apollo.BaseMutationOptions; +export const DeleteInventoryDocument = gql` + mutation DeleteInventory($inventoryId: UUID!) { + webshop { + deleteInventory(inventoryId: $inventoryId) + } +} + `; +export type DeleteInventoryMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteInventoryMutation__ + * + * To run a mutation, you first call `useDeleteInventoryMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteInventoryMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteInventoryMutation, { data, loading, error }] = useDeleteInventoryMutation({ + * variables: { + * inventoryId: // value for 'inventoryId' + * }, + * }); + */ +export function useDeleteInventoryMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteInventoryDocument, options); + } +export type DeleteInventoryMutationHookResult = ReturnType; +export type DeleteInventoryMutationResult = Apollo.MutationResult; +export type DeleteInventoryMutationOptions = Apollo.BaseMutationOptions; +export const DeleteProductDocument = gql` + mutation DeleteProduct($productId: UUID!) { + webshop { + deleteProduct(productId: $productId) + } +} + `; +export type DeleteProductMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteProductMutation__ + * + * To run a mutation, you first call `useDeleteProductMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteProductMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteProductMutation, { data, loading, error }] = useDeleteProductMutation({ + * variables: { + * productId: // value for 'productId' + * }, + * }); + */ +export function useDeleteProductMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteProductDocument, options); + } +export type DeleteProductMutationHookResult = ReturnType; +export type DeleteProductMutationResult = Apollo.MutationResult; +export type DeleteProductMutationOptions = Apollo.BaseMutationOptions; export const SongsDocument = gql` query Songs { songs { diff --git a/frontend/pages/webshop/index.tsx b/frontend/pages/webshop/index.tsx index c556016fb..784cf0df1 100644 --- a/frontend/pages/webshop/index.tsx +++ b/frontend/pages/webshop/index.tsx @@ -1,13 +1,30 @@ +import { Button } from '@mui/material'; +import Link from 'next/link'; import PageHeader from '~/components/PageHeader'; import Webshop from '~/components/Webshop/Webshop'; import genGetProps from '~/functions/genGetServerSideProps'; +import { useApiAccess } from '~/providers/ApiAccessProvider'; import { useSetPageName } from '~/providers/PageNameProvider'; export default function WebshopPage() { useSetPageName('Webshop'); + const { hasAccess } = useApiAccess(); return ( <> Webshop + {hasAccess('webshop:create') && ( + + + + )} ); diff --git a/frontend/pages/webshop/product/[id]/edit.tsx b/frontend/pages/webshop/product/[id]/edit.tsx new file mode 100644 index 000000000..5ebacb427 --- /dev/null +++ b/frontend/pages/webshop/product/[id]/edit.tsx @@ -0,0 +1,46 @@ +import { Stack } from '@mui/material'; +import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; +import genGetProps from '~/functions/genGetServerSideProps'; +import { + useProductQuery, + useUpdateProductMutation, +} from '~/generated/graphql'; +import { useSnackbar } from '~/providers/SnackbarProvider'; +import handleApolloError from '~/functions/handleApolloError'; +import ProductEditor from '~/components/Webshop/ProductEditor'; + +export default function CreateProductPage() { + const router = useRouter(); + const { id } = router.query; + const { t } = useTranslation(); + const { showMessage } = useSnackbar(); + const { data } = useProductQuery({ + variables: { + id, + }, + }); + const [updateProduct] = useUpdateProductMutation({ + onCompleted: () => { + showMessage('Product updated', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error creating product'); + }, + }); + + return ( + + {data?.product && ( + { + await updateProduct({ variables: { input: { ...input, productId: id as string } } }); + }} + /> + )} + + ); +} + +export const getServerSideProps = genGetProps(['webshop']); diff --git a/frontend/pages/webshop/product/[id]/manage.tsx b/frontend/pages/webshop/product/[id]/manage.tsx new file mode 100644 index 000000000..fb8ea3cd3 --- /dev/null +++ b/frontend/pages/webshop/product/[id]/manage.tsx @@ -0,0 +1,20 @@ +import { Divider, Stack } from '@mui/material'; +import { useRouter } from 'next/router'; +import ManageProduct from '~/components/Webshop/ManageProduct'; +import ManageProductInventory from '~/components/Webshop/ManageProductInventory'; +import genGetProps from '~/functions/genGetServerSideProps'; + +export default function ManageProductPage() { + const router = useRouter(); + const { id } = router.query; + + return ( + + + + + + ); +} + +export const getServerSideProps = genGetProps(['webshop']); diff --git a/frontend/pages/webshop/product/create.tsx b/frontend/pages/webshop/product/create.tsx index c374aacff..bec7a7519 100644 --- a/frontend/pages/webshop/product/create.tsx +++ b/frontend/pages/webshop/product/create.tsx @@ -1,13 +1,32 @@ -import CreateProduct from '~/components/Webshop/CreateProduct'; +import { Stack } from '@mui/material'; +import { useTranslation } from 'next-i18next'; import genGetProps from '~/functions/genGetServerSideProps'; -import { useSetPageName } from '~/providers/PageNameProvider'; +import { + useCreateProductMutation, +} from '~/generated/graphql'; +import { useSnackbar } from '~/providers/SnackbarProvider'; +import handleApolloError from '~/functions/handleApolloError'; +import ProductEditor from '~/components/Webshop/ProductEditor'; -export default function WebshopPage() { - useSetPageName('Create Product'); +export default function CreateProductPage() { + const { t } = useTranslation(); + const { showMessage } = useSnackbar(); + const [createProduct] = useCreateProductMutation({ + onCompleted: () => { + showMessage('Product created', 'success'); + }, + onError: (error) => { + handleApolloError(error, showMessage, t, 'Error creating product'); + }, + }); return ( - <> - - + + { + await createProduct({ variables: { input } }); + }} + /> + ); } diff --git a/frontend/providers/AddFoodPreferencePopup.tsx b/frontend/providers/AddFoodPreferencePopup.tsx index c68a28eed..f1ffe2333 100644 --- a/frontend/providers/AddFoodPreferencePopup.tsx +++ b/frontend/providers/AddFoodPreferencePopup.tsx @@ -11,13 +11,26 @@ export default function AddFoodPreferencePopup({ open, id, refetchUser }: { open const [updateFoodPreferenceMutation] = useUpdateFoodPreferenceMutation(); const { t } = useTranslation(['common']); return ( -

+ {t('add_food_preference')} {t('food_preference_examples')} setFoodPreference(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + updateFoodPreferenceMutation({ + variables: { + id, + foodPreference, + }, + }).then(() => refetchUser()); + } + }} autoFocus multiline margin="dense"