fixed assignments view once again

This commit is contained in:
z1glr
2025-01-21 09:41:27 +00:00
parent c9fb212386
commit 67a4001883
29 changed files with 695 additions and 449 deletions

View File

@@ -4,27 +4,19 @@ import (
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
) )
type assignments map[string]*string type EventAssignment struct {
TaskID int `db:"taskID" json:"taskID"`
type eventAssignmentDB struct { TaskName string `db:"taskName" json:"taskName"`
TaskName string `db:"taskName"` UserName *string `db:"userName" json:"userName"`
UserName *string `db:"userName"`
} }
func Event(eventID int) (assignments, error) { func Event(eventID int) ([]EventAssignment, error) {
// get the assignments from the database // get the assignments from the database
var assignmentRows []eventAssignmentDB var assignmentRows []EventAssignment
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, taskID, TASKS.name 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
} else { } else {
// transform the rows into the returned map return assignmentRows, nil
eventAssignments := assignments{}
for _, assignment := range assignmentRows {
eventAssignments[assignment.TaskName] = assignment.UserName
}
return eventAssignments, nil
} }
} }

View File

@@ -10,19 +10,19 @@ type AvailabilityDB struct {
} }
type Availability struct { type Availability struct {
Text string `db:"text" json:"text" validate:"required"` Name string `db:"name" json:"name" validate:"required"`
Enabled bool `db:"enabled" json:"enabled" validate:"required"` Enabled bool `db:"enabled" json:"enabled" validate:"required"`
Color string `db:"color" json:"color" validate:"required"` Color string `db:"color" json:"color" validate:"required"`
} }
func Add(a Availability) error { func Add(a Availability) error {
_, err := db.DB.NamedExec("INSERT INTO AVAILABILITIES (text, color, enabled) VALUES (:text, :color, :enabled)", a) _, err := db.DB.NamedExec("INSERT INTO AVAILABILITIES (name, color, enabled) VALUES (:name, :color, :enabled)", a)
return err return err
} }
func Update(a AvailabilityDB) error { func Update(a AvailabilityDB) error {
_, err := db.DB.NamedExec("UPDATE AVAILABILITIES SET text = :text, color = :color, enabled = :enabled WHERE id = :id", a) _, err := db.DB.NamedExec("UPDATE AVAILABILITIES SET name = :name, color = :color, enabled = :enabled WHERE id = :id", a)
return err return err
} }
@@ -47,7 +47,7 @@ func Keys() (map[int]Availability, error) {
for _, a := range availabilitiesRaw { for _, a := range availabilitiesRaw {
availabilities[a.Id] = Availability{ availabilities[a.Id] = Availability{
Text: a.Text, Name: a.Name,
Enabled: a.Enabled, Enabled: a.Enabled,
Color: a.Color, Color: a.Color,
} }
@@ -56,3 +56,9 @@ func Keys() (map[int]Availability, error) {
return availabilities, nil return availabilities, nil
} }
} }
func Delete(id int) error {
_, err := db.DB.Exec("DELETE FROM AVAILABILITIES WHERE id = $1", id)
return err
}

View File

@@ -24,7 +24,7 @@ func Event(eventID int) (map[string]string, error) {
return nil, err return nil, err
} else { } else {
for _, a := range availabilitiesRows { for _, a := range availabilitiesRows {
eventAvailabilities[a.UserName] = availabilitiesMap[a.AvailabilityID].Text eventAvailabilities[a.UserName] = availabilitiesMap[a.AvailabilityID].Name
} }
return eventAvailabilities, nil return eventAvailabilities, nil

View File

@@ -19,6 +19,8 @@ func init() {
// connect to the database // connect to the database
DB = sqlx.MustOpen("sqlite", config.Database) DB = sqlx.MustOpen("sqlite", config.Database)
DB.MustExec("PRAGMA foreign_keys = ON")
// create the tables if they don't exist // create the tables if they don't exist
if dbSetupInstructions, err := os.ReadFile("setup.sql"); err != nil { if dbSetupInstructions, err := os.ReadFile("setup.sql"); err != nil {
panic("can't read database-setup") panic("can't read database-setup")

View File

@@ -11,7 +11,7 @@ import (
type EventWithAssignment struct { type EventWithAssignment struct {
eventDataDB eventDataDB
Tasks map[string]*string `json:"tasks"` Tasks []assignments.EventAssignment `json:"tasks"`
} }
type EventWithAvailabilities struct { type EventWithAvailabilities struct {
@@ -61,6 +61,7 @@ type EventCreate struct {
} }
func Create(event EventCreate) error { func Create(event EventCreate) error {
// convert the date to utc
if result, err := db.DB.NamedExec("INSERT INTO EVENTS (date, description) VALUES (:date, :description)", event); err != nil { if result, err := db.DB.NamedExec("INSERT INTO EVENTS (date, description) VALUES (:date, :description)", event); err != nil {
return err return err
} else if id, err := result.LastInsertId(); err != nil { } else if id, err := result.LastInsertId(); err != nil {
@@ -160,7 +161,7 @@ func Update(event EventPatch) error {
func All() ([]eventDataDB, error) { func All() ([]eventDataDB, error) {
var dbRows []eventDataDB var dbRows []eventDataDB
if err := db.DB.Select(&dbRows, "SELECT *, DATE_FORMAT(date, '%Y-%m-%dT%H:%i:%s') as date FROM EVENTS"); err != nil { if err := db.DB.Select(&dbRows, "SELECT * FROM EVENTS"); err != nil {
return nil, err return nil, err
} else { } else {
return dbRows, nil return dbRows, nil

View File

@@ -10,7 +10,7 @@ type TaskDB struct {
} }
type Task struct { type Task struct {
Text string `json:"text" db:"text" validate:"required"` Name string `json:"name" db:"name" validate:"required"`
Enabled bool `json:"enabled" db:"enabled" validate:"required"` Enabled bool `json:"enabled" db:"enabled" validate:"required"`
} }
@@ -34,7 +34,7 @@ func GetMap() (map[int]Task, error) {
for _, a := range tasksRaw { for _, a := range tasksRaw {
tasks[a.ID] = Task{ tasks[a.ID] = Task{
Text: a.Text, Name: a.Name,
Enabled: a.Enabled, Enabled: a.Enabled,
} }
} }
@@ -44,13 +44,19 @@ func GetMap() (map[int]Task, error) {
} }
func Add(t Task) error { func Add(t Task) error {
_, err := db.DB.NamedExec("INSERT INTO TASKS (text, enabled) VALUES (:text, :enabled)", &t) _, err := db.DB.NamedExec("INSERT INTO TASKS (name, enabled) VALUES (:name, :enabled)", &t)
return err return err
} }
func Update(t TaskDB) error { func Update(t TaskDB) error {
_, err := db.DB.NamedExec("UPDATE TASKS set text = :text, enabled = :enabled WHERE id = :id", &t) _, err := db.DB.NamedExec("UPDATE TASKS set name = :name, enabled = :enabled WHERE id = :id", &t)
return err
}
func Delete(i int) error {
_, err := db.DB.Exec("DELETE FROM TASKS WHERE id = $1", i)
return err return err
} }

View File

@@ -16,15 +16,13 @@ func getAvailabilities(args HandlerArgs) responseMessage {
return response return response
} else { } else {
response.Data = struct { response.Data = avails
Availabilities []availabilities.AvailabilityDB `json:"availabilities"`
}{Availabilities: avails}
return response return response
} }
} }
func postAvailabilitie(args HandlerArgs) responseMessage { func postAvailability(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}
// check admin // check admin
@@ -65,7 +63,7 @@ func postAvailabilitie(args HandlerArgs) responseMessage {
} }
} }
func patchAvailabilities(args HandlerArgs) responseMessage { func patchAvailabilitiy(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}
// check admin // check admin
@@ -105,3 +103,32 @@ func patchAvailabilities(args HandlerArgs) responseMessage {
} }
} }
} }
func deleteAvailability(args HandlerArgs) responseMessage {
// check admin
if !args.User.Admin {
logger.Warn().Msg("availability-deletion failed: user is no admin")
return responseMessage{
Status: fiber.StatusUnauthorized,
}
// parse the query
} else if taskID := args.C.QueryInt("id", -1); taskID == -1 {
logger.Log().Msg("availability-deletion failed: invalid query: doesn't include \"id\"")
return responseMessage{
Status: fiber.StatusBadRequest,
}
// delete the task from the database
} else if err := availabilities.Delete(taskID); err != nil {
logger.Error().Msgf("availability-deletion failed: can't delete task from database: %v", err)
return responseMessage{
Status: fiber.StatusInternalServerError,
}
} else {
return responseMessage{}
}
}

View File

@@ -118,18 +118,31 @@ func getEventsUserPending(args HandlerArgs) responseMessage {
} }
func deleteEvent(args HandlerArgs) responseMessage { func deleteEvent(args HandlerArgs) responseMessage {
response := responseMessage{}
// check for admin // check for admin
if !args.User.Admin { if !args.User.Admin {
response.Status = fiber.StatusForbidden
logger.Warn().Msg("event-delete failed: user is no admin")
return responseMessage{
Status: fiber.StatusForbidden,
}
// -1 can't be valid // -1 can't be valid
} else if eventId := args.C.QueryInt("id", -1); eventId == -1 { } else if eventId := args.C.QueryInt("id", -1); eventId == -1 {
response.Status = fiber.StatusBadRequest logger.Log().Msgf("event-delete failed: \"id\" is missing in query")
} else if err := events.Delete(eventId); err != nil {
response.Status = fiber.StatusInternalServerError
}
return response return responseMessage{
Status: fiber.StatusBadRequest,
}
} else if err := events.Delete(eventId); err != nil {
logger.Error().Msgf("event-delete failed: can't delete from database: %v", err)
return responseMessage{
Status: fiber.StatusInternalServerError,
}
} else {
logger.Log().Msgf("deleted event with id %d", eventId)
return responseMessage{}
}
} }

View File

@@ -86,20 +86,22 @@ func init() {
"POST": { "POST": {
"events": postEvent, "events": postEvent,
"users": postUser, "users": postUser,
"availabilities": postAvailabilitie, "availabilities": postAvailability,
"tasks": postTask, "tasks": postTask,
}, },
"PATCH": { "PATCH": {
"users": patchUser, "users": patchUser,
"events": patchEvent, "events": patchEvent,
"availabilities": patchAvailabilities, "availabilities": patchAvailabilitiy,
"tasks": patchTask, "tasks": patchTask,
}, },
"PUT": { "PUT": {
"users/password": putPassword, "users/password": putPassword,
}, },
"DELETE": { "DELETE": {
"event": deleteEvent, "event": deleteEvent,
"tasks": deleteTask,
"availabilities": deleteAvailability,
}, },
} }

View File

@@ -6,32 +6,15 @@ import (
) )
func getTasks(args HandlerArgs) responseMessage { func getTasks(args HandlerArgs) responseMessage {
// check wether the "map"-query is given if taskSlice, err := tasks.GetSlice(); err != nil {
if args.C.QueryBool("map") { logger.Error().Msgf("can't get tasks: %v", err)
if tasks, err := tasks.GetMap(); err != nil {
logger.Error().Msgf("can't get tasks: %v", err)
return responseMessage{ return responseMessage{
Status: fiber.StatusInternalServerError, Status: fiber.StatusInternalServerError,
}
} else {
return responseMessage{
Data: tasks,
}
} }
} else { } else {
if taskSlice, err := tasks.GetSlice(); err != nil { return responseMessage{
logger.Error().Msgf("can't get tasks: %v", err) Data: taskSlice,
return responseMessage{
Status: fiber.StatusInternalServerError,
}
} else {
return responseMessage{
Data: struct {
Tasks []tasks.TaskDB `json:"tasks"`
}{Tasks: taskSlice},
}
} }
} }
} }
@@ -115,3 +98,32 @@ func patchTask(args HandlerArgs) responseMessage {
} }
} }
} }
func deleteTask(args HandlerArgs) responseMessage {
// check admin
if !args.User.Admin {
logger.Warn().Msg("task-deletion failed: user is no admin")
return responseMessage{
Status: fiber.StatusUnauthorized,
}
// parse the query
} else if taskID := args.C.QueryInt("id", -1); taskID == -1 {
logger.Log().Msg("task-deletion failed: invalid query: doesn't include \"id\"")
return responseMessage{
Status: fiber.StatusBadRequest,
}
// delete the task from the database
} else if err := tasks.Delete(taskID); err != nil {
logger.Error().Msgf("task-deletion failed: can't delete task from database: %v", err)
return responseMessage{
Status: fiber.StatusInternalServerError,
}
} else {
return responseMessage{}
}
}

View File

@@ -1,19 +1,19 @@
CREATE TABLE IF NOT EXISTS TASKS ( CREATE TABLE IF NOT EXISTS TASKS (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
text varchar(64) NOT NULL, name varchar(64) NOT NULL,
enabled BOOL DEFAULT(true) enabled BOOL DEFAULT 1
); );
CREATE TABLE IF NOT EXISTS AVAILABILITIES ( CREATE TABLE IF NOT EXISTS AVAILABILITIES (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
text varchar(32) NOT NULL, name varchar(32) NOT NULL,
color varchar(7) NOT NULL, color varchar(7) NOT NULL,
enabled BOOL DEFAULT(true) enabled BOOL DEFAULT 1
); );
CREATE TABLE IF NOT EXISTS USERS ( CREATE TABLE IF NOT EXISTS USERS (
name varchar(64) PRIMARY KEY, name varchar(64) PRIMARY KEY,
password binary(60) NOT NULL, password BLOB NOT NULL,
admin BOOL NOT NULL DEFAULT(false), admin BOOL NOT NULL DEFAULT(false),
tokenID varchar(64) NOT NULL, tokenID varchar(64) NOT NULL,
CHECK (length(password) = 60), CHECK (length(password) = 60),

116
client/package-lock.json generated
View File

@@ -244,9 +244,9 @@
} }
}, },
"node_modules/@formatjs/icu-messageformat-parser": { "node_modules/@formatjs/icu-messageformat-parser": {
"version": "2.10.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.10.0.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.0.tgz",
"integrity": "sha512-PDeky6nDAyHYEtmSi2X1PG9YpqE+2BRTJT7JvPix8K8JX1wBWQNao6KcPtmZpttQHUHmzMcd/rne7lFesSzUKQ==", "integrity": "sha512-Hp81uTjjdTk3FLh/dggU5NK7EIsVWc5/ZDWrIldmf2rBuPejuZ13CZ/wpVE2SToyi4EiroPTQ1XJcJuZFIxTtw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.2", "@formatjs/ecma402-abstract": "2.3.2",
@@ -4652,17 +4652,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz",
"integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/type-utils": "8.20.0", "@typescript-eslint/type-utils": "8.21.0",
"@typescript-eslint/utils": "8.20.0", "@typescript-eslint/utils": "8.21.0",
"@typescript-eslint/visitor-keys": "8.20.0", "@typescript-eslint/visitor-keys": "8.21.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -4682,16 +4682,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz",
"integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/types": "8.20.0", "@typescript-eslint/types": "8.21.0",
"@typescript-eslint/typescript-estree": "8.20.0", "@typescript-eslint/typescript-estree": "8.21.0",
"@typescript-eslint/visitor-keys": "8.20.0", "@typescript-eslint/visitor-keys": "8.21.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -4707,14 +4707,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz",
"integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.20.0", "@typescript-eslint/types": "8.21.0",
"@typescript-eslint/visitor-keys": "8.20.0" "@typescript-eslint/visitor-keys": "8.21.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4725,14 +4725,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz",
"integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.20.0", "@typescript-eslint/typescript-estree": "8.21.0",
"@typescript-eslint/utils": "8.20.0", "@typescript-eslint/utils": "8.21.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.0" "ts-api-utils": "^2.0.0"
}, },
@@ -4749,9 +4749,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz",
"integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4763,14 +4763,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz",
"integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.20.0", "@typescript-eslint/types": "8.21.0",
"@typescript-eslint/visitor-keys": "8.20.0", "@typescript-eslint/visitor-keys": "8.21.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@@ -4846,16 +4846,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz",
"integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.20.0", "@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/types": "8.20.0", "@typescript-eslint/types": "8.21.0",
"@typescript-eslint/typescript-estree": "8.20.0" "@typescript-eslint/typescript-estree": "8.21.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4870,13 +4870,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz",
"integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.20.0", "@typescript-eslint/types": "8.21.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@@ -5318,9 +5318,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001692", "version": "1.0.30001695",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
"integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==", "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -6519,9 +6519,9 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "11.18.1", "version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.1.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-EQa8c9lWVOm4zlz14MsBJWr8woq87HsNmsBnQNvcS0hs8uzw6HtGAxZyIU7EGTVpHD1C1n01ufxRyarXcNzpPg==", "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"motion-dom": "^11.18.1", "motion-dom": "^11.18.1",
@@ -6657,9 +6657,9 @@
} }
}, },
"node_modules/get-tsconfig": { "node_modules/get-tsconfig": {
"version": "4.8.1", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.9.0.tgz",
"integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", "integrity": "sha512-52n24W52sIueosRe0XZ8Ex5Yle+WbhfCKnV/gWXpbVR8FXNTfqdKEKUSypKso66VRHTvvcQxL44UTZbJRlCTnw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6938,14 +6938,14 @@
} }
}, },
"node_modules/intl-messageformat": { "node_modules/intl-messageformat": {
"version": "10.7.12", "version": "10.7.14",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.12.tgz", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.14.tgz",
"integrity": "sha512-4HBsPDJ61jZwNikauvm0mcLvs1AfCBbihiqOX2AGs1MX7SA1H0SNKJRSWxpZpToGoNzvoYLsJJ2pURkbEDg+Dw==", "integrity": "sha512-mMGnE4E1otdEutV5vLUdCxRJygHB5ozUBxsPB5qhitewssrS/qGruq9bmvIRkkGsNeK5ZWLfYRld18UHGTIifQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.2", "@formatjs/ecma402-abstract": "2.3.2",
"@formatjs/fast-memoize": "2.2.6", "@formatjs/fast-memoize": "2.2.6",
"@formatjs/icu-messageformat-parser": "2.10.0", "@formatjs/icu-messageformat-parser": "2.11.0",
"tslib": "2" "tslib": "2"
} }
}, },

View File

@@ -2,11 +2,13 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { Task } from "./lib";
import { Availability } from "./app/admin/(availabilities)/AvailabilityEditor";
export interface EventData { export interface EventData {
id: number; id: number;
date: string; date: string;
tasks: Partial<Record<string, string | null>>; tasks: { taskID: number; taskName: string; userName: string | null }[];
description: string; description: string;
} }
@@ -17,6 +19,9 @@ export interface User {
interface Zustand { interface Zustand {
user: User | null; user: User | null;
tasks?: Task[];
availabilities?: Availability[];
patch: (zustand?: Partial<Zustand>) => void;
reset: (zustand?: Partial<Zustand>) => void; reset: (zustand?: Partial<Zustand>) => void;
} }
@@ -26,19 +31,24 @@ const initialState = {
const zustand = create<Zustand>()( const zustand = create<Zustand>()(
persist( persist(
(set) => ({ (set, get) => ({
...initialState, ...initialState,
reset: (newZustand) => reset: (newZustand) => {
console.debug("reset");
set({ set({
...initialState, ...initialState,
...newZustand, ...newZustand,
}), });
},
patch: (patch) => set({ ...get(), ...patch }),
}), }),
{ {
name: "golunteer-storage", name: "golunteer-storage",
partialize: (state) => partialize: (state) =>
Object.fromEntries( Object.fromEntries(
Object.entries(state).filter(([key]) => ["user"].includes(key)), Object.entries(state).filter(([key]) =>
["user", "tasksList", "tasksMap"].includes(key),
),
), ),
}, },
), ),

View File

@@ -33,7 +33,7 @@ export default function Main({ children }: { children: React.ReactNode }) {
const response = await welcomeResult.json(); const response = await welcomeResult.json();
if (response.userName !== undefined && response.userName !== "") { if (response.userName !== undefined && response.userName !== "") {
zustand.getState().reset({ user: response }); zustand.getState().patch({ user: response });
loggedIn = true; loggedIn = true;
} }

View File

@@ -1,10 +1,10 @@
import { color2Tailwind, colors } from "@/components/Colorselector"; import { colors } from "@/components/Colorselector";
import { apiCall } from "@/lib"; import { apiCall, getAvailabilities } from "@/lib";
import { AddLarge, Edit } from "@carbon/icons-react"; import { AddLarge, Edit, TrashCan } from "@carbon/icons-react";
import { import {
Button, Button,
ButtonGroup,
Checkbox, Checkbox,
Chip,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -18,26 +18,20 @@ import { useState } from "react";
import AddAvailability from "./AddAvailability"; import AddAvailability from "./AddAvailability";
import { Availability } from "./AvailabilityEditor"; import { Availability } from "./AvailabilityEditor";
import EditAvailability from "./EditAvailability"; import EditAvailability from "./EditAvailability";
import DeleteConfirmation from "@/components/DeleteConfirmation";
import AvailabilityChip from "@/components/AvailabilityChip";
import zustand from "@/Zustand";
export default function Availabilities() { export default function Availabilities() {
const [showAddAvailability, setShowAddAvailability] = useState(false); const [showAddAvailability, setShowAddAvailability] = useState(false);
const [editAvailability, setEditAvailability] = useState<Availability>(); const [editAvailability, setEditAvailability] = useState<Availability>();
const [deleteAvailability, setDeleteAvailability] = useState<Availability>();
const availabilities = useAsyncList<Availability>({ const availabilities = useAsyncList<Availability>({
async load() { async load() {
const result = await apiCall("GET", "availabilities"); return {
items: await getAvailabilities(),
if (result.ok) { };
const json = await result.json();
return {
items: json.availabilities,
};
} else {
return {
items: [],
};
}
}, },
async sort({ items, sortDescriptor }) { async sort({ items, sortDescriptor }) {
return { return {
@@ -46,7 +40,7 @@ export default function Availabilities() {
switch (sortDescriptor.column) { switch (sortDescriptor.column) {
case "text": case "text":
cmp = a.text.localeCompare(b.text); cmp = a.name.localeCompare(b.name);
break; break;
case "enabled": case "enabled":
if (a.enabled && !b.enabled) { if (a.enabled && !b.enabled) {
@@ -76,6 +70,26 @@ export default function Availabilities() {
}, },
}); });
function reload() {
// clear the availabilites in the zustand
zustand.getState().patch({ availabilities: undefined });
// refresh the availabilites
availabilities.reload();
}
async function sendDeleteAvailability(id: number | undefined) {
if (id !== undefined) {
const result = await apiCall("DELETE", "availabilities", { id });
if (result.ok) {
reload();
setDeleteAvailability(undefined);
}
}
}
const topContent = ( const topContent = (
<> <>
<Button <Button
@@ -101,10 +115,7 @@ export default function Availabilities() {
> >
<TableHeader> <TableHeader>
<TableColumn allowsSorting key="userName"> <TableColumn allowsSorting key="userName">
Text Name
</TableColumn>
<TableColumn allowsSorting key="color" align="center">
Color
</TableColumn> </TableColumn>
<TableColumn allowsSorting key="admin" align="center"> <TableColumn allowsSorting key="admin" align="center">
Enabled Enabled
@@ -115,35 +126,36 @@ export default function Availabilities() {
</TableHeader> </TableHeader>
<TableBody items={availabilities.items}> <TableBody items={availabilities.items}>
{(availability) => ( {(availability) => (
<TableRow key={availability.text}> <TableRow key={availability.name}>
<TableCell
className={`text-${color2Tailwind(availability.color)}`}
>
{availability.text}
</TableCell>
<TableCell> <TableCell>
<Chip <AvailabilityChip availability={availability} />
classNames={{
base: `bg-${color2Tailwind(availability.color)}`,
}}
>
{availability.color}
</Chip>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Checkbox isSelected={availability.enabled} /> <Checkbox isSelected={availability.enabled} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <ButtonGroup>
isIconOnly <Button
variant="light" isIconOnly
size="sm" variant="light"
onPress={() => setEditAvailability(availability)} size="sm"
> onPress={() => setEditAvailability(availability)}
<Tooltip content="Edit availability"> >
<Edit /> <Tooltip content="Edit availability">
</Tooltip> <Edit />
</Button> </Tooltip>
</Button>
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => setDeleteAvailability(availability)}
color="danger"
className="text-danger"
>
<TrashCan />
</Button>
</ButtonGroup>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -153,7 +165,7 @@ export default function Availabilities() {
<AddAvailability <AddAvailability
isOpen={showAddAvailability} isOpen={showAddAvailability}
onOpenChange={setShowAddAvailability} onOpenChange={setShowAddAvailability}
onSuccess={availabilities.reload} onSuccess={reload}
/> />
<EditAvailability <EditAvailability
@@ -162,8 +174,25 @@ export default function Availabilities() {
onOpenChange={(isOpen) => onOpenChange={(isOpen) =>
!isOpen ? setEditAvailability(undefined) : null !isOpen ? setEditAvailability(undefined) : null
} }
onSuccess={availabilities.reload} onSuccess={reload}
/> />
<DeleteConfirmation
isOpen={!!deleteAvailability}
onOpenChange={(isOpen) =>
!isOpen ? setDeleteAvailability(undefined) : null
}
header="Delete Availability"
onDelete={() => sendDeleteAvailability(deleteAvailability?.id)}
>
{!!deleteAvailability ? (
<>
The availability{" "}
<AvailabilityChip availability={deleteAvailability} /> will be
deleted.
</>
) : null}
</DeleteConfirmation>
</div> </div>
); );
} }

View File

@@ -9,10 +9,10 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@heroui/react"; } from "@heroui/react";
import React, { FormEvent, useState } from "react"; import React, { FormEvent, useEffect, useState } from "react";
export interface Availability { export interface Availability {
text: string; name: string;
color: string; color: string;
id: number | undefined; id: number | undefined;
enabled: boolean; enabled: boolean;
@@ -26,13 +26,22 @@ export default function AvailabilityEditor(props: {
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
onSubmit?: (e: Availability) => void; onSubmit?: (e: Availability) => void;
}) { }) {
const [text, setText] = useState(props.value?.text ?? ""); const [name, setName] = useState(props.value?.name ?? "");
const [color, setColor] = useState(props.value?.color ?? "Red"); const [color, setColor] = useState(props.value?.color ?? "Red");
const [enabled, setEnabled] = useState(props.value?.enabled ?? true); const [enabled, setEnabled] = useState(props.value?.enabled ?? true);
// clear the inputs on closing
useEffect(() => {
if (!props.isOpen) {
setName("");
setColor("");
setEnabled(true);
}
}, [props.isOpen]);
function submit(e: FormEvent<HTMLFormElement>) { function submit(e: FormEvent<HTMLFormElement>) {
const formData = Object.fromEntries(new FormData(e.currentTarget)) as { const formData = Object.fromEntries(new FormData(e.currentTarget)) as {
text: string; name: string;
color: string; color: string;
enabled: string; enabled: string;
}; };
@@ -64,10 +73,10 @@ export default function AvailabilityEditor(props: {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Input <Input
value={text} value={name}
onValueChange={setText} onValueChange={setName}
name="text" name="name"
label="Text" label="Name"
isRequired isRequired
variant="bordered" variant="bordered"
/> />

View File

@@ -2,6 +2,7 @@ import { apiCall } from "@/lib";
import AvailabilityEditor, { Availability } from "./AvailabilityEditor"; import AvailabilityEditor, { Availability } from "./AvailabilityEditor";
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import { Renew } from "@carbon/icons-react"; import { Renew } from "@carbon/icons-react";
import AvailabilityChip from "@/components/AvailabilityChip";
export default function EditAvailability(props: { export default function EditAvailability(props: {
value: Availability | undefined; value: Availability | undefined;
@@ -24,9 +25,9 @@ export default function EditAvailability(props: {
header={ header={
<> <>
Edit Availability{" "} Edit Availability{" "}
<span className="font-numbers font-normal italic"> {!!props.value ? (
&quot;{props.value?.text}&quot; <AvailabilityChip availability={props.value} className="ms-4" />
</span> ) : null}
</> </>
} }
footer={ footer={

View File

@@ -1,5 +1,5 @@
import { apiCall } from "@/lib"; import { apiCall, Task } from "@/lib";
import TaskEditor, { Task } from "./TaskEditor"; import TaskEditor from "./TaskEditor";
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import { AddLarge } from "@carbon/icons-react"; import { AddLarge } from "@carbon/icons-react";

View File

@@ -20,12 +20,12 @@ export default function EditTask(props: {
return ( return (
<TaskEditor <TaskEditor
key={props.value?.id} key={props.value?.name}
header={ header={
<> <>
Edit Task{" "} Edit Task{" "}
<span className="font-numbers font-normal italic"> <span className="font-numbers font-normal italic">
&quot;{props.value?.text}&quot; &quot;{props.value?.name}&quot;
</span> </span>
</> </>
} }

View File

@@ -9,7 +9,7 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@heroui/react"; } from "@heroui/react";
import React, { FormEvent, useState } from "react"; import React, { FormEvent, useEffect, useState } from "react";
export default function TaskEditor(props: { export default function TaskEditor(props: {
header: React.ReactNode; header: React.ReactNode;
@@ -19,13 +19,20 @@ export default function TaskEditor(props: {
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
onSubmit?: (e: Task) => void; onSubmit?: (e: Task) => void;
}) { }) {
const [text, setText] = useState(props.value?.text ?? ""); const [name, setName] = useState(props.value?.name ?? "");
const [enabled, setEnabled] = useState(props.value?.enabled ?? true); const [enabled, setEnabled] = useState(props.value?.enabled ?? true);
// clear the inputs on closing
useEffect(() => {
if (!props.isOpen) {
setName("");
setEnabled(true);
}
}, [props.isOpen]);
function submit(e: FormEvent<HTMLFormElement>) { function submit(e: FormEvent<HTMLFormElement>) {
const formData = Object.fromEntries(new FormData(e.currentTarget)) as { const formData = Object.fromEntries(new FormData(e.currentTarget)) as {
text: string; name: string;
color: string;
enabled: string; enabled: string;
}; };
@@ -56,10 +63,10 @@ export default function TaskEditor(props: {
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Input <Input
value={text} value={name}
onValueChange={setText} onValueChange={setName}
name="text" name="name"
label="Text" label="Name"
isRequired isRequired
variant="bordered" variant="bordered"
/> />

View File

@@ -1,7 +1,8 @@
import { apiCall, Task } from "@/lib"; import { apiCall, Task } from "@/lib";
import { AddLarge, Edit } from "@carbon/icons-react"; import { AddLarge, Edit, TrashCan } from "@carbon/icons-react";
import { import {
Button, Button,
ButtonGroup,
Checkbox, Checkbox,
Table, Table,
TableBody, TableBody,
@@ -15,20 +16,23 @@ import { useAsyncList } from "@react-stately/data";
import { useState } from "react"; import { useState } from "react";
import AddTask from "./AddTask"; import AddTask from "./AddTask";
import EditTask from "./EditTask"; import EditTask from "./EditTask";
import DeleteConfirmation from "@/components/DeleteConfirmation";
import zustand from "@/Zustand";
export default function Tasks() { export default function Tasks() {
const [showAddTask, setShowAddTask] = useState(false); const [showAddTask, setShowAddTask] = useState(false);
const [editTask, setEditTask] = useState<Task>(); const [editTask, setEditTask] = useState<Task>();
const [deleteTask, setDeleteTask] = useState<Task>();
const tasks = useAsyncList<Task>({ const tasks = useAsyncList<Task>({
async load() { async load() {
const result = await apiCall("GET", "tasks"); const result = await apiCall("GET", "tasks");
if (result.ok) { if (result.ok) {
const json = await result.json(); const json = (await result.json()) as Task[];
return { return {
items: json.tasks, items: json,
}; };
} else { } else {
return { return {
@@ -43,7 +47,7 @@ export default function Tasks() {
switch (sortDescriptor.column) { switch (sortDescriptor.column) {
case "text": case "text":
cmp = a.text.localeCompare(b.text); cmp = a.name.localeCompare(b.name);
break; break;
case "enabled": case "enabled":
if (a.enabled && !b.enabled) { if (a.enabled && !b.enabled) {
@@ -64,6 +68,25 @@ export default function Tasks() {
}, },
}); });
function reload() {
// clear the zustand
zustand.getState().patch({ tasks: undefined });
// reload the tasks
tasks.reload();
}
async function sendDeleteTask(id: number | undefined) {
if (id !== undefined) {
const result = await apiCall("DELETE", "tasks", { id });
if (result.ok) {
tasks.reload();
setDeleteTask(undefined);
}
}
}
const topContent = ( const topContent = (
<> <>
<Button <Button
@@ -89,7 +112,7 @@ export default function Tasks() {
> >
<TableHeader> <TableHeader>
<TableColumn allowsSorting key="userName"> <TableColumn allowsSorting key="userName">
Text Name
</TableColumn> </TableColumn>
<TableColumn allowsSorting key="admin" align="center"> <TableColumn allowsSorting key="admin" align="center">
Enabled Enabled
@@ -101,21 +124,33 @@ export default function Tasks() {
<TableBody items={tasks.items}> <TableBody items={tasks.items}>
{(task) => ( {(task) => (
<TableRow key={task.id}> <TableRow key={task.id}>
<TableCell>{task.text}</TableCell> <TableCell>{task.name}</TableCell>
<TableCell> <TableCell>
<Checkbox isSelected={task.enabled} /> <Checkbox isSelected={task.enabled} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <ButtonGroup>
isIconOnly <Button
variant="light" isIconOnly
size="sm" variant="light"
onPress={() => setEditTask(task)} size="sm"
> onPress={() => setEditTask(task)}
<Tooltip content="Edit task"> >
<Edit /> <Tooltip content="Edit task">
</Tooltip> <Edit />
</Button> </Tooltip>
</Button>
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => setDeleteTask(task)}
color="danger"
className="text-danger"
>
<TrashCan />
</Button>
</ButtonGroup>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -125,15 +160,32 @@ export default function Tasks() {
<AddTask <AddTask
isOpen={showAddTask} isOpen={showAddTask}
onOpenChange={setShowAddTask} onOpenChange={setShowAddTask}
onSuccess={tasks.reload} onSuccess={reload}
/> />
<EditTask <EditTask
value={editTask} value={editTask}
isOpen={!!editTask} isOpen={!!editTask}
onOpenChange={(isOpen) => (!isOpen ? setEditTask(undefined) : null)} onOpenChange={(isOpen) => (!isOpen ? setEditTask(undefined) : null)}
onSuccess={tasks.reload} onSuccess={reload}
/> />
<DeleteConfirmation
isOpen={!!deleteTask}
onOpenChange={(isOpen) => (!isOpen ? setDeleteTask(undefined) : null)}
header="Delete Task"
onDelete={() => sendDeleteTask(deleteTask?.id)}
>
{!!deleteTask ? (
<>
The task{" "}
<span className="font-numbers text-accent-1">
{deleteTask.name}
</span>{" "}
will be deleted.
</>
) : null}
</DeleteConfirmation>
</div> </div>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import AddEvent from "@/components/Event/AddEvent"; import AddEvent from "@/components/Event/AddEvent";
import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent"; import EditEvent from "@/components/Event/EditEvent";
import LocalDate from "@/components/LocalDate"; import LocalDate from "@/components/LocalDate";
import { apiCall, getTaskMap } from "@/lib"; import { apiCall, getTasks } from "@/lib";
import { EventData } from "@/Zustand"; import { EventData } from "@/Zustand";
import { import {
Add, Add,
@@ -31,6 +31,7 @@ import {
TableBody, TableBody,
TableCell, TableCell,
TableColumn, TableColumn,
TableColumnProps,
TableHeader, TableHeader,
TableRow, TableRow,
Tooltip, Tooltip,
@@ -46,20 +47,30 @@ export default function AdminPanel() {
const [deleteEvent, setDeleteEvent] = 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<{
key: string | number;
label: string;
align?: string;
}>({
async load() { async load() {
const tasks = await getTaskMap(); const tasks = await getTasks();
return { const headers = {
items: [ items: [
{ key: "date", label: "Date" }, { key: "date", label: "Date" },
{ key: "description", label: "Description" }, { key: "description", label: "Description" },
...Object.values(tasks) ...tasks
.filter((task) => task.enabled) .filter((task) => task.enabled)
.map((task) => ({ label: task.text, key: task.text })), .map((task) => ({
{ key: "actions", label: "Action" }, label: task.name,
key: task.id ?? -1,
align: "center",
})),
{ key: "actions", label: "Action", align: "center" },
], ],
}; };
return headers;
}, },
}); });
@@ -72,7 +83,9 @@ export default function AdminPanel() {
); );
if (result.ok) { if (result.ok) {
return { items: await result.json() }; const data = await result.json();
return { items: data };
} else { } else {
return { items: [] }; return { items: [] };
} }
@@ -145,7 +158,7 @@ export default function AdminPanel() {
<Edit /> <Edit />
</Tooltip> </Tooltip>
</Button> </Button>
<Button> <Button onPress={() => alert("implement")}>
<Tooltip content="Duplicate event"> <Tooltip content="Duplicate event">
<Copy /> <Copy />
</Tooltip> </Tooltip>
@@ -165,13 +178,13 @@ export default function AdminPanel() {
); );
default: default:
// only show the selector, if the task is needed for the event // only show the selector, if the task is needed for the event
if (Object.keys(event.tasks).includes(key as string)) { if (event.tasks?.some((t) => t.taskID == key)) {
return ( return (
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger>
{!!event.tasks[key as string] ? ( {!!event.tasks.find((t) => t.taskID === key)?.userName ? (
<Chip onClose={() => alert("implement")}> <Chip onClose={() => alert("implement")}>
{event.tasks[key as string]} {event.tasks.find((t) => t.taskID === key)?.taskName}
</Chip> </Chip>
) : ( ) : (
<Button isIconOnly size="sm" radius="md" variant="flat"> <Button isIconOnly size="sm" radius="md" variant="flat">
@@ -203,17 +216,6 @@ export default function AdminPanel() {
} }
} }
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 = ( const topContent = (
<div> <div>
<Button <Button
@@ -251,7 +253,7 @@ export default function AdminPanel() {
<TableColumn <TableColumn
allowsSorting={task.key === "date"} allowsSorting={task.key === "date"}
key={task.key} key={task.key}
className="" align={task.align as TableColumnProps<string>["align"]}
> >
{task.label} {task.label}
</TableColumn> </TableColumn>
@@ -277,8 +279,11 @@ export default function AdminPanel() {
<EditEvent <EditEvent
isOpen={editEvent !== undefined} isOpen={editEvent !== undefined}
onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)} onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)}
onSubmit={updateEvent} onSuccess={() => {
initialState={editEvent} setEditEvent(undefined);
events.reload();
}}
value={editEvent}
footer={ footer={
<Button <Button
color="primary" color="primary"

View File

@@ -0,0 +1,23 @@
import { Chip, ChipProps } from "@heroui/react";
import { color2Tailwind } from "./Colorselector";
import { Availability } from "@/app/admin/(availabilities)/AvailabilityEditor";
export default function AvailabilityChip({
availability,
className,
}: {
availability: Availability;
className?: string;
classNames?: ChipProps["classNames"];
}) {
return (
<Chip
classNames={{
base: `bg-${color2Tailwind(availability.color)}`,
}}
className={className}
>
{availability.name}
</Chip>
);
}

View File

@@ -0,0 +1,49 @@
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@heroui/react";
import React from "react";
import { TrashCan } from "@carbon/icons-react";
export default function DeleteConfirmation(props: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
children: React.ReactNode;
header: React.ReactNode;
onDelete?: () => void;
}) {
return (
<Modal
isOpen={props.isOpen}
onOpenChange={(isOpen) => {
props.onOpenChange(isOpen);
}}
shadow={"none" as "sm"}
backdrop="blur"
className="bg-accent-5"
>
<ModalContent>
<ModalHeader>
<h1 className="text-2xl">{props.header}</h1>
</ModalHeader>
<ModalBody>{props.children}</ModalBody>
<ModalFooter>
<Button variant="bordered" onPress={() => props.onOpenChange(false)}>
Cancel
</Button>
<Button
startContent={<TrashCan />}
color="danger"
onPress={() => props.onDelete?.()}
>
Delete event
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import EditEvent, { EventSubmitData } from "./EditEvent";
import { apiCall } from "@/lib"; import { apiCall } from "@/lib";
import { AddLarge } from "@carbon/icons-react"; import { AddLarge } from "@carbon/icons-react";
import EventEditor, { EventSubmitData } from "./EventEditor";
export default function AddEvent(props: { export default function AddEvent(props: {
className?: string; className?: string;
@@ -20,8 +20,9 @@ export default function AddEvent(props: {
} }
return ( return (
<EditEvent <EventEditor
{...props} {...props}
header="Add Event"
onSubmit={(data) => void addEvent(data)} onSubmit={(data) => void addEvent(data)}
footer={ footer={
<Button <Button
@@ -33,8 +34,6 @@ export default function AddEvent(props: {
Add Add
</Button> </Button>
} }
> />
Add Event
</EditEvent>
); );
} }

View File

@@ -1,202 +1,35 @@
import React, { useEffect, useReducer, useState } from "react"; import React from "react";
import { import { apiCall } from "@/lib";
getLocalTimeZone,
now,
parseDateTime,
toZoned,
ZonedDateTime,
} from "@internationalized/date";
import {
Checkbox,
CheckboxGroup,
DatePicker,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@heroui/react";
import { getTaskMap, Task } from "@/lib";
import { EventData } from "@/Zustand"; import { EventData } from "@/Zustand";
import EventEditor, { EventSubmitData } from "./EventEditor";
export interface EventSubmitData {
id: number;
date: string;
description: string;
tasks: number[];
}
interface State {
date: ZonedDateTime;
description: string;
tasks: string[];
}
export default function EditEvent(props: { export default function EditEvent(props: {
children: React.ReactNode; children: React.ReactNode;
footer: React.ReactNode; footer: React.ReactNode;
initialState?: EventData; value?: EventData;
className?: string; className?: string;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
onSubmit?: (data: EventSubmitData) => void; onSuccess?: () => 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 getTaskMap();
setTasksMap(tasks);
setReverseTasksMap(
Object.fromEntries(
Object.entries(tasks).map(([id, task]) => {
return [task.text, id];
}),
),
);
})();
}, []);
// sends the patch-event-request to the backend // sends the patch-event-request to the backend
function patchEvent() { async function patchEvent(data: EventSubmitData) {
if (props.initialState !== undefined) { const result = await apiCall("PATCH", "events", undefined, data);
const { description, tasks, date } = state;
const data: EventSubmitData = { if (result.ok) {
id: props.initialState?.id, props.onSuccess?.();
description,
tasks: tasks.map((task) => parseInt(task)),
date: date.toAbsoluteString().slice(0, -1),
};
props.onSubmit?.(data);
} }
} }
return ( return (
<Modal <EventEditor
value={props.value}
key={props.value?.id}
header="Edit Event"
isOpen={props.isOpen} isOpen={props.isOpen}
shadow={"none" as "sm"} // somehow "none" isn't allowed
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
backdrop="blur" footer={props.footer}
classNames={{ onSubmit={patchEvent}
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.enabled)
.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

@@ -0,0 +1,131 @@
import React, { useEffect, useState } from "react";
import {
getLocalTimeZone,
now,
parseAbsoluteToLocal,
} from "@internationalized/date";
import {
Checkbox,
CheckboxGroup,
DatePicker,
DateValue,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@heroui/react";
import zustand, { EventData } from "@/Zustand";
export interface EventSubmitData {
id: number;
date: string;
description: string;
tasks: number[];
}
export default function EventEditor(props: {
header: React.ReactNode;
footer: React.ReactNode;
value?: EventData;
className?: string;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onSubmit?: (data: EventSubmitData) => void;
}) {
const [date, setDate] = useState<DateValue>(
!!props.value?.date
? parseAbsoluteToLocal(props.value?.date)
: now(getLocalTimeZone()),
);
const [description, setDescription] = useState(
props.value?.description ?? "",
);
const [eventTasks, setEventTasks] = useState<string[]>(
props.value?.tasks.map((k) => k.taskID.toString()) ?? [],
);
const tasks = zustand((state) => state.tasks);
function onSubmit() {
if (!!props.onSubmit) {
props.onSubmit({
id: props.value?.id ?? -1,
date: date.toAbsoluteString(),
description,
tasks: eventTasks.map((t) => parseInt(t)),
});
}
}
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();
onSubmit();
}}
>
<ModalContent>
<ModalHeader>
<h1 className="text-center text-2xl">{props.header}</h1>
</ModalHeader>
<ModalBody>
<DatePicker
isRequired
label="Event date"
name="date"
variant="bordered"
hideTimeZone
granularity="minute"
value={date}
onChange={setDate}
/>
<Textarea
variant="bordered"
placeholder="Description"
name="description"
value={description}
onValueChange={setDescription}
/>
<CheckboxGroup
name="tasks"
value={eventTasks}
onValueChange={setEventTasks}
validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{!!tasks ? (
tasks
?.filter((task) => task.enabled)
.map((task) => (
<div key={task.id}>
<Checkbox value={task.id?.toString()}>
{task.name}
</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>{props.footer}</ModalFooter>
</ModalContent>
</Form>
</Modal>
);
}

View File

@@ -1,7 +1,7 @@
"use local"; "use local";
import { DateFormatter } from "@/lib"; import { DateFormatter } from "@/lib";
import { getLocalTimeZone, parseDateTime } from "@internationalized/date"; import { parseAbsoluteToLocal } 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: {
@@ -14,9 +14,7 @@ export default function LocalDate(props: {
return ( return (
<span className={props.className}> <span className={props.className}>
{props.children !== undefined {props.children !== undefined
? formatter.format( ? formatter.format(parseAbsoluteToLocal(props.children).toDate())
parseDateTime(props.children).toDate(getLocalTimeZone()),
)
: ""} : ""}
</span> </span>
); );

View File

@@ -1,4 +1,6 @@
import { DateFormatter as IntlDateFormatter } from "@internationalized/date"; import { DateFormatter as IntlDateFormatter } from "@internationalized/date";
import zustand from "./Zustand";
import { Availability } from "./app/admin/(availabilities)/AvailabilityEditor";
type QueryParams = Record<string, string | { toString(): string }>; type QueryParams = Record<string, string | { toString(): string }>;
@@ -95,18 +97,55 @@ export function vaidatePassword(password: string): string[] {
export interface Task { export interface Task {
id: number | undefined; id: number | undefined;
text: string; name: string;
enabled: boolean; enabled: boolean;
} }
export async function getTaskMap(): Promise<Record<number, Task>> { export async function getTask(name: string): Promise<Task | undefined> {
const result = await apiCall<Task[]>("GET", "tasks", { map: true }); // get the tasks
const tasks = await getTasks();
if (result.ok) { return tasks.find((t) => t.name === name);
const tasks = await result.json(); }
return tasks; export async function getTasks(): Promise<Task[]> {
// check wether it is cached in zustand
const state = zustand.getState();
if (!!state.tasks) {
return state.tasks;
} else { } else {
return {}; const result = await apiCall<Task[]>("GET", "tasks");
if (result.ok) {
const tasks = (await result.json()) as Task[];
state.patch({ tasks: tasks });
return tasks;
} else {
return [];
}
}
}
export async function getAvailabilities(): Promise<Availability[]> {
// check wether it is cached in zustand
const state = zustand.getState();
if (!!state.availabilities) {
return state.availabilities;
} else {
const result = await apiCall<Task[]>("GET", "availabilities");
if (result.ok) {
const tasks = await result.json();
state.patch({ availabilities: tasks });
return tasks;
} else {
return [];
}
} }
} }