From cc4555f1754b9ff29688ee1ade11c0479ee7c258 Mon Sep 17 00:00:00 2001 From: z1glr Date: Sat, 19 Apr 2025 18:40:13 +0000 Subject: [PATCH] added filter to don't show past events by default --- backend/pkg/db/events/events.go | 16 +- backend/pkg/db/users/User.go | 12 +- backend/pkg/router/events.go | 7 +- backend/pkg/router/user.go | 7 +- client/src/app/assignments/page.tsx | 42 ++- client/src/app/availabilities/page.tsx | 257 ++++++++++++++++++ client/src/app/events/page.tsx | 61 +++-- client/src/app/layout.tsx | 4 + client/src/components/DateEventsSince.tsx | 21 ++ .../components/Event/AvailabilitySelector.tsx | 5 +- client/src/components/FilterPopover.tsx | 22 ++ client/src/lib.ts | 2 +- compose.yaml | 2 + 13 files changed, 406 insertions(+), 52 deletions(-) create mode 100644 client/src/app/availabilities/page.tsx create mode 100644 client/src/components/DateEventsSince.tsx create mode 100644 client/src/components/FilterPopover.tsx diff --git a/backend/pkg/db/events/events.go b/backend/pkg/db/events/events.go index 756be86..ded2651 100644 --- a/backend/pkg/db/events/events.go +++ b/backend/pkg/db/events/events.go @@ -217,10 +217,18 @@ func Update(event EventPatch) error { } } -func All() ([]EventData, error) { +func All(args ...string) ([]EventData, error) { + var since string + + if len(args) > 0 && args[0] != "" { + since = args[0] + } else { + since = "0000-00-00" + } + var dbRows []EventData - if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS ORDER BY date"); err != nil { + if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS WHERE date >= $1 ORDER BY date", since); err != nil { return nil, err } else { return dbRows, nil @@ -246,9 +254,9 @@ func WithAssignments() ([]EventWithAssignments, error) { } } -func WithAvailabilities() ([]EventWithAvailabilities, error) { +func WithAvailabilities(since string) ([]EventWithAvailabilities, error) { // get all events - if eventsDB, err := All(); err != nil { + if eventsDB, err := All(since); err != nil { return nil, err } else { events := make([]EventWithAvailabilities, len(eventsDB)) diff --git a/backend/pkg/db/users/User.go b/backend/pkg/db/users/User.go index 92d85fc..774558e 100644 --- a/backend/pkg/db/users/User.go +++ b/backend/pkg/db/users/User.go @@ -122,10 +122,18 @@ func (u *UserDB) ToUser() (User, error) { } } -func (userName UserName) WithUserAvailability() ([]events.EventWithAssignmentsUserAvailability, error) { +func (userName UserName) WithUserAvailability(args ...string) ([]events.EventWithAssignmentsUserAvailability, error) { + var since string + + if len(args) > 0 && args[0] != "" { + since = args[0] + } else { + since = "0000-00-00" + } + var events []events.EventWithAssignmentsUserAvailability - if err := db.DB.Select(&events, "SELECT EVENTS.eventID, EVENTS.description, EVENTS.date, USER_AVAILABILITIES.availabilityID FROM EVENTS LEFT JOIN USER_AVAILABILITIES ON EVENTS.eventID = USER_AVAILABILITIES.eventID AND USER_AVAILABILITIES.userName = $1 ORDER BY date", userName); err != nil { + if err := db.DB.Select(&events, "SELECT EVENTS.eventID, EVENTS.description, EVENTS.date, USER_AVAILABILITIES.availabilityID FROM EVENTS LEFT JOIN USER_AVAILABILITIES ON EVENTS.eventID = USER_AVAILABILITIES.eventID AND USER_AVAILABILITIES.userName = $1 WHERE EVENTS.date >= $2 ORDER BY date", userName, since); err != nil { return nil, err } else { // get the assignments for every event diff --git a/backend/pkg/router/events.go b/backend/pkg/router/events.go index a741a76..155fbcf 100644 --- a/backend/pkg/router/events.go +++ b/backend/pkg/router/events.go @@ -80,7 +80,10 @@ func (a *Handler) getEventsAssignments() { } func (a *Handler) getEventsAvailabilities() { - if events, err := events.WithAvailabilities(); err != nil { + // get the "since"-query-parameter + since := a.C.Query("since") + + if events, err := events.WithAvailabilities(since); err != nil { a.Status = fiber.StatusInternalServerError logger.Error().Msgf("can't retrieve events with availabilities: %v", err) @@ -91,7 +94,7 @@ func (a *Handler) getEventsAvailabilities() { func (a *Handler) getEventUserAssignmentAvailability() { // retrieve the assignments - if events, err := a.UserName.WithUserAvailability(); err != nil { + if events, err := a.UserName.WithUserAvailability(a.C.Query("since")); err != nil { a.Status = fiber.StatusBadRequest logger.Info().Msgf("getting events with tasks and user-availability failed: %v", err) diff --git a/backend/pkg/router/user.go b/backend/pkg/router/user.go index b08fc88..36a4237 100644 --- a/backend/pkg/router/user.go +++ b/backend/pkg/router/user.go @@ -6,12 +6,7 @@ import ( ) func (a *Handler) getUsers() { - // check admin - if !a.Admin { - a.Status = fiber.StatusForbidden - - logger.Info().Msgf("user is no admin") - } else if users, err := users.Get(); err != nil { + if users, err := users.Get(); err != nil { a.Status = fiber.StatusInternalServerError logger.Error().Msgf("can't get users: %v", err) diff --git a/client/src/app/assignments/page.tsx b/client/src/app/assignments/page.tsx index 159d0a8..635f6a0 100644 --- a/client/src/app/assignments/page.tsx +++ b/client/src/app/assignments/page.tsx @@ -3,7 +3,7 @@ import AddEvent from "@/components/Event/AddEvent"; import EditEvent from "@/components/Event/EditEvent"; import LocalDate from "@/components/LocalDate"; -import { apiCall, getAvailabilities, getTasks } from "@/lib"; +import { apiCall, getAvailabilities, getTasks, QueryParams } from "@/lib"; import { EventData } from "@/Zustand"; import { Add, @@ -16,6 +16,7 @@ import { import { Button, ButtonGroup, + DateValue, Modal, ModalBody, ModalContent, @@ -34,6 +35,9 @@ import { useAsyncList } from "@react-stately/data"; import React, { Key, useEffect, useState } from "react"; import { Availability } from "../admin/(availabilities)/AvailabilityEditor"; import VolunteerSelector from "./VolunteerSelector"; +import { getLocalTimeZone, today } from "@internationalized/date"; +import DateEventsSince from "@/components/DateEventsSince"; +import FilterPopover from "@/components/FilterPopover"; export type EventWithAvailabilities = EventData & { availabilities: Record; @@ -41,6 +45,9 @@ export type EventWithAvailabilities = EventData & { export default function AdminPanel() { const [showAddEvent, setShowAddEvent] = useState(false); + const [sinceDate, setSinceDate] = useState( + today(getLocalTimeZone()), + ); const [editEvent, setEditEvent] = useState(); const [deleteEvent, setDeleteEvent] = useState(); const [availabilityMap, setAvailabilityMap] = useState< @@ -78,9 +85,18 @@ export default function AdminPanel() { // get the individual events const events = useAsyncList({ async load() { + let params: QueryParams | undefined = undefined; + + if (sinceDate) { + params = { + since: sinceDate, + }; + } + const result = await apiCall( "GET", "events/availabilities", + params, ); if (result.ok) { @@ -125,6 +141,9 @@ export default function AdminPanel() { })(); }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void events.reload(), [sinceDate]); + function getAvailabilityById(availabilityID: number): Availability { return availabilityMap[availabilityID]; } @@ -218,14 +237,19 @@ export default function AdminPanel() { } const topContent = ( -
- +
+
+ +
+ + +
); diff --git a/client/src/app/availabilities/page.tsx b/client/src/app/availabilities/page.tsx new file mode 100644 index 0000000..2244350 --- /dev/null +++ b/client/src/app/availabilities/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import LocalDate from "@/components/LocalDate"; +import { apiCall, getAvailabilities, getUsers, QueryParams } from "@/lib"; +import zustand, { BaseEvent } from "@/Zustand"; +import { NotAvailable } from "@carbon/icons-react"; +import { + DateValue, + Table, + TableBody, + TableCell, + TableColumn, + TableColumnProps, + TableHeader, + TableRow, +} from "@heroui/react"; +import { useAsyncList } from "@react-stately/data"; +import React, { Key, useEffect, useState } from "react"; +import { Availability } from "../admin/(availabilities)/AvailabilityEditor"; +import { EventWithAvailabilities } from "../assignments/page"; +import AvailabilityChip from "@/components/AvailabilityChip"; +import AvailabilitySelector from "@/components/Event/AvailabilitySelector"; +import DateEventsSince from "@/components/DateEventsSince"; +import { getLocalTimeZone, today } from "@internationalized/date"; +import FilterPopover from "@/components/FilterPopover"; + +type EventWithUserAvailabilityMap = BaseEvent & { + availabilities: Record; +}; + +export default function Availabilities() { + const [sinceDate, setSinceDate] = useState( + today(getLocalTimeZone()), + ); + const [availabilityMap, setAvailabilityMap] = useState< + Record + >({}); + const user = zustand((state) => state.user); + + // get the users and craft them into the headers + const headers = useAsyncList<{ + key: string | number; + label: string; + align?: string; + }>({ + async load() { + const users = await getUsers(); + + const headers = { + items: [ + { key: "date", label: "Date" }, + { key: "description", label: "Description" }, + { key: user?.userName ?? "me", label: "Me", align: "center" }, + ...users + .filter((eventUser) => eventUser.userName !== user?.userName) + .map((user) => ({ + label: user.userName, + key: user.userName ?? -1, + align: "center", + })), + ], + }; + + return headers; + }, + }); + + // get the individual events + const events = useAsyncList({ + async load() { + let params: QueryParams | undefined = undefined; + + if (sinceDate) { + params = { + since: sinceDate, + }; + } + + const result = await apiCall( + "GET", + "events/availabilities", + params, + ); + + if (result.ok) { + const data = await result.json(); + + // convert the availabilities to a map + const eventAvailabilities: EventWithUserAvailabilityMap[] = data.map( + (event) => { + const availabilities: Record = {}; + Object.entries(event.availabilities).forEach( + ([availability, users]) => { + users.forEach((u) => (availabilities[u] = availability)); + }, + ); + + return { + ...event, + availabilities, + }; + }, + ); + + return { + items: eventAvailabilities, + }; + } else { + return { items: [] }; + } + }, + async sort({ items, sortDescriptor }) { + return { + items: items.sort((a, b) => { + let cmp = 0; + + // if it is the date-column, convert to a date + if (sortDescriptor.column === "date") { + const first = a[sortDescriptor.column]; + const second = b[sortDescriptor.column]; + + cmp = first < second ? -1 : 1; + } + + if (sortDescriptor.direction === "descending") { + cmp *= -1; + } + + return cmp; + }), + }; + }, + }); + + // retrieve the availabilites and store them in a map + useEffect(() => { + (async () => { + setAvailabilityMap( + Object.fromEntries( + (await getAvailabilities()).map((a) => [a.availabilityID, a]), + ), + ); + })(); + }, []); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void events.reload(), [sinceDate]); + + function getAvailabilityById(availabilityID: number): Availability { + return availabilityMap[availabilityID]; + } + + function getKeyValue( + event: EventWithUserAvailabilityMap, + key: Key, + ): React.ReactNode { + switch (key) { + case "date": + return ( + + {event[key]} + + ); + case "description": + return ( +
+ {event[key]} +
+ ); + + case user?.userName ?? "me": + const availability = parseInt(event.availabilities[key as string]); + + return ( + + ); + + default: + if (event.availabilities[key as string] === undefined) { + return ; + } else { + return ( + + {getAvailabilityById( + parseInt(event.availabilities[key as string]), + )} + + ); + } + } + } + + const topContent = ( +
+
+ + + +
+
+ ); + + return ( +
+

Availabilities

+ + tr]:first:!shadow-border", + }} + onSortChange={events.sort} + className="w-fit max-w-full" + > + + {(task) => ( + ["align"]} + > + {task.label} + + )} + + + {(event) => ( + + {(columnKey) => ( + {getKeyValue(event, columnKey)} + )} + + )} + +
+
+ ); +} diff --git a/client/src/app/events/page.tsx b/client/src/app/events/page.tsx index 0e53a48..ac0398b 100644 --- a/client/src/app/events/page.tsx +++ b/client/src/app/events/page.tsx @@ -2,23 +2,25 @@ import AddEvent from "@/components/Event/AddEvent"; import AssigmentTable from "@/components/Event/AssignmentTable"; import Event from "@/components/Event/Event"; -import { apiCall, getAvailabilities, getUserTasks } from "@/lib"; +import { apiCall, getAvailabilities, getUserTasks, QueryParams } from "@/lib"; import zustand, { EventDataWithAvailabilityAvailabilities } from "@/Zustand"; -import { Add, Filter } from "@carbon/icons-react"; +import { Add } from "@carbon/icons-react"; import { Button, Checkbox, CheckboxGroup, - Popover, - PopoverContent, - PopoverTrigger, + DateValue, + Divider, Tab, Tabs, } from "@heroui/react"; import { useAsyncList } from "@react-stately/data"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import AvailabilitySelector from "@/components/Event/AvailabilitySelector"; import AvailabilityTable from "@/components/Event/AvailabilityTable"; +import DateEventsSince from "@/components/DateEventsSince"; +import { getLocalTimeZone, today } from "@internationalized/date"; +import FilterPopover from "@/components/FilterPopover"; const filterValues: { text: string; value: string }[] = [ { @@ -39,14 +41,26 @@ export default function Events() { const [showAddItemDialogue, setShowAddItemDialogue] = useState(false); const [filter, setFilter] = useState("all"); const [contentFilter, setContentFilter] = useState(["description", "tasks"]); + const [sinceDate, setSinceDate] = useState( + today(getLocalTimeZone()), + ); const user = zustand((state) => state.user); const events = useAsyncList({ async load() { + let params: QueryParams | undefined = undefined; + + if (sinceDate) { + params = { + since: sinceDate, + }; + } + const result = await apiCall( "GET", "events/user/assignmentAvailability", + params, ); if (result.ok) { @@ -79,6 +93,9 @@ export default function Events() { }, }); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => void events.reload(), [sinceDate]); + function showEvent(event: EventDataWithAvailabilityAvailabilities): boolean { switch (filter) { case "assigned": @@ -115,27 +132,17 @@ export default function Events() { -
- - - - - - - {filterValues.map((f) => ( - - {f.text} - - ))} - - - -
+ + + {filterValues.map((f) => ( + + {f.text} + + ))} + + + +
diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 27db605..07394e8 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -31,6 +31,10 @@ export default function RootLayout({ text: "Events", href: "/events", }, + { + text: "Availabilities", + href: "/availabilities", + }, { text: "Assignments", href: "/assignments", diff --git a/client/src/components/DateEventsSince.tsx b/client/src/components/DateEventsSince.tsx new file mode 100644 index 0000000..18d22f9 --- /dev/null +++ b/client/src/components/DateEventsSince.tsx @@ -0,0 +1,21 @@ +import { DatePicker, DateValue } from "@heroui/react"; + +export default function DateEventsSince({ + sinceDate, + setSinceDate, + className, +}: { + sinceDate: DateValue | null; + setSinceDate: (value: DateValue | null) => void; + className?: string; +}) { + return ( + + ); +} diff --git a/client/src/components/Event/AvailabilitySelector.tsx b/client/src/components/Event/AvailabilitySelector.tsx index 17c9f97..234cd65 100644 --- a/client/src/components/Event/AvailabilitySelector.tsx +++ b/client/src/components/Event/AvailabilitySelector.tsx @@ -9,11 +9,13 @@ export default function AvailabilitySelector({ event, className, startSelection, + noHeader, onRefresh, }: { event: EventAvailability; className?: string; startSelection?: number; + noHeader?: boolean; onRefresh?: () => void; }) { const [value, setValue] = useState(new Set([])); @@ -55,8 +57,9 @@ export default function AvailabilitySelector({ return (