diff --git a/.DS_Store b/.DS_Store index 9fa61ff..ab1a50e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/pom.xml b/pom.xml index 508f5b5..a53d4a9 100644 --- a/pom.xml +++ b/pom.xml @@ -129,43 +129,42 @@ - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - install-frontend-dependencies - generate-sources - - exec - - - src/main/client - npm - - install - - - - - build-and-include-frontend - generate-sources - - exec - - - src/main/client - npm - - run - build - - - - - + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + install-frontend-dependencies + generate-sources + + exec + + + src/main/client + npm + + install + + + + + build-and-include-frontend + generate-sources + + exec + + + src/main/client + npm + + run + build + + + + + diff --git a/src/main/client/package-lock.json b/src/main/client/package-lock.json index fdd1388..c936eed 100644 --- a/src/main/client/package-lock.json +++ b/src/main/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@radix-ui/react-slot": "^1.2.0", "@tailwindcss/vite": "^4.1.3", "@tanstack/react-query": "^5.67.2", "axios": "^1.8.2", @@ -989,6 +990,39 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -1582,13 +1616,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2322,7 +2356,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -6900,6 +6934,20 @@ "fastq": "^1.6.0" } }, + "@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "requires": {} + }, + "@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + }, "@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -7214,13 +7262,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -7684,7 +7732,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "damerau-levenshtein": { "version": "1.0.8", diff --git a/src/main/client/package.json b/src/main/client/package.json index aca923c..8ffdf4c 100644 --- a/src/main/client/package.json +++ b/src/main/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-slot": "^1.2.0", "@tailwindcss/vite": "^4.1.3", "@tanstack/react-query": "^5.67.2", "axios": "^1.8.2", diff --git a/src/main/client/src/components/Card/index.tsx b/src/main/client/src/components/Card/index.tsx deleted file mode 100644 index 720771d..0000000 --- a/src/main/client/src/components/Card/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React, { ReactNode } from "react"; -import "../styling/generic_table.css"; - -interface CardProps { - children: ReactNode; - footer?: string; - onClick?: () => void; - style?: React.CSSProperties; - className?: string; -} - -const Card: React.FC = ({ children, onClick, style, className }) => { - return ( -
- {children} -
- ); -}; - -export default Card; \ No newline at end of file diff --git a/src/main/client/src/components/Table/index.tsx b/src/main/client/src/components/Table/index.tsx index 9a46cc6..483c74f 100644 --- a/src/main/client/src/components/Table/index.tsx +++ b/src/main/client/src/components/Table/index.tsx @@ -1,66 +1,64 @@ -import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; -import { Equipment } from "../../types.spec.ts"; +import React, { + MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { ColumnDef } from "@/types.spec.ts"; -const Table = ({ - content, - openModal - }: { - content: Equipment[]; - openModal: (id: number) => void; -}) => { - const rows = [ - "ID", - "Name", - "Quantity", - "Type", - "Location", - "Status", - "Comment" - ]; +function Table>({ + rows, + content, + children, +}: { + content: TData[]; + rows: ColumnDef[]; + openModal?: (id: number) => void; + children?: ((rowData: TData) => React.ReactNode) | React.ReactElement<{ rowData: TData }>; +}) { + const [processed, setProcessed] = useState(content); + const buttonRefs = useRef>([]); - const [processed, setProcessed] = useState(content); - - const buttonRefs = useRef>([]); - - - const sortTable = useCallback((row: string, direction: string): void => { - row = row.toLowerCase(); - if (direction === "asc") { - setProcessed( - [...content].sort((a: Equipment, b: Equipment) => { - if (a[row] > b[row]) return -1; - else if (a[row] < b[row]) return 1; - else return 0; - }) - ); - } else { - setProcessed(() => - [...content].sort((a, b) => { - if (a[row] < b[row]) return -1; - else if (a[row] > b[row]) return 1; - else return 0; - }) - ); - } - }, [content]); + const sortTable = useCallback( + (row: string, direction: string): void => { + if (direction === "asc") { + setProcessed( + [...content].sort((a: TData, b: TData) => { + if (a[row] > b[row]) return -1; + else if (a[row] < b[row]) return 1; + else return 0; + }), + ); + } else { + setProcessed(() => + [...content].sort((a: TData, b: TData) => { + if (a[row] < b[row]) return -1; + else if (a[row] > b[row]) return 1; + else return 0; + }), + ); + } + }, + [content], + ); const resetButtons = (current: HTMLButtonElement) => { - buttonRefs.current.map((button) => { - if (button !== current) { + buttonRefs.current.forEach((button) => { + if (button && button !== current) { button.removeAttribute("data-dir"); } }); }; - const handleSort = (event: MouseEvent, row: string) => { + + const handleSort = (event: MouseEvent, row: ColumnDef) => { const curr = event.currentTarget; - resetButtons(event.currentTarget); + resetButtons(curr); if (curr.getAttribute("data-dir") === "desc") { - // Handle sorting - sortTable(row, "asc"); + sortTable(row.accessor, "asc"); curr.setAttribute("data-dir", "asc"); } else { - // Handle sorting - sortTable(row, "desc"); + sortTable(row.accessor, "desc"); curr.setAttribute("data-dir", "desc"); } }; @@ -69,59 +67,61 @@ const Table = ({ setProcessed([...content]); sortTable("ID", "des"); }, [content, sortTable]); + + // Function to render action cell with row data + const renderActionCell = (rowData: TData) => { + if (!children) return null; + + if (typeof children === "function") { + return children(rowData); + } + + if (React.isValidElement(children)) { + return React.cloneElement(children, { rowData } as { rowData: TData }); + } + + return children; + }; + return ( - - {rows.map((row, index) => ( - - ))} - - - - - - {processed.length === 0 && ( - - - )} - {processed.map((row: Equipment, index: number) => ( - - - - - - - - - + {rows.map((row, index) => ( + + ))} + {children && } - ))} + + + {processed.length === 0 && ( + + + + )} + {processed.map((rowData, index) => ( + + {rows.map((row, cellIndex) => ( + + ))} + {children && } + + ))}
- - Action
No Results Found
{row.id}{row.name}{row.quantity}{row.type}{row.location} - - {row.status} - - {row.comment == "nan" ? "" : row.comment} - - + + Action
+ No Results Found +
+ {row.cell ? row.cell : rowData[row.accessor]} + {renderActionCell(rowData)}
); -}; +} export default Table; diff --git a/src/main/client/src/components/styling/dashboard.css b/src/main/client/src/components/styling/dashboard.css index 2977dcf..b0abb9d 100644 --- a/src/main/client/src/components/styling/dashboard.css +++ b/src/main/client/src/components/styling/dashboard.css @@ -16,7 +16,7 @@ .dashboard { height: 100%; - padding: 3rem; + padding: 1rem; width: 90%; .dashboard__title { diff --git a/src/main/client/src/components/styling/history.css b/src/main/client/src/components/styling/history.css index 8e9d119..2d6bc3b 100644 --- a/src/main/client/src/components/styling/history.css +++ b/src/main/client/src/components/styling/history.css @@ -1,6 +1,6 @@ .history { height: 100%; - padding: 3rem; + padding: 1rem; width: 90%; .history__title { diff --git a/src/main/client/src/components/ui/button.tsx b/src/main/client/src/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/src/main/client/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/main/client/src/components/ui/card.tsx b/src/main/client/src/components/ui/card.tsx new file mode 100644 index 0000000..113d66c --- /dev/null +++ b/src/main/client/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/main/client/src/hooks/useBookingList.tsx b/src/main/client/src/hooks/useBookingList.tsx new file mode 100644 index 0000000..821df6f --- /dev/null +++ b/src/main/client/src/hooks/useBookingList.tsx @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuthorizedClient } from "@/hooks/useAuthorizedClient/useAuthorizedClient.tsx"; +import { Booking } from "@/types.spec.ts"; +import { AxiosResponse } from "axios"; + +export const useBookingList = () => { + const client = useAuthorizedClient() + return useQuery>( + { + queryKey: ["bookings", "history"], + queryFn: () => client.get("/bookings") + } + ); +} \ No newline at end of file diff --git a/src/main/client/src/index.css b/src/main/client/src/index.css index b2eda3d..1e7faca 100644 --- a/src/main/client/src/index.css +++ b/src/main/client/src/index.css @@ -45,9 +45,6 @@ } * { - padding: 0; - margin: 0; - box-sizing: border-box; font-family: "Montserrat", "Saira", sans-serif; --primary-bg: #181833; --bright: #794bff; diff --git a/src/main/client/src/main.tsx b/src/main/client/src/main.tsx index 26821b1..54024e9 100644 --- a/src/main/client/src/main.tsx +++ b/src/main/client/src/main.tsx @@ -11,6 +11,7 @@ import { AuthProvider } from "./provider/AuthProvider.tsx"; import { PrivateRoutes } from "./components/PrivateRoutes"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Login } from "./routes/Login/Login.tsx"; +import { Approvals } from "@/routes/Approvals"; const router = createBrowserRouter([ { @@ -29,6 +30,10 @@ const router = createBrowserRouter([ path: "/history", element: }, + { + path: "/approvals", + element: + }, { path: "/notifications", element: diff --git a/src/main/client/src/routes/Approvals/approvalsColumns.tsx b/src/main/client/src/routes/Approvals/approvalsColumns.tsx new file mode 100644 index 0000000..fd313b3 --- /dev/null +++ b/src/main/client/src/routes/Approvals/approvalsColumns.tsx @@ -0,0 +1,24 @@ +import { ColumnDef } from "@/types.spec.ts"; + +export const approvalsColumns: ColumnDef[] = [ + { + header: "User", + accessor: "userName", + }, + { + header: "Equipment", + accessor: "equipment", + }, + { + header: "From", + accessor: "bookedFrom", + }, + { + header: "To", + accessor: "bookedTo", + }, + { + header: "Comment", + accessor: "reason", + } +] \ No newline at end of file diff --git a/src/main/client/src/routes/Approvals/index.tsx b/src/main/client/src/routes/Approvals/index.tsx new file mode 100644 index 0000000..f5b874d --- /dev/null +++ b/src/main/client/src/routes/Approvals/index.tsx @@ -0,0 +1,92 @@ +import Table from "@/components/Table"; +import { useBookingList } from "@/hooks/useBookingList.tsx"; +import { approvalsColumns } from "@/routes/Approvals/approvalsColumns.tsx"; +import Loading from "@/components/Loader"; +import { Button } from "@/components/ui/button"; +import { CheckCircle, XCircle } from "lucide-react"; +import { useAuthorizedClient } from "@/hooks/useAuthorizedClient/useAuthorizedClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export const Approvals = () => { + const queryClient = useQueryClient(); + const client = useAuthorizedClient(); + const { isPending, data } = useBookingList() + + const { mutate: approvalMutate, isPending: approvalPending } = useMutation({ + mutationFn: ({ bookingId }: { bookingId: number }) => handleApprove(bookingId), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["bookings", "history"] }) }, + }) + + const { mutate: rejectMutate, isPending: rejectionPending } = useMutation({ + mutationFn: ({ bookingId }: { bookingId: number }) => handleReject(bookingId), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["bookings", "history"] }) }, + }) + + const handleApprove = async (bookingId: number) => { + return client.post(`/bookings/${bookingId}/approve`); + } + + + const handleReject = async (bookingId: number) => { + return client.post(`/bookings/${bookingId}/reject`); + } + return ( +
+

Approvals

+ + {isPending || approvalPending ? ( + + ) : ( + { }}> + {(rowData) => { + + if (rowData.status == "REJECTED") { + + return
+ + } + + + if (rowData.status == "APPROVED") { + + return
+ +
+ + } + + return
+ + +
+ }} +
+ )} + + +
+ ) +} \ No newline at end of file diff --git a/src/main/client/src/routes/Dashboard/equipmentColumns.tsx b/src/main/client/src/routes/Dashboard/equipmentColumns.tsx new file mode 100644 index 0000000..f31c9b2 --- /dev/null +++ b/src/main/client/src/routes/Dashboard/equipmentColumns.tsx @@ -0,0 +1,32 @@ +import { ColumnDef } from "@/types.spec.ts"; + +export const equipmentColumns:ColumnDef[] = [ + { + header: "ID", + accessor: "id", + }, + { + header: "Name", + accessor: "name", + }, + { + header: "Type", + accessor: "type", + }, + { + header: "Status", + accessor: "status", + }, + { + header: "Location", + accessor: "location", + }, + { + header: "Quantity", + accessor: "quantity", + }, + { + header: "Comment", + accessor: "comment", + }, +] \ No newline at end of file diff --git a/src/main/client/src/routes/Dashboard/index.tsx b/src/main/client/src/routes/Dashboard/index.tsx index b69bc41..4db7ae0 100644 --- a/src/main/client/src/routes/Dashboard/index.tsx +++ b/src/main/client/src/routes/Dashboard/index.tsx @@ -6,6 +6,7 @@ import BookingModal from "../../components/BookingModal"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthorizedClient } from "../../hooks/useAuthorizedClient/useAuthorizedClient.tsx"; import { Equipment } from "../../types.spec.ts"; +import { equipmentColumns } from "@/routes/Dashboard/equipmentColumns.tsx"; const Dashboard = () => { const queryClient = useQueryClient(); @@ -172,7 +173,13 @@ const Dashboard = () => { {isPending ? ( ) : ( - +
+ {(rowData) => ( + + )} +
)}
); diff --git a/src/main/client/src/routes/History/index.tsx b/src/main/client/src/routes/History/index.tsx index acf931c..7b4bce3 100644 --- a/src/main/client/src/routes/History/index.tsx +++ b/src/main/client/src/routes/History/index.tsx @@ -2,70 +2,55 @@ import { Key, useEffect, useState } from "react"; import TableCard from "../../components/TableCard"; import TableCardContainer from "../../components/TableCardContainer"; import "../../components/styling/history.css"; -import { Tab, Tabs } from "../../components/Tabs"; +import { Tab, Tabs } from "@/components/Tabs"; import Loader from "../../components/Loader"; import Loading from "../../components/Loader"; import { useAuthorizedClient } from "../../hooks/useAuthorizedClient/useAuthorizedClient.tsx"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Booking } from "@/types.spec.ts"; +import { useBookingList } from "@/hooks/useBookingList.tsx"; const History: React.FC = () => { const rows = ["ID", "Name", "Request Date", "Action"]; - - interface Booking { - id: string; - equipment: { - id: string; - name: string; - }; - booked_from: string; - status: string; - returned: boolean; - approved: boolean; - } - const [filtered, setFiltered] = useState([]); - const [data, setData] = useState([]); const [currentTab, setCurrentTab] = useState("Pending"); const client = useAuthorizedClient(); - const tabRows = ["Pending", "Approved", "Returned"]; + const tabRows = ["Pending", "Approved", "Returned", "Rejected"]; const queryClient = useQueryClient(); - const { isPending, data: bookingList, isSuccess } = useQuery( - { - queryKey: ["bookings", "history"], - queryFn: () => client.get("/bookings") - } - ); + const [data, setData] = useState([]); + const { isPending, isSuccess, data: response } = useBookingList(); useEffect(() => { if (isSuccess) { - setData(bookingList.data); + setData(response.data); } - }, [bookingList, isSuccess]); - + }, [isSuccess, response]); useEffect(() => { const filterData = (tabName: string) => { if (tabName === "Pending") { - setFiltered(data.filter(booking => !booking.approved)); + setFiltered(data.filter(booking => booking.status === "PENDING")); } else if (tabName === "Approved") { - setFiltered(data.filter(booking => booking.approved)); + setFiltered(data.filter(booking => booking.status === "APPROVED")); + } else if (tabName === "Returned") { + setFiltered(data.filter(booking => booking.status === "RETURNED")); } else { - setFiltered(data.filter(booking => booking.returned)); + setFiltered(data.filter(booking => booking.status === "REJECTED")); } }; filterData(currentTab); }, [data, currentTab]); - const cancelBooking = async (id: string) => { + const cancelBooking = async (id: number) => { return client.delete(`/bookings/${id}`); }; const { mutate, isPending: cancelPending } = useMutation( { - mutationFn: (id: string) => cancelBooking(id), + mutationFn: (id: number) => cancelBooking(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookings", "history"] }) } ); @@ -84,7 +69,7 @@ const History: React.FC = () => { )} {filtered.map((booking: Booking, index: Key) => ( - + { currentTab === "Pending" ? ( diff --git a/src/main/client/src/routes/Notifications/index.tsx b/src/main/client/src/routes/Notifications/index.tsx index cc1af54..399fbca 100644 --- a/src/main/client/src/routes/Notifications/index.tsx +++ b/src/main/client/src/routes/Notifications/index.tsx @@ -1,13 +1,20 @@ import { useEffect, useState } from "react"; import useNotifications, { Notification } from "../../hooks/useNotifications"; import Env from "../../Env"; -import CardGrid from "../../components/CardGrid"; -import Card from "../../components/Card"; -import { AlertCircle, Bell, Calendar, Check, CheckCircle, EyeClosed, RotateCcw, XCircle } from "lucide-react"; - +import { + AlertCircle, + Bell, + Calendar, + Check, + CheckCircle, + EyeClosed, + RotateCcw, + XCircle, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card.tsx"; +import CardGrid from "@/components/CardGrid"; const Notifications = () => { - const [notifications, setNotifications] = useState([]); const stream = useNotifications(1); @@ -23,23 +30,39 @@ const Notifications = () => { const markAsRead = (id: number) => { fetch(`${Env.BASE_URL}/notifications/${id}`, { - method: "POST" + method: "POST", }).then((response) => { if (!response.ok) { throw new Error("Failed to mark notification as read"); } - setNotifications(notifications.map((notification) => { - if (notification.id === id) { - return { ...notification, read: true }; - } - return notification; - })); + setNotifications( + notifications.map((notification) => { + if (notification.id === id) { + return { ...notification, read: true }; + } + return notification; + }), + ); }); }; - // convert notification type to css variable name, e.g. REQUEST_CREATED -> --request-created - const typeToCssVar = (type: string) => { - return `--${type.toLowerCase().replace(/_/g, "-")}`; + const typeToCssClass = (type: string) => { + switch (type) { + case "REQUEST_CREATED": + return "blue-500"; + case "REQUEST_APPROVED": + return "green-500"; + case "REQUEST_REJECTED": + return "red-500"; + case "OUTSTANDING_RETURN": + return "orange-500"; + case "RETURN_COMPLETED": + return "green-500"; + case "UPCOMING_RETURN": + return "blue-500"; + default: + return "purple-500"; + } }; const iconForType = (type: string) => { @@ -62,18 +85,35 @@ const Notifications = () => { }; const styleNotification = (notification: Notification) => { - let style = "notification__item"; - if (notification.read) { - style += " card-grid-item-read"; - } + const style = notification.read && "card-grid-item-read" ; + const colorClass = typeToCssClass(notification.type); + return ( - -
- {iconForType(notification.type)} - {notification.message} -
- {!notification.read && markAsRead(notification.id)} />} + + +
+ {iconForType(notification.type)} +
+
+
+
+

{notification.type}

+

+ {notification.message} +

+ 5 min ago +
+
+ {!notification.read && ( + markAsRead(notification.id)} + /> + )} +
+
+
+
); }; @@ -90,14 +130,10 @@ const Notifications = () => {
)} - {notifications.map((notification) => ( - styleNotification(notification) - ))} + {notifications.map((notification) => styleNotification(notification))}
); }; export default Notifications; - - diff --git a/src/main/client/src/types.spec.ts b/src/main/client/src/types.spec.ts index d106e3e..6b67344 100644 --- a/src/main/client/src/types.spec.ts +++ b/src/main/client/src/types.spec.ts @@ -1,3 +1,5 @@ +import { ReactElement } from "react"; + export interface Equipment { id: number; name: string; @@ -20,3 +22,23 @@ export interface UserObject { dob: string; profilePicture: string; } + + +export interface Booking { + id: number; + userId: number; + userName: string; + equipment: string; + bookedTo: string; + bookedFrom: string; + status: string; + returned: boolean; + approved: boolean; +} + + +export interface ColumnDef{ + accessor: string; + header: string; + cell?: ReactElement; +} \ No newline at end of file diff --git a/src/main/java/com/tibs/Ergon/controller/BookingController.java b/src/main/java/com/tibs/Ergon/controller/BookingController.java index 17656fa..3a2599f 100644 --- a/src/main/java/com/tibs/Ergon/controller/BookingController.java +++ b/src/main/java/com/tibs/Ergon/controller/BookingController.java @@ -4,17 +4,20 @@ import com.tibs.Ergon.repository.BookingRepository; import com.tibs.Ergon.request.BookingRequest; import com.tibs.Ergon.service.BookingService; +import com.tibs.Ergon.util.UserUtil; +import com.tibs.Ergon.dto.BookingResponseDTO; +import com.tibs.Ergon.mapper.BookingMapper; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; - import java.net.URI; import java.net.URISyntaxException; -import java.util.Collection; +import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; @RequestMapping("/api/bookings") @RestController @@ -22,21 +25,41 @@ public class BookingController { private final Logger log = LoggerFactory.getLogger(BookingController.class); private final BookingRepository bookingRepository; private final BookingService bookingService; + private final BookingMapper bookingMapper; - public BookingController(BookingRepository bookingRepository, BookingService bookingService) { - this.bookingRepository = bookingRepository; + @Autowired + public BookingController(BookingService bookingService, BookingMapper bookingMapper, BookingRepository bookingRepository) { this.bookingService = bookingService; + this.bookingMapper = bookingMapper; + this.bookingRepository = bookingRepository; } @GetMapping("") - public Collection bookings() { - return bookingRepository.findAll(); - } + public ResponseEntity> bookings() { + List bookings = bookingRepository.findAll(); + List dtos = bookings.stream() + .map(bookingMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); } @GetMapping("/{id}") public ResponseEntity getBooking(@PathVariable Long id) { - Optional found = bookingRepository.findById(id); - return found.map(response -> ResponseEntity.ok().body(response)).orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND)); + Booking booking = this.bookingService.getBookingById(id); + return ResponseEntity.ok(this.bookingMapper.toDTO(booking)); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approveBooking(@PathVariable Long id) { + String currentUser = UserUtil.userName(); + bookingService.approveBooking(id, currentUser); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/reject") + public ResponseEntity rejectBooking(@PathVariable Long id) { + String currentUser = UserUtil.userName(); + bookingService.rejectBooking(id, currentUser); + return ResponseEntity.ok().build(); } @PostMapping("") diff --git a/src/main/java/com/tibs/Ergon/dto/BookingResponseDTO.java b/src/main/java/com/tibs/Ergon/dto/BookingResponseDTO.java new file mode 100644 index 0000000..870378f --- /dev/null +++ b/src/main/java/com/tibs/Ergon/dto/BookingResponseDTO.java @@ -0,0 +1,23 @@ +package com.tibs.Ergon.dto; + +import java.time.LocalDate; + +import com.tibs.Ergon.enums.BookingStatusEnum; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class BookingResponseDTO { + private Long id; + private LocalDate bookedFrom; + private LocalDate bookedTo; + private BookingStatusEnum status; + private String reason; + private Long userId; + private String userName; + private Long approverId; + private String approverName; + private String equipment; +} \ No newline at end of file diff --git a/src/main/java/com/tibs/Ergon/enums/BookingStatusEnum.java b/src/main/java/com/tibs/Ergon/enums/BookingStatusEnum.java new file mode 100644 index 0000000..c630fa5 --- /dev/null +++ b/src/main/java/com/tibs/Ergon/enums/BookingStatusEnum.java @@ -0,0 +1,9 @@ +package com.tibs.Ergon.enums; + +public enum BookingStatusEnum { + PENDING, + APPROVED, + REJECTED, + RETURNED, + CANCELLED, +} diff --git a/src/main/java/com/tibs/Ergon/mapper/BookingMapper.java b/src/main/java/com/tibs/Ergon/mapper/BookingMapper.java new file mode 100644 index 0000000..37147dd --- /dev/null +++ b/src/main/java/com/tibs/Ergon/mapper/BookingMapper.java @@ -0,0 +1,27 @@ +package com.tibs.Ergon.mapper; +import com.tibs.Ergon.dto.BookingResponseDTO; +import com.tibs.Ergon.model.Booking; +import org.springframework.stereotype.Component; + +@Component +public class BookingMapper { + + public BookingResponseDTO toDTO(Booking booking) { + if (booking == null) { + return null; + } + + return BookingResponseDTO.builder() + .id(booking.getId()) + .bookedFrom(booking.getBooked_from()) + .bookedTo(booking.getBooked_to()) + .status(booking.getStatus()) + .reason(booking.getReason()) + .userId(booking.getUser() != null ? booking.getUser().getId() : null) + .userName(booking.getUser() != null ? booking.getUser().getUsername() : null) + .approverId(booking.getApprover() != null ? booking.getApprover().getId() : null) + .approverName(booking.getApprover() != null ? booking.getApprover().getUsername() : null) + .equipment(booking.getEquipment().getName()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/tibs/Ergon/model/Booking.java b/src/main/java/com/tibs/Ergon/model/Booking.java index 1aed632..38a5e16 100644 --- a/src/main/java/com/tibs/Ergon/model/Booking.java +++ b/src/main/java/com/tibs/Ergon/model/Booking.java @@ -1,6 +1,8 @@ package com.tibs.Ergon.model; import com.fasterxml.jackson.annotation.JsonBackReference; +import com.tibs.Ergon.enums.BookingStatusEnum; + import jakarta.persistence.*; import lombok.*; @@ -20,9 +22,10 @@ public class Booking { @NonNull private LocalDate booked_from; private LocalDate booked_to; - private Boolean approved; + private BookingStatusEnum status; private Boolean returned; private String reason; + private LocalDate returnedDate; @ManyToOne(cascade = CascadeType.PERSIST) @JoinColumn(name = "user_id") diff --git a/src/main/java/com/tibs/Ergon/repository/BookingRepository.java b/src/main/java/com/tibs/Ergon/repository/BookingRepository.java index 60fb1d0..ccdc314 100644 --- a/src/main/java/com/tibs/Ergon/repository/BookingRepository.java +++ b/src/main/java/com/tibs/Ergon/repository/BookingRepository.java @@ -1,10 +1,11 @@ package com.tibs.Ergon.repository; +import com.tibs.Ergon.enums.BookingStatusEnum; import com.tibs.Ergon.model.Booking; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface BookingRepository extends JpaRepository { - List findByReturnedFalseAndApprovedTrue(); -} + List findByStatus(BookingStatusEnum status); +} \ No newline at end of file diff --git a/src/main/java/com/tibs/Ergon/service/BookingService.java b/src/main/java/com/tibs/Ergon/service/BookingService.java index 6d19020..a1e5227 100644 --- a/src/main/java/com/tibs/Ergon/service/BookingService.java +++ b/src/main/java/com/tibs/Ergon/service/BookingService.java @@ -1,6 +1,8 @@ package com.tibs.Ergon.service; +import com.tibs.Ergon.enums.BookingStatusEnum; import com.tibs.Ergon.enums.NotificationTypeEnum; +import com.tibs.Ergon.enums.RoleEnum; import com.tibs.Ergon.expception.BookingNotFound; import com.tibs.Ergon.expception.EquipmentNotAvailable; import com.tibs.Ergon.expception.UserNotFound; @@ -51,16 +53,17 @@ public Booking createBooking(BookingRequest request) { .reason(request.getReason()) .user(user) .returned(false) - .approved(false) + .status(BookingStatusEnum.PENDING) + .returnedDate(null) .build(); Booking booking = bookingRepo.save(newBooking); equipmentService.linkToBooking(booking, equipment); - notificationService.createNotification(NotificationTypeEnum.REQUEST_CREATED, user.getId(), "Booking request created for " + equipment.getName()); + notificationService.createNotification(NotificationTypeEnum.REQUEST_CREATED, user.getId(), + "Booking request created for " + equipment.getName()); return newBooking; -// TODO: Create an approval request alongside this } else { throw new EquipmentNotAvailable(); } @@ -68,22 +71,55 @@ public Booking createBooking(BookingRequest request) { public void cancelBooking(Booking booking) { bookingRepo.delete(booking); - notificationService.createNotification(NotificationTypeEnum.REQUEST_CREATED, booking.getUser().getId(), "Booking request cancelled for " + booking.getEquipment().getName()); + notificationService.createNotification(NotificationTypeEnum.REQUEST_CREATED, booking.getUser().getId(), + "Booking request cancelled for " + booking.getEquipment().getName()); } - public void approveBooking(GeneralBookingRequest request) { - Booking booking = bookingRepo.findById(request.getBookingId()).orElseThrow(BookingNotFound::new); - User user = userRepo.findById(request.getUserId()).orElseThrow(UserNotFound::new); - // TODO: FINISH THIS FUNCTION - // TODO: Booking should have approved status and User should be notified + public void approveBooking(Long bookingId, String username) { + Booking booking = bookingRepo.findById(bookingId).orElseThrow(BookingNotFound::new); + User currentUser = userRepo.findByUsername(username).orElseThrow(UserNotFound::new); + if (currentUser.getRole() == RoleEnum.ROLE_ADMIN) { + if (booking.getStatus() != BookingStatusEnum.PENDING) { + throw new RuntimeException("Booking is not in a state to be approved"); + } + booking.setStatus(BookingStatusEnum.APPROVED); + booking.setApprover(currentUser); + bookingRepo.save(booking); + notificationService.createNotification(NotificationTypeEnum.REQUEST_APPROVED, booking.getUser().getId(), + "Booking request approved for " + booking.getEquipment().getName() + " by " + + currentUser.getUsername()); + } else { + throw new RuntimeException("User is not authorized to approve this booking"); + } } + public void rejectBooking(Long bookingId, String username) { + Booking booking = bookingRepo.findById(bookingId).orElseThrow(BookingNotFound::new); + User currentUser = userRepo.findByUsername(username).orElseThrow(UserNotFound::new); + if (currentUser.getRole() == RoleEnum.ROLE_ADMIN) { + if (booking.getStatus() != BookingStatusEnum.PENDING) { + throw new RuntimeException("Booking is not in a state to be rejected"); + } + booking.setStatus(BookingStatusEnum.REJECTED); + booking.setApprover(currentUser); + bookingRepo.save(booking); + notificationService.createNotification(NotificationTypeEnum.REQUEST_REJECTED, booking.getUser().getId(), + "Booking request rejected for " + booking.getEquipment().getName() + " by " + currentUser.getUsername()); + } else { + throw new RuntimeException("User is not authorized to reject this booking"); + } + } private void validateBookingRequest(BookingRequest request) { - if (request.getTo().isBefore(LocalDate.now()) || request.getFrom().isAfter(request.getTo()) || request.getFrom().isEqual(request.getTo())) { + if (request.getTo().isBefore(LocalDate.now()) || request.getFrom().isAfter(request.getTo()) + || request.getFrom().isEqual(request.getTo())) { throw new RuntimeException("Invalid dates provided"); } } + public Booking getBookingById(Long id) { + return bookingRepo.findById(id).orElseThrow(BookingNotFound::new); + } + } \ No newline at end of file