diff --git a/Dockerfile b/Dockerfile index 44f6080..407d83a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,3 +18,4 @@ WORKDIR /usr/src/bolt EXPOSE 3000 WORKDIR /usr/src/bolt/server CMD ["yarn", "start-migrate"] + diff --git a/client/package.json b/client/package.json index 5812bc1..1b17ce0 100644 --- a/client/package.json +++ b/client/package.json @@ -8,6 +8,7 @@ "@bugsnag/plugin-react": "^7.9.2", "accounting": "^0.4.1", "cleave.js": "^1.6.0", + "dateformat": "^4.5.1", "graphql": "^15.5.0", "graphql-tag": "^2.11.0", "graphql-tools": "^7.0.4", @@ -59,6 +60,7 @@ "@hex-labs/stylelint-config": "^1.1.5", "@types/accounting": "^0.4.1", "@types/cleave.js": "^1.4.4", + "@types/dateformat": "^3.0.1", "@types/jest": "26.0.22", "@types/lodash": "^4.14.168", "@types/react": "^17.0.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 848da12..6519ca3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -25,6 +25,9 @@ import { USER_INFO } from "./graphql/Queries"; import LoadingSpinner from "./components/util/LoadingSpinner"; import CreateItemWrapper from "./components/items/CreateItemWrapper"; import EditItemWrapper from "./components/items/EditItemWrapper"; +import AdminRequestsWrapper from "./components/requests/AdminRequestsWrapper"; +import EditRequestWrapper from "./components/requests/EditRequestWrapper"; +import CreateRequestWrapper from "./components/requests/CreateRequestWrapper"; interface OwnProps {} @@ -81,6 +84,9 @@ const App: React.FC = props => { + + + dateFormat(row.createdAt, "hh:MM:ss TT -- mm/dd/yy"), + sortable: true, + center: true, + grow: 3, + }, + { + name: "Edit", + selector: (row: any) => ( + + ), + center: true, + }, +]; + +const AdminRequestsWrapper: React.FC = () => { + const { data, loading, error } = useQuery(ALL_REQUESTS); + const [searchQuery, setSearchQuery] = useState(""); + + if (error) { + return ( + + ); + } + + const filteredData = + data && data.requests + ? data.requests.filter( + (request: Request) => + request.id.toString().indexOf(searchQuery) !== -1 || + request.item.name.toLowerCase().indexOf(searchQuery) !== -1 || + request.user.name.toLowerCase().indexOf(searchQuery) !== -1 || + request.status.toLowerCase().indexOf(searchQuery) !== -1 + ) + : []; + + return ( + <> +
+ { + setSearchQuery(value.trim().toLowerCase()); + }} + style={{ marginBottom: "10px", marginRight: "30px" }} + /> + + } + /> + + ); +}; + +export default AdminRequestsWrapper; diff --git a/client/src/components/requests/CreateRequestWrapper.tsx b/client/src/components/requests/CreateRequestWrapper.tsx new file mode 100644 index 0000000..e5c27c6 --- /dev/null +++ b/client/src/components/requests/CreateRequestWrapper.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Header } from "semantic-ui-react"; + +import { READY_FOR_PICKUP } from "../../types/Hardware"; +import RequestEditForm from "./RequestEditForm"; + +const CreateRequestWrapper: React.FC = () => ( +
+
Create Request
+ +
+); + +export default CreateRequestWrapper; diff --git a/client/src/components/requests/EditRequestWrapper.tsx b/client/src/components/requests/EditRequestWrapper.tsx new file mode 100644 index 0000000..fa5110d --- /dev/null +++ b/client/src/components/requests/EditRequestWrapper.tsx @@ -0,0 +1,46 @@ +import { useQuery } from "@apollo/client"; +import React from "react"; +import { useParams } from "react-router"; +import { Header, Message } from "semantic-ui-react"; + +import { GET_REQUEST } from "../../graphql/Queries"; +import RequestEditForm from "./RequestEditForm"; + +interface ParamTypes { + requestId: string; +} + +const EditRequestWrapper: React.FC = () => { + const { requestId } = useParams(); + + const { data, loading, error } = useQuery(GET_REQUEST, { + variables: { + requestId: parseInt(requestId), + }, + }); + + if (loading) { + return null; + } + + if (error) { + return ; + } + + return ( +
+
Edit Request
+ +
+ ); +}; + +export default EditRequestWrapper; diff --git a/client/src/components/requests/RequestEditForm.tsx b/client/src/components/requests/RequestEditForm.tsx new file mode 100644 index 0000000..10981a2 --- /dev/null +++ b/client/src/components/requests/RequestEditForm.tsx @@ -0,0 +1,244 @@ +import { useMutation, useQuery } from "@apollo/client"; +import React, { ChangeEvent, useState } from "react"; +import { withToastManager } from "react-toast-notifications"; +import { Form, Message, Popup, Button, Dropdown, DropdownProps } from "semantic-ui-react"; + +import { CREATE_REQUEST, UPDATE_REQUEST } from "../../graphql/Mutations"; +import { ALL_REQUESTS, REQUEST_FORM_INFO } from "../../graphql/Queries"; +import { APPROVED, READY_FOR_PICKUP, SUBMITTED } from "../../types/Hardware"; + +export type FormRequest = { + userId: string; + itemId: string; + quantity: number; + status: string; +}; + +interface Props { + preloadRequestId?: string; + preloadRequest: FormRequest; + createRequest: boolean; + toastManager: any; + loading?: boolean; +} + +const RequestEditForm: React.FC = props => { + const [requestData, setRequestData] = useState(props.preloadRequest); + + const { data, loading, error } = useQuery(REQUEST_FORM_INFO); + const [updateRequest, { loading: updateLoading }] = useMutation(UPDATE_REQUEST); + const [createRequest, { loading: createLoading }] = useMutation(CREATE_REQUEST, { + refetchQueries: [{ query: ALL_REQUESTS }], + }); + + if (error) { + return ; + } + + const userOptions = loading + ? [] + : data.users.map((user: any) => ({ + text: `${user.name} [${user.email}]`, + value: user.uuid, + })); + + const itemOptions = loading + ? [] + : data.items.map((item: any) => ({ + text: `${item.category.name} - ${item.name} [${item.location.name}]`, + value: item.id, + })); + + const selectedItem = !loading && data.items.find((item: any) => item.id === requestData.itemId); + + const onSubmit = async () => { + const mutationData = props.createRequest + ? { + userId: requestData.userId, + itemId: requestData.itemId, + quantity: requestData.quantity, + status: requestData.status, + } + : { + id: props.preloadRequestId, + userId: requestData.userId, + itemId: requestData.itemId, + quantity: requestData.quantity, + }; + + try { + if (props.createRequest) { + await createRequest({ variables: { newRequest: mutationData } }); + } else { + await updateRequest({ variables: { updatedRequest: mutationData } }); + } + + props.toastManager.add( + `Requisition ${props.createRequest ? "created" : "saved"} successfully`, + { + appearance: "success", + autoDismiss: true, + placement: "top-center", + } + ); + } catch (err) { + console.error(JSON.parse(JSON.stringify(err))); + props.toastManager.add(`Couldn't save your requisition because of an error: ${err.message}`, { + appearance: "error", + autoDismiss: false, + placement: "top-center", + }); + } + }; + + const handleInputChangeDropdown = ( + event: React.SyntheticEvent, + dropdownData: DropdownProps + ): void => { + const { name, value } = dropdownData; + setRequestData(oldValue => ({ + ...oldValue, + [name]: value, + })); + }; + + const handleInputChange = (event: ChangeEvent) => { + const { target } = event; + let { value }: { value: any } = target; + const { name } = target; + const inputType = target.type; + + // Convert number input values to numbers + if (inputType === "number") { + value = Number.parseFloat(value); + } + + setRequestData(oldValue => ({ + ...oldValue, + [name]: value, + })); + }; + + return ( +
+ {props.createRequest && ( + + )} + + + + + + +

{requestData.status}

+
+
+

Item

+ + + + + + +

{selectedItem?.maxRequestQty}

+
+
+ + + + {selectedItem?.maxRequestQty && requestData.quantity > selectedItem?.maxRequestQty && ( + + )} + {selectedItem?.qtyAvailableForApproval && + requestData.quantity > selectedItem?.qtyAvailableForApproval && ( + + )} +

Calculated Quantities

+ + + Unreserved} + content="The number of an item that is not reserved" + /> +

{selectedItem?.qtyUnreserved}

+
+ + In stock} + content="The number of an item that should be physically at the hardware desk" + /> +

{selectedItem?.qtyInStock}

+
+ + Available for approval} + content="The number of an item that is available to be allocated to requests waiting to be approved" + /> +

{selectedItem?.qtyAvailableForApproval}

+
+
+ + + ); +}; + +export default withToastManager(RequestEditForm); diff --git a/client/src/graphql/Fragments.ts b/client/src/graphql/Fragments.ts index 146c3a6..bd978c6 100644 --- a/client/src/graphql/Fragments.ts +++ b/client/src/graphql/Fragments.ts @@ -38,3 +38,35 @@ export const USER_INFO_FRAGMENT = gql` admin } `; + +export const REQUEST_INFO_FRAGMENT = gql` + fragment RequestInfoFragment on Request { + id + user { + ...UserInfoFragment + } + item { + id + name + qtyUnreserved + qtyInStock + qtyAvailableForApproval + returnRequired + maxRequestQty + location { + id + name + hidden + } + category { + id + name + } + } + status + quantity + createdAt + updatedAt + } + ${USER_INFO_FRAGMENT} +`; diff --git a/client/src/graphql/Queries.ts b/client/src/graphql/Queries.ts index b8bad6e..60909a1 100644 --- a/client/src/graphql/Queries.ts +++ b/client/src/graphql/Queries.ts @@ -1,6 +1,6 @@ import gql from "graphql-tag"; -import { ITEM_INFO_FRAGMENT, USER_INFO_FRAGMENT } from "./Fragments"; +import { ITEM_INFO_FRAGMENT, REQUEST_INFO_FRAGMENT, USER_INFO_FRAGMENT } from "./Fragments"; export const ITEM_EDIT_GET_ITEM = gql` query getItem($itemId: Int!) { @@ -70,6 +70,38 @@ export const ALL_USERS = gql` ${USER_INFO_FRAGMENT} `; +export const ALL_REQUESTS = gql` + query requests { + requests(search: {}) { + ...RequestInfoFragment + } + } + ${REQUEST_INFO_FRAGMENT} +`; + +export const GET_REQUEST = gql` + query request($requestId: Int!) { + request(id: $requestId) { + ...RequestInfoFragment + } + } + ${REQUEST_INFO_FRAGMENT} +`; + +export const REQUEST_FORM_INFO = gql` + query requestForm { + users(search: {}) { + uuid + name + email + } + items { + ...ItemInfoFragment + } + } + ${ITEM_INFO_FRAGMENT} +`; + export const DESK_REQUESTS = gql` query { locations { diff --git a/client/yarn.lock b/client/yarn.lock index 7d70aa7..c3c263f 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2199,6 +2199,11 @@ dependencies: "@types/react" "*" +"@types/dateformat@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc" + integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g== + "@types/eslint@^7.2.6": version "7.2.10" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.10.tgz#4b7a9368d46c0f8cd5408c23288a59aa2394d917" @@ -4971,6 +4976,11 @@ dataloader@2.0.0: resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ== +dateformat@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.5.1.tgz#c20e7a9ca77d147906b6dc2261a8be0a5bd2173c" + integrity sha512-OD0TZ+B7yP7ZgpJf5K2DIbj3FZvFvxgFUuaqA/V5zTjAtAAXZ1E8bktHxmAGs4x5b7PflqA9LeQ84Og7wYtF7Q== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" diff --git a/server/src/api/api.graphql b/server/src/api/api.graphql index 6471d70..c8e4e05 100644 --- a/server/src/api/api.graphql +++ b/server/src/api/api.graphql @@ -5,12 +5,16 @@ type Query { users(search: UserSearch!): [User!] # Get information about a specific item, given its ID item(id: Int!): Item + # Gets all items + items: [Item!] # Get an array of every item in the database allItems: [ItemsByLocation!] # Get a list containing each category that is on at least one item categories: [Category!] # Get detailed statistics about all items itemStatistics: [ItemWithStatistics!] + # Get information about a specific request, given its ID + request(id: Int!): Request # Return request(s) matching the search query provided and that the user has permission to see requests(search: RequestSearch!): [Request!] # Return setting(s) with the name provided @@ -188,10 +192,13 @@ input RequestInput { userId: String! itemId: Int! quantity: Int! + status: RequestStatus } input RequestUpdateInput { id: Int! + userId: String + itemId: Int quantity: Int status: RequestStatus userHaveId: Boolean diff --git a/server/src/api/resolvers/common.ts b/server/src/api/resolvers/common.ts index c914f2d..1a79ad1 100644 --- a/server/src/api/resolvers/common.ts +++ b/server/src/api/resolvers/common.ts @@ -1,9 +1,9 @@ import { GraphQLError } from "graphql"; import { PubSub } from "graphql-subscriptions"; -import { Category, Setting, Location, Item, Request } from "@prisma/client"; +import { Category, Setting, Location, Item, Request, User } from "@prisma/client"; import { localTimestamp, onlyIfAdmin } from "../util"; -import { Item as GraphQLItem } from "../graphql.types"; +import { Item as GraphQLItem, Request as GraphQLRequest } from "../graphql.types"; import { ItemAllQtys, QuantityController } from "../controllers/QuantityController"; import { prisma } from "../../common"; @@ -24,6 +24,25 @@ export function populateItem( }; } +export function populateRequest( + request: Request & { + item: Item & { + location: Location; + category: Category; + }; + user: User; + }, + isAdmin: boolean, + itemQuantities: ItemAllQtys +): GraphQLRequest { + return { + ...request, + item: populateItem(request.item, isAdmin, itemQuantities), + createdAt: localTimestamp(request.createdAt), + updatedAt: localTimestamp(request.updatedAt), + }; +} + export async function getItem(itemId: number, isAdmin: boolean): Promise { if (itemId <= 0) { throw new GraphQLError( diff --git a/server/src/api/resolvers/mutation.ts b/server/src/api/resolvers/mutation.ts index 3e7d5c1..415cdd7 100644 --- a/server/src/api/resolvers/mutation.ts +++ b/server/src/api/resolvers/mutation.ts @@ -226,10 +226,16 @@ export const Mutation: MutationResolvers = { args.newRequest.quantity = 1; } - const initialStatus: RequestStatus = - !item.approvalRequired && item.qtyUnreserved >= args.newRequest.quantity - ? "APPROVED" - : "SUBMITTED"; + let initialStatus: RequestStatus; + + if (context.user.admin === true && args.newRequest.status) { + initialStatus = args.newRequest.status; + } else { + initialStatus = + !item.approvalRequired && item.qtyUnreserved >= args.newRequest.quantity + ? "APPROVED" + : "SUBMITTED"; + } const newRequest = await prisma.request.create({ data: { @@ -307,6 +313,14 @@ export const Mutation: MutationResolvers = { updateObj.quantity = args.updatedRequest.quantity; } + if (args.updatedRequest.userId) { + updateObj.userId = args.updatedRequest.userId; + } + + if (args.updatedRequest.itemId) { + updateObj.itemId = args.updatedRequest.itemId; + } + let updatedUserHaveID = null; if (typeof args.updatedRequest.userHaveId !== "undefined") { updatedUserHaveID = args.updatedRequest.userHaveId; diff --git a/server/src/api/resolvers/query.ts b/server/src/api/resolvers/query.ts index 50057be..d51f0bf 100644 --- a/server/src/api/resolvers/query.ts +++ b/server/src/api/resolvers/query.ts @@ -2,9 +2,8 @@ import { GraphQLError } from "graphql"; import { ItemsByCategory, ItemsByLocation, QueryResolvers, RequestStatus } from "../graphql.types"; import { QuantityController } from "../controllers/QuantityController"; -import { getItem, getSetting, populateItem } from "./common"; +import { getItem, getSetting, populateItem, populateRequest } from "./common"; import { prisma } from "../../common"; -import { localTimestamp } from "../util"; export const Query: QueryResolvers = { /* Queries */ @@ -50,6 +49,23 @@ export const Query: QueryResolvers = { * Access level: any signed in user */ item: async (root, args, context) => await getItem(args.id, context.user.admin), + items: async (root, args, context) => { + const items = await prisma.item.findMany({ + where: { + hidden: context.user.admin ? undefined : false, + location: { + hidden: context.user.admin ? undefined : false, + }, + }, + include: { + location: true, + category: true, + }, + }); + + const itemQuantities = await QuantityController.all(); + return items.map(item => populateItem(item, context.user.admin, itemQuantities)); + }, /** * Bulk items API * TODO: pagination/returned quantity limit @@ -141,6 +157,39 @@ export const Query: QueryResolvers = { }; }); }, + request: async (root, args, context) => { + if (!context.user.admin) { + throw new GraphQLError("You do not have permission to access this endpoint"); + } + + if (args.id <= 0) { + throw new GraphQLError( + "Invalid request ID. The request ID you provided was <= 0, but request IDs must be >= 1." + ); + } + + const request = await prisma.request.findFirst({ + where: { + id: args.id, + }, + include: { + item: { + include: { + location: true, + category: true, + }, + }, + user: true, + }, + }); + + if (request === null) { + return null; + } + + const itemQuantities = await QuantityController.all([request.item.id]); + return populateRequest(request, context.user.admin, itemQuantities); + }, requests: async (root, args, context) => { const searchObj: any = {}; @@ -219,13 +268,7 @@ export const Query: QueryResolvers = { }); const itemQuantities = await QuantityController.all(items); - - return requests.map(request => ({ - ...request, - item: populateItem(request.item, context.user.admin, itemQuantities), - createdAt: localTimestamp(request.createdAt), - updatedAt: localTimestamp(request.updatedAt), - })); + return requests.map(request => populateRequest(request, context.user.admin, itemQuantities)); }, setting: async (root, args) => await getSetting(args.name), };