implemented modifing of tasks of an event

This commit is contained in:
z1glr
2025-01-13 22:22:10 +00:00
parent 0685283007
commit a3c6fd685d
12 changed files with 468 additions and 288 deletions

View File

@@ -19,7 +19,7 @@ export default function Account() {
async function changePassword(e: FormEvent<HTMLFormElement>) {
const data = Object.fromEntries(new FormData(e.currentTarget));
const result = await apiCall("PATCH", "users/password", undefined, data);
const result = await apiCall("PUT", "users/password", undefined, data);
if (result.ok) {
setPassword("");
@@ -28,9 +28,9 @@ export default function Account() {
return (
<>
<h2 className="text-center text-4xl">Account</h2>
<h2 className="mb-4 text-center text-4xl">Account</h2>
<div>
<Card className="max-w-md">
<Card className="mx-auto max-w-md bg-accent-5" shadow="none">
<CardHeader>
<h3 className="text-2xl">Change Password</h3>
</CardHeader>

View File

@@ -1,6 +1,7 @@
"use client";
import AddEvent from "@/components/Event/AddEvent";
import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent";
import LocalDate from "@/components/LocalDate";
import { apiCall, getTasks } from "@/lib";
import { EventData } from "@/Zustand";
@@ -10,6 +11,7 @@ import {
Copy,
Edit,
NotAvailable,
Renew,
TrashCan,
} from "@carbon/icons-react";
import {
@@ -59,6 +61,10 @@ function availability2Color(availability?: Availability) {
}
export default function AdminPanel() {
const [showAddEvent, setShowAddEvent] = useState(false);
const [editEvent, setEditEvent] = useState<EventData | undefined>();
const [deleteEvent, setDeleteEvent] = useState<EventData | undefined>();
// get the available tasks and craft them into the headers
const headers = useAsyncList({
async load() {
@@ -115,14 +121,14 @@ export default function AdminPanel() {
});
// send a delete request to the backend and close the popup on success
async function deleteEvent(eventId: number) {
const result = await apiCall("DELETE", "event", { id: eventId });
async function sendDeleteEvent() {
if (deleteEvent !== undefined) {
const result = await apiCall("DELETE", "event", { id: deleteEvent.id });
if (result.ok) {
// store the received events
events.reload();
setShowDeleteConfirm(false);
if (result.ok) {
// store the received events
events.reload();
}
}
}
@@ -141,12 +147,20 @@ export default function AdminPanel() {
</LocalDate>
);
case "description":
return <span className="whitespace-pre-wrap italic">{event[key]}</span>;
return (
<div className="max-w-32 whitespace-pre-wrap italic">
{event[key]}
</div>
);
case "actions":
return (
<div className="flex justify-end">
<ButtonGroup isIconOnly variant="light" size="sm">
<Button>
<Button
onPress={() => {
setEditEvent(event);
}}
>
<Tooltip content="Edit event">
<Edit />
</Tooltip>
@@ -159,8 +173,7 @@ export default function AdminPanel() {
<Button
color="danger"
onPress={() => {
setActiveEvent(event);
setShowDeleteConfirm(true);
setDeleteEvent(event);
}}
>
<Tooltip content="Delete event">
@@ -203,33 +216,6 @@ export default function AdminPanel() {
)}
</DropdownMenu>
</Dropdown>
// <Select
// aria-label={`User selection for task ${key} and event ${event.date}`}
// variant="underlined"
// fullWidth
// selectedKeys={new Set([event.tasks[key as string] ?? ""])}
// classNames={{
// popoverContent: "w-fit",
// value: "mr-6",
// label: "mr-6",
// }}
// className="[&_*]:overflow-visible"
// >
// {Object.entries(event.availabilities).map(
// ([volunteer, availability]) => (
// <SelectItem
// key={volunteer}
// // color={availability2Color(availability)}
// className={[
// // "text-" + availability2Color(availability),
// // availability2Tailwind(availability),
// ].join(" ")}
// >
// {volunteer} ({availability})
// </SelectItem>
// ),
// )}
// </Select>
);
} else {
return <NotAvailable className="mx-auto text-foreground-300" />;
@@ -237,9 +223,16 @@ export default function AdminPanel() {
}
}
const [showAddEvent, setShowAddEvent] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeEvent, setActiveEvent] = useState<EventData | undefined>();
async function updateEvent(data: EventSubmitData) {
const result = await apiCall("PATCH", "events", undefined, data);
if (result.ok) {
// clear the selected-event to hide the modal
setEditEvent(undefined);
events.reload();
}
}
const topContent = (
<div>
@@ -271,7 +264,7 @@ export default function AdminPanel() {
th: "font-subheadline text-xl text-accent-1 bg-transparent ",
thead: "[&>tr]:first:!shadow-border",
}}
className="w-fit"
className="w-fit max-w-full"
>
<TableHeader columns={headers.items}>
{(task) => (
@@ -301,45 +294,62 @@ export default function AdminPanel() {
onSuccess={() => events.reload()}
/>
{activeEvent !== undefined ? (
<Modal
isOpen={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
shadow={"none" as "sm"}
backdrop="blur"
className="bg-accent-5"
>
<ModalContent>
<ModalHeader>
<h1 className="text-2xl">Confirm event deletion</h1>
</ModalHeader>
<ModalBody>
The event{" "}
<span className="font-numbers text-accent-1">
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
{activeEvent.date}
</LocalDate>
</span>{" "}
will be deleted.
</ModalBody>
<ModalFooter>
<Button
startContent={<TrashCan />}
color="danger"
onPress={() => deleteEvent(activeEvent.id)}
>
Delete event
</Button>
<Button
variant="bordered"
onPress={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
</ModalFooter>
</ModalContent>
</Modal>
) : null}
<EditEvent
isOpen={editEvent !== undefined}
onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)}
onSubmit={updateEvent}
initialState={editEvent}
footer={
<Button
color="primary"
radius="full"
startContent={<Renew />}
type="submit"
>
Update
</Button>
}
>
Edit Event
</EditEvent>
<Modal
isOpen={!!deleteEvent}
onOpenChange={(isOpen) => (!isOpen ? setDeleteEvent(undefined) : null)}
shadow={"none" as "sm"}
backdrop="blur"
className="bg-accent-5"
>
<ModalContent>
<ModalHeader>
<h1 className="text-2xl">Confirm event deletion</h1>
</ModalHeader>
<ModalBody>
The event{" "}
<span className="font-numbers text-accent-1">
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
{deleteEvent?.date}
</LocalDate>
</span>{" "}
will be deleted.
</ModalBody>
<ModalFooter>
<Button
variant="bordered"
onPress={() => setDeleteEvent(undefined)}
>
Cancel
</Button>
<Button
startContent={<TrashCan />}
color="danger"
onPress={() => sendDeleteEvent()}
>
Delete event
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}

View File

@@ -1,33 +1,7 @@
import { useEffect, useReducer, useState } from "react";
import { Add } from "@carbon/icons-react";
import zustand from "../../Zustand";
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
import {
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@nextui-org/react";
import { apiCall, getTasks, Task } from "@/lib";
interface state {
date: ZonedDateTime;
description: string;
tasks: string[];
}
interface dispatchAction {
action: "set" | "reset";
value?: Partial<state>;
}
import { Button } from "@nextui-org/react";
import EditEvent, { EventSubmitData } from "./EditEvent";
import { apiCall } from "@/lib";
import { AddLarge } from "@carbon/icons-react";
export default function AddEvent(props: {
className?: string;
@@ -35,138 +9,32 @@ export default function AddEvent(props: {
onOpenChange: (isOpen: boolean) => void;
onSuccess?: () => void;
}) {
// initial state for the inputs
const initialState: state = {
date: now(getLocalTimeZone()),
description: "",
tasks: [],
};
// handle state dispatches
function reducer(state: state, action: dispatchAction): state {
if (action.action === "reset") {
return initialState;
} else {
return { ...state, ...action.value };
}
}
const [state, dispatchState] = useReducer(reducer, initialState);
const [tasks, setTasks] = useState<Record<number, Task>>({});
// get the available tasks
useEffect(() => {
(async () => {
setTasks(await getTasks());
})();
}, []);
// sends the addEvent request to the backend
async function addEvent() {
const data = {
...state,
tasks: state.tasks.map((task) => parseInt(task)),
date: state.date.toAbsoluteString().slice(0, -1),
};
async function addEvent(data: EventSubmitData) {
const result = await apiCall("POST", "events", undefined, data);
if (result.ok) {
zustand.getState().setEvents(await result.json());
props.onOpenChange(false);
props.onSuccess?.();
}
}
// reset the state when the modal gets closed
useEffect(() => {
if (!props.isOpen) {
dispatchState({ action: "reset" });
}
}, [props.isOpen]);
return (
<Modal
isOpen={props.isOpen}
shadow={"none" as "sm"} // somehow "none" isn't allowed
onOpenChange={props.onOpenChange}
backdrop="blur"
classNames={{
base: "bg-accent-5 ",
}}
<EditEvent
{...props}
onSubmit={(data) => void addEvent(data)}
footer={
<Button
color="primary"
radius="full"
startContent={<AddLarge />}
type="submit"
>
Add
</Button>
}
>
<Form
validationBehavior="native"
onSubmit={(e) => {
e.preventDefault();
void addEvent();
}}
>
<ModalContent>
<ModalHeader>
<h1 className="text-center text-2xl">Add Event</h1>
</ModalHeader>
<ModalBody>
<DatePicker
isRequired
label="Event date"
name="date"
variant="bordered"
hideTimeZone
granularity="minute"
value={state.date}
onChange={(dt) =>
!!dt
? dispatchState({ action: "set", value: { date: dt } })
: null
}
/>
<Textarea
variant="bordered"
placeholder="Description"
name="description"
value={state.description}
onValueChange={(s) =>
dispatchState({ action: "set", value: { description: s } })
}
/>
<CheckboxGroup
value={state.tasks}
name="tasks"
onValueChange={(s) =>
dispatchState({ action: "set", value: { tasks: s } })
}
validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{tasks !== undefined ? (
Object.entries(tasks)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => (
<div key={id}>
<Checkbox value={id}>{task.text}</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
radius="full"
startContent={<Add size={32} />}
type="submit"
>
Add
</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
Add Event
</EditEvent>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useReducer, useState } from "react";
import {
getLocalTimeZone,
now,
parseDateTime,
toZoned,
ZonedDateTime,
} from "@internationalized/date";
import {
Checkbox,
CheckboxGroup,
DatePicker,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@nextui-org/react";
import { getTasks, Task } from "@/lib";
import { EventData } from "@/Zustand";
export interface EventSubmitData {
id: number;
date: string;
description: string;
tasks: number[];
}
interface State {
date: ZonedDateTime;
description: string;
tasks: string[];
}
export default function EditEvent(props: {
children: React.ReactNode;
footer: React.ReactNode;
initialState?: EventData;
className?: string;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onSubmit?: (data: EventSubmitData) => void;
}) {
const [reverseTasksMap, setReverseTasksMap] = useState<
Record<string, string>
>({});
const [state, dispatchState] = useReducer(
dispatchStateHandler,
dispatchStateHandler({} as State, { action: "reset" }),
);
const [tasksMap, setTasksMap] = useState<Record<number, Task>>({});
// initialize the state
function initialState(): State {
if (props.initialState !== undefined && reverseTasksMap !== undefined) {
const { description, date, tasks } = props.initialState;
return {
description,
date: toZoned(parseDateTime(date), getLocalTimeZone()),
tasks: Object.keys(tasks).map((task) => reverseTasksMap[task]),
};
} else {
return {
date: now(getLocalTimeZone()),
description: "",
tasks: [],
};
}
}
// update the state if the initialState-prop changes
useEffect(() => {
if (props.initialState !== undefined) {
dispatchState({ action: "reset" });
}
}, [props.initialState]);
// handle dispatch-calls
function dispatchStateHandler(
state: State,
args: { action: "patch" | "reset"; value?: Partial<State> },
): State {
if (args.action === "reset") {
return initialState();
} else {
return {
...state,
...args.value,
};
}
}
// shortcut for patching the state
function patchState(values: Partial<State>) {
dispatchState({ action: "patch", value: values });
}
// handle state dispatches
// get the available tasks and initialize the state with them
useEffect(() => {
(async () => {
const tasks = await getTasks();
setTasksMap(tasks);
setReverseTasksMap(
Object.fromEntries(
Object.entries(tasks).map(([id, task]) => {
return [task.text, id];
}),
),
);
})();
}, []);
// sends the patch-event-request to the backend
function patchEvent() {
if (props.initialState !== undefined) {
const { description, tasks, date } = state;
const data: EventSubmitData = {
id: props.initialState?.id,
description,
tasks: tasks.map((task) => parseInt(task)),
date: date.toAbsoluteString().slice(0, -1),
};
props.onSubmit?.(data);
}
}
return (
<Modal
isOpen={props.isOpen}
shadow={"none" as "sm"} // somehow "none" isn't allowed
onOpenChange={props.onOpenChange}
backdrop="blur"
classNames={{
base: "bg-accent-5 ",
}}
>
<Form
validationBehavior="native"
onSubmit={(e) => {
e.preventDefault();
void patchEvent();
}}
>
<ModalContent>
<ModalHeader>
<h1 className="text-center text-2xl">{props.children}</h1>
</ModalHeader>
<ModalBody>
<DatePicker
isRequired
label="Event date"
name="date"
variant="bordered"
hideTimeZone
granularity="minute"
value={state.date}
onChange={(date) => (!!date ? patchState({ date }) : null)}
/>
<Textarea
variant="bordered"
placeholder="Description"
name="description"
value={state.description}
onValueChange={(description) => patchState({ description })}
/>
<CheckboxGroup
name="tasks"
value={state.tasks}
onValueChange={(tasks) => patchState({ tasks })}
validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{tasksMap !== undefined ? (
Object.entries(tasksMap)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => (
<div key={id}>
<Checkbox value={id}>{task.text}</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>{props.footer}</ModalFooter>
</ModalContent>
</Form>
</Modal>
);
}

View File

@@ -5,7 +5,7 @@ import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
import { useLocale } from "@react-aria/i18n";
export default function LocalDate(props: {
children: string;
children?: string;
className?: string;
options: Intl.DateTimeFormatOptions;
}) {
@@ -13,9 +13,11 @@ export default function LocalDate(props: {
return (
<span className={props.className}>
{formatter.format(
parseDateTime(props.children).toDate(getLocalTimeZone()),
)}
{props.children !== undefined
? formatter.format(
parseDateTime(props.children).toDate(getLocalTimeZone()),
)
: ""}
</span>
);
}

View File

@@ -9,32 +9,32 @@ export type APICallResult<T extends object> = Response & {
export async function apiCall<K extends object>(
method: "GET",
api: string,
params?: QueryParams,
query?: QueryParams,
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "POST" | "PATCH",
method: "POST" | "PATCH" | "PUT",
api: string,
params?: QueryParams,
query?: QueryParams,
body?: object,
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "DELETE",
api: string,
params?: QueryParams,
query?: QueryParams,
body?: object,
): Promise<APICallResult<K>>;
export async function apiCall<K extends object>(
method: "GET" | "POST" | "PATCH" | "DELETE",
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
api: string,
params?: QueryParams,
query?: QueryParams,
body?: object,
): Promise<APICallResult<K>> {
let url = window.origin + "/api/" + api;
if (params) {
if (query) {
const urlsearchparams = new URLSearchParams(
Object.fromEntries(
Object.entries(params).map(([key, value]): [string, string] => {
Object.entries(query).map(([key, value]): [string, string] => {
if (typeof value !== "string") {
return [key, value.toString()];
} else {
@@ -109,6 +109,6 @@ export async function getTasks(): Promise<Record<number, Task>> {
return tasks;
} else {
return [];
return {};
}
}