feat(events): added view for user to look at theirs event slot entries
Build and Push Docker image / build-and-push (push) Successful in 1m46s Details

This commit is contained in:
Valentin Kolb 2024-05-14 00:13:28 +02:00
parent 36e6605647
commit 0fc99bd187
11 changed files with 357 additions and 15 deletions

View File

@ -17,10 +17,12 @@ Dieses Repository enthält den Quellcode für die StuVe IT Frontend Webseite.
## Backend ## Backend
Als Backend wird ein selbst erweitertes Pocketbase verwendet. Dieses ist in diesem [Repo](https://gitlab.uni-ulm.de/stuve-it/it-tools/backend/) Als Backend wird ein selbst erweitertes Pocketbase verwendet. Dieses ist in
diesem [Repo](https://gitlab.uni-ulm.de/stuve-it/it-tools/backend/)
zu finden. zu finden.
## Projekt Struktur ## Projekt Struktur
``` ```
├── index.html # html Datei die den React Code in die Seite einbindet ├── index.html # html Datei die den React Code in die Seite einbindet
├── tsconfig.json # Typescript Config Datei ├── tsconfig.json # Typescript Config Datei
@ -41,14 +43,31 @@ zu finden.
### Pocketbase API ### Pocketbase API
Um die Kommunikation mit dem Backend zu erleichtern ist in der Datei "s@/lib/pocketbase.ts" ein Pocketbase Client implementiert. Um die Kommunikation mit dem Backend zu erleichtern ist in der Datei "@/lib/pocketbase.ts" ein Pocketbase Client
implementiert.
Dieser Client ist ein React Hook und kann in jeder React Komponente verwendet werden. Dieser Client ist ein React Hook und kann in jeder React Komponente verwendet werden.
```typescript ```typescript
import {usePB} from @/lib/pocketbase"; import {usePB} from "@/lib/pocketbase"
import {useQuery} from "@tanstack/react-query";
const {pb} = usePB(); const {pb} = usePB()
const user = useUser()
const query = useQuery({
queryKey: ["collection", id],
queryFn: async () => {
return await pb.collection("collection").getOne(id)
},
enabled: !!id && !!user
})
``` ```
#### Pocketbase Modell Types #### Pocketbase Modell Types
#### Todo
- todo api rules so that only event admins can set status
- todo api rules so that only entries for future events can be created
- todo api rule so that entries for past events cant be edited or deleted

View File

@ -39,7 +39,6 @@ export default function FormBuilder({
const [showPreview, setShowPreview] = useDisclosure(false) const [showPreview, setShowPreview] = useDisclosure(false)
const formValues = useForm({ const formValues = useForm({
// mode: "uncontrolled", todo: fix this
initialValues: defaultValue, initialValues: defaultValue,
validateInputOnChange: true, validateInputOnChange: true,
validate: { validate: {

View File

@ -29,7 +29,7 @@ const themeOverride = createTheme({
components: { components: {
Alert: Alert.extend({ Alert: Alert.extend({
defaultProps: { defaultProps: {
radius: 'md', radius: 'var(--border-radius)',
} }
}) })
} }

View File

@ -83,7 +83,8 @@ export type EventListSlotEntriesWithUserModel =
slotDescription: string | null, slotDescription: string | null,
eventList: string, eventList: string,
listDescription: string | null, listDescription: string | null,
event: string event: string,
eventName: string,
} }
& Pick<EventModel, "defaultEntryQuestionSchema" | "defaultEntryStatusSchema"> & Pick<EventModel, "defaultEntryQuestionSchema" | "defaultEntryStatusSchema">
& Pick<EventListModel, "entryQuestionSchema" | "entryStatusSchema"> & Pick<EventListModel, "entryQuestionSchema" | "entryStatusSchema">

View File

@ -4,6 +4,7 @@ import EventOverview from "@/pages/events/EventOverview/index.page.tsx";
import EventView from "./s/EventView.tsx"; import EventView from "./s/EventView.tsx";
import EventNavigate from "@/pages/events/EventNavigate.tsx"; import EventNavigate from "@/pages/events/EventNavigate.tsx";
import EditEventRouter from "@/pages/events/e/:eventId/EditEventRouter.tsx"; import EditEventRouter from "@/pages/events/e/:eventId/EditEventRouter.tsx";
import UserEntries from "@/pages/events/entries/UserEntries.tsx";
export default function EventsRouter() { export default function EventsRouter() {
@ -13,6 +14,7 @@ export default function EventsRouter() {
<Route path={":eventId"} element={<EventNavigate/>}/> <Route path={":eventId"} element={<EventNavigate/>}/>
<Route path={"s/:eventId/*"} element={<EventView/>}/> <Route path={"s/:eventId/*"} element={<EventView/>}/>
<Route path={"e/:eventId/*"} element={<EditEventRouter/>}/> <Route path={"e/:eventId/*"} element={<EditEventRouter/>}/>
<Route path={"entries"} element={<UserEntries/>}/>
<Route path={"*"} element={<NotFound/>}/> <Route path={"*"} element={<NotFound/>}/>
</Routes> </Routes>
<Outlet/> <Outlet/>

View File

@ -102,7 +102,7 @@ export default function ListSearch({event}: { event: EventModel }) {
{/* {/*
todo: filter todo: more filter
<div className={"section stack"}> <div className={"section stack"}>
<Title order={4} c={"blue"}> <Title order={4} c={"blue"}>
Filter Filter

View File

@ -0,0 +1,133 @@
import {useUser} from "@/lib/user.ts";
import PromptLoginModal from "@/components/auth/modals/PromptLoginModal.tsx";
import {Link, useNavigate} from "react-router-dom";
import {useState} from "react";
import {useDebouncedValue, useToggle} from "@mantine/hooks";
import {useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {
ActionIcon,
Anchor,
Breadcrumbs,
Center,
Group,
Loader,
LoadingOverlay,
Pagination,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip
} from "@mantine/core";
import ShowHelp from "@/components/ShowHelp.tsx";
import {IconConfetti, IconDatabaseOff, IconSortAscending, IconSortDescending} from "@tabler/icons-react";
import UserEntryRow from "@/pages/events/entries/UserEntryRow.tsx";
export default function UserEntries() {
const user = useUser()
const navigate = useNavigate()
const {pb} = usePB()
const [page, setPage] = useState(1)
const [sortDirection, toggleSortDirection] = useToggle(['DESC', 'ASC']);
const [eventSearch, setEventSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(eventSearch, 200)
const entriesQuery = useQuery({
queryKey: ["userEntries", user?.id, page, debouncedSearch, sortDirection],
queryFn: async () => (
await pb.collection("eventListSlotEntriesWithUser").getList(page, 50, {
filter: `userId='${user?.id}'${debouncedSearch ? `&&event.name~'${debouncedSearch}'` : ""}`,
sort: sortDirection === "ASC" ? "-event.startDate" : "event.startDate"
})
),
enabled: !!user
})
if (!user) {
return <PromptLoginModal onAbort={() => navigate("/")}/>
}
return <>
{entriesQuery.isLoading && <LoadingOverlay/>}
<div className={"section-transparent"}>
<Breadcrumbs>{[
{title: "Home", to: "/"},
{title: "Events", to: "/events"},
{title: "Meine Einträge", to: `/events/entries`},
].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}>
{title}
</Anchor>
))}</Breadcrumbs>
</div>
<div className={"section"}>
<Title order={1} c={"blue"}>Meine Einträge</Title>
</div>
<div className={"section-transparent"}>
<ShowHelp>
Auf dieser Seite findest du alle deine Einträge zu Events und kannst diese verwalten.
</ShowHelp>
</div>
<div className={"section stack"}>
<TextInput
value={eventSearch}
onChange={ev => setEventSearch(ev.target.value)}
leftSection={<IconConfetti/>}
rightSection={entriesQuery.isLoading ? <Loader size={"xs"}/> : (
<Tooltip label={`${
sortDirection === "ASC" ? "Events (alt → neu)" : "Events (neu → alt)"
}`} withArrow>
<ActionIcon
onClick={() => toggleSortDirection()}
variant={"transparent"}
aria-label={"toggle sort"}
>
{sortDirection === "ASC" ? <IconSortAscending/> : <IconSortDescending/>}
</ActionIcon>
</Tooltip>
)}
placeholder={"Nach Events suchen ..."}
/>
<Group justify={"space-between"}>
<Text c={"dimmed"} size={"xs"}>
{eventSearch ? `Suche nach Einträgen für Event '${eventSearch}'` : "Alle deine Einträge"}
</Text>
<Text c={"dimmed"} size={"xs"}>
{entriesQuery.data?.totalItems ?? 0} {eventSearch ? "Ergebnisse" : "Einträge"}
</Text>
</Group>
<PocketBaseErrorAlert error={entriesQuery.error}/>
{entriesQuery.data?.totalItems === 0 && <Stack align={"center"}>
<ThemeIcon variant={"transparent"} color={"gray"} size={"md"}>
<IconDatabaseOff/>
</ThemeIcon>
<Title order={4} c={"dimmed"}>Keine Einträge vorhanden</Title>
</Stack>
}
{entriesQuery.data?.items.map(entry => (
<UserEntryRow entry={entry} refetch={() => entriesQuery.refetch()} key={entry.id}/>
))}
<Center>
<Pagination total={entriesQuery.data?.totalPages ?? 1} value={page} onChange={setPage} size={"xs"}/>
</Center>
</div>
</>
}

View File

@ -0,0 +1,27 @@
.container {
background-color: var(--mantine-color-body);
border: var(--border);
border-radius: var(--border-radius);
padding: var(--mantine-spacing-xs);
}
.row {
display: flex;
justify-content: start;
align-items: center;
gap: var(--gap);
font-size: var(--mantine-font-size-sm);
& > :nth-child(2) {
width: 20%;
}
& > :nth-child(3) {
width: 20%;
}
& > :nth-child(4) {
flex: 1;
}
}

View File

@ -0,0 +1,127 @@
import {EventListSlotEntriesWithUserModel} from "@/models/EventTypes.ts";
import {ActionIcon, Collapse, Group, Text, ThemeIcon, Tooltip} from "@mantine/core";
import {IconChevronDown, IconChevronRight, IconConfetti, IconForms, IconList, IconTrash} from "@tabler/icons-react";
import TextWithIcon from "@/components/layout/TextWithIcon";
import {
EventListSlotEntryDetails
} from "@/pages/events/e/:eventId/EventLists/:listId/EventListSlotsTable/EventListSlotEntryRow.tsx";
import {useDisclosure} from "@mantine/hooks";
import {RenderDateRange} from "@/pages/events/e/:eventId/EventLists/components/RenderDateRange.tsx";
import classes from "./UserEntryRow.module.css"
import {humanDeltaFromNow} from "@/lib/datetime.ts";
import {useMutation} from "@tanstack/react-query";
import {showSuccessNotification} from "@/components/util.tsx";
import {useConfirmModal} from "@/components/ConfirmModal.tsx";
import {usePB} from "@/lib/pocketbase.tsx";
import {
UpdateEventListSlotEntryFormModal
} from "@/pages/events/e/:eventId/EventLists/components/UpdateEventListSlotEntryFormModal.tsx";
export default function UserEntryRow({entry, refetch}: {
entry: EventListSlotEntriesWithUserModel,
refetch: () => void
}) {
const {pb} = usePB()
const [expanded, expandedHandler] = useDisclosure(false)
const [showEditFormModal, showEditFormModalHandler] = useDisclosure(false)
const delta = humanDeltaFromNow(entry.slotStartDate, entry.slotEndDate)
const deleteEntryMutation = useMutation({
mutationFn: async () => {
await pb.collection("eventListSlotEntries").delete(entry.id)
},
onSuccess: () => {
refetch()
showSuccessNotification("Eintrag gelöscht")
}
})
const {ConfirmModal, toggleConfirmModal} = useConfirmModal({
title: 'Eintrag löschen',
description: `Möchtest du den Eintrag von ${entry.userName} wirklich löschen?`,
onConfirm: () => deleteEntryMutation.mutate()
})
return <div className={classes.container}>
<ConfirmModal/>
<UpdateEventListSlotEntryFormModal
opened={showEditFormModal}
close={showEditFormModalHandler.close}
refetch={refetch}
entry={entry}
/>
<div className={classes.row}>
<Tooltip label={expanded ? "Details verbergen" : "Details anzeigen"} withArrow>
<ActionIcon onClick={expandedHandler.toggle} variant={"light"} size={"xs"}>
{expanded ? <IconChevronDown/> : <IconChevronRight/>}
</ActionIcon>
</Tooltip>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconConfetti/>
</ThemeIcon>
}>
{entry.eventName}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconList/>
</ThemeIcon>
}>
{entry.listName}
</TextWithIcon>
<Group gap={"xs"}>
<RenderDateRange start={new Date(entry.slotStartDate)} end={new Date(entry.slotEndDate)}/>
<Text size={"sm"} c={"dimmed"}>
{delta.message}
</Text>
</Group>
<Group gap={"xs"}>
<Tooltip
label={delta.delta === "PAST" ? "Vergangene Einträge können nicht bearbeiten werden" : "Eintrag bearbeiten"}
withArrow>
<ActionIcon
variant={"light"}
size={"xs"}
onClick={showEditFormModalHandler.toggle}
aria-label={"edit entry form"}
disabled={delta.delta === "PAST"}
>
<IconForms/>
</ActionIcon>
</Tooltip>
<Tooltip
label={delta.delta === "PAST" ? "Vergangene Einträge können nicht gelöscht werden" : "Eintrag löschen"}
withArrow>
<ActionIcon
variant={"light"}
color={"red"}
size={"xs"}
onClick={toggleConfirmModal}
aria-label={"delete entry"}
disabled={delta.delta === "PAST"}
>
<IconTrash/>
</ActionIcon>
</Tooltip>
</Group>
</div>
<Collapse in={expanded}>
<EventListSlotEntryDetails entry={entry}/>
</Collapse>
</div>
}

View File

@ -22,6 +22,8 @@ export default function EventListSlotView({slot, refetch}: {
const slotIsFull = slot.maxEntries === 0 || slot.maxEntries === null ? false : slot.entriesCount >= slot.maxEntries const slotIsFull = slot.maxEntries === 0 || slot.maxEntries === null ? false : slot.entriesCount >= slot.maxEntries
const slotIsInPast = new Date(slot.endDate) < new Date()
const schema = { const schema = {
fields: [ fields: [
...slot.entryQuestionSchema?.fields ?? [], ...slot.entryQuestionSchema?.fields ?? [],
@ -36,7 +38,7 @@ export default function EventListSlotView({slot, refetch}: {
const createEntryMutation = useMutation({ const createEntryMutation = useMutation({
mutationFn: async (data: FieldEntries) => { mutationFn: async (data: FieldEntries) => {
await pb.collection("eventListSlotEntries").create({ await pb.collection("eventListSlotEntries").create({
entryQuestionData: data, // todo entryQuestionData: data,
eventListsSlot: slot.id, eventListsSlot: slot.id,
ldapUser: user?.realm === "STUVE" ? user.id : null, ldapUser: user?.realm === "STUVE" ? user.id : null,
guestUser: user?.realm === "GUEST" ? user.id : null guestUser: user?.realm === "GUEST" ? user.id : null
@ -73,7 +75,11 @@ export default function EventListSlotView({slot, refetch}: {
} }
{ {
slotIsFull ? <> slotIsInPast ? <>
<Alert color={"red"}>
Dieser Zeitslot ist bereits vorbei
</Alert>
</> : slotIsFull ? <>
<Alert color={"red"} title={"Zeitslot voll"}> <Alert color={"red"} title={"Zeitslot voll"}>
Dieser Zeitslot ist bereits voll Dieser Zeitslot ist bereits voll
</Alert> </Alert>

View File

@ -1,12 +1,11 @@
import {useParams, useSearchParams} from "react-router-dom"; import {Link, useParams, useSearchParams} from "react-router-dom";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {useQuery} from "@tanstack/react-query"; import {useQuery} from "@tanstack/react-query";
import NotFound from "../../not-found/index.page.tsx"; import NotFound from "../../not-found/index.page.tsx";
import {Accordion, Alert, Button, Center, Group, Loader, Title} from "@mantine/core"; import {Accordion, Alert, Anchor, Breadcrumbs, Button, Center, Group, Loader, Title} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx"; import PBAvatar from "@/components/PBAvatar.tsx";
import InnerHtml from "@/components/InnerHtml"; import InnerHtml from "@/components/InnerHtml";
import {IconExternalLink, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react"; import {IconExternalLink, IconLogin, IconPencil, IconSectionSign} from "@tabler/icons-react";
import {useSettings} from "@/lib/settings.ts";
import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx"; import EventData from "@/pages/events/e/:eventId/EventComponents/EventData.tsx";
import EventListView from "@/pages/events/s/EventListView.tsx"; import EventListView from "@/pages/events/s/EventListView.tsx";
import {useEventRights} from "@/pages/events/util.ts"; import {useEventRights} from "@/pages/events/util.ts";
@ -24,7 +23,6 @@ export default function SharedEvent() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const listIds = searchParams.get('lists')?.split(",") ?? [] const listIds = searchParams.get('lists')?.split(",") ?? []
const settings = useSettings()
const eventQuery = useQuery({ const eventQuery = useQuery({
queryKey: ["event", eventId], queryKey: ["event", eventId],
@ -61,6 +59,18 @@ export default function SharedEvent() {
</Alert> </Alert>
</div>} </div>}
<div className={"section-transparent"}>
<Breadcrumbs>{[
{title: "Home", to: "/"},
{title: "Events", to: "/events"},
{title: event.name, to: `/events/${event.id}`},
].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}>
{title}
</Anchor>
))}</Breadcrumbs>
</div>
{(canEditEvent || canEditEventList) && <div className={"section-transparent"}> {(canEditEvent || canEditEventList) && <div className={"section-transparent"}>
<Alert color={"green"} title={"Du kannst dieses Event bearbeiten"}> <Alert color={"green"} title={"Du kannst dieses Event bearbeiten"}>
<Button <Button
@ -105,7 +115,25 @@ export default function SharedEvent() {
{event.isStuveEvent && ( {event.isStuveEvent && (
<Accordion.Item value={"stuve-agb"}> <Accordion.Item value={"stuve-agb"}>
<Accordion.Control icon={<IconSectionSign/>}>AGB der Stuve</Accordion.Control> <Accordion.Control icon={<IconSectionSign/>}>AGB der Stuve</Accordion.Control>
<Accordion.Panel><InnerHtml html={settings?.agb?.value || ""}/></Accordion.Panel> <Accordion.Panel>
<Alert title={"StuVe Event"}>
Dieses Event wird im Rahmen der StuVe durchgeführt. Die AGB der StuVe sind für
dieses Event gültig.
<br/>
<Button
mt={"xs"}
component={Link}
to={`/legal/terms-and-conditions`}
variant={"light"}
target={"_blank"}
leftSection={<IconExternalLink/>}
>
AGB der StuVe
</Button>
</Alert>
</Accordion.Panel>
</Accordion.Item> </Accordion.Item>
)} )}
{event.additionalAgb && ( {event.additionalAgb && (