implemented modifing of tasks of an event
This commit is contained in:
@@ -6,14 +6,14 @@ import (
|
|||||||
|
|
||||||
type assignments map[string]*string
|
type assignments map[string]*string
|
||||||
|
|
||||||
type assignemntDB struct {
|
type eventAssignmentDB struct {
|
||||||
TaskName string `db:"taskName"`
|
TaskName string `db:"taskName"`
|
||||||
UserName *string `db:"userName"`
|
UserName *string `db:"userName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Event(eventID int) (assignments, error) {
|
func Event(eventID int) (assignments, error) {
|
||||||
// get the assignments from the database
|
// get the assignments from the database
|
||||||
var assignmentRows []assignemntDB
|
var assignmentRows []eventAssignmentDB
|
||||||
|
|
||||||
if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil {
|
if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package events
|
package events
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
"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/db/availabilities"
|
||||||
@@ -18,7 +20,7 @@ type EventWithAvailabilities struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type eventDataDB struct {
|
type eventDataDB struct {
|
||||||
Id int `db:"id" json:"id"`
|
ID int `db:"id" json:"id" validate:"required"`
|
||||||
Date string `db:"date" json:"date" validate:"required"`
|
Date string `db:"date" json:"date" validate:"required"`
|
||||||
Description string `db:"description" json:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,7 @@ 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 assignments 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{
|
||||||
@@ -42,7 +44,7 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
|
|||||||
return EventWithAvailabilities{}, err
|
return EventWithAvailabilities{}, err
|
||||||
|
|
||||||
// get the availabilities
|
// get the availabilities
|
||||||
} else if availabilities, err := availabilities.Event(e.Id); err != nil {
|
} else if availabilities, err := availabilities.Event(e.ID); err != nil {
|
||||||
return EventWithAvailabilities{}, err
|
return EventWithAvailabilities{}, err
|
||||||
} else {
|
} else {
|
||||||
return EventWithAvailabilities{
|
return EventWithAvailabilities{
|
||||||
@@ -53,8 +55,9 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EventCreate struct {
|
type EventCreate struct {
|
||||||
eventDataDB
|
Date string `db:"date" json:"date" validate:"required"`
|
||||||
Tasks []int `json:"tasks" validate:"required,min=1"`
|
Description string `db:"description" json:"description"`
|
||||||
|
Tasks []int `json:"tasks" validate:"required,min=1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Create(event EventCreate) error {
|
func Create(event EventCreate) error {
|
||||||
@@ -91,6 +94,69 @@ func Create(event EventCreate) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventPatch struct {
|
||||||
|
eventDataDB
|
||||||
|
Tasks []int `json:"tasks" validate:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Update(event EventPatch) error {
|
||||||
|
// update the event itself
|
||||||
|
if _, err := db.DB.NamedExec("UPDATE EVENTS SET description = :description, date = :date WHERE id = :id", event); err != nil {
|
||||||
|
return err
|
||||||
|
|
||||||
|
// get the tasks currently assigned to the event
|
||||||
|
} else {
|
||||||
|
type TaskID struct {
|
||||||
|
ID int `db:"taskID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskRows []TaskID
|
||||||
|
|
||||||
|
if err := db.DB.Select(&taskRows, "SELECT taskID FROM USER_ASSIGNMENTS WHERE eventID = ?", event.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
type Task struct {
|
||||||
|
TaskID
|
||||||
|
EventID int `db:"eventID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the rows that need to be deleted
|
||||||
|
deleteRows := []Task{}
|
||||||
|
|
||||||
|
for _, row := range taskRows {
|
||||||
|
if !slices.Contains(event.Tasks, row.ID) {
|
||||||
|
deleteRows = append(deleteRows, Task{TaskID: row, EventID: event.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract the rows that need to be created
|
||||||
|
createRows := []Task{}
|
||||||
|
|
||||||
|
for _, id := range event.Tasks {
|
||||||
|
if !slices.Contains(taskRows, TaskID{ID: id}) {
|
||||||
|
createRows = append(createRows, Task{TaskID: TaskID{ID: id}, EventID: event.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the no longer needed rows
|
||||||
|
if len(deleteRows) > 0 {
|
||||||
|
if _, err := db.DB.NamedExec("DELETE FROM USER_ASSIGNMENTS WHERE eventID = :eventID AND taskID = :taskID", deleteRows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create the new tasks
|
||||||
|
if len(createRows) > 0 {
|
||||||
|
if _, err := db.DB.NamedExec("INSERT INTO USER_ASSIGNMENTS (eventID, taskID) VALUES (:eventID, :taskID)", createRows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func All() ([]eventDataDB, error) {
|
func All() ([]eventDataDB, error) {
|
||||||
var dbRows []eventDataDB
|
var dbRows []eventDataDB
|
||||||
|
|
||||||
@@ -110,7 +176,7 @@ func WithAssignments() ([]EventWithAssignment, error) {
|
|||||||
|
|
||||||
for ii, e := range eventsDB {
|
for ii, e := range eventsDB {
|
||||||
if ev, err := e.Event(); err != nil {
|
if ev, err := e.Event(); err != nil {
|
||||||
logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.Id, err)
|
logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.ID, err)
|
||||||
} else {
|
} else {
|
||||||
events[ii] = ev
|
events[ii] = ev
|
||||||
}
|
}
|
||||||
@@ -129,7 +195,7 @@ func WithAvailabilities() ([]EventWithAvailabilities, error) {
|
|||||||
|
|
||||||
for ii, e := range eventsDB {
|
for ii, e := range eventsDB {
|
||||||
if ev, err := e.EventWithAvailabilities(); err != nil {
|
if ev, err := e.EventWithAvailabilities(); err != nil {
|
||||||
logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.Id, err)
|
logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.ID, err)
|
||||||
} else {
|
} else {
|
||||||
events[ii] = ev
|
events[ii] = ev
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type tasksDB struct {
|
type tasksDB 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"`
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func refresh() {
|
|||||||
tasks := map[int]Task{}
|
tasks := map[int]Task{}
|
||||||
|
|
||||||
for _, a := range tasksRaw {
|
for _, a := range tasksRaw {
|
||||||
tasks[a.Id] = Task{
|
tasks[a.ID] = Task{
|
||||||
Text: a.Text,
|
Text: a.Text,
|
||||||
Disabled: a.Disabled,
|
Disabled: a.Disabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,40 +8,68 @@ import (
|
|||||||
func postEvent(args HandlerArgs) responseMessage {
|
func postEvent(args HandlerArgs) responseMessage {
|
||||||
response := responseMessage{}
|
response := responseMessage{}
|
||||||
|
|
||||||
// write the event
|
// check admin
|
||||||
var body events.EventCreate
|
if !args.User.Admin {
|
||||||
|
response.Status = fiber.StatusForbidden
|
||||||
// try to parse the body
|
|
||||||
if err := args.C.BodyParser(&body); err != nil {
|
|
||||||
response.Status = fiber.StatusBadRequest
|
|
||||||
|
|
||||||
logger.Log().Msgf("can't parse body: %v", err)
|
|
||||||
|
|
||||||
// validate the parsed body
|
|
||||||
} else if err := validate.Struct(body); err != nil {
|
|
||||||
response.Status = fiber.StatusBadRequest
|
|
||||||
|
|
||||||
logger.Log().Msgf("invalid body: %v", err)
|
|
||||||
|
|
||||||
// create the event
|
|
||||||
} else if err := events.Create(body); err != nil {
|
|
||||||
response.Status = fiber.StatusInternalServerError
|
|
||||||
|
|
||||||
logger.Error().Msgf("can't create event: %v", err)
|
|
||||||
} else {
|
} else {
|
||||||
// respond with the new events
|
|
||||||
if events, err := events.WithAssignments(); err != nil {
|
// write the event
|
||||||
|
var body events.EventCreate
|
||||||
|
|
||||||
|
// try to parse the body
|
||||||
|
if err := args.C.BodyParser(&body); err != nil {
|
||||||
|
response.Status = fiber.StatusBadRequest
|
||||||
|
|
||||||
|
logger.Log().Msgf("can't parse body: %v", err)
|
||||||
|
|
||||||
|
// validate the parsed body
|
||||||
|
} else if err := validate.Struct(body); err != nil {
|
||||||
|
response.Status = fiber.StatusBadRequest
|
||||||
|
|
||||||
|
logger.Log().Msgf("invalid body: %v", err)
|
||||||
|
|
||||||
|
// create the event
|
||||||
|
} else if err := events.Create(body); err != nil {
|
||||||
response.Status = fiber.StatusInternalServerError
|
response.Status = fiber.StatusInternalServerError
|
||||||
|
|
||||||
logger.Error().Msgf("can't retrieve events: %v", err)
|
logger.Error().Msgf("can't create event: %v", err)
|
||||||
} else {
|
|
||||||
response.Data = events
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func patchEvent(args HandlerArgs) responseMessage {
|
||||||
|
response := responseMessage{}
|
||||||
|
|
||||||
|
// check admin
|
||||||
|
if !args.User.Admin {
|
||||||
|
response.Status = fiber.StatusForbidden
|
||||||
|
} else {
|
||||||
|
// parse the body
|
||||||
|
var body events.EventPatch
|
||||||
|
|
||||||
|
if err := args.C.BodyParser(&body); err != nil {
|
||||||
|
response.Status = fiber.StatusBadRequest
|
||||||
|
|
||||||
|
logger.Log().Msgf("can't parse body: %v", err)
|
||||||
|
|
||||||
|
// validate the body
|
||||||
|
} else if err := validate.Struct(body); err != nil {
|
||||||
|
response.Status = fiber.StatusBadRequest
|
||||||
|
|
||||||
|
logger.Log().Msgf("ivnalid body: %v", err)
|
||||||
|
|
||||||
|
// update the event
|
||||||
|
} else if err := events.Update(body); err != nil {
|
||||||
|
response.Status = fiber.StatusInternalServerError
|
||||||
|
|
||||||
|
logger.Error().Msgf("updating the event failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
func getEventsAssignments(args HandlerArgs) responseMessage {
|
func getEventsAssignments(args HandlerArgs) responseMessage {
|
||||||
response := responseMessage{}
|
response := responseMessage{}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func init() {
|
|||||||
"GET": app.Get,
|
"GET": app.Get,
|
||||||
"POST": app.Post,
|
"POST": app.Post,
|
||||||
"PATCH": app.Patch,
|
"PATCH": app.Patch,
|
||||||
|
"PUT": app.Put,
|
||||||
"DELETE": app.Delete,
|
"DELETE": app.Delete,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +87,11 @@ func init() {
|
|||||||
"users": postUser,
|
"users": postUser,
|
||||||
},
|
},
|
||||||
"PATCH": {
|
"PATCH": {
|
||||||
"users/password": patchPassword,
|
"users": patchUser,
|
||||||
"users": patchUser,
|
"events": patchEvent,
|
||||||
|
},
|
||||||
|
"PUT": {
|
||||||
|
"users/password": putPassword,
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"event": deleteEvent,
|
"event": deleteEvent,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func postUser(args HandlerArgs) responseMessage {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func patchPassword(args HandlerArgs) responseMessage {
|
func putPassword(args HandlerArgs) responseMessage {
|
||||||
response := responseMessage{}
|
response := responseMessage{}
|
||||||
// parse the body
|
// parse the body
|
||||||
var body users.UserChangePassword
|
var body users.UserChangePassword
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function Account() {
|
|||||||
async function changePassword(e: FormEvent<HTMLFormElement>) {
|
async function changePassword(e: FormEvent<HTMLFormElement>) {
|
||||||
const data = Object.fromEntries(new FormData(e.currentTarget));
|
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) {
|
if (result.ok) {
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -28,9 +28,9 @@ export default function Account() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-center text-4xl">Account</h2>
|
<h2 className="mb-4 text-center text-4xl">Account</h2>
|
||||||
<div>
|
<div>
|
||||||
<Card className="max-w-md">
|
<Card className="mx-auto max-w-md bg-accent-5" shadow="none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h3 className="text-2xl">Change Password</h3>
|
<h3 className="text-2xl">Change Password</h3>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import AddEvent from "@/components/Event/AddEvent";
|
import AddEvent from "@/components/Event/AddEvent";
|
||||||
|
import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent";
|
||||||
import LocalDate from "@/components/LocalDate";
|
import LocalDate from "@/components/LocalDate";
|
||||||
import { apiCall, getTasks } from "@/lib";
|
import { apiCall, getTasks } from "@/lib";
|
||||||
import { EventData } from "@/Zustand";
|
import { EventData } from "@/Zustand";
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
NotAvailable,
|
NotAvailable,
|
||||||
|
Renew,
|
||||||
TrashCan,
|
TrashCan,
|
||||||
} from "@carbon/icons-react";
|
} from "@carbon/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +61,10 @@ function availability2Color(availability?: Availability) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPanel() {
|
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
|
// get the available tasks and craft them into the headers
|
||||||
const headers = useAsyncList({
|
const headers = useAsyncList({
|
||||||
async load() {
|
async load() {
|
||||||
@@ -115,14 +121,14 @@ export default function AdminPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// send a delete request to the backend and close the popup on success
|
// send a delete request to the backend and close the popup on success
|
||||||
async function deleteEvent(eventId: number) {
|
async function sendDeleteEvent() {
|
||||||
const result = await apiCall("DELETE", "event", { id: eventId });
|
if (deleteEvent !== undefined) {
|
||||||
|
const result = await apiCall("DELETE", "event", { id: deleteEvent.id });
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
// store the received events
|
// store the received events
|
||||||
events.reload();
|
events.reload();
|
||||||
|
}
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +147,20 @@ export default function AdminPanel() {
|
|||||||
</LocalDate>
|
</LocalDate>
|
||||||
);
|
);
|
||||||
case "description":
|
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":
|
case "actions":
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ButtonGroup isIconOnly variant="light" size="sm">
|
<ButtonGroup isIconOnly variant="light" size="sm">
|
||||||
<Button>
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setEditEvent(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tooltip content="Edit event">
|
<Tooltip content="Edit event">
|
||||||
<Edit />
|
<Edit />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -159,8 +173,7 @@ export default function AdminPanel() {
|
|||||||
<Button
|
<Button
|
||||||
color="danger"
|
color="danger"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setActiveEvent(event);
|
setDeleteEvent(event);
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip content="Delete event">
|
<Tooltip content="Delete event">
|
||||||
@@ -203,33 +216,6 @@ export default function AdminPanel() {
|
|||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</Dropdown>
|
</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 {
|
} else {
|
||||||
return <NotAvailable className="mx-auto text-foreground-300" />;
|
return <NotAvailable className="mx-auto text-foreground-300" />;
|
||||||
@@ -237,9 +223,16 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [showAddEvent, setShowAddEvent] = useState(false);
|
async function updateEvent(data: EventSubmitData) {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const result = await apiCall("PATCH", "events", undefined, data);
|
||||||
const [activeEvent, setActiveEvent] = useState<EventData | undefined>();
|
|
||||||
|
if (result.ok) {
|
||||||
|
// clear the selected-event to hide the modal
|
||||||
|
setEditEvent(undefined);
|
||||||
|
|
||||||
|
events.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const topContent = (
|
const topContent = (
|
||||||
<div>
|
<div>
|
||||||
@@ -271,7 +264,7 @@ export default function AdminPanel() {
|
|||||||
th: "font-subheadline text-xl text-accent-1 bg-transparent ",
|
th: "font-subheadline text-xl text-accent-1 bg-transparent ",
|
||||||
thead: "[&>tr]:first:!shadow-border",
|
thead: "[&>tr]:first:!shadow-border",
|
||||||
}}
|
}}
|
||||||
className="w-fit"
|
className="w-fit max-w-full"
|
||||||
>
|
>
|
||||||
<TableHeader columns={headers.items}>
|
<TableHeader columns={headers.items}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
@@ -301,45 +294,62 @@ export default function AdminPanel() {
|
|||||||
onSuccess={() => events.reload()}
|
onSuccess={() => events.reload()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeEvent !== undefined ? (
|
<EditEvent
|
||||||
<Modal
|
isOpen={editEvent !== undefined}
|
||||||
isOpen={showDeleteConfirm}
|
onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)}
|
||||||
onOpenChange={setShowDeleteConfirm}
|
onSubmit={updateEvent}
|
||||||
shadow={"none" as "sm"}
|
initialState={editEvent}
|
||||||
backdrop="blur"
|
footer={
|
||||||
className="bg-accent-5"
|
<Button
|
||||||
>
|
color="primary"
|
||||||
<ModalContent>
|
radius="full"
|
||||||
<ModalHeader>
|
startContent={<Renew />}
|
||||||
<h1 className="text-2xl">Confirm event deletion</h1>
|
type="submit"
|
||||||
</ModalHeader>
|
>
|
||||||
<ModalBody>
|
Update
|
||||||
The event{" "}
|
</Button>
|
||||||
<span className="font-numbers text-accent-1">
|
}
|
||||||
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
|
>
|
||||||
{activeEvent.date}
|
Edit Event
|
||||||
</LocalDate>
|
</EditEvent>
|
||||||
</span>{" "}
|
|
||||||
will be deleted.
|
<Modal
|
||||||
</ModalBody>
|
isOpen={!!deleteEvent}
|
||||||
<ModalFooter>
|
onOpenChange={(isOpen) => (!isOpen ? setDeleteEvent(undefined) : null)}
|
||||||
<Button
|
shadow={"none" as "sm"}
|
||||||
startContent={<TrashCan />}
|
backdrop="blur"
|
||||||
color="danger"
|
className="bg-accent-5"
|
||||||
onPress={() => deleteEvent(activeEvent.id)}
|
>
|
||||||
>
|
<ModalContent>
|
||||||
Delete event
|
<ModalHeader>
|
||||||
</Button>
|
<h1 className="text-2xl">Confirm event deletion</h1>
|
||||||
<Button
|
</ModalHeader>
|
||||||
variant="bordered"
|
<ModalBody>
|
||||||
onPress={() => setShowDeleteConfirm(false)}
|
The event{" "}
|
||||||
>
|
<span className="font-numbers text-accent-1">
|
||||||
Cancel
|
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
|
||||||
</Button>
|
{deleteEvent?.date}
|
||||||
</ModalFooter>
|
</LocalDate>
|
||||||
</ModalContent>
|
</span>{" "}
|
||||||
</Modal>
|
will be deleted.
|
||||||
) : null}
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onPress={() => setDeleteEvent(undefined)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startContent={<TrashCan />}
|
||||||
|
color="danger"
|
||||||
|
onPress={() => sendDeleteEvent()}
|
||||||
|
>
|
||||||
|
Delete event
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
import { useEffect, useReducer, useState } from "react";
|
import { Button } from "@nextui-org/react";
|
||||||
import { Add } from "@carbon/icons-react";
|
import EditEvent, { EventSubmitData } from "./EditEvent";
|
||||||
import zustand from "../../Zustand";
|
import { apiCall } from "@/lib";
|
||||||
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
|
import { AddLarge } from "@carbon/icons-react";
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEvent(props: {
|
export default function AddEvent(props: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -35,138 +9,32 @@ export default function AddEvent(props: {
|
|||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
}) {
|
}) {
|
||||||
// initial state for the inputs
|
async function addEvent(data: EventSubmitData) {
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiCall("POST", "events", undefined, data);
|
const result = await apiCall("POST", "events", undefined, data);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
zustand.getState().setEvents(await result.json());
|
|
||||||
|
|
||||||
props.onOpenChange(false);
|
props.onOpenChange(false);
|
||||||
|
|
||||||
props.onSuccess?.();
|
props.onSuccess?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset the state when the modal gets closed
|
|
||||||
useEffect(() => {
|
|
||||||
if (!props.isOpen) {
|
|
||||||
dispatchState({ action: "reset" });
|
|
||||||
}
|
|
||||||
}, [props.isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<EditEvent
|
||||||
isOpen={props.isOpen}
|
{...props}
|
||||||
shadow={"none" as "sm"} // somehow "none" isn't allowed
|
onSubmit={(data) => void addEvent(data)}
|
||||||
onOpenChange={props.onOpenChange}
|
footer={
|
||||||
backdrop="blur"
|
<Button
|
||||||
classNames={{
|
color="primary"
|
||||||
base: "bg-accent-5 ",
|
radius="full"
|
||||||
}}
|
startContent={<AddLarge />}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Form
|
Add Event
|
||||||
validationBehavior="native"
|
</EditEvent>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
202
client/src/components/Event/EditEvent.tsx
Normal file
202
client/src/components/Event/EditEvent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
|
|||||||
import { useLocale } from "@react-aria/i18n";
|
import { useLocale } from "@react-aria/i18n";
|
||||||
|
|
||||||
export default function LocalDate(props: {
|
export default function LocalDate(props: {
|
||||||
children: string;
|
children?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
options: Intl.DateTimeFormatOptions;
|
options: Intl.DateTimeFormatOptions;
|
||||||
}) {
|
}) {
|
||||||
@@ -13,9 +13,11 @@ export default function LocalDate(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={props.className}>
|
<span className={props.className}>
|
||||||
{formatter.format(
|
{props.children !== undefined
|
||||||
parseDateTime(props.children).toDate(getLocalTimeZone()),
|
? formatter.format(
|
||||||
)}
|
parseDateTime(props.children).toDate(getLocalTimeZone()),
|
||||||
|
)
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,32 +9,32 @@ export type APICallResult<T extends object> = Response & {
|
|||||||
export async function apiCall<K extends object>(
|
export async function apiCall<K extends object>(
|
||||||
method: "GET",
|
method: "GET",
|
||||||
api: string,
|
api: string,
|
||||||
params?: QueryParams,
|
query?: QueryParams,
|
||||||
): Promise<APICallResult<K>>;
|
): Promise<APICallResult<K>>;
|
||||||
export async function apiCall<K extends object>(
|
export async function apiCall<K extends object>(
|
||||||
method: "POST" | "PATCH",
|
method: "POST" | "PATCH" | "PUT",
|
||||||
api: string,
|
api: string,
|
||||||
params?: QueryParams,
|
query?: QueryParams,
|
||||||
body?: object,
|
body?: object,
|
||||||
): Promise<APICallResult<K>>;
|
): Promise<APICallResult<K>>;
|
||||||
export async function apiCall<K extends object>(
|
export async function apiCall<K extends object>(
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
api: string,
|
api: string,
|
||||||
params?: QueryParams,
|
query?: QueryParams,
|
||||||
body?: object,
|
body?: object,
|
||||||
): Promise<APICallResult<K>>;
|
): Promise<APICallResult<K>>;
|
||||||
export async function apiCall<K extends object>(
|
export async function apiCall<K extends object>(
|
||||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
||||||
api: string,
|
api: string,
|
||||||
params?: QueryParams,
|
query?: QueryParams,
|
||||||
body?: object,
|
body?: object,
|
||||||
): Promise<APICallResult<K>> {
|
): Promise<APICallResult<K>> {
|
||||||
let url = window.origin + "/api/" + api;
|
let url = window.origin + "/api/" + api;
|
||||||
|
|
||||||
if (params) {
|
if (query) {
|
||||||
const urlsearchparams = new URLSearchParams(
|
const urlsearchparams = new URLSearchParams(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(params).map(([key, value]): [string, string] => {
|
Object.entries(query).map(([key, value]): [string, string] => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return [key, value.toString()];
|
return [key, value.toString()];
|
||||||
} else {
|
} else {
|
||||||
@@ -109,6 +109,6 @@ export async function getTasks(): Promise<Record<number, Task>> {
|
|||||||
|
|
||||||
return tasks;
|
return tasks;
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user