diff --git a/labman/prisma/migrations/20260309105825_add_statuses/migration.sql b/labman/prisma/migrations/20260309105825_add_statuses/migration.sql new file mode 100644 index 0000000..2bbf039 --- /dev/null +++ b/labman/prisma/migrations/20260309105825_add_statuses/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Borrower" ADD COLUMN "status" TEXT; + +-- AlterTable +ALTER TABLE "Equipment" ADD COLUMN "status" TEXT; + +-- AlterTable +ALTER TABLE "Item" ALTER COLUMN "status" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Loan" ALTER COLUMN "status" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "status" TEXT; diff --git a/labman/prisma/migrations/20260309142105_status_correction/migration.sql b/labman/prisma/migrations/20260309142105_status_correction/migration.sql new file mode 100644 index 0000000..965fcac --- /dev/null +++ b/labman/prisma/migrations/20260309142105_status_correction/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - Made the column `status` on table `Borrower` required. This step will fail if there are existing NULL values in that column. + - Made the column `status` on table `Equipment` required. This step will fail if there are existing NULL values in that column. + - Made the column `status` on table `Item` required. This step will fail if there are existing NULL values in that column. + - Made the column `status` on table `Loan` required. This step will fail if there are existing NULL values in that column. + +*/ +UPDATE "Borrower" SET "status" = 'Active' WHERE "status" IS NULL; +UPDATE "Equipment" SET "status" = 'Active' WHERE "status" IS NULL; + +-- AlterTable +ALTER TABLE "Borrower" ALTER COLUMN "status" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Equipment" ALTER COLUMN "status" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Item" ALTER COLUMN "status" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Loan" ALTER COLUMN "status" SET NOT NULL; diff --git a/labman/prisma/schema.prisma b/labman/prisma/schema.prisma index cdbca87..fba02de 100644 --- a/labman/prisma/schema.prisma +++ b/labman/prisma/schema.prisma @@ -27,6 +27,7 @@ model Equipment { name String @unique image String? createdAt DateTime @default(now()) + status String items Item[] category EquipmentCategory @relation(fields: [categoryId], references: [id]) } @@ -60,6 +61,7 @@ model User { username String @unique createdAt DateTime @default(now()) latestActivity DateTime @default(now()) + status String? sessions Session[] loans Loan[] } @@ -70,6 +72,7 @@ model Borrower { phone String? @unique email String? @unique note String? + status String creationDate DateTime @default(now()) loans Loan[] } diff --git a/labman/src/app/(main)/globals.css b/labman/src/app/(main)/globals.css index ed354b4..093271e 100644 --- a/labman/src/app/(main)/globals.css +++ b/labman/src/app/(main)/globals.css @@ -83,7 +83,7 @@ html { } .item-view { - @apply bg-brand-950 rounded-md p-3 h-180 overflow-y-auto + @apply bg-brand-950 rounded-md p-3 h-149 overflow-y-auto } .side-form-label { diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx index 1fe817c..c3fd74b 100644 --- a/labman/src/app/(main)/layout.tsx +++ b/labman/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import NavBar from "@/components/core/NavBar"; //import {PopupProvider} from "./popupProvider" import {getUser} from "@/lib/actions"; import SideBar from "@/components/core/SideBar"; +import {SideViewProvider} from "@/app/sideViewContext"; const spartan = League_Spartan({ subsets: ["latin"], @@ -27,20 +28,20 @@ export default async function RootLayout({ return ( - - -
- - - -
- - {children} -
-
+ +
+ + + +
+ + {children} +
+
+
); diff --git a/labman/src/app/(main)/loans/page.tsx b/labman/src/app/(main)/loans/page.tsx index b8f6e3d..4fe4e93 100644 --- a/labman/src/app/(main)/loans/page.tsx +++ b/labman/src/app/(main)/loans/page.tsx @@ -15,12 +15,23 @@ export default async function Loans() { } const loans = await prisma.loan.findMany({ include: { + borrower: true, item: { include: { - equipment: true + equipment: { + include: { + category: true, + items: { + include: { + loans: true, + activeLoan: true + } + } + + } + } } - }, - borrower: true + } } }); diff --git a/labman/src/app/(main)/popupProvider.tsx b/labman/src/app/(main)/popupProvider.tsx deleted file mode 100644 index 7d919a1..0000000 --- a/labman/src/app/(main)/popupProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/*"use client" -import {createContext, useState} from "react"; - -export const popupContext = createContext(null); - -export const PopupProvider = ({children}) => { - const [isOpen, setIsOpen] = useState(false); - const [message, setMessage] = useState(""); - - return ( - - <> - {children} -
-
-

{message}

-
-
- -
- ) -} */ \ No newline at end of file diff --git a/labman/src/app/api/equipment/route.ts b/labman/src/app/api/equipment/route.ts index f963878..f69d561 100644 --- a/labman/src/app/api/equipment/route.ts +++ b/labman/src/app/api/equipment/route.ts @@ -33,6 +33,7 @@ export async function POST(req: Request) { name, image, categoryId, + status: "Active", items: { create: { status: "Available" diff --git a/labman/src/app/sideViewContext.tsx b/labman/src/app/sideViewContext.tsx new file mode 100644 index 0000000..a304d85 --- /dev/null +++ b/labman/src/app/sideViewContext.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; + +type SideViewCtx = { + sideView: string; + setSideView: React.Dispatch>; +}; + +const SideViewContext = createContext(null); + +export function SideViewProvider({ children, initialType = "",}: { children: React.ReactNode; initialType?: string; }) { + const [sideView, setSideView] = useState(initialType); + + return ( + + {children} + + ); +} + +export function useSideView() { + const ctx = useContext(SideViewContext); + if (!ctx) throw new Error("useString must be used within "); + return ctx; +} \ No newline at end of file diff --git a/labman/src/components/core/Card.tsx b/labman/src/components/core/Card.tsx index 06907b6..7d52d8b 100644 --- a/labman/src/components/core/Card.tsx +++ b/labman/src/components/core/Card.tsx @@ -6,11 +6,13 @@ import {LoanClass} from "@/types/Loan"; interface CardProps { user?: UserClass loan?: LoanClass; + setSideView?: (view: string) => void; + setSelectedLoanId?: (id: number | null) => void; } // TODO: Could have a button to reactivate a loan, not a priority right now -export default function Card({ loan, user }: CardProps) { +export default function Card({ loan, user, setSelectedLoanId, setSideView }: CardProps) { let {name, start, last} = {name: "", start: "", last: ""}; @@ -31,9 +33,9 @@ export default function Card({ loan, user }: CardProps) { } return( -
+
-

{name}

+

{name}

{loan && |} {loan &&

Unit {loan.item?.id}

} {loan &&
@@ -67,11 +69,15 @@ export default function Card({ loan, user }: CardProps) {
}
- - + + { loan && loan.status != "Returned" && }
-
) } \ No newline at end of file diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx index 04ba179..a0cce6e 100644 --- a/labman/src/components/core/CardList.tsx +++ b/labman/src/components/core/CardList.tsx @@ -1,41 +1,13 @@ "use client" import Card from "@/components/core/Card"; -import {useState} from "react"; +import {useState, useOptimistic, startTransition} from "react"; import {User} from "@/generated/prisma"; import {UserClass} from "@/types/User"; import {returnLoan, deleteLoan, deleteUser} from "@/lib/actions"; import {LoanClass} from "@/types/Loan"; - - -type Loans = { - id: number; - startDate: Date; - endDate: Date; - status: string; - - borrower: { - id: number; - name: string; - phone?: string | null; - email?: string | null - note?: string | null - creationDate: Date; - - } - item: { - id: number; - equipment: { - id: number; - name: string; - categoryId: number; - image: string | null; - createdAt: Date; - - } - } -}[]; - -type Loan = Loans[0]; +import EditLoan from "@/components/loans/EditLoan"; +import {useSideView} from "@/app/sideViewContext"; +import {Loan} from "@/types/Loan"; interface CardListProps { loansProp?: Loan[]; @@ -47,10 +19,20 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp const [loans, setLoans] = useState(loansProp); const [users, setUsers] = useState(usersProp); - + const [optimisticUsers, removeUser] = useOptimistic( + users, + (currentUsers, idToRemove : number) => + currentUsers.map(user => + user.id === idToRemove ? { ...user, status: "deleting" } : user)) +// TODO: Temporary use of password field until I add another alternative const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [selectedLoanId, setSelectedLoanId] = useState(null); + + const { sideView, setSideView } = useSideView(); + + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const res = await fetch("/api/register", { @@ -67,7 +49,6 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp const newUser : User = await res.json(); if (newUser) { - console.log(newUser) setUsername(""); setPassword(""); setUsers(prev => [...prev, newUser]); @@ -78,14 +59,28 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp } } + async function deleteAction(id: number) { + const res = await deleteUser(id); + if (res.type === "error") return alert(res.message); + setUsers(prev => prev.filter(user => user.id !== id)); + } + async function handleDeleteUser(userId: number) { if (window.confirm("Are you sure you want to delete this user?")) { - setUsers(prev => prev.filter(user => user.id !== userId)); - await deleteUser(userId); + // Optimistically remove the user from the UI + startTransition(async () => { + removeUser(userId) + try { + await deleteAction(userId); + } catch (e) { + alert("Failed to delete user: " + e); + } + }) } } + const hasReturnedLoans = loans.some( (loan) => loan.status === "Returned" ) @@ -118,6 +113,11 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp return (
+ { sideView == "loanEdit" && selectedLoanId && loan.id === selectedLoanId)!} + setLoans={setLoans} + + />} { users.length !== 0 &&
setUsername(e.target.value)} type="text" name="username" placeholder="Username" className="bg-white rounded-md p-2 m-2 placeholder-black text-black" /> @@ -140,9 +140,9 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp returnLoan: async (id : number) => handleReturnLoan(id) } ) - return ; + return ; })} - {users.map(userDto => { + {optimisticUsers.map(userDto => { const user = new UserClass( userDto.id, userDto.username, @@ -150,7 +150,8 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp userDto.latestActivity, { deleteUser: async (id: number) => handleDeleteUser(id) - } + }, + userDto.status ) return ; })} diff --git a/labman/src/components/core/NavBar.tsx b/labman/src/components/core/NavBar.tsx index c0f2ae7..127d9bf 100644 --- a/labman/src/components/core/NavBar.tsx +++ b/labman/src/components/core/NavBar.tsx @@ -1,5 +1,4 @@ "use client" -import Button from "@/components/core/Button"; import PathName from "@/components/core/PathName"; import {logout} from "@/lib/actions"; diff --git a/labman/src/components/core/SideView/ItemList.tsx b/labman/src/components/core/SideView/ItemList.tsx new file mode 100644 index 0000000..bf27470 --- /dev/null +++ b/labman/src/components/core/SideView/ItemList.tsx @@ -0,0 +1,79 @@ +import {Equipment, Unit} from "@/types/inventory"; +import {useRef} from "react"; +import {useEffect} from "react"; + +type BaseProps = { + equipmentData: Equipment; +} + +type SelectableProps = BaseProps & { + variant: "selectable"; + selectedUnit: Unit | undefined; + setSelectedUnit: (unit: Unit) => void; +} + +type editableProps = BaseProps & { + variant: "editable"; + handleAddUnit: (name: string) => void; + handleDeleteUnit: (id: number) => void; + } + + type Props = SelectableProps | editableProps; + +export default function ItemList(props: Props) { + + // The initially selected unit has to persist between renders but uses to useEffect to update when the loan changes + let selectedUnitRef : React.RefObject; + + if (props.variant === "selectable") { + selectedUnitRef = useRef(props.selectedUnit?.id); + + useEffect(() => { + selectedUnitRef.current = props.selectedUnit?.id; + }, [props.equipmentData]) + } + + + return( +
+

Items

+
+ { props.variant === "editable" && } +
+ {props.equipmentData.items.map((unit, index) => ( +
+

{unit.id}

+ {(() => { + switch (props.variant) { + case "editable": + return ( + <> + { unit.activeLoan && unit.activeLoan.status !== "Returned" &&

Borrowed

} + {(unit.activeLoan == null || unit.activeLoan.status === "Returned") && + } + + + ) + case "selectable": + return ( + <> + { unit.activeLoan && (unit.activeLoan.status !== "Returned" && unit.id !== selectedUnitRef.current) &&

Borrowed

} + { (unit.activeLoan == null || unit.activeLoan.status === "Returned" || unit.id == selectedUnitRef.current) && } + + ) + } + })()} +
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/labman/src/components/core/SideView/SideView.tsx b/labman/src/components/core/SideView/SideView.tsx new file mode 100644 index 0000000..4c48b81 --- /dev/null +++ b/labman/src/components/core/SideView/SideView.tsx @@ -0,0 +1,121 @@ +import {JSX} from "react"; +import {Equipment, Unit} from "@/types/inventory"; +import {useSideView} from "@/app/sideViewContext"; +import ItemList from "@/components/core/SideView/ItemList"; +import {loanCount} from "@/utils/inventoryUtils"; + +interface SideViewProps { + children: JSX.Element; + title: string; + equipmentData: Equipment; + itemList?: JSX.Element; + +} + +export default function SideView( { children, title, equipmentData, itemList} : SideViewProps) { + const {sideView, setSideView} = useSideView(); + return ( + <> + {/* Dark backdrop */} +
setSideView("")} + /> + + {/* Right-side panel */} +
+ + {/* Vertical split */} +
+ {/* Left side of a panel */} +
+

{title}

+
+ {/* + + + */} +
+ +
+ {children} + {/* + + + + + + + + + + + + */} +
+ --------------------------------------------------------------------------------------- + {/*selectedUnit && setSelectedUnit ? : + */} + {itemList} + {/*

Items

+
+
+ {equipmentData.items.map((unit, index) => ( + hasActiveLoan = unit.activeLoan != null, +
+

Unit {index + 1}

+ { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} + { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } + +
+ )) } +
+
*/} +
+ + {/* Right side of panel */} +
+
+ +
+
+
+

{equipmentData.name}

+

{equipmentData.category.name}

+

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

+
+ ---------------------------------------------------------------------------------- + {/*

History

*/} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/labman/src/components/core/card.test.tsx b/labman/src/components/core/card.test.tsx index 7040460..c869ebb 100644 --- a/labman/src/components/core/card.test.tsx +++ b/labman/src/components/core/card.test.tsx @@ -50,9 +50,13 @@ const loan = new LoanClass ( equipment: { id: 1, name: "test", - categoryId: 1, image: "", createdAt: new Date(), + category: { + id: 1, + name: "test" + }, + items: [] } }, diff --git a/labman/src/components/inventory/EquipmentClient.tsx b/labman/src/components/inventory/EquipmentClient.tsx index 1974fb2..ab671fc 100644 --- a/labman/src/components/inventory/EquipmentClient.tsx +++ b/labman/src/components/inventory/EquipmentClient.tsx @@ -7,6 +7,7 @@ import SortIcon from "@/components/inventory/sortIcon"; import EquipmentInfo from "@/components/inventory/EquipmentInfo"; import LoanView from "@/components/inventory/LoanView"; import {Equipment} from "@/types/inventory"; +import {useSideView} from "@/app/sideViewContext"; @@ -33,7 +34,7 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) { const [image, setImage] = useState(""); const [selectedEquipment, setSelectedEquipment ] = useState(null); - const [sideView, setSideView] = useState(""); + const { sideView, setSideView } = useSideView(); const [sort, setSort] = useState<{ column: SortColumn, direction: SortDirection}>({ column: null, @@ -119,14 +120,12 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) { { sideView == "eqInfo" && selectedEquipment && } { sideView == "loanView" && selectedEquipment && } diff --git a/labman/src/components/inventory/EquipmentInfo.tsx b/labman/src/components/inventory/EquipmentInfo.tsx index 864de91..6357405 100644 --- a/labman/src/components/inventory/EquipmentInfo.tsx +++ b/labman/src/components/inventory/EquipmentInfo.tsx @@ -3,6 +3,8 @@ import {useEffect, useState} from "react"; import {addUnit, deleteUnit, updateEquipment} from "@/lib/actions"; import {Equipment} from "@/types/inventory"; import {loanCount} from "@/utils/inventoryUtils"; +import SideView from "@/components/core/SideView/SideView"; +import ItemList from "@/components/core/SideView/ItemList"; type Unit = { id: number; @@ -16,14 +18,13 @@ type Unit = { interface EquipmentInfoProps { equipmentData: Equipment; - setSideView: (view: string) => void; allEquipment: Equipment[]; setAllEquipment: React.Dispatch>; setSelectedEquipment: (equipment: Equipment | null) => void; deleteEquipment: (name: string) => void; } -export default function EquipmentInfo({equipmentData, setSideView, setAllEquipment, setSelectedEquipment, deleteEquipment}: EquipmentInfoProps) { +export default function EquipmentInfo({equipmentData, setAllEquipment, setSelectedEquipment, deleteEquipment}: EquipmentInfoProps) { const [initialFormData, setInitialFormData] = useState({name: equipmentData?.name, category: equipmentData?.category.name, image: equipmentData?.image}) @@ -91,7 +92,7 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme const updatedEq = await updateEquipment(equipmentData!.id, formData.name!, formData.category!, formData.image!) const updatedEquipment = { - ...equipmentData!, + ...equipmentData, name: updatedEq.name, category: { id: updatedEq.category.id, @@ -111,85 +112,37 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme let hasActiveLoan = false; return ( - <> - {/* Dark backdrop */} -
setSideView("")} - /> - - {/* Right-side panel */} -
- - {/* Vertical split */} -
- {/* Left side of a panel */} -
-

Equipment information

-
- - - -
- -
-
- - setFormData({...formData, name: e.target.value})} - className="side-form-input" /> - - setFormData({...formData, category: e.target.value})} - className="side-form-input" /> - {/* + }> + <> +
+ + + +
+ +
+ + + setFormData({...formData, name: e.target.value})} + className="side-form-input" /> + + setFormData({...formData, category: e.target.value})} + className="side-form-input" /> + {/* setFormData({...formData, image: e.target.value})} className="side-form-input" /> */} - -
- --------------------------------------------------------------------------------------- -

Items

-
- -
- { equipmentData.items.map((unit, index) => ( - // TODO: Figure out why it requires the code to be so explicit here. - hasActiveLoan = unit.activeLoan != null, -
-

Unit {index + 1}

- { unit.activeLoan && unit.activeLoan.status !== "Returned" &&

Borrowed

} - { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } -
- )) } -
-
-
- - {/* Right side of panel */} -
-
- - -
-
-
-

{equipmentData?.name}

-

{equipmentData?.category.name}

-

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

-
- ---------------------------------------------------------------------------------- -

History

-
-
+
-
- + + ); } \ No newline at end of file diff --git a/labman/src/components/inventory/LoanView.tsx b/labman/src/components/inventory/LoanView.tsx index 9f73816..a0ed501 100644 --- a/labman/src/components/inventory/LoanView.tsx +++ b/labman/src/components/inventory/LoanView.tsx @@ -2,16 +2,9 @@ import {useEffect, useState} from "react"; import {addLoan} from "@/lib/actions"; import {Equipment} from "@/types/inventory"; -import {loanCount} from "@/utils/inventoryUtils"; - -type Unit = { - id: number; - equipmentId: number; - status: string; - createdAt: Date; - notes: string[]; - errors: string[]; -}; +import {Unit} from "@/types/inventory"; +import SideView from "@/components/core/SideView/SideView" +import ItemList from "@/components/core/SideView/ItemList"; type Borrower = { id: number; @@ -22,12 +15,11 @@ type Borrower = { interface LoanViewProps { equipmentData: Equipment; - setSideView: (view: string) => void; setAllEquipment: React.Dispatch>; setSelectedEquipment: (equipment: Equipment | null) => void; } -export default function LoanView({setSideView, equipmentData, setAllEquipment, setSelectedEquipment} : LoanViewProps) { +export default function LoanView({equipmentData, setAllEquipment, setSelectedEquipment} : LoanViewProps) { const [borrowers, setBorrowers] = useState([]); useEffect(() => { fetch("/api/borrower") @@ -79,140 +71,91 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s //TODO: More imrpovements to do on this form and the other forms plus valditation of the form data. Delaying this until the core functionality is done. return ( - <> - {/* Dark backdrop */} -
setSideView("")} - /> - - {/* Right-side panel */} -
- - {/* Vertical split */} -
- {/* Left side of a panel */} -
-

New Loan

-
- - - -
- -
-
- - { - console.log(e.target.value); - setFormData({...formData, borrower: e.target.value}) - console.log(formData); - const borrower = borrowers.find(borrower => borrower.name === e.target.value); - if (borrower) setFormData({...formData, borrowerPhone: borrower.phone, borrowerEmail: borrower.email}); - - }} - /> - - {borrowers.map(borrower => )} - - - { - const selected = e.target.value; - if (selected < today) return; - setFormData({...formData, startDate: selected}) - }} - /> - - { - const selected = e.target.value; - if (selected < formData.startDate) return; - setFormData({...formData, endDate: e.target.value}); - - }} - /> - - { - console.log(e.target.value); - setFormData({...formData, borrowerPhone: e.target.value}) - console.log(formData); - }} - /> - - { - setFormData({...formData, borrowerEmail: e.target.value}) - }} - /> -
-
- --------------------------------------------------------------------------------------- -

Items

-
-
- {equipmentData.items.map((unit, index) => ( - hasActiveLoan = unit.activeLoan != null, -
-

Unit {index + 1}

- { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&

Borrowed

} - { (unit.activeLoan == null || unit.activeLoan.status === "Returned") && } - -
- )) } -
-
-
- - {/* Right side of panel */} -
-
- - -
-
-
-

{equipmentData.name}

-

{equipmentData.category.name}

-

{equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available

-
- ---------------------------------------------------------------------------------- -

History

-
-
+ }> + <> +
+ + + +
+ +
+
+ + { + const borrower = borrowers.find(borrower => borrower.name === e.target.value); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) + + }} + /> + + {borrowers.map(borrower => )} + + + { + const selected = e.target.value; + if (selected < today) return; + setFormData({...formData, startDate: selected}) + }} + /> + + { + const selected = e.target.value; + if (selected < formData.startDate) return; + setFormData({...formData, endDate: e.target.value}); + + }} + /> + + { + console.log(e.target.value); + setFormData({...formData, borrowerPhone: e.target.value}) + console.log(formData); + }} + /> + + { + setFormData({...formData, borrowerEmail: e.target.value}) + }} + /> +
-
- + + ); } \ No newline at end of file diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx new file mode 100644 index 0000000..3236e11 --- /dev/null +++ b/labman/src/components/loans/EditLoan.tsx @@ -0,0 +1,201 @@ +"use client" + +import {useEffect, useState} from "react"; +import {updateLoan} from "@/lib/actions"; +import {Loan} from "@/types/Loan"; +import SideView from "@/components/core/SideView/SideView"; +import {Unit} from "@/types/inventory"; +import ItemList from "@/components/core/SideView/ItemList"; + + +type Borrower = { + id: number; + name: string; + phone: string; + email: string; +} + +interface EditLoanProps { + loan: Loan; + setLoans: React.Dispatch>; +} + +export default function EditLoan({loan, setLoans}: EditLoanProps) { + + const [initialFormData, setInitialFormData] = useState({borrower: loan.borrower.name, startDate: loan.startDate, endDate: loan.endDate, borrowerPhone: loan.borrower.phone, borrowerMail: loan.borrower.email}) + const [formData, setFormData] = useState(initialFormData); + + const phoneRequired = formData.borrowerMail?.trim() === ""; + const emailRequired = formData.borrowerPhone?.trim() === ""; + + const [selectedUnit, setSelectedUnit] = useState(loan.item); + + const [borrowers, setBorrowers] = useState([]); + useEffect(() => { + fetch("/api/borrower") + .then(res => res.json()) + .then(data => setBorrowers(data)) + }, [initialFormData]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if ( + !formData.borrower.trim() + || !formData.startDate + || !formData.endDate + || (!formData.borrowerPhone?.trim() && !formData.borrowerMail?.trim()) + ) { + alert("Please fill in all required fields"); + return; + } + let updatedLoan: Loan; + + // Will wait for a confirmation from the user before updating the loan + // To prevent empty lines in the message it will render a list of strings instead of a single string, and filter out strings that are empty + if (confirm(`These changes will be applied:\n${[ + formData.borrower !== initialFormData.borrower ? `Borrower name: ${initialFormData.borrower} -> ${formData.borrower}` : "", + formData.startDate !== initialFormData.startDate ? `Start date: ${initialFormData.startDate.toLocaleDateString()} -> ${formData.startDate.toLocaleDateString()}` : "", + formData.endDate !== initialFormData.endDate ? `End date: ${initialFormData.endDate.toLocaleDateString()} -> ${formData.endDate.toLocaleDateString()}` : "", + formData.borrowerPhone !== initialFormData.borrowerPhone ? `Borrower phone number: ${initialFormData.borrowerPhone} -> ${formData.borrowerPhone}` : "", + formData.borrowerMail !== initialFormData.borrowerMail ? `Borrower email: ${initialFormData.borrowerMail} -> ${formData.borrowerMail}` : "", + selectedUnit.id !== loan.item.id ? `Unit ${loan.item.id} -> Borrowed equipment unit: Unit ${selectedUnit.id}` : "" + ].filter(Boolean).join("\n")}`)) { + + const res = await updateLoan( + loan.id, + formData.startDate, + formData.endDate, + formData.borrower, + loan.borrower.id, + selectedUnit.id, + formData.borrowerPhone, + formData.borrowerMail + ); + console.log(res); + + if (res.type === "error") { + alert(res.message); + return; + } else if (res.type === "success") { + updatedLoan = res.data; + } + + setLoans(prev => + prev.map(loan => loan.id === updatedLoan.id ? updatedLoan : loan) + + ) + setInitialFormData(formData); + + } else { + return; + } + + + } + + return( + <> + }> + <> +
+ + + +
+ +
+ +
+ + { + const borrower = borrowers.find(borrower => borrower.name === e.target.value); + // TODO: Better to use functional updates because React hasn't re-rendered the component yet and should not do two state changes at once due to batching + setFormData(prev => ({ + ...prev, + borrower: e.target.value, + borrowerPhone: borrower?.phone || "", + borrowerMail: borrower?.email || "" + })) + }} + /> + + {borrowers.map(borrower => )} + + + { + const selected = e.target.value; + setFormData({...formData, startDate: new Date(selected)}) + }} + /> + + { + const selected = e.target.value; + if (selected < formData.startDate.toLocaleDateString()) return; + setFormData({...formData, endDate: new Date(selected)}); + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.value.length === 8) { + if (borrowers.find(borrower => borrower.phone === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same phone number already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerPhone: e.target.value}) + + }} + /> + + { + e.target.setCustomValidity(""); + if (e.target.checkValidity()) { + if (borrowers.find(borrower => borrower.email === e.target.value && borrower.id !== loan.borrower.id)) { + e.target.setCustomValidity("A borrower with the same email already exists"); + e.target.reportValidity(); + } + } + setFormData({...formData, borrowerMail: e.target.value}) + }} + /> +
+
+ + +
+ + ) +} \ No newline at end of file diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts index 005802b..6bdee3d 100644 --- a/labman/src/lib/actions.ts +++ b/labman/src/lib/actions.ts @@ -3,33 +3,42 @@ import prisma from "@/lib/prisma" import {revalidatePath} from "next/cache"; import {deleteSession, validateSessionToken} from "@/auth/session" import {cookies} from "next/headers"; +import {Borrower} from "@/generated/prisma"; +import {Loan} from "@/types/Loan"; +import {redirect} from "next/navigation"; -export async function deleteUser(userId : number) { +type ActionResult = | { type: "success"; data: T} | { type: "confirm"; message: string} | { type: "error"; message: string} + +// Used to delay the execution of an action for testing purposes +function delay(ms : number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function deleteUser(userId : number) : Promise> { + + if (await getUser() === null) { + return {type: "error", message: "Could not find a valid user"} + } + + if (userId === 1) {return {type: "error", message: "Cannot delete admin user"}} const user = await prisma.user.findUnique({ - where: { - id: userId - }, - include: { - sessions: true - } + where: {id: userId}, + include: {sessions: true} }) - if (user) { - console.log(user.sessions) for (const session of user.sessions) { await deleteSession(session.id); } } await prisma.user.delete({ - where: { - id: userId - } + where: {id: userId} }); revalidatePath("/users"); + return {type: "success", data: undefined}; } export async function getSession() { @@ -38,33 +47,26 @@ export async function getSession() { if (token) { return validateSessionToken(token); } else { - console.log("No active session"); return null; } } export async function logout() { - console.log("Logging out"); const session = await getSession(); - if (session) { - await deleteSession(session.id); - } + if (session) {await deleteSession(session.id);} + redirect("/login") } export async function deleteEquipment(name: string) { await prisma.equipment.delete({ - where: { - name: name - } + where: {name: name} }) revalidatePath("/"); } export async function deleteUnit(id: number) { await prisma.item.delete({ - where: { - id: id - } + where: {id: id} }) revalidatePath("/"); } @@ -72,81 +74,90 @@ export async function deleteUnit(id: number) { export async function addUnit(equipmentName: string) { const equipment = await prisma.equipment.findUnique({ - where: { - name: equipmentName - } + where: {name: equipmentName} }) if (!equipment) {alert("Equipment not found"); return} const newUnit = await prisma.item.create({ - data: { - equipmentId: equipment.id, - status: "Available", - }, + data: {equipmentId: equipment.id, status: "Available",}, // TODO: Relational properties always have to be specified or else they will not be included in the response - include: { - loans: true, - activeLoan: true - } + include: {loans: true, activeLoan: true} }) revalidatePath("/"); - console.log("Added unit"); return newUnit; } export async function updateEquipment (equipmentId: number, name: string, category: string, image: string) { let categoryId = 0; - let equipmentCategory = await prisma.equipmentCategory.findUnique({where: {name: category}}) if (equipmentCategory) { - console.log("Category exists"); categoryId = equipmentCategory.id } else { - console.log("Category exists") equipmentCategory = await prisma.equipmentCategory.create({data: {name: category}}) categoryId = equipmentCategory.id } const equipment = await prisma.equipment.update({ - where: { - id: equipmentId, - }, - data : { - name: name, - categoryId: categoryId, - image: image - }, + where: {id: equipmentId}, + data : {name: name, categoryId: categoryId, image: image}, include: {category: true} }) revalidatePath("/"); return equipment; } -export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) { - const dateStart = new Date(start); - const dateEnd = new Date(end); +export async function addBorrower(name: string, phone?: string | null, email?: string | null, borrowerId?: number) : Promise> { const user = await getUser(); + if (!user) {return {type: "error", message: "Could not find a valid user"}} + let borrower : Borrower | null = null; - if (!user) {alert("Could not find a valid user"); return} - - let borrower; // If phone or email is actually empty, set it to null if (phone?.trim() === "") {phone = null} - if (email?.trim() === "") {email = null} + // If borrowerId is provided, update the borrower with the provided information + if (borrowerId) { + borrower = await prisma.borrower.findUnique({where:{id: borrowerId}}) + /* TODO: Potentially unsafe. The function can in theory be called with an unrelated id updating the wrong borrower + Since the unique values phone and mail can change they can't be used to verify the borrower. + A possible solution is to compare the old values with what is currently stored in the database. + But it shouldn't really be a big deal as there is now way for the client to abuse it.*/ + if (!borrower) {return {type: "error", message: "Could not find borrower with id " + borrowerId}} + + + if (phone && borrower.phone !== phone) { + console.log("Phone number has changed, checking for duplicates") + if (await prisma.borrower.findUnique({where:{phone: phone}})) { + console.log("Borrower with phone number already exists") + + return {type: "error", message: `A borrower with the same phone number already exists.`} + } + } else if (email && borrower.email !== email) { + if (await prisma.borrower.findUnique({where:{email: email}})) + return {type: "error", message: `A borrower with the same email already exists.`} + } + + borrower = await prisma.borrower.update( + { + where: {id: borrowerId}, + data: {name: name, phone: phone, email: email} + } + ) + return {type: "success", data: borrower}; + } + if (phone) { borrower = await prisma.borrower.findUnique({where:{phone: phone}}) - /* if (borrower && borrower.name !== borrowerName) { - if (window.confirm(`A borrower with the same phone number already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { - borrower = null; - } - } */ + /* if (borrower && borrower.name !== borrowerName) { + if (window.confirm(`A borrower with the same phone number already exists with a different name (${borrower.name}). Click OK to assign this loan to ${borrowerName}. Click Cancel to assign it to ${borrower.name} instead.`)) { + borrower = null; + } + } */ } else if (email) { borrower = await prisma.borrower.findUnique({where:{email: email}}) @@ -156,43 +167,108 @@ export async function addLoan (borrowerName : string, start : string, end : stri } } */ } else { - alert("No borrower phone/email provided"); return; + return {type: "error", message: "No borrower phone/email provided"} } - - - if (!borrower) { borrower = await prisma.borrower.create({ data: { - name: borrowerName, + name: name, phone: phone, email: email, + status: "Active", note: "", creationDate: new Date(), } }) } + return {type: "success", data: borrower}; +} + +export async function updateLoan (loanId: number, start : Date, end : Date, borrowerName : string, borrowerId : number, unitId : number, phone? : string | null, email? : string | null) : Promise> { + if (await getUser() === null) {return {type:"error", message: "Could not find a valid user"}} + + // Check if the loan exists + const currentLoan = await prisma.loan.findUnique({where: {id: loanId}}) + if (!currentLoan) {return {type: "error", message: "Could not find corresponding loan in database"}} + + // Find the connected borrower and update borrower details if needed + const res = await addBorrower(borrowerName, phone, email, borrowerId) + if (res.type !== "success") {return {type: "error", message: res.message}} + + // If the user has changed the unit being loaned, check that this is unit is available + if (currentLoan.itemId !== unitId) { + const newUnit = await prisma.item.findUnique({where: {id: unitId}}) + if (!newUnit) {return {type: "error", message: "Could not find corresponding unit in database"}} + if (newUnit.status !== "Available") {return {type: "error", message: "The selected unit is not available"}} + + await prisma.item.update({ + where: {id: currentLoan.itemId}, + data: {status: "Available", activeLoanId: null} + }) + + await prisma.item.update({ + where: {id: unitId}, + data: {status: "Unavailable", activeLoanId: null} + }) + } + + const loan = await prisma.loan.update({ + where: {id: loanId}, + data: { + startDate: start, + endDate: end, + borrowerId: res.data.id, + itemId: unitId + }, + include: { + borrower: true, + item: { + include: { + equipment: { + include: { + category: true, + items: { + include: { + loans: true, + activeLoan: true + } + } + + } + } + } + } + } + }) + revalidatePath("/loans"); + return {type: "success", data: loan}; + +} + +export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) { + const dateStart = new Date(start); + const dateEnd = new Date(end); + const user = await getUser(); + if (!user) {alert("Could not find a valid user"); return} + + const res = await addBorrower(borrowerName, phone, email) + if (res.type !== "success") {return} const loan = await prisma.loan.create({ data: { startDate: dateStart, endDate: dateEnd, status: "Active", - borrowerId: borrower.id, + borrowerId: res.data.id, userId: user.id, itemId: unitId } }) await prisma.item.update({ - where: { - id: unitId - }, - data: { - status: "Unavailable", - activeLoanId: loan.id - } + where: {id: unitId}, + data: {status: "Unavailable", activeLoanId: loan.id} }) revalidatePath("/"); return loan; @@ -204,16 +280,25 @@ export async function getUser() { if (session) { const tSession = await prisma.session.findUnique({ where: { id: session.id }, - include: { - user: true - } + include: {user: true} }) return tSession?.user; - } + } else { + return null; + } } export async function deleteLoan(id: number) { + const loan = await prisma.loan.findUnique({ + where: {id: id} + }) + if (!loan) {return} + + await prisma.item.update({ + where: {activeLoanId: loan.id}, + data: {status: "Available", activeLoanId: null} + }) await prisma.loan.delete({ where: { id: id @@ -223,13 +308,14 @@ export async function deleteLoan(id: number) { } export async function returnLoan(id: number) { - await prisma.loan.update({ - where: { - id: id - }, - data: { - status: "Returned" - } + const loan = await prisma.loan.update({ + where: {id: id}, + data: {status: "Returned"} }) + await prisma.item.update({ + where: {activeLoanId: loan.id}, + data: {status: "Available", activeLoanId: null} + }) + revalidatePath("/loans"); } \ No newline at end of file diff --git a/labman/src/types/Loan.ts b/labman/src/types/Loan.ts index db8082e..67d8fd0 100644 --- a/labman/src/types/Loan.ts +++ b/labman/src/types/Loan.ts @@ -1,8 +1,36 @@ -export type LoanActions = { +import {Equipment} from "@/types/inventory"; + +export type LoanActions = { deleteLoan: (id: number) => void; returnLoan: (id: number) => void; } +export type Loan = { + id: number; + startDate: Date; + endDate: Date; + status: string; + + borrower: { + id: number; + name: string; + phone?: string | null; + email?: string | null + note?: string | null + creationDate: Date; + + } + item: { + id: number; + equipmentId: number; + status: string; + createdAt: Date; + notes: string[]; + errors: string[]; + equipment: Equipment; + } +} + type Borrower = { id: number; name: string; @@ -14,14 +42,7 @@ type Borrower = { type Item = { id: number; - equipment: { - id: number; - name: string; - categoryId: number; - image: string | null; - createdAt: Date; - - } + equipment: Equipment; } export class LoanClass { diff --git a/labman/src/types/User.ts b/labman/src/types/User.ts index 11544cd..5ac5cb3 100644 --- a/labman/src/types/User.ts +++ b/labman/src/types/User.ts @@ -8,7 +8,8 @@ export class UserClass { public username: string, public createdAt: Date, public latestActivity: Date, - private actions: UserActions + private actions: UserActions, + public status: string | null ) {} diff --git a/labman/src/types/inventory.ts b/labman/src/types/inventory.ts index dbf4d10..a9619a7 100644 --- a/labman/src/types/inventory.ts +++ b/labman/src/types/inventory.ts @@ -23,4 +23,13 @@ export type Equipment = { }[] } +export type Unit = { + id: number; + equipmentId: number; + status: string; + createdAt: Date; + notes: string[]; + errors: string[]; +}; + // TODO: Difference between null and undefined and ? means optional \ No newline at end of file