Compare commits

...

2 Commits

Author SHA1 Message Date
theis.gaedigk f8e29dca10 improved loan tabel on admin panel 2026-04-26 22:21:00 +02:00
theis.gaedigk e52fc13da4 updated changelog 2026-04-26 22:09:19 +02:00
4 changed files with 358 additions and 267 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewAPI={() => setActiveView("API")} viewAPI={() => setActiveView("API")}
viewConfig={() => setActiveView("Server Konfiguration")} viewConfig={() => setActiveView("Server Konfiguration")}
/> />
<Box flex="1" display="flex" flexDirection="column"> <Box flex="1" display="flex" flexDirection="column" minH={0}>
<Flex <Flex
as="header" as="header"
align="center" align="center"
@@ -68,7 +68,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
</Button> </Button>
</Flex> </Flex>
</Flex> </Flex>
<Box as="main" flex="1" p={6}> <Box as="main" flex="1" p={6} minH={0} overflow="hidden">
{activeView === "" && ( {activeView === "" && (
<Flex <Flex
align="center" align="center"
+228 -184
View File
@@ -57,32 +57,32 @@ const ItemTable: React.FC = () => {
const handleItemNameChange = (id: number, value: string) => { const handleItemNameChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it)) prev.map((it) => (it.id === id ? { ...it, item_name: value } : it)),
); );
}; };
const handleCanBorrowRoleChange = (id: number, value: string) => { const handleCanBorrowRoleChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it)) prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it)),
); );
}; };
const handleLockerNumberChange = (id: number, value: string) => { const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it)) prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it)),
); );
}; };
const handleDoorKeyChange = (id: number, value: string) => { const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it)) prev.map((it) => (it.id === id ? { ...it, door_key: value } : it)),
); );
}; };
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
description: string description: string,
) => { ) => {
setIsError(false); setIsError(false);
setErrorStatus(status); setErrorStatus(status);
@@ -102,7 +102,7 @@ const ItemTable: React.FC = () => {
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
} },
); );
const data = await response.json(); const data = await response.json();
return data; return data;
@@ -193,185 +193,229 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */} {/* make table fill available width, like UserTable */}
{!isLoading && ( {!isLoading && (
<Table.Root <Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
size="sm" <Table.Root
striped size="sm"
w="100%" striped
style={{ tableLayout: "auto" }} // Spalten nach Content stickyHeader
> css={{
<Table.Header> "& [data-sticky]": {
<Table.Row> position: "sticky",
<Table.ColumnHeader> zIndex: 1,
<strong>#</strong> bg: "bg",
</Table.ColumnHeader>
<Table.ColumnHeader> _after: {
<strong>Gegenstand</strong> content: '""',
</Table.ColumnHeader> position: "absolute",
<Table.ColumnHeader> pointerEvents: "none",
<strong>Ausleih Berechtigung</strong> top: "0",
</Table.ColumnHeader> bottom: "-1px",
<Table.ColumnHeader> width: "32px",
<strong>Im Schließfach</strong> },
</Table.ColumnHeader> },
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong> "& [data-sticky=end]": {
</Table.ColumnHeader> _after: {
<Table.ColumnHeader width="1%" whiteSpace="nowrap"> insetInlineEnd: "0",
<strong>Schlüssel</strong> translate: "100% 0",
</Table.ColumnHeader> shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
<Table.ColumnHeader> },
<strong>Eintrag erstellt am</strong> },
</Table.ColumnHeader>
<Table.ColumnHeader> "& [data-sticky=start]": {
<strong>Eintrag aktualisiert am</strong> _after: {
</Table.ColumnHeader> insetInlineStart: "0",
<Table.ColumnHeader> translate: "-100% 0",
<strong>LaP *</strong> shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
</Table.ColumnHeader> },
<Table.ColumnHeader> },
<strong>Dav **</strong>
</Table.ColumnHeader> "& thead tr": {
<Table.ColumnHeader width="1%" whiteSpace="nowrap"> shadow: "0 1px 0 0 {colors.border}",
<strong>Aktionen</strong> "&:has(th[data-sticky])": {
</Table.ColumnHeader> zIndex: 2,
</Table.Row> },
</Table.Header> },
<Table.Body> }}
{items.map((item) => ( >
<Table.Row key={item.id}> <Table.Header>
<Table.Cell>{item.id}</Table.Cell> <Table.Row>
<Table.Cell> <Table.ColumnHeader>
<Input <strong>#</strong>
size="sm" </Table.ColumnHeader>
w="max-content" <Table.ColumnHeader>
onChange={(e) => <strong>Gegenstand</strong>
handleItemNameChange(item.id, e.target.value) </Table.ColumnHeader>
} <Table.ColumnHeader>
value={item.item_name} <strong>Ausleih Berechtigung</strong>
/> </Table.ColumnHeader>
</Table.Cell> <Table.ColumnHeader>
<Table.Cell> <strong>Im Schließfach</strong>
<Input </Table.ColumnHeader>
size="sm" <Table.ColumnHeader width="1%" whiteSpace="nowrap">
w="max-content" <strong>Schließfachnummer</strong>
onChange={(e) => </Table.ColumnHeader>
handleCanBorrowRoleChange(item.id, e.target.value) <Table.ColumnHeader width="1%" whiteSpace="nowrap">
} <strong>Schlüssel</strong>
value={item.can_borrow_role} </Table.ColumnHeader>
/> <Table.ColumnHeader>
</Table.Cell> <strong>Eintrag erstellt am</strong>
<Table.Cell> </Table.ColumnHeader>
<Button <Table.ColumnHeader>
onClick={() => <strong>Eintrag aktualisiert am</strong>
changeSafeState(item.id).then(() => setReload(!reload)) </Table.ColumnHeader>
} <Table.ColumnHeader>
size="xs" <strong>LaP *</strong>
rounded="full" </Table.ColumnHeader>
px={3} <Table.ColumnHeader>
py={1} <strong>Dav **</strong>
gap={2} </Table.ColumnHeader>
variant="ghost" <Table.ColumnHeader width="1%" whiteSpace="nowrap">
color={item.in_safe ? "green.600" : "red.600"} <strong>Aktionen</strong>
borderWidth="1px" </Table.ColumnHeader>
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row> </Table.Row>
))} </Table.Header>
</Table.Body> <Table.Body>
</Table.Root> {items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>
{formatDateTime(item.entry_created_at)}
</Table.Cell>
<Table.Cell>
{formatDateTime(item.entry_updated_at)}
</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role,
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet.",
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)} )}
<Text>* LaP = Letzte ausleihende Person</Text> <Text>* LaP = Letzte ausleihende Person</Text>
<Text>** Dav = Derzeit ausgeliehen von</Text> <Text>** Dav = Derzeit ausgeliehen von</Text>
+127 -81
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { import {
Box,
Table, Table,
Spinner, Spinner,
Text, Text,
@@ -31,7 +32,7 @@ const LoanTable: React.FC = () => {
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
description: string description: string,
) => { ) => {
setIsError(false); setIsError(false);
setErrorStatus(status); setErrorStatus(status);
@@ -65,7 +66,7 @@ const LoanTable: React.FC = () => {
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
} },
); );
const data = await response.json(); const data = await response.json();
return data; return data;
@@ -83,7 +84,7 @@ const LoanTable: React.FC = () => {
}, [reload]); }, [reload]);
return ( return (
<> <Box h="full" display="flex" flexDirection="column" minH={0}>
{/* Action toolbar */} {/* Action toolbar */}
<HStack <HStack
mb={4} mb={4}
@@ -131,86 +132,131 @@ const LoanTable: React.FC = () => {
</VStack> </VStack>
)} )}
{!isLoading && ( {!isLoading && (
<Table.Root size="sm" striped> <Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
<Table.Header> <Table.Root
<Table.Row> size="sm"
<Table.ColumnHeader> striped
<strong>#</strong> stickyHeader
</Table.ColumnHeader> css={{
<Table.ColumnHeader> "& [data-sticky]": {
<strong>Besitzer</strong> position: "sticky",
</Table.ColumnHeader> zIndex: 1,
<Table.ColumnHeader> bg: "bg",
<strong>Ausleih code</strong>
</Table.ColumnHeader> _after: {
<Table.ColumnHeader> content: '""',
<strong>Startdatum</strong> position: "absolute",
</Table.ColumnHeader> pointerEvents: "none",
<Table.ColumnHeader> top: "0",
<strong>Enddatum</strong> bottom: "-1px",
</Table.ColumnHeader> width: "32px",
<Table.ColumnHeader> },
<strong>Ausleihdatum</strong> },
</Table.ColumnHeader>
<Table.ColumnHeader> "& [data-sticky=end]": {
<strong>Rückgabedatum</strong> _after: {
</Table.ColumnHeader> insetInlineEnd: "0",
<Table.ColumnHeader> translate: "100% 0",
<strong>Erstellt am</strong> shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
</Table.ColumnHeader> },
<Table.ColumnHeader> },
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader> "& [data-sticky=start]": {
<Table.ColumnHeader> _after: {
<strong>Notiz</strong> insetInlineStart: "0",
</Table.ColumnHeader> translate: "-100% 0",
<Table.ColumnHeader> shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
<strong>Aktionen</strong> },
</Table.ColumnHeader> },
</Table.Row>
</Table.Header> "& thead tr": {
<Table.Body> shadow: "0 1px 0 0 {colors.border}",
{items.map((item) => ( "&:has(th[data-sticky])": {
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}> zIndex: 2,
<Table.Cell>{item.id}</Table.Cell> },
<Table.Cell>{item.username}</Table.Cell> },
<Table.Cell> }}
<Code>{item.loan_code}</Code> >
</Table.Cell> <Table.Header>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell> <Table.Row>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell> <Table.ColumnHeader>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell> <strong>#</strong>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell> </Table.ColumnHeader>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell> <Table.ColumnHeader>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell> <strong>Besitzer</strong>
<Table.Cell>{item.note}</Table.Cell> </Table.ColumnHeader>
<Table.Cell> <Table.ColumnHeader>
<Button <strong>Ausleihcode</strong>
onClick={() => </Table.ColumnHeader>
deleteLoan(item.id).then((response) => { <Table.ColumnHeader>
if (response.success) { <strong>Startdatum</strong>
setItems(items.filter((i) => i.id !== item.id)); </Table.ColumnHeader>
setError( <Table.ColumnHeader>
"success", <strong>Enddatum</strong>
"Loan deleted", </Table.ColumnHeader>
"The loan has been successfully deleted." <Table.ColumnHeader>
); <strong>Ausleihdatum</strong>
} </Table.ColumnHeader>
}) <Table.ColumnHeader>
} <strong>Rückgabedatum</strong>
colorPalette="red" </Table.ColumnHeader>
size="sm" <Table.ColumnHeader>
ml={2} <strong>Erstellt am</strong>
> </Table.ColumnHeader>
<Trash2 /> <Table.ColumnHeader>
</Button> <strong>Ausgeliehene Artikel</strong>
</Table.Cell> </Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row> </Table.Row>
))} </Table.Header>
</Table.Body> <Table.Body>
</Table.Root> {items.map((item) => (
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)} )}
</> </Box>
); );
}; };
+1
View File
@@ -15,6 +15,7 @@ This update provides some new features for the design. It also contains some imp
- Improved error logging - Improved error logging
- If you try to delete a loan that has not been returned yet, you will get an 507 error code. - If you try to delete a loan that has not been returned yet, you will get an 507 error code.
- When the admin deletes a loan, the loan will be still visible in the database, but it will be marked as deleted. This is to prevent data loss and to keep track of deleted loans. - When the admin deletes a loan, the loan will be still visible in the database, but it will be marked as deleted. This is to prevent data loss and to keep track of deleted loans.
- Mailer improvements: The mailer is now more clearly organised. Two large code files are now split into five smaller code files which are easier to maintain. Also the design of the mails has improved.
## Fixed bugs ## Fixed bugs