started work on task assignment table

This commit is contained in:
z1glr
2025-01-11 12:27:41 +00:00
parent 2a746cf76d
commit 4f203704a6
13 changed files with 226 additions and 157 deletions

View File

@@ -1,36 +0,0 @@
package availabilites
import (
"github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/users"
)
type eventAvailabilites struct {
userName string `db:"userName"`
AvailabilityID int `db:"availabilityID"`
}
func Event(eventID int) (map[string]string, error) {
// get the availabilites for the event
var availabilitesRows []eventAvailabilites
if err := db.DB.Select(&availabilitesRows, "SELECT (userID, availabilityID) FROM USER_AVAILABILITES WHERE eventID = ?", eventID); err != nil {
return nil, err
} else {
// transform the result into a map
eventAvailabilities := map[string]string{}
// get the availabilites
if availabilitesMap, err := Keys(); err != nil {
return nil, err
} else if usersMap, err := users.Get(); err != nil {
return nil, err
} else {
for _, a := range availabilitesRows {
eventAvailabilities[usersMap[a.userName].Name] = availabilitesMap[a.AvailabilityID].Text
}
return eventAvailabilities, nil
}
}
}

View File

@@ -1,4 +1,4 @@
package availabilites package availabilities
import ( import (
"fmt" "fmt"
@@ -8,7 +8,7 @@ import (
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
) )
type availabilitesDB struct { type availabilitiesDB struct {
Id int `db:"id"` Id int `db:"id"`
Text string `db:"text"` Text string `db:"text"`
Disabled bool `db:"disabled"` Disabled bool `db:"disabled"`
@@ -22,31 +22,31 @@ type Availability struct {
var c *cache.Cache var c *cache.Cache
func Keys() (map[int]Availability, error) { func Keys() (map[int]Availability, error) {
if availabilities, hit := c.Get("availabilites"); !hit { if availabilities, hit := c.Get("availabilities"); !hit {
refresh() refresh()
return nil, fmt.Errorf("availabilites not stored cached") return nil, fmt.Errorf("availabilities not stored cached")
} else { } else {
return availabilities.(map[int]Availability), nil return availabilities.(map[int]Availability), nil
} }
} }
func refresh() { func refresh() {
// get the availabilitesRaw from the database // get the availabilitiesRaw from the database
var availabilitesRaw []availabilitesDB var availabilitiesRaw []availabilitiesDB
if err := db.DB.Select(&availabilitesRaw, "SELECT * FROM AVAILABILITIES"); err == nil { if err := db.DB.Select(&availabilitiesRaw, "SELECT * FROM AVAILABILITIES"); err == nil {
// convert the result in a map // convert the result in a map
availabilites := map[int]Availability{} availabilities := map[int]Availability{}
for _, a := range availabilitesRaw { for _, a := range availabilitiesRaw {
availabilites[a.Id] = Availability{ availabilities[a.Id] = Availability{
Text: a.Text, Text: a.Text,
Disabled: a.Disabled, Disabled: a.Disabled,
} }
} }
c.Set("availabilites", availabilites) c.Set("availabilities", availabilities)
} }
} }

View File

@@ -0,0 +1,36 @@
package availabilities
import (
"github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/users"
)
type eventAvailabilities struct {
UserName string `db:"userName"`
AvailabilityID int `db:"availabilityID"`
}
func Event(eventID int) (map[string]string, error) {
// get the availabilities for the event
var availabilitiesRows []eventAvailabilities
if err := db.DB.Select(&availabilitiesRows, "SELECT userName, availabilityID FROM USER_AVAILABILITIES WHERE eventID = ?", eventID); err != nil {
return nil, err
} else {
// transform the result into a map
eventAvailabilities := map[string]string{}
// get the availabilities
if availabilitiesMap, err := Keys(); err != nil {
return nil, err
} else if usersMap, err := users.Get(); err != nil {
return nil, err
} else {
for _, a := range availabilitiesRows {
eventAvailabilities[usersMap[a.UserName].Name] = availabilitiesMap[a.AvailabilityID].Text
}
return eventAvailabilities, nil
}
}
}

View File

@@ -3,6 +3,7 @@ package events
import ( import (
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/assignments" "github.com/johannesbuehl/golunteer/backend/pkg/db/assignments"
"github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities"
"github.com/johannesbuehl/golunteer/backend/pkg/logger" "github.com/johannesbuehl/golunteer/backend/pkg/logger"
) )
@@ -11,6 +12,11 @@ type EventWithAssignment struct {
Tasks map[string]*string `json:"tasks"` Tasks map[string]*string `json:"tasks"`
} }
type EventWithAvailabilities struct {
EventWithAssignment
Availabilities map[string]string `json:"availabilities"`
}
type eventDataDB struct { type eventDataDB struct {
Id int `db:"id" json:"id"` Id int `db:"id" json:"id"`
Date string `db:"date" json:"date" validate:"required"` Date string `db:"date" json:"date" validate:"required"`
@@ -18,18 +24,34 @@ type eventDataDB struct {
} }
// transform the database-entry to an Event // transform the database-entry to an Event
func (e *eventDataDB) Event() (EventWithAssignment, error) { func (e eventDataDB) Event() (EventWithAssignment, error) {
// get the availabilites associated with the event // get the assignments associated with the event
if assignemnts, err := assignments.Event(e.Id); err != nil { if assignemnts, err := assignments.Event(e.Id); err != nil {
return EventWithAssignment{}, err return EventWithAssignment{}, err
} else { } else {
return EventWithAssignment{ return EventWithAssignment{
eventDataDB: *e, eventDataDB: e,
Tasks: assignemnts, Tasks: assignemnts,
}, nil }, nil
} }
} }
func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error) {
// get the event with assignments
if event, err := e.Event(); err != nil {
return EventWithAvailabilities{}, err
// get the availabilities
} else if availabilities, err := availabilities.Event(e.Id); err != nil {
return EventWithAvailabilities{}, err
} else {
return EventWithAvailabilities{
EventWithAssignment: event,
Availabilities: availabilities,
}, nil
}
}
type EventCreate struct { type EventCreate struct {
eventDataDB eventDataDB
Tasks []int `json:"tasks" validate:"required,min=1"` Tasks []int `json:"tasks" validate:"required,min=1"`
@@ -98,6 +120,25 @@ func WithAssignments() ([]EventWithAssignment, error) {
} }
} }
func WithAvailabilities() ([]EventWithAvailabilities, error) {
// get all events
if eventsDB, err := All(); err != nil {
return nil, err
} else {
events := make([]EventWithAvailabilities, len(eventsDB))
for ii, e := range eventsDB {
if ev, err := e.EventWithAvailabilities(); err != nil {
logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.Id, err)
} else {
events[ii] = ev
}
}
return events, nil
}
}
func UserPending(userName string) (int, error) { func UserPending(userName string) (int, error) {
var result struct { var result struct {
Count int `db:"count(*)"` Count int `db:"count(*)"`

View File

@@ -9,10 +9,10 @@ import (
) )
type User struct { type User struct {
Name string `db:"text"` Name string `db:"name"`
Password []byte `db:"password"` Password []byte `db:"password"`
TokenID string `db:"tokenID"` TokenID string `db:"tokenID"`
Admin bool `db:"disabled"` Admin bool `db:"admin"`
} }
var c *cache.Cache var c *cache.Cache
@@ -21,7 +21,7 @@ func Get() (map[string]User, error) {
if users, hit := c.Get("users"); !hit { if users, hit := c.Get("users"); !hit {
refresh() refresh()
return nil, fmt.Errorf("users not stored cached") return nil, fmt.Errorf("users not cached")
} else { } else {
return users.(map[string]User), nil return users.(map[string]User), nil
} }

View File

@@ -56,6 +56,25 @@ func getEventsAssignments(args HandlerArgs) responseMessage {
return response return response
} }
func getEventsAvailabilities(args HandlerArgs) responseMessage {
response := responseMessage{}
// check for admin
if !args.User.Admin {
response.Status = fiber.StatusForbidden
} else {
if events, err := events.WithAvailabilities(); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't retrieve events with availabilities: %v", err)
} else {
response.Data = events
}
}
return response
}
func getEventsUserPending(args HandlerArgs) responseMessage { func getEventsUserPending(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}

View File

@@ -73,8 +73,8 @@ func handleLogin(c *fiber.Ctx) error {
} else { } else {
// password is correct -> generate the JWT // password is correct -> generate the JWT
if jwt, err := config.SignJWT(JWTPayload{ if jwt, err := config.SignJWT(JWTPayload{
UserID: requestBody.Username, UserName: requestBody.Username,
TokenID: result.TokenID, TokenID: result.TokenID,
}); err != nil { }); err != nil {
response.Status = fiber.StatusInternalServerError response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't create JWT: %v", err) logger.Error().Msgf("can't create JWT: %v", err)

View File

@@ -75,9 +75,10 @@ func init() {
// map with the individual registered endpoints // map with the individual registered endpoints
endpoints := map[string]map[string]func(HandlerArgs) responseMessage{ endpoints := map[string]map[string]func(HandlerArgs) responseMessage{
"GET": { "GET": {
"events/assignments": getEventsAssignments, "events/assignments": getEventsAssignments,
"events/user/pending": getEventsUserPending, "events/availabilities": getEventsAvailabilities,
"tasks": getTasks, "events/user/pending": getEventsUserPending,
"tasks": getTasks,
}, },
"POST": {"events": postEvent}, "POST": {"events": postEvent},
"PATCH": {}, "PATCH": {},
@@ -160,8 +161,8 @@ func removeSessionCookie(c *fiber.Ctx) {
// payload of the JSON webtoken // payload of the JSON webtoken
type JWTPayload struct { type JWTPayload struct {
UserID string `json:"userID"` UserName string `json:"userName"`
TokenID string `json:"tokenID"` TokenID string `json:"tokenID"`
} }
// complete JSON webtoken // complete JSON webtoken
@@ -172,7 +173,7 @@ type JWT struct {
// extracts the json webtoken from the request // extracts the json webtoken from the request
// //
// @returns (userID, tokenID, error) // @returns (userName, tokenID, error)
func extractJWT(c *fiber.Ctx) (string, string, error) { func extractJWT(c *fiber.Ctx) (string, string, error) {
// get the session-cookie // get the session-cookie
cookie := c.Cookies("session") cookie := c.Cookies("session")
@@ -191,7 +192,7 @@ func extractJWT(c *fiber.Ctx) (string, string, error) {
// extract the claims from the JWT // extract the claims from the JWT
if claims, ok := token.Claims.(*JWT); ok && token.Valid { if claims, ok := token.Claims.(*JWT); ok && token.Valid {
return claims.CustomClaims.UserID, claims.CustomClaims.TokenID, nil return claims.CustomClaims.UserName, claims.CustomClaims.TokenID, nil
} else { } else {
return "", "", fmt.Errorf("invalid JWT") return "", "", fmt.Errorf("invalid JWT")
} }

View File

@@ -3,6 +3,7 @@
import { DateFormatter as IntlDateFormatter } from "@internationalized/date"; import { DateFormatter as IntlDateFormatter } from "@internationalized/date";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { apiCall } from "./lib";
export type Task = string; export type Task = string;
@@ -24,7 +25,6 @@ interface Zustand {
userName: string; userName: string;
admin: boolean; admin: boolean;
} | null; } | null;
tasks?: Record<number, { text: string; disabled: boolean }>;
setEvents: (events: EventData[]) => void; setEvents: (events: EventData[]) => void;
reset: (zustand?: Partial<Zustand>) => void; reset: (zustand?: Partial<Zustand>) => void;
setPendingEvents: (c: number) => void; setPendingEvents: (c: number) => void;
@@ -58,6 +58,23 @@ const zustand = create<Zustand>()(
), ),
); );
export async function getTasks(): Promise<
Record<number, { text: string; disabled: boolean }>
> {
const result = await apiCall<{ text: string; disabled: boolean }[]>(
"GET",
"tasks",
);
if (result.ok) {
const tasks = await result.json();
return tasks;
} else {
return [];
}
}
export class DateFormatter { export class DateFormatter {
private formatter; private formatter;

View File

@@ -6,24 +6,11 @@ import { useState } from "react";
import AddEvent from "../components/Event/AddEvent"; import AddEvent from "../components/Event/AddEvent";
import zustand from "../Zustand"; import zustand from "../Zustand";
import AssignmentTable from "@/components/Event/AssignmentTable"; import AssignmentTable from "@/components/Event/AssignmentTable";
import { useAsyncList } from "@react-stately/data";
import { apiCall } from "@/lib";
import { Button } from "@nextui-org/react"; import { Button } from "@nextui-org/react";
export default function EventVolunteer() { export default function EventVolunteer() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false); const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
// fetch the events from the server
useAsyncList({
load: async () => {
const data = await apiCall("GET", "events");
return {
items: [],
};
},
});
return ( return (
<div className="relative flex-1"> <div className="relative flex-1">
<h2 className="mb-4 text-center text-4xl">Overview</h2> <h2 className="mb-4 text-center text-4xl">Overview</h2>

View File

@@ -2,7 +2,8 @@
import AddEvent from "@/components/Event/AddEvent"; import AddEvent from "@/components/Event/AddEvent";
import LocalDate from "@/components/LocalDate"; import LocalDate from "@/components/LocalDate";
import zustand, { Availability, EventData, Task, Tasks } from "@/Zustand"; import { apiCall } from "@/lib";
import { Availability, EventData, getTasks, Task } from "@/Zustand";
import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react"; import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react";
import { import {
Button, Button,
@@ -25,6 +26,8 @@ import {
import { useAsyncList } from "@react-stately/data"; import { useAsyncList } from "@react-stately/data";
import React, { Key, useState } from "react"; import React, { Key, useState } from "react";
type EventWithAvailabilities = EventData & { availabilities: string[] };
function availability2Tailwind(availability?: Availability) { function availability2Tailwind(availability?: Availability) {
switch (availability) { switch (availability) {
case "yes": case "yes":
@@ -46,19 +49,38 @@ function availability2Color(availability?: Availability) {
} }
export default function AdminPanel() { export default function AdminPanel() {
const tasks = [ // get the available tasks and craft them into the headers
{ key: "date", label: "Date" }, const headers = useAsyncList({
{ key: "description", label: "Description" },
...Tasks.map((task) => ({ label: task, key: task })),
{ key: "actions", label: "Action" },
];
const list = useAsyncList({
async load() { async load() {
const tasks = await getTasks();
return { return {
items: [...zustand.getState().events], items: [
{ key: "date", label: "Date" },
{ key: "description", label: "Description" },
...Object.entries(tasks)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => ({ label: task.text, key: id })),
{ key: "actions", label: "Action" },
],
}; };
}, },
});
// get the individual events
const events = useAsyncList<EventWithAvailabilities>({
async load() {
const result = await apiCall<EventWithAvailabilities[]>(
"GET",
"events/availabilities",
);
if (result.ok) {
return { items: await result.json() };
} else {
return { items: [] };
}
},
async sort({ items, sortDescriptor }) { async sort({ items, sortDescriptor }) {
return { return {
items: items.sort((a, b) => { items: items.sort((a, b) => {
@@ -82,7 +104,10 @@ export default function AdminPanel() {
}, },
}); });
function getKeyValue(event: EventData, key: Key): React.ReactNode { function getKeyValue(
event: EventWithAvailabilities,
key: Key,
): React.ReactNode {
switch (key) { switch (key) {
case "date": case "date":
return ( return (
@@ -136,17 +161,17 @@ export default function AdminPanel() {
}} }}
className="[&_*]:overflow-visible" className="[&_*]:overflow-visible"
> >
{Object.entries(event.volunteers).map( {Object.entries(event.availabilities).map(
([volunteer, availability]) => ( ([volunteer, availability]) => (
<SelectItem <SelectItem
key={volunteer} key={volunteer}
color={availability2Color(availability)} // color={availability2Color(availability)}
className={[ className={[
"text-" + availability2Color(availability), // "text-" + availability2Color(availability),
availability2Tailwind(availability), // availability2Tailwind(availability),
].join(" ")} ].join(" ")}
> >
{volunteer} {volunteer} ({availability})
</SelectItem> </SelectItem>
), ),
)} )}
@@ -157,7 +182,7 @@ export default function AdminPanel() {
const [showAddEvent, setShowAddEvent] = useState(false); const [showAddEvent, setShowAddEvent] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeEvent, setActiveEvent] = useState(zustand.getState().events[0]); const [activeEvent, setActiveEvent] = useState<EventData | undefined>();
const topContent = ( const topContent = (
<div> <div>
@@ -181,8 +206,8 @@ export default function AdminPanel() {
topContent={topContent} topContent={topContent}
topContentPlacement="outside" topContentPlacement="outside"
isHeaderSticky isHeaderSticky
sortDescriptor={list.sortDescriptor} sortDescriptor={events.sortDescriptor}
onSortChange={list.sort} onSortChange={events.sort}
classNames={{ classNames={{
wrapper: "bg-accent-4", wrapper: "bg-accent-4",
tr: "even:bg-accent-5 ", tr: "even:bg-accent-5 ",
@@ -191,7 +216,7 @@ export default function AdminPanel() {
}} }}
className="w-fit" className="w-fit"
> >
<TableHeader columns={tasks}> <TableHeader columns={headers.items}>
{(task) => ( {(task) => (
<TableColumn <TableColumn
allowsSorting={task.key === "date"} allowsSorting={task.key === "date"}
@@ -202,7 +227,7 @@ export default function AdminPanel() {
</TableColumn> </TableColumn>
)} )}
</TableHeader> </TableHeader>
<TableBody items={list.items} emptyContent={"No events scheduled"}> <TableBody items={events.items} emptyContent={"No events scheduled"}>
{(event) => ( {(event) => (
<TableRow key={event.id}> <TableRow key={event.id}>
{(columnKey) => ( {(columnKey) => (
@@ -215,39 +240,41 @@ export default function AdminPanel() {
<AddEvent isOpen={showAddEvent} onOpenChange={setShowAddEvent} /> <AddEvent isOpen={showAddEvent} onOpenChange={setShowAddEvent} />
<Modal {activeEvent !== undefined ? (
isOpen={showDeleteConfirm} <Modal
onOpenChange={setShowDeleteConfirm} isOpen={showDeleteConfirm}
shadow={"none" as "sm"} onOpenChange={setShowDeleteConfirm}
backdrop="blur" shadow={"none" as "sm"}
className="bg-accent-5" backdrop="blur"
> className="bg-accent-5"
<ModalContent> >
<ModalHeader> <ModalContent>
<h1 className="text-2xl">Confirm event deletion</h1> <ModalHeader>
</ModalHeader> <h1 className="text-2xl">Confirm event deletion</h1>
<ModalBody> </ModalHeader>
The event{" "} <ModalBody>
<span className="font-numbers text-accent-1"> The event{" "}
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}> <span className="font-numbers text-accent-1">
{activeEvent.date} <LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
</LocalDate> {activeEvent.date}
</span>{" "} </LocalDate>
will be deleted. </span>{" "}
</ModalBody> will be deleted.
<ModalFooter> </ModalBody>
<Button startContent={<TrashCan />} color="danger"> <ModalFooter>
Delete event <Button startContent={<TrashCan />} color="danger">
</Button> Delete event
<Button </Button>
variant="bordered" <Button
onPress={() => setShowDeleteConfirm(false)} variant="bordered"
> onPress={() => setShowDeleteConfirm(false)}
Cancel >
</Button> Cancel
</ModalFooter> </Button>
</ModalContent> </ModalFooter>
</Modal> </ModalContent>
</Modal>
) : null}
</div> </div>
); );
} }

View File

@@ -36,18 +36,8 @@ export default function RootLayout({
href: "/assignments", href: "/assignments",
}, },
{ {
text: "Assign Tasks", text: "Admin",
href: "/admin/assign", href: "/admin",
admin: true,
},
{
text: "Users",
href: "/admin/users",
admin: true,
},
{
text: "Configuration",
href: "/admin/config",
admin: true, admin: true,
}, },
]; ];

View File

@@ -1,6 +1,6 @@
import { useEffect, useReducer } from "react"; import { useEffect, useReducer } from "react";
import { Add } from "@carbon/icons-react"; import { Add } from "@carbon/icons-react";
import zustand, { Task } from "../../Zustand"; import zustand, { getTasks, Task } from "../../Zustand";
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date"; import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
import { import {
Button, Button,
@@ -54,20 +54,7 @@ export default function AddEvent(props: {
// get the available tasks // get the available tasks
useEffect(() => { useEffect(() => {
(async () => { void getTasks();
const result = await apiCall<{ text: string; disabled: boolean }[]>(
"GET",
"tasks",
);
if (result.ok) {
const tasks = await result.json();
zustand.setState(() => ({
tasks,
}));
}
})();
}, []); }, []);
// sends the addEvent request to the backend // sends the addEvent request to the backend