Compare commits

..

10 Commits

7 changed files with 549 additions and 606 deletions
+257 -325
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.28.0", "@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@lottiefiles/dotlottie-react": "^0.19.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0", "i18next": "^25.6.0",
-28
View File
@@ -1,28 +0,0 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
export const unlockAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/f839baa1-9c64-44c4-9386-f0e4c87ab208/2Iw1m4k86d.lottie"
autoplay
/>
);
};
export const approvalAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/b7257009-9e3f-43e2-8112-a176f4696e4c/iQxxqAVOGX.lottie"
autoplay
/>
);
};
export const logoutAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/4975758c-de38-4d15-9f74-927709751d32/v8FtKpnD1y.lottie"
autoplay
/>
);
};
+137 -157
View File
@@ -18,7 +18,6 @@ import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher"; import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie";
export interface User { export interface User {
username: string; username: string;
@@ -28,8 +27,6 @@ export interface User {
export const HomePage = () => { export const HomePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAnimation, setShowAnimation] = useState(false);
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom); const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(""); const [endDate, setEndDate] = useState("");
@@ -49,172 +46,155 @@ export const HomePage = () => {
setSelectedItems((prevSelected) => setSelectedItems((prevSelected) =>
prevSelected.includes(itemId) prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId) ? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId], : [...prevSelected, itemId]
); );
}; };
const showApprovalAnimation = (seconds: number) => {
const milliseconds = seconds * 1000;
setShowAnimation(true);
window.setTimeout(() => {
setShowAnimation(false);
}, milliseconds);
};
return ( return (
<> <Container className="px-6 sm:px-8 pt-10">
{showAnimation && ( <Header />
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none"> {isMsg && (
<div>{approvalAnimation()}</div> <MyAlert
</div> status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)} )}
<Container className="px-6 sm:px-8 pt-10"> <Stack as="main">
<Header /> <Text>{t("timezone-info")}</Text>
{isMsg && ( <label htmlFor="startDate">
<MyAlert <strong>
status={msgStatus} <Text>{t("start-date")}</Text>
title={msgTitle} </strong>
description={msgDescription} </label>
/> <Input
)} id="startDate"
<Stack as="main"> placeholder={t("start-date")}
<Text>{t("timezone-info")}</Text> type="datetime-local"
<label htmlFor="startDate"> value={startDate}
<strong> onChange={(e) => setStartDate(e.target.value)}
<Text>{t("start-date")}</Text> />
</strong> <label htmlFor="endDate">
</label> <strong>
<Input <Text>{t("end-date")}</Text>
id="startDate" </strong>
placeholder={t("start-date")} </label>
type="datetime-local" <Input
value={startDate} id="endDate"
onChange={(e) => setStartDate(e.target.value)} placeholder={t("end-date")}
/> type="datetime-local"
<label htmlFor="endDate"> value={endDate}
<strong> onChange={(e) => setEndDate(e.target.value)}
<Text>{t("end-date")}</Text> />
</strong> <Button
</label> onClick={async () => {
<Input setIsLoadingA(true);
id="endDate" if (!startDate || !endDate) {
placeholder={t("end-date")} setMsgStatus("error");
type="datetime-local" setMsgTitle(t("missing-fields"));
value={endDate} setMsgDescription(t("missing-fields-desc"));
onChange={(e) => setEndDate(e.target.value)} setIsMsg(true);
/> setIsLoadingA(false);
<Button return;
onClick={async () => { }
setIsLoadingA(true); await getBorrowableItems(startDate, endDate).then((response) => {
if (!startDate || !endDate) { setIsLoadingA(false);
if (response && response.status === "error") {
setMsgStatus("error"); setMsgStatus("error");
setMsgTitle(t("missing-fields")); setMsgTitle(response.title || t("error"));
setMsgDescription(t("missing-fields-desc")); setMsgDescription(response.description || t("unknown-error"));
setIsMsg(true); setIsMsg(true);
setIsLoadingA(false);
return; return;
} }
await getBorrowableItems(startDate, endDate).then((response) => { setBorrowableItems(response.data);
setIsLoadingA(false); setIsMsg(false);
if (response && response.status === "error") { });
setMsgStatus("error"); }}
setMsgTitle(response.title || t("error")); >
setMsgDescription(response.description || t("unknown-error")); {t("get-borrowable-items")}
setIsMsg(true); </Button>
return; {isLoadingA && (
} <VStack colorPalette="teal">
setBorrowableItems(response.data); <Spinner color="colorPalette.600" />
setIsMsg(false); <Text color="colorPalette.600">{t("loading")}</Text>
}); </VStack>
}} )}
> {borrowableItems.length > 0 && (
{t("get-borrowable-items")} <Table.ScrollArea borderWidth="1px" rounded="md">
</Button> <Table.Root size="sm" stickyHeader>
{isLoadingA && ( <Table.Header>
<VStack colorPalette="teal"> <Table.Row bg="bg.subtle">
<Spinner color="colorPalette.600" /> <Table.ColumnHeader></Table.ColumnHeader>
<Text color="colorPalette.600">{t("loading")}</Text> <Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
</VStack> </Table.Row>
)} </Table.Header>
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
<Table.Header>
<Table.Row bg="bg.subtle">
<Table.ColumnHeader></Table.ColumnHeader>
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body> <Table.Body>
{borrowableItems.map((item) => ( {borrowableItems.map((item) => (
<Table.Row key={item.id}> <Table.Row key={item.id}>
<Table.Cell> <Table.Cell>
<input <input
onChange={() => handleCheckboxChange(item.id)} onChange={() => handleCheckboxChange(item.id)}
type="checkbox" type="checkbox"
name={item.id} name={item.id}
id={item.id} id={item.id}
/> />
</Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row>
))}
<Table.Row>
<Table.Cell colSpan={2}>
<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.Cell> </Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row> </Table.Row>
</Table.Body> ))}
</Table.Root> <Table.Row>
</Table.ScrollArea> <Table.Cell colSpan={2}>
)} <InputGroup
{selectedItems.length >= 1 && ( endElement={
<Button <Span color="fg.muted" textStyle="xs">
onClick={() => {note.length} / {MAX_CHARACTERS}
createLoan(selectedItems, startDate, endDate, note).then( </Span>
(response) => { }
if (response.status === "error") { >
setMsgStatus("error"); <Input
setMsgTitle(response.title || t("error")); placeholder={t("optional-note")}
setMsgDescription( value={note}
response.description || t("unknown-error"), maxLength={MAX_CHARACTERS}
); onChange={(e) => {
setIsMsg(true); setNote(
return; e.currentTarget.value.slice(0, MAX_CHARACTERS)
} );
showApprovalAnimation(3); }}
setMsgStatus("success"); />
setMsgTitle(t("success")); </InputGroup>
setMsgDescription(t("loan-success")); </Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error")
);
setIsMsg(true); setIsMsg(true);
}, return;
) }
} setMsgStatus("success");
> setMsgTitle(t("success"));
{t("create-loan")} setMsgDescription(t("loan-success"));
</Button> setIsMsg(true);
)} }
</Stack> )
</Container> }
</> >
{t("create-loan")}
</Button>
)}
</Stack>
</Container>
); );
}; };
+52 -92
View File
@@ -4,47 +4,26 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms"; import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useNavigate, useLocation } from "react-router-dom"; import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import { unlockAnimation } from "@/components/dotLottie";
import { logoutAnimation } from "@/components/dotLottie";
export const LoginPage = () => { export const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom); const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom); const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [showAnimation, setShowAnimation] = useState(false);
const [showLogout, setShowLogout] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const from = location.state?.from?.pathname || "/"; const from = location.state?.from?.pathname || "/";
useEffect(() => { useEffect(() => {
if (triggerLogout) { if (isLoggedIn) {
setShowLogout(true);
window.setTimeout(() => {
setShowLogout(false);
}, 4500);
}
if (!isLoggedIn) return;
// Existing sessions should redirect immediately, fresh logins wait for animation.
if (!showAnimation) {
navigate(from, { replace: true }); navigate(from, { replace: true });
return; window.location.reload(); // if deleted, the user context is not updated in time
} }
}, [isLoggedIn, navigate, from]);
const timeoutId = window.setTimeout(() => {
navigate(from, { replace: true });
window.location.reload(); // keeps user context in sync after login
}, 3000);
return () => window.clearTimeout(timeoutId);
}, [isLoggedIn, showAnimation, navigate, from]);
const loginFnc = async (username: string, password: string) => { const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, { const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -63,8 +42,6 @@ export const LoginPage = () => {
}; };
} }
setShowAnimation(true);
Cookies.set("token", data.token); Cookies.set("token", data.token);
setIsLoggedIn(true); setIsLoggedIn(true);
return { success: true }; return { success: true };
@@ -85,75 +62,58 @@ export const LoginPage = () => {
return; return;
} }
setTriggerLogout(false); setTriggerLogout(false);
navigate(from, { replace: true });
}; };
if (isLoggedIn) {
return <Navigate to={from} replace />;
}
return ( return (
<> <div className="flex flex-1 items-center justify-center p-4">
{showAnimation && ( <form onSubmit={(e) => e.preventDefault()}>
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none"> <Card.Root maxW="sm">
<div>{unlockAnimation()}</div> <Card.Header>
</div> <Card.Title>{t("login")}</Card.Title>
)} <Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
{showLogout && ( <Card.Body>
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none"> <Stack gap="4" w="full">
<div>{logoutAnimation()}</div> <Field.Root>
</div> <Field.Label>{t("username")}</Field.Label>
)} <Input
value={username}
<div className="flex flex-1 items-center justify-center p-4"> onChange={(e) => setUsername(e.target.value)}
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert
status="error"
title={errorMsg}
description={errorDsc}
/> />
)} </Field.Root>
<Button <Field.Root>
type="submit" <Field.Label>{t("password")}</Field.Label>
onClick={() => handleLogin()} <PasswordInput
variant="solid" value={password}
> onChange={(e) => setPassword(e.target.value)}
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={t("logout-success")}
description={t("logout-success-desc")}
/> />
)} </Field.Root>
</Card.Footer> </Stack>
</Card.Root> </Card.Body>
</form> <Card.Footer justifyContent="flex-end">
</div> {isError && (
</> <MyAlert status="error" title={errorMsg} description={errorDsc} />
)}
<Button type="submit" onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={t("logout-success")}
description={t("logout-success-desc")}
/>
)}
</Card.Footer>
</Card.Root>
</form>
</div>
); );
}; };
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"backend-info": { "backend-info": {
"version": "v2.1.1 (dev)" "version": "v2.1.1 (demo)"
}, },
"frontend-info": { "frontend-info": {
"version": "v2.1.2 (dev)" "version": "v2.1.2 (demo)"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.3.2 (dev)" "version": "v1.3.2 (demo)"
} }
} }
+100
View File
@@ -0,0 +1,100 @@
USE borrow_system_new;
-- USERS
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
VALUES
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
-- ITEMS
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
VALUES
('Item1', 1, true, 1, 101, NULL, NULL),
('Item2', 1, true, 2, 102, 'user1', 'user1'),
('Item3', 2, true, 3, 103, 'user2', NULL),
('Item4', 1, false, NULL, NULL, NULL, NULL),
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
-- LOANS
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
created_at,
loaned_items_id,
loaned_items_name,
deleted,
note
)
VALUES
(
'user1',
JSON_ARRAY('Locker1', 'Locker2'),
'123456',
'2026-02-01 09:00:00',
'2026-02-10 17:00:00',
'2026-02-01 09:15:00',
NULL,
'2026-02-01 09:00:00',
JSON_ARRAY(1, 2),
JSON_ARRAY('Item1', 'Item2'),
false,
'Erste allgemeine Ausleihe'
),
(
'user2',
JSON_ARRAY('Locker3'),
'234567',
'2026-02-02 10:00:00',
'2026-02-05 16:00:00',
'2026-02-02 10:05:00',
'2026-02-05 15:30:00',
'2026-02-02 10:00:00',
JSON_ARRAY(3),
JSON_ARRAY('Item3'),
false,
'Zurückgegeben vor Enddatum'
),
(
'user3',
JSON_ARRAY(),
'345678',
'2026-02-03 08:30:00',
'2026-02-15 18:00:00',
NULL,
NULL,
'2026-02-03 08:30:00',
JSON_ARRAY(5),
JSON_ARRAY('Item5'),
false,
'Noch ausgeliehen'
),
(
'user1',
JSON_ARRAY('Locker4'),
'456789',
'2025-12-01 09:00:00',
'2025-12-03 17:00:00',
'2025-12-01 09:10:00',
'2025-12-03 16:45:00',
'2025-12-01 09:00:00',
JSON_ARRAY(1),
JSON_ARRAY('Item1'),
true,
'Alte, gelöschte Ausleihe'
);
-- API KEYS
INSERT INTO apiKeys (api_key, entry_name)
VALUES
('10000001', 'Entry1'),
('10000002', 'Entry2'),
('10000003', 'Entry3'),
('10000004', 'Entry4');