feat: Implement loan management features including fetching, creating, and deleting loans

- Added database functions for managing loans: getLoans, getUserLoans, deleteLoan, and createLoan.
- Updated frontend to include new Form4 for displaying user loans and handling loan deletions.
- Replaced Form3 with Form4 in App component.
- Enhanced Form1 to set borrowing dates and fetch available items.
- Improved Form2 to display borrowable items and allow selection for loans.
- Introduced utility functions for handling loan creation and deletion in userHandler.
- Added event listeners for local storage updates to keep UI in sync.
- Updated fetchData utility to retrieve loans and user loans from the backend.
This commit is contained in:
2025-08-19 19:13:52 +02:00
parent 1195e050da
commit 9287c949ca
12 changed files with 991 additions and 126 deletions

View File

@@ -1,5 +1,13 @@
import express from "express"; import express from "express";
import { loginFunc, getItemsFromDatabase } from "../services/database.js"; import {
loginFunc,
getItemsFromDatabase,
getLoansFromDatabase,
getUserLoansFromDatabase,
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js"; import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router(); const router = express.Router();
@@ -27,4 +35,114 @@ router.get("/items", authenticate, async (req, res) => {
} }
}); });
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.get("/userLoans", authenticate, async (req, res) => {
const result = await getUserLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch user loans" });
}
});
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.post("/borrowableItems", 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" });
}
});
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
itemIds
);
if (result.success) {
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
}
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
}
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
}
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
});
export default router; export default router;

View File

@@ -20,6 +20,7 @@ CREATE TABLE `loans` (
`returned_date` timestamp NULL DEFAULT NULL, `returned_date` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'), `loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`) UNIQUE KEY `loan_code` (`loan_code`)
); );
@@ -27,7 +28,7 @@ CREATE TABLE `loans` (
CREATE TABLE `items` ( CREATE TABLE `items` (
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`item_name` varchar(255) NOT NULL, `item_name` varchar(255) NOT NULL,
`can_borrow_role` varchar(255) NOT NULL, `can_borrow_role` INT NOT NULL,
`inSafe` tinyint(1) NOT NULL DEFAULT '1', `inSafe` tinyint(1) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `item_name` (`item_name`) UNIQUE KEY `item_name` (`item_name`)
@@ -42,28 +43,95 @@ CREATE TABLE `lockers` (
UNIQUE KEY `locker_number` (`locker_number`) UNIQUE KEY `locker_number` (`locker_number`)
); );
-- Mock data -- Mock data for users
INSERT INTO `users` (`username`, `password`, `role`) VALUES
('alice', 'password1', 1),
('bob', 'password2', 2),
('carol', 'password3', 1),
('dave', 'password4', 3),
('eve', 'password5', 2),
('frank', 'password6', 1),
('grace', 'password7', 2),
('heidi', 'password8', 3),
('ivan', 'password9', 1),
('judy', 'password10', 2),
('mallory', 'password11', 1),
('oscar', 'password12', 3),
('peggy', 'password13', 2),
('trent', 'password14', 1),
('victor', 'password15', 2),
('wendy', 'password16', 3),
('zoe', 'password17', 1),
('quinn', 'password18', 2),
('ruth', 'password19', 1),
('sam', 'password20', 3);
-- Users -- Mock data for loans
INSERT INTO users (username, password) VALUES INSERT INTO `loans` (`username`, `loan_code`, `start_date`, `end_date`, `returned_date`, `loaned_items_id`, `loaned_items_name`)
('alice', 'password123'), VALUES
('bob', 'securepass'), ('alice', 1001, '2025-08-01 09:00:00', '2025-08-10 09:00:00', NULL, '[1,2]', '["Laptop","Projector"]'),
('charlie', 'charliepwd'); ('bob', 1002, '2025-08-02 10:00:00', '2025-08-12 10:00:00', NULL, '[3]', '["Tablet"]'),
('carol', 1003, '2025-08-03 11:00:00', '2025-08-13 11:00:00', NULL, '[4,5]', '["Camera","Tripod"]'),
('dave', 1004, '2025-08-04 12:00:00', '2025-08-14 12:00:00', NULL, '[6]', '["Microphone"]'),
('eve', 1005, '2025-08-05 13:00:00', '2025-08-15 13:00:00', NULL, '[7,8]', '["Speaker","Monitor"]'),
('frank', 1006, '2025-08-06 14:00:00', '2025-08-16 14:00:00', NULL, '[9]', '["Keyboard"]'),
('grace', 1007, '2025-08-07 15:00:00', '2025-08-17 15:00:00', NULL, '[10,11]', '["Mouse","Printer"]'),
('heidi', 1008, '2025-08-08 16:00:00', '2025-08-18 16:00:00', NULL, '[12]', '["Scanner"]'),
('ivan', 1009, '2025-08-09 17:00:00', '2025-08-19 17:00:00', NULL, '[13,14]', '["Router","Switch"]'),
('judy', 1010, '2025-08-10 18:00:00', '2025-08-20 18:00:00', NULL, '[15]', '["Projector"]'),
('mallory', 1011, '2025-08-11 09:00:00', '2025-08-21 09:00:00', NULL, '[16,17]', '["Laptop","Tablet"]'),
('oscar', 1012, '2025-08-12 10:00:00', '2025-08-22 10:00:00', NULL, '[18]', '["Camera"]'),
('peggy', 1013, '2025-08-13 11:00:00', '2025-08-23 11:00:00', NULL, '[19,20]', '["Tripod","Microphone"]'),
('trent', 1014, '2025-08-14 12:00:00', '2025-08-24 12:00:00', NULL, '[1]', '["Laptop"]'),
('victor', 1015, '2025-08-15 13:00:00', '2025-08-25 13:00:00', NULL, '[2,3]', '["Projector","Tablet"]'),
('wendy', 1016, '2025-08-16 14:00:00', '2025-08-26 14:00:00', NULL, '[4]', '["Camera"]'),
('zoe', 1017, '2025-08-17 15:00:00', '2025-08-27 15:00:00', NULL, '[5,6]', '["Tripod","Microphone"]'),
('quinn', 1018, '2025-08-18 16:00:00', '2025-08-28 16:00:00', NULL, '[7]', '["Speaker"]'),
('ruth', 1019, '2025-08-19 17:00:00', '2025-08-29 17:00:00', NULL, '[8,9]', '["Monitor","Keyboard"]'),
('sam', 1020, '2025-08-20 18:00:00', '2025-08-30 18:00:00', NULL, '[10]', '["Mouse"]');
-- Items -- Mock data for items
INSERT INTO items (item_name, can_borrow_role) VALUES INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('Laptop', 'student'), ('Laptop', 1, 1),
('Projector', 'teacher'), ('Projector', 2, 1),
('Tablet', 'student,teacher'); ('Tablet', 1, 1),
('Camera', 2, 1),
('Tripod', 1, 1),
('Microphone', 3, 1),
('Speaker', 2, 1),
('Monitor', 1, 1),
('Keyboard', 2, 1),
('Mouse', 1, 1),
('Printer', 3, 1),
('Scanner', 2, 1),
('Router', 1, 1),
('Switch', 2, 1),
('Charger', 1, 1),
('USB Cable', 2, 1),
('HDMI Cable', 1, 1),
('Webcam', 3, 1),
('Headphones', 2, 1),
('Smartphone', 1, 1);
-- Loans -- Mock data for lockers
INSERT INTO loans (username, loan_code, start_date, end_date, returned_date) VALUES INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('alice', 100001, '2025-08-01 09:00:00', '2025-08-10 17:00:00', NULL),
('bob', 100002, '2025-08-05 10:00:00', '2025-08-12 15:00:00', '2025-08-12 14:30:00'),
('charlie', 100003, '2025-08-07 11:00:00', '2025-08-15 16:00:00', NULL);
-- Lockers
INSERT INTO lockers (item, locker_number) VALUES
('Laptop', 101), ('Laptop', 101),
('Projector', 102), ('Projector', 102),
('Tablet', 103); ('Tablet', 103),
('Camera', 104),
('Tripod', 105),
('Microphone', 106),
('Speaker', 107),
('Monitor', 108),
('Keyboard', 109),
('Mouse', 110),
('Printer', 111),
('Scanner', 112),
('Router', 113),
('Switch', 114),
('Charger', 115),
('USB Cable', 116),
('HDMI Cable', 117),
('Webcam', 118),
('Headphones', 119),
('Smartphone', 120);

View File

@@ -41,3 +41,211 @@ export const getItemsFromDatabase = async (role) => {
} }
return { success: false }; return { success: false };
}; };
export const getLoansFromDatabase = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
username,
]);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
return { success: true, data: "No loans found for this user" };
} else {
return { success: false };
}
};
export const deleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
loanId,
]);
if (result.affectedRows > 0) {
return { success: true };
} else {
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.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 createLoanInDatabase = async (
username,
startDate,
endDate,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names
const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(1000 + Math.random() * 900000); // 4-6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
`,
[
username,
loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};

View File

@@ -3,7 +3,7 @@ import Layout from "./layout/Layout";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Form1 from "./components/Form1"; import Form1 from "./components/Form1";
import Form2 from "./components/Form2"; import Form2 from "./components/Form2";
import Form3 from "./components/Form3"; import Form4 from "./components/Form4";
import LoginForm from "./components/LoginForm"; import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { fetchAllData, ALL_ITEMS_UPDATED_EVENT } from "./utils/fetchData"; import { fetchAllData, ALL_ITEMS_UPDATED_EVENT } from "./utils/fetchData";
@@ -20,13 +20,15 @@ function App() {
} }
localStorage.setItem("borrowableItems", JSON.stringify([])); localStorage.setItem("borrowableItems", JSON.stringify([]));
localStorage.setItem("borrowCode", "123456");
}, []); }, []);
// Mock flow without real logic: show the three sections stacked for design preview // Mock flow without real logic: show the three sections stacked for design preview
const handleLogout = () => { const handleLogout = () => {
Cookies.remove("token"); Cookies.remove("token");
localStorage.removeItem("allItems"); localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
// Let listeners refresh from empty state // Let listeners refresh from empty state
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT)); window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success"); myToast("Logged out successfully!", "success");
@@ -40,7 +42,7 @@ function App() {
<div className="h-px bg-blue-100" /> <div className="h-px bg-blue-100" />
<Form2 /> <Form2 />
<div className="h-px bg-blue-100" /> <div className="h-px bg-blue-100" />
<Form3 /> <Form4 />
</div> </div>
</Layout> </Layout>
) : ( ) : (

View File

@@ -1,10 +1,25 @@
import React from "react"; import React from "react";
import Cookies from "js-cookie";
import { getBorrowableItems } from "../utils/fetchData";
const Form1: React.FC = () => { const Form1: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-bold text-blue-700">1. Zeitraum wählen</h2> <h2 className="text-xl font-bold text-blue-700">1. Zeitraum wählen</h2>
<form className="space-y-4"> <form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const fd = new FormData(form);
const start = (fd.get("startDate") as string) || "";
const end = (fd.get("endDate") as string) || "";
Cookies.set("startDate", start);
Cookies.set("endDate", end);
getBorrowableItems();
console.log("Zeitraum erfolgreich gesetzt!");
}}
>
<div> <div>
<label <label
htmlFor="startDate" htmlFor="startDate"
@@ -15,6 +30,7 @@ const Form1: React.FC = () => {
<input <input
type="datetime-local" type="datetime-local"
id="startDate" id="startDate"
name="startDate"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70" className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70"
/> />
</div> </div>
@@ -28,6 +44,7 @@ const Form1: React.FC = () => {
<input <input
type="datetime-local" type="datetime-local"
id="endDate" id="endDate"
name="endDate"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70" className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none bg-white/70"
/> />
</div> </div>

View File

@@ -1,59 +1,170 @@
import React from "react"; import React from "react";
import Cookies from "js-cookie";
import { createLoan } from "../utils/userHandler";
import { addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
}
const LOCAL_STORAGE_KEY = "borrowableItems";
// Einfache Type-Guard/Validierung
const isBorrowItem = (v: any): v is BorrowItem =>
v &&
typeof v.id === "number" &&
(typeof v.item_name === "string" || typeof v.name === "string") &&
(typeof v.can_borrow_role === "string" || typeof v.role === "string");
// Helfer: unterschiedliche Server-Shapes normalisieren
function normalizeBorrowable(data: any): BorrowItem[] {
const rawArr = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: Array.isArray(data?.data)
? data.data
: [];
return rawArr
.map((raw: any) => {
const idRaw =
raw.id ?? raw.item_id ?? raw.itemId ?? raw.itemID ?? raw.itemIdPk;
const id = Number(idRaw);
const item_name = String(raw.item_name ?? raw.name ?? raw.title ?? "");
const can_borrow_role = String(
raw.can_borrow_role ?? raw.role ?? raw.requiredRole ?? ""
);
const inSafeRaw =
raw.inSafe ?? raw.in_safe ?? raw.inLocker ?? raw.isInSafe ?? raw.safe;
const inSafe =
typeof inSafeRaw === "boolean"
? Number(inSafeRaw)
: Number(isNaN(Number(inSafeRaw)) ? 0 : Number(inSafeRaw));
if (!Number.isFinite(id) || !item_name) return null;
return { id, item_name, can_borrow_role, inSafe };
})
.filter(Boolean) as BorrowItem[];
}
// Hook, der automatisch aus dem Local Storage liest und auf Änderungen hört
function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]);
const readFromStorage = React.useCallback(() => {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]";
const parsed = JSON.parse(raw);
const arr = normalizeBorrowable(parsed);
setItems(arr);
} catch {
setItems([]);
}
}, []);
React.useEffect(() => {
// Initial read
readFromStorage();
// Cross-tab updates
const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
};
window.addEventListener("storage", onStorage);
// Same-tab updates via Custom Event
const onBorrowableUpdated = () => readFromStorage();
window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
};
}, [readFromStorage]);
return items;
}
const Form2: React.FC = () => { const Form2: React.FC = () => {
const items = JSON.parse(localStorage.getItem("borrowableItems") || "[]"); const items = useBorrowableItems();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-bold text-blue-700"> <h2 className="text-xl font-bold text-blue-700">
2. Gegenstand auswählen 2. Gegenstand auswählen
</h2> </h2>
<form className="space-y-4">
{items.length === 0 ? (
<div className="text-red-600 font-medium text-center bg-red-50 border border-red-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((item: any) => (
<label
key={item.id}
htmlFor={String(item.id)}
className="group cursor-pointer bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition border border-blue-100 flex items-start gap-3"
>
<input
type="checkbox"
name={String(item.id)}
id={String(item.id)}
className="mt-1 h-4 w-4 rounded border-blue-300 text-blue-600 focus:ring-blue-400"
/>
<div>
<h3 className="text-sm font-semibold text-blue-800">
{item.title}
</h3>
<p className="text-xs text-blue-500/80 line-clamp-2">
{item.description}
</p>
</div>
</label>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-2"> {items.length === 0 ? (
<button <div className="text-red-600 font-medium text-center bg-red-50 border border-red-200 rounded-xl p-4">
type="button" Keine Gegenstände verfügbar für diesen Zeitraum.
className="flex-1 sm:flex-none sm:w-36 bg-white text-blue-700 border border-blue-200 hover:bg-blue-50 font-medium py-2 px-4 rounded-xl shadow-sm transition"
>
Zurück
</button>
<button
type="submit"
className="flex-1 sm:flex-none sm:w-40 bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition"
>
Ausleihen
</button>
</div> </div>
</form> ) : (
<div className="overflow-x-auto rounded-xl border border-blue-100 shadow-sm bg-white/80">
<table className="min-w-full divide-y divide-blue-100">
<thead className="bg-blue-50/60">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700">
Gegenstand
</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-blue-700">
<input type="checkbox" className="invisible" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-50">
{items.map((item) => (
<tr
key={item.id}
className="hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-2 text-sm font-medium text-blue-900">
{item.item_name}
</td>
<td className="px-4 py-2 text-sm text-blue-700">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) {
addToRemove(item.id);
} else {
rmFromRemove(item.id);
}
}}
id={`item-${item.id}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
onClick={() => {
createLoan(
Cookies.get("startDate") ?? "",
Cookies.get("endDate") ?? ""
);
}}
type="button"
className="flex-1 sm:flex-none sm:w-40 bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition"
>
Ausleihen
</button>
</div>
</div> </div>
); );
}; };

View File

@@ -1,22 +0,0 @@
import React from "react";
const Form3: React.FC = () => {
const code = localStorage.getItem("borrowCode") ?? "—";
return (
<div className="space-y-4">
<h2 className="text-xl font-bold text-blue-700">3. Ausleihe bestätigt</h2>
<div className="mt-2 p-6 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
<p className="text-blue-800 font-semibold">Ihr Ausleihcode lautet</p>
<div className="text-3xl font-extrabold tracking-widest text-blue-700 mt-1">
{code}
</div>
<p className="text-blue-600 mt-2 text-sm">
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
</p>
</div>
</div>
);
};
export default Form3;

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from "react";
import { Trash, ArrowLeftRight } from "lucide-react";
import { handleDeleteLoan } from "../utils/userHandler";
type Loan = {
id: number;
username: string;
loan_code: number;
start_date: string;
end_date: string;
returned_date: string | null;
created_at: string;
loaned_items_id: number[];
loaned_items_name: string[];
};
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
};
const readUserLoansFromStorage = (): Loan[] => {
const raw = localStorage.getItem("userLoans");
if (!raw || raw === '"No loans found for this user"') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Loan[]) : [];
} catch {
return [];
}
};
const Form4: React.FC = () => {
const [userLoans, setUserLoans] = useState<Loan[]>(() =>
readUserLoansFromStorage()
);
// Keep in sync if localStorage changes (e.g., other tabs or parts of the app)
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "userLoans") {
setUserLoans(readUserLoansFromStorage());
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const onDelete = async (loanID: number) => {
const ok = await handleDeleteLoan(loanID);
if (ok) {
setUserLoans((prev) =>
prev.filter((l) => Number(l.id) !== Number(loanID))
);
}
};
if (userLoans.length === 0) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-center text-gray-600 shadow-sm">
<p>Keine Ausleihen gefunden.</p>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-lg font-semibold tracking-tight text-gray-900">
Meine Ausleihen
</p>
<p className="text-sm text-gray-600">
Wenn du eine Ausleihe ändern oder löschen möchtest, klicke auf das
Papierkorb-Symbol.
</p>
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="min-w-full text-sm text-gray-700">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr className="border-b border-gray-200">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Leihcode
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Start
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Ende
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Zurückgegeben
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Erstellt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-600">
Gegenstände
</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-600">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{userLoans.map((loan) => (
<tr
key={loan.id}
className="odd:bg-white even:bg-gray-50 hover:bg-gray-100/60 transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{loan.loan_code}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{formatDate(loan.start_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{formatDate(loan.end_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{formatDate(loan.returned_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-gray-900">
{formatDate(loan.created_at)}
</td>
<td className="px-4 py-3">
<div className="max-w-[22rem] truncate text-gray-900">
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="inline-flex items-center rounded-md p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Scroll hint */}
<div className="border-t border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ArrowLeftRight className="h-4 w-4 text-gray-400" />
<span>Hinweis: Horizontal scrollen, um alle Spalten zu sehen.</span>
</div>
</div>
</div>
</div>
);
};
export default Form4;

View File

@@ -7,12 +7,9 @@ type ObjectProps = {
const Object: React.FC<ObjectProps> = ({ title, description }) => { const Object: React.FC<ObjectProps> = ({ title, description }) => {
return ( return (
<div className="flex items-start gap-3"> <div>
<div className="shrink-0 w-3 h-3 mt-1.5 rounded-full bg-green-400" /> <h3 className="text-sm font-semibold text-blue-800">{title}</h3>
<div> <p className="text-xs text-blue-500/80 line-clamp-2">{description}</p>
<h3 className="text-sm font-semibold text-blue-800">{title}</h3>
<p className="text-xs text-blue-500/80 line-clamp-2">{description}</p>
</div>
</div> </div>
); );
}; };

View File

@@ -18,37 +18,67 @@ const Sidebar: React.FC = () => {
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler); return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
}, []); }, []);
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe)); // außerhalb zuerst
return ( return (
<aside className="w-full md:w-80 md:min-h-screen bg-white/90 backdrop-blur md:border-r border-blue-100 shadow-xl flex flex-col p-6"> <aside className="w-full md:w-80 md:h-screen md:sticky md:top-0 overflow-y-auto bg-white/90 backdrop-blur md:border-r border-blue-100 shadow-xl flex flex-col p-6">
<h2 className="text-2xl font-extrabold mb-4 text-blue-700 tracking-tight flex items-center gap-2"> <h2 className="text-2xl font-extrabold mb-4 text-blue-700 tracking-tight flex items-center justify-between">
<svg <span className="flex items-center gap-2">
className="w-6 h-6 text-blue-500" <svg
fill="none" className="w-6 h-6 text-blue-500"
stroke="currentColor" fill="none"
strokeWidth={2} stroke="currentColor"
viewBox="0 0 24 24" strokeWidth={2}
> viewBox="0 0 24 24"
<path >
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9" strokeLinejoin="round"
/> d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
</svg> />
Geräte Übersicht </svg>
Geräte Übersicht
</span>
{outCount > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700">
{outCount} außerhalb
</span>
)}
</h2> </h2>
<div className="space-y-4 flex-1 overflow-auto pr-1"> <div className="space-y-4 flex-1 pr-1">
{items.map((item: any) => ( {sorted.map((item: any) => (
<div <div
key={item.item_name} key={item.item_name}
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition" className={`bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition ${
item.inSafe ? "" : "ring-1 ring-red-200"
}`}
> >
<Object <div className="flex items-start gap-3">
title={item.item_name} <span className="relative mt-1 inline-flex" aria-hidden="true">
description={ {!item.inSafe && (
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach" <span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
} )}
/> <span
className={`inline-block w-3 h-3 rounded-full ${
item.inSafe ? "bg-green-400" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -3,11 +3,12 @@ import { myToast } from "./toastify";
// Event name used to notify the app when the list of items has been updated // Event name used to notify the app when the list of items has been updated
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated"; export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
export const fetchAllData = async (token: string | undefined) => { export const fetchAllData = async (token: string | undefined) => {
if (!token) return; if (!token) return;
// First we fetch all items that are potentially available for borrowing
try { try {
// First we fetch all items that are potentially available for borrowing
const response = await fetch("http://localhost:8002/api/items", { const response = await fetch("http://localhost:8002/api/items", {
method: "GET", method: "GET",
headers: { headers: {
@@ -27,6 +28,50 @@ export const fetchAllData = async (token: string | undefined) => {
} catch (error) { } catch (error) {
myToast("An error occurred", "error"); myToast("An error occurred", "error");
} }
// get all loans
try {
const response = await fetch("http://localhost:8002/api/loans", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
myToast("Failed to fetch loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("allLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
// get user loans
try {
const response = await fetch("http://localhost:8002/api/userLoans", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
myToast("Failed to fetch user loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("userLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
}; };
export const loginUser = async (username: string, password: string) => { export const loginUser = async (username: string, password: string) => {
@@ -56,3 +101,37 @@ export const loginUser = async (username: string, password: string) => {
return { success: false } as const; return { success: false } as const;
} }
}; };
export const getBorrowableItems = async () => {
const startDate = Cookies.get("startDate");
const endDate = Cookies.get("endDate");
if (!startDate || !endDate) {
myToast("Bitte wähle einen Zeitraum aus.", "error");
return;
}
try {
const response = await fetch("http://localhost:8002/api/borrowableItems", {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ startDate, endDate }),
});
if (!response.ok) {
myToast("Failed to fetch borrowable items", "error");
return;
}
const data = await response.json();
localStorage.setItem("borrowableItems", JSON.stringify(data));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
console.log("Borrowable items fetched successfully");
} catch (error) {
myToast("An error occurred", "error");
}
};

View File

@@ -0,0 +1,96 @@
import { myToast } from "./toastify";
import Cookies from "js-cookie";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try {
const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanID}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Löschen der Ausleihe", "error");
return false;
}
const raw = localStorage.getItem("userLoans");
let current: Array<{ id: number }> = [];
try {
const parsed = raw ? JSON.parse(raw) : [];
current = Array.isArray(parsed) ? parsed : [];
} catch {
current = [];
}
const updated = current.filter(
(loan) => Number(loan.id) !== Number(loanID)
);
localStorage.setItem("userLoans", JSON.stringify(updated));
myToast("Ausleihe erfolgreich gelöscht!", "success");
return true;
} catch (error) {
console.error("Error deleting loan:", error);
myToast("Fehler beim löschen der Ausleihe", "error");
return false;
}
};
// Parse existing cookie and coerce to numbers
let removeArr: number[] = (() => {
try {
const raw = Cookies.get("removeArr");
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n))
: [];
} catch {
return [];
}
})();
const rawCookies = Cookies.withConverter({
write: (value: string) => value, // store raw JSON
});
export const addToRemove = (itemID: number) => {
if (!Number.isFinite(itemID)) return;
if (!removeArr.includes(itemID)) {
removeArr.push(itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
}
};
export const rmFromRemove = (itemID: number) => {
removeArr = removeArr.filter((item) => item !== itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
};
export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr;
const response = await fetch("http://localhost:8002/api/createLoan", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
body: JSON.stringify({ items, startDate, endDate }),
});
if (!response.ok) {
myToast("Fehler beim Erstellen der Ausleihe", "error");
return false;
}
// Clear selection on success
removeArr = [];
Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success");
return true;
};