refactor: update API endpoints and enhance loan management features

This commit is contained in:
2025-11-17 22:49:54 +01:00
parent 88a2c74e88
commit 084a0fa2e2
13 changed files with 209 additions and 20 deletions

View File

@@ -27,7 +27,7 @@ function App() {
useEffect(() => { useEffect(() => {
if (Cookies.get("token")) { if (Cookies.get("token")) {
const verifyToken = async () => { const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, { const response = await fetch(`${API_BASE}/verify`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,

View File

@@ -64,7 +64,7 @@ export const Header = () => {
return; return;
} }
const response = await fetch(`${API_BASE}/api/changePassword`, { const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -5,7 +5,7 @@ export const useVersionInfoQuery = () =>
useQuery({ useQuery({
queryKey: ["versionInfo"], queryKey: ["versionInfo"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${API_BASE}/server-info`, { const response = await fetch(`${API_BASE}/`, {
method: "GET", method: "GET",
}); });
if (response.ok) { if (response.ok) {

View File

@@ -7,6 +7,8 @@ import {
Spinner, Spinner,
VStack, VStack,
Table, Table,
InputGroup,
Span,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { getBorrowableItems } from "@/utils/Fetcher"; import { getBorrowableItems } from "@/utils/Fetcher";
@@ -31,6 +33,9 @@ export const HomePage = () => {
const [isLoadingA, setIsLoadingA] = useState(false); const [isLoadingA, setIsLoadingA] = useState(false);
const [selectedItems, setSelectedItems] = useState<number[]>([]); const [selectedItems, setSelectedItems] = useState<number[]>([]);
const MAX_CHARACTERS = 500;
const [note, setNote] = useState("");
// Error handling states // Error handling states
const [isMsg, setIsMsg] = useState(false); const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error"); const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
@@ -136,13 +141,29 @@ export const HomePage = () => {
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(e.currentTarget.value.slice(0, MAX_CHARACTERS));
}}
/>
</InputGroup>
</Table.Root> </Table.Root>
</Table.ScrollArea> </Table.ScrollArea>
)} )}
{selectedItems.length >= 1 && ( {selectedItems.length >= 1 && (
<Button <Button
onClick={() => onClick={() =>
createLoan(selectedItems, startDate, endDate).then((response) => { createLoan(selectedItems, startDate, endDate, note).then((response) => {
if (response.status === "error") { if (response.status === "error") {
setMsgStatus("error"); setMsgStatus("error");
setMsgTitle(response.title || t("error")); setMsgTitle(response.title || t("error"));

View File

@@ -39,6 +39,8 @@ type Device = {
can_borrow_role: string; can_borrow_role: string;
inSafe: number; inSafe: number;
entry_created_at: string; entry_created_at: string;
last_borrowed_person: string | null;
currently_borrowing: string | null;
}; };
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
@@ -68,7 +70,7 @@ const Landingpage: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`); const loanRes = await fetch(`${API_BASE}/api/loans/all-loans`);
const loanData = await loanRes.json(); const loanData = await loanRes.json();
if (Array.isArray(loanData)) { if (Array.isArray(loanData)) {
setLoans(loanData); setLoans(loanData);
@@ -80,7 +82,7 @@ const Landingpage: React.FC = () => {
); );
} }
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`); const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`);
const deviceData = await deviceRes.json(); const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) { if (Array.isArray(deviceData)) {
setDevices(deviceData); setDevices(deviceData);
@@ -200,6 +202,14 @@ const Landingpage: React.FC = () => {
<Text> <Text>
{t("rent-role")}: {device.can_borrow_role} {t("rent-role")}: {device.can_borrow_role}
</Text> </Text>
<Text>
{t("last-borrowed-person")}:{" "}
{device.last_borrowed_person || "N/A"}
</Text>
<Text>
{t("currently-borrowed-by")}:{" "}
{device.currently_borrowing || "N/A"}
</Text>
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
))} ))}

View File

@@ -25,7 +25,7 @@ export const LoginPage = () => {
}, [isLoggedIn, navigate]); }, [isLoggedIn, navigate]);
const loginFnc = async (username: string, password: string) => { const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/login`, { const response = await fetch(`${API_BASE}/api/users/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View File

@@ -43,7 +43,7 @@ export const MyLoansPage = () => {
const fetchLoans = async () => { const fetchLoans = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const res = await fetch(`${API_BASE}/api/userLoans`, { const res = await fetch(`${API_BASE}/api/loans/loans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -75,7 +75,7 @@ export const MyLoansPage = () => {
const deleteLoan = async (loanId: number) => { const deleteLoan = async (loanId: number) => {
try { try {
const res = await fetch(`${API_BASE}/api/SETdeleteLoan/${loanId}`, { const res = await fetch(`${API_BASE}/api/loans/delete-loan/${loanId}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,

View File

@@ -6,7 +6,7 @@ export const getBorrowableItems = async (
endDate: string endDate: string
) => { ) => {
try { try {
const response = await fetch(`${API_BASE}/api/borrowableItems`, { const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -47,15 +47,16 @@ export const getBorrowableItems = async (
export const createLoan = async ( export const createLoan = async (
itemIds: number[], itemIds: number[],
startDate: string, startDate: string,
endDate: string endDate: string,
note: string | null
) => { ) => {
const response = await fetch(`${API_BASE}/api/createLoan`, { const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
}, },
body: JSON.stringify({ items: itemIds, startDate, endDate }), body: JSON.stringify({ items: itemIds, startDate, endDate, note }),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -49,9 +49,9 @@ export const createLoanInDatabase = async (
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
// Ensure all items exist and collect names // Ensure all items exist and collect names + lockers
const [itemsRows] = await conn.query( const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)", "SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
[itemIds] [itemIds]
); );
if (!itemsRows || itemsRows.length !== itemIds.length) { if (!itemsRows || itemsRows.length !== itemIds.length) {
@@ -62,12 +62,22 @@ export const createLoanInDatabase = async (
message: "One or more items not found", message: "One or more items not found",
}; };
} }
const itemNames = itemIds const itemNames = itemIds
.map( .map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name (id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
) )
.filter(Boolean); .filter(Boolean);
// Build lockers array (unique, only 2-digit strings)
const lockers = [
...new Set(
itemsRows
.map((r) => r.safe_nr)
.filter((sn) => typeof sn === "string" && /^\d{2}$/.test(sn))
),
];
// Check availability (no overlap with existing loans) // Check availability (no overlap with existing loans)
const [confRows] = await conn.query( const [confRows] = await conn.query(
` `
@@ -113,18 +123,18 @@ export const createLoanInDatabase = async (
}; };
} }
// Insert loan // Insert loan (now includes lockers)
const [insertRes] = await conn.query( const [insertRes] = await conn.query(
` `
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name, note) INSERT INTO loans (username, loan_code, start_date, end_date, lockers, loaned_items_id, loaned_items_name, note)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?) VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), CAST(? AS JSON), ?)
`, `,
[ [
username, username,
loanCode, loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "), new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "), new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(lockers),
JSON.stringify(itemIds.map((n) => Number(n))), JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames), JSON.stringify(itemNames),
note, note,
@@ -142,6 +152,7 @@ export const createLoanInDatabase = async (
end_date: end, end_date: end,
items: itemIds, items: itemIds,
item_names: itemNames, item_names: itemNames,
lockers,
}, },
}; };
} catch (err) { } catch (err) {
@@ -172,6 +183,70 @@ export const getLoansFromDatabase = async (username) => {
"SELECT * FROM loans WHERE username = ? AND deleted = 0;", "SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username] [username]
); );
if (result.length > 0) {
return { success: true, status: true, data: result };
} else if (result.length === 0) {
return { success: true, status: true, data: [] };
}
return { success: false };
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getALLLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans WHERE deleted = 0;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const getItems = async () => {
const [result] = await pool.query("SELECT * FROM items;");
if (result.length > 0) { if (result.length > 0) {
return { success: true, data: result }; return { success: true, data: result };
} }

View File

@@ -20,6 +20,22 @@ export const loginFunc = async (username, password) => {
return { success: false }; return { success: false };
}; };
export const getItems = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getALLLoans = async () => {
const [rows] = await pool.query("SELECT * FROM loans;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const changePassword = async (username, oldPassword, newPassword) => { export const changePassword = async (username, oldPassword, newPassword) => {
// get user current password // get user current password
const [user] = await pool.query( const [user] = await pool.query(

View File

@@ -9,6 +9,10 @@ import {
createLoanInDatabase, createLoanInDatabase,
getLoanInfoWithID, getLoanInfoWithID,
getLoansFromDatabase, getLoansFromDatabase,
getBorrowableItemsFromDatabase,
getALLLoans,
getItems,
SETdeleteLoanFromDatabase,
} from "./database/loansMgmt.database.js"; } from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js"; import { sendMailLoan } from "./services/mailer.js";
@@ -85,9 +89,62 @@ router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase(req.user.username); const result = await getLoansFromDatabase(req.user.username);
if (result.success) { if (result.success) {
res.status(200).json(result.data); res.status(200).json(result.data);
} else if (result.status) {
res.status(200).json([]);
} else { } else {
res.status(500).json({ message: "Failed to fetch loans" }); res.status(500).json({ message: "Failed to fetch loans" });
} }
}); });
router.get("/all-items", authenticate, async (req, res) => {
const result = await getItems();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.get("/all-loans", authenticate, async (req, res) => {
const result = await getALLLoans();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.post("/borrowable-items", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
export default router; export default router;

View File

@@ -1,3 +1,7 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) { function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9"; const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList = const itemsList =
@@ -112,7 +116,7 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
].join("\n"); ].join("\n");
} }
function sendMailLoan(user, items, startDate, endDate, createdDate) { export function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST, host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT, port: process.env.MAIL_PORT,

View File

@@ -2,6 +2,7 @@ import express from "express";
import cors from "cors"; import cors from "cors";
import env from "dotenv"; import env from "dotenv";
import info from "./info.json" assert { type: "json" }; import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js";
// frontend routes // frontend routes
import loansMgmtRouter from "./routes/app/loanMgmt.route.js"; import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
@@ -46,6 +47,10 @@ app.listen(port, () => {
console.log(`Server is running on port: ${port}`); console.log(`Server is running on port: ${port}`);
}); });
app.get("/verify", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.send(info); res.send(info);
}); });