update
This commit is contained in:
commit
3b2ae530a5
14
backend/.env
Normal file
14
backend/.env
Normal file
@ -0,0 +1,14 @@
|
||||
PORT=8080
|
||||
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
||||
JWT_SECRET=tegvideo7010!
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_DISPLAY_NAME=Administrator
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_UNIT=Administration
|
||||
ADMIN_GROUP=admin
|
||||
ADMIN_RIGHTS=admin,users:read,users:write
|
||||
ADMIN_TEAM_NAME=Administration
|
||||
ADMIN_TEAM_INITIAL=A
|
||||
ADMIN_TEAM_HREF=#
|
||||
25
backend/cors.go
Normal file
25
backend/cors.go
Normal file
@ -0,0 +1,25 @@
|
||||
// backend\cors.go
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if origin == s.frontendOrigin {
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
561
backend/devices.go
Normal file
561
backend/devices.go
Normal file
@ -0,0 +1,561 @@
|
||||
// backend/devices.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
Comment string `json:"comment"`
|
||||
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeviceShort struct {
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CreateDeviceRequest struct {
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
Comment string `json:"comment"`
|
||||
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
|
||||
}
|
||||
|
||||
type UpdateDeviceRequest = CreateDeviceRequest
|
||||
|
||||
var errDeviceNotFound = errors.New("device does not exist")
|
||||
|
||||
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.handleListDevices(w, r)
|
||||
case http.MethodPost:
|
||||
s.handleCreateDevice(w, r)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.db.Query(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM devices
|
||||
ORDER BY inventory_number ASC
|
||||
`,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not load devices")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
devices := make([]Device, 0)
|
||||
deviceMap := map[string]*Device{}
|
||||
|
||||
for rows.Next() {
|
||||
var device Device
|
||||
|
||||
if err := rows.Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
&device.Manufacturer,
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.Comment,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||
return
|
||||
}
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
|
||||
devices = append(devices, device)
|
||||
deviceMap[device.ID] = &devices[len(devices)-1]
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||
return
|
||||
}
|
||||
|
||||
relationRows, err := s.db.Query(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
device_relations.device_id,
|
||||
related_devices.id,
|
||||
related_devices.inventory_number,
|
||||
related_devices.manufacturer,
|
||||
related_devices.model
|
||||
FROM device_relations
|
||||
INNER JOIN devices AS related_devices
|
||||
ON related_devices.id = device_relations.related_device_id
|
||||
ORDER BY related_devices.inventory_number ASC
|
||||
`,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not load device relations")
|
||||
return
|
||||
}
|
||||
defer relationRows.Close()
|
||||
|
||||
for relationRows.Next() {
|
||||
var deviceID string
|
||||
var relatedDevice DeviceShort
|
||||
|
||||
if err := relationRows.Scan(
|
||||
&deviceID,
|
||||
&relatedDevice.ID,
|
||||
&relatedDevice.InventoryNumber,
|
||||
&relatedDevice.Manufacturer,
|
||||
&relatedDevice.Model,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := deviceMap[deviceID]
|
||||
if exists {
|
||||
device.RelatedDevices = append(device.RelatedDevices, relatedDevice)
|
||||
}
|
||||
}
|
||||
|
||||
if err := relationRows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": devices,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateDeviceRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
normalizeDeviceInput(&input)
|
||||
|
||||
if input.InventoryNumber == "" {
|
||||
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
var device Device
|
||||
|
||||
err = tx.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO devices (
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status,
|
||||
comment
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING
|
||||
id,
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
input.InventoryNumber,
|
||||
input.Manufacturer,
|
||||
input.Model,
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.Comment,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
&device.Manufacturer,
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.Comment,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||
return
|
||||
}
|
||||
|
||||
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
|
||||
if errors.Is(err, errDeviceNotFound) {
|
||||
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusInternalServerError, "Could not assign related device")
|
||||
return
|
||||
}
|
||||
|
||||
changes, err := buildDeviceCreateHistoryChanges(r.Context(), tx, input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||
return
|
||||
}
|
||||
|
||||
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "created", changes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||
return
|
||||
}
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"device": device,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateDeviceRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
normalizeDeviceInput(&input)
|
||||
|
||||
if input.InventoryNumber == "" {
|
||||
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not load device history state")
|
||||
return
|
||||
}
|
||||
|
||||
var device Device
|
||||
|
||||
err = tx.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
UPDATE devices
|
||||
SET
|
||||
inventory_number = $2,
|
||||
manufacturer = $3,
|
||||
model = $4,
|
||||
serial_number = $5,
|
||||
mac_address = $6,
|
||||
location = $7,
|
||||
loan_status = $8,
|
||||
comment = $9,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
id,
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
deviceID,
|
||||
input.InventoryNumber,
|
||||
input.Manufacturer,
|
||||
input.Model,
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.Comment,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
&device.Manufacturer,
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.Comment,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||
return
|
||||
}
|
||||
|
||||
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
|
||||
if errors.Is(err, errDeviceNotFound) {
|
||||
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
writeError(w, http.StatusInternalServerError, "Could not update device relations")
|
||||
return
|
||||
}
|
||||
|
||||
changes, err := buildDeviceUpdateHistoryChanges(r.Context(), tx, oldDevice, oldRelatedDeviceIDs, input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||
return
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||
return
|
||||
}
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"device": device,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
|
||||
var exists bool
|
||||
|
||||
err := tx.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM devices
|
||||
WHERE id = $1
|
||||
)
|
||||
`,
|
||||
deviceID,
|
||||
).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return errDeviceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDeviceInput(input *CreateDeviceRequest) {
|
||||
input.InventoryNumber = strings.TrimSpace(input.InventoryNumber)
|
||||
input.Manufacturer = strings.TrimSpace(input.Manufacturer)
|
||||
input.Model = strings.TrimSpace(input.Model)
|
||||
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||
input.Location = strings.TrimSpace(input.Location)
|
||||
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
|
||||
input.Comment = strings.TrimSpace(input.Comment)
|
||||
|
||||
if input.LoanStatus == "" {
|
||||
input.LoanStatus = "Verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error {
|
||||
_, err := tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
DELETE FROM device_relations
|
||||
WHERE device_id = $1 OR related_device_id = $1
|
||||
`,
|
||||
deviceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, relatedDeviceID := range relatedDeviceIDs {
|
||||
relatedDeviceID = strings.TrimSpace(relatedDeviceID)
|
||||
|
||||
if relatedDeviceID == "" || relatedDeviceID == deviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[relatedDeviceID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[relatedDeviceID] = true
|
||||
|
||||
if err := ensureDeviceExists(ctx, tx, relatedDeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := assignRelatedDevice(ctx, tx, deviceID, relatedDeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := assignRelatedDevice(ctx, tx, relatedDeviceID, deviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignRelatedDevice(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceID string) error {
|
||||
_, err := tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO device_relations (
|
||||
device_id,
|
||||
related_device_id
|
||||
)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (device_id, related_device_id) DO NOTHING
|
||||
`,
|
||||
deviceID,
|
||||
relatedDeviceID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type deviceTransaction interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
25
backend/go.mod
Normal file
25
backend/go.mod
Normal file
@ -0,0 +1,25 @@
|
||||
module main
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgconn v1.14.3
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
43
backend/go.sum
Normal file
43
backend/go.sum
Normal file
@ -0,0 +1,43 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
377
backend/history.go
Normal file
377
backend/history.go
Normal file
@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type DeviceHistoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
UserID string `json:"userId"`
|
||||
UserDisplayName string `json:"userDisplayName"`
|
||||
Action string `json:"action"`
|
||||
Changes []DeviceHistoryChange `json:"changes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DeviceHistoryChange struct {
|
||||
Field string `json:"field"`
|
||||
Label string `json:"label"`
|
||||
OldValue string `json:"oldValue"`
|
||||
NewValue string `json:"newValue"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
h.id,
|
||||
h.device_id,
|
||||
COALESCE(h.user_id::TEXT, ''),
|
||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||
h.action,
|
||||
h.changes,
|
||||
h.created_at
|
||||
FROM device_history h
|
||||
LEFT JOIN users u ON u.id = h.user_id
|
||||
WHERE h.device_id = $1
|
||||
ORDER BY h.created_at DESC
|
||||
`,
|
||||
deviceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not load device history")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
history := []DeviceHistoryEntry{}
|
||||
|
||||
for rows.Next() {
|
||||
var entry DeviceHistoryEntry
|
||||
var changesRaw []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&entry.ID,
|
||||
&entry.DeviceID,
|
||||
&entry.UserID,
|
||||
&entry.UserDisplayName,
|
||||
&entry.Action,
|
||||
&changesRaw,
|
||||
&entry.CreatedAt,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||
return
|
||||
}
|
||||
|
||||
if len(changesRaw) > 0 {
|
||||
if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not parse device history")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
history = append(history, entry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"history": history,
|
||||
})
|
||||
}
|
||||
|
||||
func userFromContext(ctx context.Context) (User, bool) {
|
||||
user, ok := ctx.Value(userContextKey).(User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func recordDeviceHistory(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
deviceID string,
|
||||
user User,
|
||||
action string,
|
||||
changes []DeviceHistoryChange,
|
||||
) error {
|
||||
changesJSON, err := json.Marshal(changes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO device_history (
|
||||
device_id,
|
||||
user_id,
|
||||
action,
|
||||
changes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4::JSONB)
|
||||
`,
|
||||
deviceID,
|
||||
user.ID,
|
||||
action,
|
||||
string(changesJSON),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Device, []string, error) {
|
||||
var device Device
|
||||
|
||||
err := tx.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM devices
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
deviceID,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
&device.Manufacturer,
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.Comment,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return device, nil, err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT related_device_id
|
||||
FROM device_relations
|
||||
WHERE device_id = $1
|
||||
ORDER BY related_device_id ASC
|
||||
`,
|
||||
deviceID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return device, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
relatedDeviceIDs := []string{}
|
||||
|
||||
for rows.Next() {
|
||||
var relatedDeviceID string
|
||||
|
||||
if err := rows.Scan(&relatedDeviceID); err != nil {
|
||||
return device, nil, err
|
||||
}
|
||||
|
||||
relatedDeviceIDs = append(relatedDeviceIDs, relatedDeviceID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return device, nil, err
|
||||
}
|
||||
|
||||
return device, relatedDeviceIDs, nil
|
||||
}
|
||||
|
||||
func buildDeviceCreateHistoryChanges(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
input CreateDeviceRequest,
|
||||
) ([]DeviceHistoryChange, error) {
|
||||
changes := []DeviceHistoryChange{}
|
||||
|
||||
changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", "", input.InventoryNumber)
|
||||
changes = appendHistoryChange(changes, "manufacturer", "Hersteller", "", input.Manufacturer)
|
||||
changes = appendHistoryChange(changes, "model", "Modell", "", input.Model)
|
||||
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber)
|
||||
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress)
|
||||
changes = appendHistoryChange(changes, "location", "Standort", "", input.Location)
|
||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
|
||||
changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment)
|
||||
|
||||
relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changes = appendHistoryChange(changes, "relatedDeviceIds", "Zugeordnete Geräte / Zubehör", "", relatedDevices)
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func buildDeviceUpdateHistoryChanges(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
oldDevice Device,
|
||||
oldRelatedDeviceIDs []string,
|
||||
input UpdateDeviceRequest,
|
||||
) ([]DeviceHistoryChange, error) {
|
||||
changes := []DeviceHistoryChange{}
|
||||
|
||||
changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", oldDevice.InventoryNumber, input.InventoryNumber)
|
||||
changes = appendHistoryChange(changes, "manufacturer", "Hersteller", oldDevice.Manufacturer, input.Manufacturer)
|
||||
changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model)
|
||||
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber)
|
||||
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress)
|
||||
changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location)
|
||||
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
||||
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
||||
|
||||
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changes = appendHistoryChange(
|
||||
changes,
|
||||
"relatedDeviceIds",
|
||||
"Zugeordnete Geräte / Zubehör",
|
||||
oldRelatedDevices,
|
||||
newRelatedDevices,
|
||||
)
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func appendHistoryChange(
|
||||
changes []DeviceHistoryChange,
|
||||
field string,
|
||||
label string,
|
||||
oldValue string,
|
||||
newValue string,
|
||||
) []DeviceHistoryChange {
|
||||
oldValue = strings.TrimSpace(oldValue)
|
||||
newValue = strings.TrimSpace(newValue)
|
||||
|
||||
if oldValue == newValue {
|
||||
return changes
|
||||
}
|
||||
|
||||
return append(changes, DeviceHistoryChange{
|
||||
Field: field,
|
||||
Label: label,
|
||||
OldValue: displayHistoryValue(oldValue),
|
||||
NewValue: displayHistoryValue(newValue),
|
||||
})
|
||||
}
|
||||
|
||||
func displayHistoryValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []string) (string, error) {
|
||||
normalizedDeviceIDs := normalizeDeviceIDs(deviceIDs)
|
||||
|
||||
if len(normalizedDeviceIDs) == 0 {
|
||||
return "-", nil
|
||||
}
|
||||
|
||||
labels := []string{}
|
||||
|
||||
for _, deviceID := range normalizedDeviceIDs {
|
||||
var label string
|
||||
|
||||
err := tx.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT TRIM(CONCAT_WS(' ', inventory_number, NULLIF(manufacturer, ''), NULLIF(model, '')))
|
||||
FROM devices
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
deviceID,
|
||||
).Scan(&label)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errDeviceNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
label = strings.TrimSpace(label)
|
||||
|
||||
if label == "" {
|
||||
label = deviceID
|
||||
}
|
||||
|
||||
labels = append(labels, label)
|
||||
}
|
||||
|
||||
return strings.Join(labels, ", "), nil
|
||||
}
|
||||
|
||||
func normalizeDeviceIDs(deviceIDs []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
|
||||
for _, deviceID := range deviceIDs {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
|
||||
if deviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[deviceID] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[deviceID] = true
|
||||
result = append(result, deviceID)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
76
backend/main.go
Normal file
76
backend/main.go
Normal file
@ -0,0 +1,76 @@
|
||||
// backend\main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks")
|
||||
}
|
||||
|
||||
port := env("PORT", "8080")
|
||||
databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable")
|
||||
jwtSecret := env("JWT_SECRET", "dev-secret-change-me")
|
||||
frontendOrigin := env("FRONTEND_ORIGIN", "http://localhost:5173")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
server := NewServer(db, jwtSecret, frontendOrigin)
|
||||
|
||||
log.Printf("Backend läuft auf http://localhost:%s", port)
|
||||
|
||||
if err := http.ListenAndServe(":"+port, server.Routes()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, target any) error {
|
||||
return json.NewDecoder(r.Body).Decode(target)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := os.Getenv(key)
|
||||
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
23
backend/routes.go
Normal file
23
backend/routes.go
Normal file
@ -0,0 +1,23 @@
|
||||
// backend\routes.go
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
|
||||
mux.HandleFunc("POST /auth/register", s.handleRegister)
|
||||
mux.HandleFunc("POST /auth/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /auth/logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
|
||||
|
||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||
|
||||
return s.cors(mux)
|
||||
}
|
||||
461
backend/server.go
Normal file
461
backend/server.go
Normal file
@ -0,0 +1,461 @@
|
||||
// backend\server.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
jwtSecret []byte
|
||||
frontendOrigin string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
Teams []Team `json:"teams"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Initial string `json:"initial"`
|
||||
Href string `json:"href"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Unit string `json:"unit"`
|
||||
Group string `json:"group"`
|
||||
Rights []string `json:"rights"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
frontendOrigin: frontendOrigin,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var input RegisterRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
username := normalizeUsername(input.Username)
|
||||
email := normalizeEmail(input.Email)
|
||||
|
||||
if username == "" || email == "" || len(input.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "Benutzername, E-Mail und Passwort mit mindestens 8 Zeichen sind erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(input.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
group := strings.TrimSpace(input.Group)
|
||||
if group == "" {
|
||||
group = "user"
|
||||
}
|
||||
|
||||
rights := input.Rights
|
||||
if len(rights) == 0 {
|
||||
rights = []string{"user"}
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not hash password")
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
|
||||
err = s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO users (
|
||||
username,
|
||||
display_name,
|
||||
email,
|
||||
password_hash,
|
||||
avatar,
|
||||
unit,
|
||||
user_group,
|
||||
rights
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING
|
||||
id,
|
||||
COALESCE(username, ''),
|
||||
COALESCE(display_name, ''),
|
||||
email,
|
||||
COALESCE(avatar, ''),
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
COALESCE(rights, '{}'::TEXT[])
|
||||
`,
|
||||
username,
|
||||
displayName,
|
||||
email,
|
||||
string(passwordHash),
|
||||
strings.TrimSpace(input.Avatar),
|
||||
strings.TrimSpace(input.Unit),
|
||||
group,
|
||||
rights,
|
||||
).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.DisplayName,
|
||||
&user.Email,
|
||||
&user.Avatar,
|
||||
&user.Unit,
|
||||
&user.Group,
|
||||
&user.Rights,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "User already exists")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.setSessionCookie(w, user); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
identifier := strings.ToLower(strings.TrimSpace(input.Identifier))
|
||||
|
||||
if identifier == "" || input.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort sind erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
user, passwordHash, err := s.getUserByIdentifier(r.Context(), identifier)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Login failed")
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.setSessionCookie(w, user); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: false,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Logged out",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(userContextKey).(User)
|
||||
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error {
|
||||
now := time.Now()
|
||||
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.ID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
tokenString, err := token.SignedString(s.jwtSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: tokenString,
|
||||
Path: "/",
|
||||
MaxAge: 60 * 60 * 24,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
|
||||
// In Produktion auf true setzen, wenn HTTPS aktiv ist.
|
||||
Secure: false,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(token.Method.Alg()), []byte(jwt.SigningMethodHS256.Alg())) != 1 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.getUserByID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (User, string, error) {
|
||||
var user User
|
||||
var passwordHash string
|
||||
|
||||
err := s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(username, ''),
|
||||
COALESCE(display_name, ''),
|
||||
email,
|
||||
password_hash,
|
||||
COALESCE(avatar, ''),
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
COALESCE(rights, '{}'::TEXT[])
|
||||
FROM users
|
||||
WHERE lower(email) = $1 OR lower(username) = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
identifier,
|
||||
).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.DisplayName,
|
||||
&user.Email,
|
||||
&passwordHash,
|
||||
&user.Avatar,
|
||||
&user.Unit,
|
||||
&user.Group,
|
||||
&user.Rights,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return user, passwordHash, err
|
||||
}
|
||||
|
||||
teams, err := s.getTeamsForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return user, passwordHash, err
|
||||
}
|
||||
|
||||
user.Teams = teams
|
||||
|
||||
return user, passwordHash, nil
|
||||
}
|
||||
|
||||
func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
||||
var user User
|
||||
|
||||
err := s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(username, ''),
|
||||
COALESCE(display_name, ''),
|
||||
email,
|
||||
COALESCE(avatar, ''),
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, ''),
|
||||
COALESCE(rights, '{}'::TEXT[])
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
userID,
|
||||
).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.DisplayName,
|
||||
&user.Email,
|
||||
&user.Avatar,
|
||||
&user.Unit,
|
||||
&user.Group,
|
||||
&user.Rights,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
teams, err := s.getTeamsForUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
user.Teams = teams
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func normalizeUsername(username string) string {
|
||||
return strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
func (s *Server) getTeamsForUser(ctx context.Context, userID string) ([]Team, error) {
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.initial,
|
||||
t.href
|
||||
FROM teams t
|
||||
INNER JOIN user_teams ut ON ut.team_id = t.id
|
||||
WHERE ut.user_id = $1
|
||||
ORDER BY t.name ASC
|
||||
`,
|
||||
userID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
teams := []Team{}
|
||||
|
||||
for rows.Next() {
|
||||
var team Team
|
||||
|
||||
if err := rows.Scan(&team.ID, &team.Name, &team.Initial, &team.Href); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
team.Current = false
|
||||
teams = append(teams, team)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
550
backend/setup/main.go
Normal file
550
backend/setup/main.go
Normal file
@ -0,0 +1,550 @@
|
||||
// backend\setup\main.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
reset := flag.Bool("reset", false, "Datenbank komplett löschen und neu anlegen")
|
||||
flag.Parse()
|
||||
|
||||
if err := godotenv.Load(".env", "backend/.env"); err != nil {
|
||||
log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks")
|
||||
}
|
||||
|
||||
databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable")
|
||||
|
||||
parsedURL, err := url.Parse(databaseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
databaseName := strings.TrimPrefix(parsedURL.Path, "/")
|
||||
if databaseName == "" {
|
||||
log.Fatal("DATABASE_URL enthält keinen Datenbanknamen")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if *reset {
|
||||
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := createDatabaseIfNotExists(ctx, parsedURL, databaseName); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := createSchema(ctx, db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
password, created, err := createAdminIfNotExists(ctx, db)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
printAdminResult(password, created)
|
||||
}
|
||||
|
||||
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
|
||||
if databaseName == "postgres" || databaseName == "template0" || databaseName == "template1" {
|
||||
return fmt.Errorf("datenbank %q darf nicht per -reset gelöscht werden", databaseName)
|
||||
}
|
||||
|
||||
maintenanceURL := *parsedURL
|
||||
maintenanceURL.Path = "/postgres"
|
||||
|
||||
db, err := pgxpool.New(ctx, maintenanceURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var exists bool
|
||||
|
||||
err = db.QueryRow(
|
||||
ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||
databaseName,
|
||||
).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
fmt.Printf("Datenbank %q existiert nicht, Reset übersprungen\n", databaseName)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
|
||||
|
||||
_, err = db.Exec(
|
||||
ctx,
|
||||
`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1
|
||||
AND pid <> pg_backend_pid()
|
||||
`,
|
||||
databaseName,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdentifier(databaseName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Datenbank %q wurde gelöscht\n", databaseName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDatabaseIfNotExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
|
||||
maintenanceURL := *parsedURL
|
||||
maintenanceURL.Path = "/postgres"
|
||||
|
||||
db, err := pgxpool.New(ctx, maintenanceURL.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var exists bool
|
||||
|
||||
err = db.QueryRow(
|
||||
ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||
databaseName,
|
||||
).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
fmt.Printf("Datenbank %q existiert bereits\n", databaseName)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, `CREATE DATABASE `+quoteIdentifier(databaseName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Datenbank %q wurde erstellt\n", databaseName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
queries := []string{
|
||||
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
username TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
|
||||
avatar TEXT,
|
||||
unit TEXT,
|
||||
user_group TEXT,
|
||||
rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
initial TEXT NOT NULL,
|
||||
href TEXT NOT NULL DEFAULT '#',
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS user_teams (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (user_id, team_id)
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
inventory_number TEXT NOT NULL,
|
||||
manufacturer TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
serial_number TEXT NOT NULL DEFAULT '',
|
||||
mac_address TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS device_relations (
|
||||
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (device_id, related_device_id),
|
||||
CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id)
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS device_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
action TEXT NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
|
||||
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Tabellen und Indizes wurden erstellt oder existieren bereits")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool, error) {
|
||||
adminUsername := env("ADMIN_USERNAME", "admin")
|
||||
adminDisplayName := env("ADMIN_DISPLAY_NAME", "Administrator")
|
||||
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
|
||||
adminAvatar := env("ADMIN_AVATAR", "")
|
||||
adminUnit := env("ADMIN_UNIT", "Administration")
|
||||
adminGroup := env("ADMIN_GROUP", "admin")
|
||||
adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write"))
|
||||
|
||||
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
|
||||
adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A")
|
||||
adminTeamHref := env("ADMIN_TEAM_HREF", "#")
|
||||
|
||||
teamID, err := ensureTeam(ctx, db, adminTeamName, adminTeamInitial, adminTeamHref)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var existingUserID string
|
||||
|
||||
err = db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE username = $1 OR email = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
adminUsername,
|
||||
adminEmail,
|
||||
).Scan(&existingUserID)
|
||||
|
||||
if err == nil {
|
||||
if err := assignUserToTeam(ctx, db, existingUserID, teamID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
password, err := generatePassword(24)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var userID string
|
||||
|
||||
err = db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO users (
|
||||
username,
|
||||
display_name,
|
||||
email,
|
||||
password_hash,
|
||||
avatar,
|
||||
unit,
|
||||
user_group,
|
||||
rights
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::TEXT[])
|
||||
RETURNING id
|
||||
`,
|
||||
adminUsername,
|
||||
adminDisplayName,
|
||||
adminEmail,
|
||||
string(passwordHash),
|
||||
adminAvatar,
|
||||
adminUnit,
|
||||
adminGroup,
|
||||
toPostgresTextArray(adminRights),
|
||||
).Scan(&userID)
|
||||
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if err := assignUserToTeam(ctx, db, userID, teamID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return password, true, nil
|
||||
}
|
||||
|
||||
func ensureTeam(ctx context.Context, db *pgxpool.Pool, name string, initial string, href string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
initial = strings.TrimSpace(initial)
|
||||
href = strings.TrimSpace(href)
|
||||
|
||||
if name == "" {
|
||||
name = "Administration"
|
||||
}
|
||||
|
||||
if initial == "" {
|
||||
initial = strings.ToUpper(string([]rune(name)[0]))
|
||||
}
|
||||
|
||||
if href == "" {
|
||||
href = "#"
|
||||
}
|
||||
|
||||
var teamID string
|
||||
|
||||
err := db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO teams (name, initial, href)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (name)
|
||||
DO UPDATE SET
|
||||
initial = EXCLUDED.initial,
|
||||
href = EXCLUDED.href,
|
||||
updated_at = now()
|
||||
RETURNING id
|
||||
`,
|
||||
name,
|
||||
initial,
|
||||
href,
|
||||
).Scan(&teamID)
|
||||
|
||||
return teamID, err
|
||||
}
|
||||
|
||||
func assignUserToTeam(ctx context.Context, db *pgxpool.Pool, userID string, teamID string) error {
|
||||
_, err := db.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO user_teams (user_id, team_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, team_id) DO NOTHING
|
||||
`,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func generatePassword(length int) (string, error) {
|
||||
randomBytes := make([]byte, length)
|
||||
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
password := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||
|
||||
if len(password) > length {
|
||||
password = password[:length]
|
||||
}
|
||||
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func printAdminResult(password string, created bool) {
|
||||
adminUsername := env("ADMIN_USERNAME", "admin")
|
||||
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
|
||||
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
if created {
|
||||
fmt.Println("=================================================")
|
||||
fmt.Println("Admin-User wurde erstellt")
|
||||
fmt.Println("=================================================")
|
||||
fmt.Printf("Benutzername: %s\n", adminUsername)
|
||||
fmt.Printf("E-Mail: %s\n", adminEmail)
|
||||
fmt.Printf("Team: %s\n", adminTeamName)
|
||||
fmt.Printf("Passwort: %s\n", password)
|
||||
fmt.Println("=================================================")
|
||||
fmt.Println("Bitte Passwort sicher speichern. Es wird nicht erneut angezeigt.")
|
||||
fmt.Println("")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("=================================================")
|
||||
fmt.Println("Admin-User existiert bereits")
|
||||
fmt.Println("=================================================")
|
||||
fmt.Printf("Benutzername: %s\n", adminUsername)
|
||||
fmt.Printf("E-Mail: %s\n", adminEmail)
|
||||
fmt.Printf("Team: %s\n", adminTeamName)
|
||||
fmt.Println("Passwort: wird nicht angezeigt, da nur der Hash gespeichert wird")
|
||||
fmt.Println("=================================================")
|
||||
fmt.Println("")
|
||||
}
|
||||
|
||||
func toPostgresTextArray(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
escaped := make([]string, 0, len(values))
|
||||
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||
|
||||
escaped = append(escaped, `"`+value+`"`)
|
||||
}
|
||||
|
||||
if len(escaped) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
return "{" + strings.Join(escaped, ",") + "}"
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func quoteIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := os.Getenv(key)
|
||||
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html class="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>teg</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3348
frontend/package-lock.json
generated
Normal file
3348
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "teg",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.10",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router": "^7.15.0",
|
||||
"tailwindcss": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
1
frontend/src/App.css
Normal file
1
frontend/src/App.css
Normal file
@ -0,0 +1 @@
|
||||
/* frontend\src\App.css */
|
||||
130
frontend/src/App.tsx
Normal file
130
frontend/src/App.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
// frontend\src\App.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import LoginForm from './components/LoginForm'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import DevicesPage from './pages/DevicesPage'
|
||||
import IncidentsPage from './pages/IncidentsPage'
|
||||
import CalendarPage from './pages/CalendarPage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import type { Team, User } from './components/types'
|
||||
import './App.css'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUser(data.user)
|
||||
} catch {
|
||||
setUser(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
setIsLoggingIn(true)
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
|
||||
const identifier = String(formData.get('identifier'))
|
||||
const password = String(formData.get('password'))
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
alert(errorData?.error ?? 'Login fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUser(data.user)
|
||||
} catch {
|
||||
alert('Backend ist nicht erreichbar')
|
||||
} finally {
|
||||
setIsLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
} finally {
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<LoginForm
|
||||
onSubmit={handleLogin}
|
||||
submitText="Anmelden"
|
||||
isSubmitting={isLoggingIn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout user={user} onLogout={handleLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage username={user.username} />} />
|
||||
<Route path="/geraete" element={<DevicesPage />} />
|
||||
<Route path="/einsaetze" element={<IncidentsPage />} />
|
||||
<Route path="/kalender" element={<CalendarPage />} />
|
||||
<Route path="/dokumente" element={<DocumentsPage />} />
|
||||
<Route path="/berichte" element={<ReportsPage />} />
|
||||
<Route path="/einstellungen/*" element={<SettingsPage user={user} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
38
frontend/src/components/AppLayout.tsx
Normal file
38
frontend/src/components/AppLayout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// frontend\src\components\AppLayout.tsx
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import Sidebar from './Sidebar'
|
||||
import Topbar from './Topbar'
|
||||
import type { User } from './types'
|
||||
|
||||
type AppLayoutProps = {
|
||||
user: User
|
||||
onLogout: () => void | Promise<void>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function AppLayout({ user, onLogout, children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Sidebar
|
||||
user={user}
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
|
||||
<div className="lg:pl-72">
|
||||
<Topbar
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
onOpenSidebar={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
frontend/src/components/Button.tsx
Normal file
200
frontend/src/components/Button.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
// frontend\src\components\ui\Button.tsx
|
||||
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'soft'
|
||||
type Size = 'xs' | 'sm' | 'md' | 'lg'
|
||||
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
|
||||
|
||||
export type ButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> & {
|
||||
children: ReactNode
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
color?: Color
|
||||
rounded?: 'sm' | 'md' | 'full'
|
||||
leadingIcon?: ReactNode
|
||||
trailingIcon?: ReactNode
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 ' +
|
||||
'disabled:cursor-not-allowed disabled:saturate-0 disabled:opacity-60 disabled:hover:brightness-100'
|
||||
|
||||
const disabledPrimary =
|
||||
'disabled:!bg-gray-300 disabled:!text-gray-600 disabled:shadow-none disabled:hover:!bg-gray-300 ' +
|
||||
'dark:disabled:!bg-white/10 dark:disabled:!text-white/40 dark:disabled:hover:!bg-white/10'
|
||||
|
||||
const disabledSecondary =
|
||||
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
|
||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5'
|
||||
|
||||
const disabledSoft =
|
||||
'disabled:bg-gray-100 disabled:text-gray-500 disabled:inset-ring-gray-200 disabled:shadow-none disabled:hover:bg-gray-100 ' +
|
||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:hover:bg-white/5'
|
||||
|
||||
const roundedMap = {
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
} as const
|
||||
|
||||
const sizeMap: Record<Size, string> = {
|
||||
xs: 'px-2 py-1 text-xs',
|
||||
sm: 'px-2.5 py-1.5 text-sm',
|
||||
md: 'px-3 py-2 text-sm',
|
||||
lg: 'px-3.5 py-2.5 text-sm',
|
||||
}
|
||||
|
||||
const colorMap: Record<Color, Record<Variant, string>> = {
|
||||
indigo: {
|
||||
primary:
|
||||
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' +
|
||||
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' +
|
||||
disabledPrimary,
|
||||
secondary:
|
||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' +
|
||||
disabledSecondary,
|
||||
soft:
|
||||
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/20 hover:inset-ring-indigo-500/25 ' +
|
||||
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' +
|
||||
disabledSoft,
|
||||
},
|
||||
|
||||
blue: {
|
||||
primary:
|
||||
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-500 focus-visible:outline-blue-600 ' +
|
||||
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' +
|
||||
disabledPrimary,
|
||||
secondary:
|
||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' +
|
||||
disabledSecondary,
|
||||
soft:
|
||||
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/20 hover:inset-ring-blue-500/25 ' +
|
||||
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' +
|
||||
disabledSoft,
|
||||
},
|
||||
|
||||
emerald: {
|
||||
primary:
|
||||
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-500 focus-visible:outline-emerald-600 ' +
|
||||
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' +
|
||||
disabledPrimary,
|
||||
secondary:
|
||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' +
|
||||
disabledSecondary,
|
||||
soft:
|
||||
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/20 hover:inset-ring-emerald-500/25 ' +
|
||||
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' +
|
||||
disabledSoft,
|
||||
},
|
||||
|
||||
red: {
|
||||
primary:
|
||||
'!bg-red-600 !text-white shadow-sm hover:!bg-red-500 focus-visible:outline-red-600 ' +
|
||||
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' +
|
||||
disabledPrimary,
|
||||
secondary:
|
||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' +
|
||||
disabledSecondary,
|
||||
soft:
|
||||
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/20 hover:inset-ring-red-500/25 ' +
|
||||
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' +
|
||||
disabledSoft,
|
||||
},
|
||||
|
||||
amber: {
|
||||
primary:
|
||||
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-400 focus-visible:outline-amber-500 ' +
|
||||
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' +
|
||||
disabledPrimary,
|
||||
secondary:
|
||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' +
|
||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' +
|
||||
disabledSecondary,
|
||||
soft:
|
||||
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/18 hover:inset-ring-amber-500/25 ' +
|
||||
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/25 ' +
|
||||
disabledSoft,
|
||||
},
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M22 12a10 10 0 0 1-10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
color = 'indigo',
|
||||
size = 'md',
|
||||
rounded = 'md',
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
isLoading = false,
|
||||
disabled,
|
||||
className,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const iconGap = leadingIcon || trailingIcon || isLoading ? 'gap-x-1.5' : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
base,
|
||||
roundedMap[rounded],
|
||||
sizeMap[size],
|
||||
colorMap[color][variant],
|
||||
iconGap,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="-ml-0.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
) : (
|
||||
leadingIcon && <span className="-ml-0.5">{leadingIcon}</span>
|
||||
)}
|
||||
|
||||
<span>{children}</span>
|
||||
|
||||
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/ButtonGroup.tsx
Normal file
116
frontend/src/components/ButtonGroup.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
// components/ui/ButtonGroup.tsx
|
||||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
export type ButtonGroupItem = {
|
||||
id: string
|
||||
label?: ReactNode
|
||||
icon?: ReactNode
|
||||
srLabel?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type ButtonGroupProps = {
|
||||
items: ButtonGroupItem[]
|
||||
value: string
|
||||
onChange: (id: string) => void
|
||||
size?: Size
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
||||
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
|
||||
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
|
||||
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
|
||||
}
|
||||
|
||||
export default function ButtonGroup({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
size = 'md',
|
||||
className,
|
||||
ariaLabel = 'Optionen',
|
||||
}: ButtonGroupProps) {
|
||||
const s = sizeMap[size]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'isolate inline-flex overflow-hidden rounded-md shadow-xs ring-1 ring-gray-300 dark:shadow-none dark:ring-gray-700',
|
||||
className
|
||||
)}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map((it, idx) => {
|
||||
const active = it.id === value
|
||||
const isFirst = idx === 0
|
||||
const isLast = idx === items.length - 1
|
||||
const iconOnly = !it.label && !!it.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
disabled={it.disabled}
|
||||
onClick={() => onChange(it.id)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center font-semibold leading-none',
|
||||
'transition-[background-color,color,box-shadow] duration-150',
|
||||
'focus:z-20 hover:z-10',
|
||||
!isFirst && 'before:absolute before:bottom-0 before:left-0 before:top-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
|
||||
isFirst && 'rounded-l-md',
|
||||
isLast && 'rounded-r-md',
|
||||
|
||||
active
|
||||
? [
|
||||
'bg-indigo-100 text-indigo-800',
|
||||
'hover:bg-indigo-200',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgba(99,102,241,0.28),inset_0_0_0_9999px_rgba(99,102,241,0.10)]',
|
||||
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50 dark:hover:shadow-none',
|
||||
].join(' ')
|
||||
: [
|
||||
'bg-white text-gray-900',
|
||||
'hover:bg-gray-50',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgba(148,163,184,0.35),inset_0_0_0_9999px_rgba(15,23,42,0.05)]',
|
||||
'dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:hover:shadow-none',
|
||||
].join(' '),
|
||||
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
||||
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
||||
)}
|
||||
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||
>
|
||||
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
|
||||
|
||||
{it.icon ? (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 transition-colors duration-150',
|
||||
iconOnly ? '' : '-ml-0.5',
|
||||
active
|
||||
? 'text-indigo-700 dark:text-indigo-200'
|
||||
: 'text-gray-500 dark:text-gray-500'
|
||||
)}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
235
frontend/src/components/Camera.tsx
Normal file
235
frontend/src/components/Camera.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
// frontend\src\components\Camera.tsx
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type BarcodeDetectorResult = {
|
||||
rawValue: string
|
||||
}
|
||||
|
||||
type BarcodeDetectorInstance = {
|
||||
detect: (source: HTMLVideoElement) => Promise<BarcodeDetectorResult[]>
|
||||
}
|
||||
|
||||
type BarcodeDetectorConstructor = new (options?: {
|
||||
formats?: string[]
|
||||
}) => BarcodeDetectorInstance
|
||||
|
||||
type CameraProps = {
|
||||
onScan?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function Camera({ onScan }: CameraProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [scannedValue, setScannedValue] = useState<string | null>(null)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
stopCamera()
|
||||
return
|
||||
}
|
||||
|
||||
startCamera()
|
||||
|
||||
return () => {
|
||||
stopCamera()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function startCamera() {
|
||||
setError(null)
|
||||
setScannedValue(null)
|
||||
setIsStarting(true)
|
||||
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setError('Dein Browser unterstützt keinen Kamera-Zugriff.')
|
||||
return
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: {
|
||||
ideal: 'environment',
|
||||
},
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
|
||||
streamRef.current = stream
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
await videoRef.current.play()
|
||||
}
|
||||
|
||||
startQrScanner()
|
||||
} catch {
|
||||
setError('Kamera konnte nicht geöffnet werden. Bitte Berechtigung prüfen.')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (frameRef.current) {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
frameRef.current = null
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null
|
||||
}
|
||||
}
|
||||
|
||||
function startQrScanner() {
|
||||
const BarcodeDetector = (
|
||||
window as Window & {
|
||||
BarcodeDetector?: BarcodeDetectorConstructor
|
||||
}
|
||||
).BarcodeDetector
|
||||
|
||||
if (!BarcodeDetector) {
|
||||
setError('QR-Code-Erkennung wird in diesem Browser nicht unterstützt. Die Kamera-Vorschau funktioniert trotzdem.')
|
||||
return
|
||||
}
|
||||
|
||||
const detector = new BarcodeDetector({
|
||||
formats: ['qr_code'],
|
||||
})
|
||||
|
||||
async function scan() {
|
||||
if (!videoRef.current) return
|
||||
|
||||
try {
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
const codes = await detector.detect(videoRef.current)
|
||||
|
||||
if (codes.length > 0) {
|
||||
const value = codes[0].rawValue
|
||||
|
||||
setScannedValue(value)
|
||||
onScan?.(value)
|
||||
stopCamera()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Weiter versuchen, falls ein einzelner Frame nicht gelesen werden kann.
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(scan)
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(scan)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="-m-2.5 flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="sr-only">QR-Code scannen</span>
|
||||
<CameraIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onClose={closeModal} className="relative z-50">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
<div className="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
QR-Code scannen
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Richte die Kamera auf einen QR-Code.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
||||
{scannedValue ? (
|
||||
<div className="p-4">
|
||||
<p className="text-sm font-medium text-white">
|
||||
QR-Code erkannt:
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
||||
{scannedValue}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStarting && (
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Kamera wird gestartet...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/Card.tsx
Normal file
102
frontend/src/components/Card.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
// frontend\src\components\Card.tsx
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type CardVariant =
|
||||
| 'basic'
|
||||
| 'edge-to-edge-mobile'
|
||||
| 'with-header'
|
||||
| 'with-footer'
|
||||
| 'with-header-footer'
|
||||
| 'gray-footer'
|
||||
| 'gray-body'
|
||||
| 'well'
|
||||
| 'well-on-gray'
|
||||
| 'well-edge-to-edge-mobile'
|
||||
|
||||
type CardProps = {
|
||||
children?: ReactNode
|
||||
header?: ReactNode
|
||||
footer?: ReactNode
|
||||
variant?: CardVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
header,
|
||||
footer,
|
||||
variant = 'basic',
|
||||
className,
|
||||
}: CardProps) {
|
||||
const isEdgeToEdgeMobile =
|
||||
variant === 'edge-to-edge-mobile' || variant === 'well-edge-to-edge-mobile'
|
||||
|
||||
const isWell =
|
||||
variant === 'well' ||
|
||||
variant === 'well-on-gray' ||
|
||||
variant === 'well-edge-to-edge-mobile'
|
||||
|
||||
const hasDivider =
|
||||
variant === 'with-header' ||
|
||||
variant === 'with-footer' ||
|
||||
variant === 'with-header-footer'
|
||||
|
||||
const hasHeader =
|
||||
header &&
|
||||
(variant === 'with-header' ||
|
||||
variant === 'with-header-footer' ||
|
||||
variant === 'gray-body')
|
||||
|
||||
const hasFooter =
|
||||
footer &&
|
||||
(variant === 'with-footer' ||
|
||||
variant === 'with-header-footer' ||
|
||||
variant === 'gray-footer')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'overflow-hidden',
|
||||
isEdgeToEdgeMobile ? 'sm:rounded-lg' : 'rounded-lg',
|
||||
isWell
|
||||
? variant === 'well-on-gray'
|
||||
? 'bg-gray-200 dark:bg-gray-800/50'
|
||||
: 'bg-gray-50 dark:bg-gray-800/50'
|
||||
: 'bg-white shadow-sm dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||
hasDivider && 'divide-y divide-gray-200 dark:divide-white/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'px-4 py-5 sm:p-6',
|
||||
variant === 'gray-body' && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={classNames(
|
||||
'px-4 py-4 sm:px-6',
|
||||
variant === 'gray-footer' && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/Container.tsx
Normal file
48
frontend/src/components/Container.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type ContainerVariant =
|
||||
| 'full-width-mobile'
|
||||
| 'constrained'
|
||||
| 'breakpoint-full-width-mobile'
|
||||
| 'breakpoint-constrained'
|
||||
| 'narrow'
|
||||
|
||||
type ContainerProps = {
|
||||
children: ReactNode
|
||||
variant?: ContainerVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const variantClasses: Record<ContainerVariant, string> = {
|
||||
'full-width-mobile': 'mx-auto max-w-7xl sm:px-6 lg:px-8',
|
||||
constrained: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||
'breakpoint-full-width-mobile': 'container mx-auto sm:px-6 lg:px-8',
|
||||
'breakpoint-constrained': 'container mx-auto px-4 sm:px-6 lg:px-8',
|
||||
narrow: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||
}
|
||||
|
||||
export default function Container({
|
||||
children,
|
||||
variant = 'constrained',
|
||||
className,
|
||||
}: ContainerProps) {
|
||||
if (variant === 'narrow') {
|
||||
return (
|
||||
<div className={classNames(variantClasses[variant], className)}>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(variantClasses[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
689
frontend/src/components/Feed.tsx
Normal file
689
frontend/src/components/Feed.tsx
Normal file
@ -0,0 +1,689 @@
|
||||
// frontend/src/components/Feed.tsx
|
||||
|
||||
import { Fragment, useState, type ComponentType, type FormEvent, type SVGProps } from 'react'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
CheckIcon,
|
||||
FaceFrownIcon,
|
||||
FaceSmileIcon,
|
||||
FireIcon,
|
||||
HandThumbUpIcon,
|
||||
HeartIcon,
|
||||
PaperClipIcon,
|
||||
TagIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||
import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||
import Button from './Button'
|
||||
|
||||
type FeedIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
type FeedVariant = 'icons' | 'comments' | 'multiple'
|
||||
|
||||
export type FeedTimelineItem = {
|
||||
id: string | number
|
||||
content: string
|
||||
target: string
|
||||
href: string
|
||||
date: string
|
||||
datetime: string
|
||||
icon: FeedIcon
|
||||
iconBackground: string
|
||||
}
|
||||
|
||||
export type FeedCommentActivityItem =
|
||||
| {
|
||||
id: string | number
|
||||
type: 'created' | 'edited' | 'sent' | 'viewed' | 'paid'
|
||||
person: {
|
||||
name: string
|
||||
}
|
||||
date: string
|
||||
dateTime: string
|
||||
}
|
||||
| {
|
||||
id: string | number
|
||||
type: 'commented'
|
||||
person: {
|
||||
name: string
|
||||
imageUrl: string
|
||||
}
|
||||
comment: string
|
||||
date: string
|
||||
dateTime: string
|
||||
}
|
||||
|
||||
export type FeedMultipleActivityItem =
|
||||
| {
|
||||
id: string | number
|
||||
type: 'comment'
|
||||
person: {
|
||||
name: string
|
||||
href: string
|
||||
}
|
||||
imageUrl: string
|
||||
comment: string
|
||||
date: string
|
||||
}
|
||||
| {
|
||||
id: string | number
|
||||
type: 'assignment'
|
||||
person: {
|
||||
name: string
|
||||
href: string
|
||||
}
|
||||
assigned: {
|
||||
name: string
|
||||
href: string
|
||||
}
|
||||
date: string
|
||||
}
|
||||
| {
|
||||
id: string | number
|
||||
type: 'tags'
|
||||
person: {
|
||||
name: string
|
||||
href: string
|
||||
}
|
||||
tags: Array<{
|
||||
name: string
|
||||
href: string
|
||||
color: string
|
||||
}>
|
||||
date: string
|
||||
}
|
||||
|
||||
type Mood = {
|
||||
name: string
|
||||
value: string | null
|
||||
icon: FeedIcon
|
||||
iconColor: string
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
type FeedProps = {
|
||||
variant?: FeedVariant
|
||||
timeline?: FeedTimelineItem[]
|
||||
activity?: FeedCommentActivityItem[]
|
||||
multipleActivity?: FeedMultipleActivityItem[]
|
||||
currentUserAvatar?: string
|
||||
showCommentForm?: boolean
|
||||
onCommentSubmit?: (comment: string, mood: Mood) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const defaultTimeline: FeedTimelineItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
content: 'Applied to',
|
||||
target: 'Front End Developer',
|
||||
href: '#',
|
||||
date: 'Sep 20',
|
||||
datetime: '2020-09-20',
|
||||
icon: UserIcon,
|
||||
iconBackground: 'bg-gray-400 dark:bg-gray-600',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: 'Advanced to phone screening by',
|
||||
target: 'Bethany Blake',
|
||||
href: '#',
|
||||
date: 'Sep 22',
|
||||
datetime: '2020-09-22',
|
||||
icon: HandThumbUpIcon,
|
||||
iconBackground: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: 'Completed phone screening with',
|
||||
target: 'Martha Gardner',
|
||||
href: '#',
|
||||
date: 'Sep 28',
|
||||
datetime: '2020-09-28',
|
||||
icon: CheckIcon,
|
||||
iconBackground: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
content: 'Advanced to interview by',
|
||||
target: 'Bethany Blake',
|
||||
href: '#',
|
||||
date: 'Sep 30',
|
||||
datetime: '2020-09-30',
|
||||
icon: HandThumbUpIcon,
|
||||
iconBackground: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
content: 'Completed interview with',
|
||||
target: 'Katherine Snyder',
|
||||
href: '#',
|
||||
date: 'Oct 4',
|
||||
datetime: '2020-10-04',
|
||||
icon: CheckIcon,
|
||||
iconBackground: 'bg-green-500',
|
||||
},
|
||||
]
|
||||
|
||||
const defaultCommentActivity: FeedCommentActivityItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'created',
|
||||
person: { name: 'Chelsea Hagon' },
|
||||
date: '7d ago',
|
||||
dateTime: '2023-01-23T10:32',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'edited',
|
||||
person: { name: 'Chelsea Hagon' },
|
||||
date: '6d ago',
|
||||
dateTime: '2023-01-23T11:03',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'sent',
|
||||
person: { name: 'Chelsea Hagon' },
|
||||
date: '6d ago',
|
||||
dateTime: '2023-01-23T11:24',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'commented',
|
||||
person: {
|
||||
name: 'Chelsea Hagon',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
comment: 'Called client, they reassured me the invoice would be paid by the 25th.',
|
||||
date: '3d ago',
|
||||
dateTime: '2023-01-23T15:56',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'viewed',
|
||||
person: { name: 'Alex Curren' },
|
||||
date: '2d ago',
|
||||
dateTime: '2023-01-24T09:12',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'paid',
|
||||
person: { name: 'Alex Curren' },
|
||||
date: '1d ago',
|
||||
dateTime: '2023-01-24T09:20',
|
||||
},
|
||||
]
|
||||
|
||||
const defaultMultipleActivity: FeedMultipleActivityItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'comment',
|
||||
person: { name: 'Eduardo Benz', href: '#' },
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1520785643438-5bf77931f493?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
|
||||
comment:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
|
||||
date: '6d ago',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'assignment',
|
||||
person: { name: 'Hilary Mahy', href: '#' },
|
||||
assigned: { name: 'Kristin Watson', href: '#' },
|
||||
date: '2d ago',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'tags',
|
||||
person: { name: 'Hilary Mahy', href: '#' },
|
||||
tags: [
|
||||
{ name: 'Bug', href: '#', color: 'fill-red-500' },
|
||||
{ name: 'Accessibility', href: '#', color: 'fill-indigo-500' },
|
||||
],
|
||||
date: '6h ago',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'comment',
|
||||
person: { name: 'Jason Meyers', href: '#' },
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
|
||||
comment:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
|
||||
date: '2h ago',
|
||||
},
|
||||
]
|
||||
|
||||
const moods: Mood[] = [
|
||||
{ name: 'Excited', value: 'excited', icon: FireIcon, iconColor: 'text-white', bgColor: 'bg-red-500' },
|
||||
{ name: 'Loved', value: 'loved', icon: HeartIcon, iconColor: 'text-white', bgColor: 'bg-pink-400' },
|
||||
{ name: 'Happy', value: 'happy', icon: FaceSmileIcon, iconColor: 'text-white', bgColor: 'bg-green-400' },
|
||||
{ name: 'Sad', value: 'sad', icon: FaceFrownIcon, iconColor: 'text-white', bgColor: 'bg-yellow-400' },
|
||||
{ name: 'Thumbsy', value: 'thumbsy', icon: HandThumbUpIcon, iconColor: 'text-white', bgColor: 'bg-blue-500' },
|
||||
{
|
||||
name: 'I feel nothing',
|
||||
value: null,
|
||||
icon: XMarkIcon,
|
||||
iconColor: 'text-gray-400 dark:text-gray-500',
|
||||
bgColor: 'bg-transparent',
|
||||
},
|
||||
]
|
||||
|
||||
const defaultAvatar =
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
function FeedWithIcons({ timeline }: { timeline: FeedTimelineItem[] }) {
|
||||
return (
|
||||
<div className="flow-root">
|
||||
<ul role="list" className="-mb-8">
|
||||
{timeline.map((event, eventIndex) => (
|
||||
<li key={event.id}>
|
||||
<div className="relative pb-8">
|
||||
{eventIndex !== timeline.length - 1 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={classNames(
|
||||
event.iconBackground,
|
||||
'flex size-8 items-center justify-center rounded-full ring-8 ring-white dark:ring-gray-900',
|
||||
)}
|
||||
>
|
||||
<event.icon aria-hidden="true" className="size-5 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.content}{' '}
|
||||
<a href={event.href} className="font-medium text-gray-900 dark:text-white">
|
||||
{event.target}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={event.datetime}>{event.date}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedWithComments({
|
||||
activity,
|
||||
currentUserAvatar,
|
||||
showCommentForm,
|
||||
onCommentSubmit,
|
||||
}: {
|
||||
activity: FeedCommentActivityItem[]
|
||||
currentUserAvatar: string
|
||||
showCommentForm: boolean
|
||||
onCommentSubmit?: (comment: string, mood: Mood) => void
|
||||
}) {
|
||||
const [selectedMood, setSelectedMood] = useState(moods[5])
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const comment = String(formData.get('comment') ?? '').trim()
|
||||
|
||||
if (!comment) {
|
||||
return
|
||||
}
|
||||
|
||||
onCommentSubmit?.(comment, selectedMood)
|
||||
event.currentTarget.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul role="list" className="space-y-6">
|
||||
{activity.map((activityItem, activityIndex) => (
|
||||
<li key={activityItem.id} className="relative flex gap-x-4">
|
||||
<div
|
||||
className={classNames(
|
||||
activityIndex === activity.length - 1 ? 'h-6' : '-bottom-6',
|
||||
'absolute top-0 left-0 flex w-6 justify-center',
|
||||
)}
|
||||
>
|
||||
<div className="w-px bg-gray-200 dark:bg-white/15" />
|
||||
</div>
|
||||
|
||||
{activityItem.type === 'commented' ? (
|
||||
<>
|
||||
<img
|
||||
alt=""
|
||||
src={activityItem.person.imageUrl}
|
||||
className="relative mt-3 size-6 flex-none rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<div className="flex-auto rounded-md p-3 ring-1 ring-gray-200 ring-inset dark:ring-white/15">
|
||||
<div className="flex justify-between gap-x-4">
|
||||
<div className="py-0.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.person.name}
|
||||
</span>{' '}
|
||||
commented
|
||||
</div>
|
||||
|
||||
<time
|
||||
dateTime={activityItem.dateTime}
|
||||
className="flex-none py-0.5 text-xs/5 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{activityItem.date}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<p className="text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{activityItem.comment}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-white dark:bg-gray-900">
|
||||
{activityItem.type === 'paid' ? (
|
||||
<CheckCircleIcon
|
||||
aria-hidden="true"
|
||||
className="size-6 text-indigo-600 dark:text-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-1.5 rounded-full bg-gray-100 ring ring-gray-300 dark:bg-white/10 dark:ring-white/20" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="flex-auto py-0.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.person.name}
|
||||
</span>{' '}
|
||||
{activityItem.type} the invoice.
|
||||
</p>
|
||||
|
||||
<time
|
||||
dateTime={activityItem.dateTime}
|
||||
className="flex-none py-0.5 text-xs/5 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{activityItem.date}
|
||||
</time>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{showCommentForm && (
|
||||
<div className="mt-6 flex gap-x-3">
|
||||
<img
|
||||
alt=""
|
||||
src={currentUserAvatar}
|
||||
className="size-6 flex-none rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="relative flex-auto">
|
||||
<div className="overflow-hidden rounded-lg pb-12 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-indigo-500">
|
||||
<label htmlFor="comment" className="sr-only">
|
||||
Kommentar hinzufügen
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
rows={2}
|
||||
placeholder="Kommentar hinzufügen..."
|
||||
className="block w-full resize-none bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pr-2 pl-3">
|
||||
<div className="flex items-center space-x-5">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white"
|
||||
>
|
||||
<PaperClipIcon aria-hidden="true" className="size-5" />
|
||||
<span className="sr-only">Datei anhängen</span>
|
||||
</button>
|
||||
|
||||
<Listbox value={selectedMood} onChange={setSelectedMood}>
|
||||
<Label className="sr-only">Stimmung</Label>
|
||||
|
||||
<div className="relative">
|
||||
<ListboxButton className="relative -m-2.5 flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white">
|
||||
<span className="flex items-center justify-center">
|
||||
{selectedMood.value === null ? (
|
||||
<span>
|
||||
<FaceSmileIcon aria-hidden="true" className="size-5 shrink-0" />
|
||||
<span className="sr-only">Stimmung hinzufügen</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span
|
||||
className={classNames(
|
||||
selectedMood.bgColor,
|
||||
'flex size-8 items-center justify-center rounded-full',
|
||||
)}
|
||||
>
|
||||
<selectedMood.icon aria-hidden="true" className="size-5 shrink-0 text-white" />
|
||||
</span>
|
||||
<span className="sr-only">{selectedMood.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<ListboxOptions
|
||||
transition
|
||||
className="absolute bottom-10 z-10 -ml-6 w-60 rounded-lg bg-white py-3 text-base shadow-sm outline-1 outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:ml-auto sm:w-64 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
{moods.map((mood) => (
|
||||
<ListboxOption
|
||||
key={mood.value ?? 'none'}
|
||||
value={mood}
|
||||
className="relative cursor-default bg-white px-3 py-2 text-gray-900 select-none data-focus:bg-gray-100 dark:bg-transparent dark:text-white dark:data-focus:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
mood.bgColor,
|
||||
'flex size-8 items-center justify-center rounded-full',
|
||||
)}
|
||||
>
|
||||
<mood.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(mood.iconColor, 'size-5 shrink-0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-3 block truncate font-medium">
|
||||
{mood.name}
|
||||
</span>
|
||||
</div>
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
Kommentieren
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedWithMultipleTypes({ activity }: { activity: FeedMultipleActivityItem[] }) {
|
||||
return (
|
||||
<div className="flow-root">
|
||||
<ul role="list" className="-mb-8">
|
||||
{activity.map((activityItem, activityIndex) => (
|
||||
<li key={activityItem.id}>
|
||||
<div className="relative pb-8">
|
||||
{activityIndex !== activity.length - 1 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-start space-x-3">
|
||||
{activityItem.type === 'comment' && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<img
|
||||
alt=""
|
||||
src={activityItem.imageUrl}
|
||||
className="flex size-10 items-center justify-center rounded-full bg-gray-400 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:ring-gray-900 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<span className="absolute -right-1 -bottom-0.5 rounded-tl bg-white px-0.5 py-px dark:bg-gray-900">
|
||||
<ChatBubbleLeftEllipsisIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.person.name}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
Commented {activityItem.date}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<p>{activityItem.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activityItem.type === 'assignment' && (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
||||
<UserCircleIcon aria-hidden="true" className="size-5 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 py-1.5">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.person.name}
|
||||
</a>{' '}
|
||||
assigned{' '}
|
||||
<a href={activityItem.assigned.href} className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.assigned.name}
|
||||
</a>{' '}
|
||||
<span className="whitespace-nowrap">{activityItem.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activityItem.type === 'tags' && (
|
||||
<>
|
||||
<div>
|
||||
<div className="relative px-1">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
||||
<TagIcon aria-hidden="true" className="size-5 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 py-0">
|
||||
<div className="text-sm/8 text-gray-500 dark:text-gray-400">
|
||||
<span className="mr-0.5">
|
||||
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||
{activityItem.person.name}
|
||||
</a>{' '}
|
||||
added tags
|
||||
</span>{' '}
|
||||
|
||||
<span className="mr-0.5">
|
||||
{activityItem.tags.map((tag) => (
|
||||
<Fragment key={tag.name}>
|
||||
<a
|
||||
href={tag.href}
|
||||
className="inline-flex items-center gap-x-1.5 rounded-full px-2 py-1 text-xs font-medium text-gray-900 inset-ring inset-ring-gray-200 dark:bg-white/5 dark:text-gray-100 dark:inset-ring-white/10"
|
||||
>
|
||||
<svg viewBox="0 0 6 6" aria-hidden="true" className={classNames(tag.color, 'size-1.5')}>
|
||||
<circle r={3} cx={3} cy={3} />
|
||||
</svg>
|
||||
{tag.name}
|
||||
</a>{' '}
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<span className="whitespace-nowrap">{activityItem.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Feed({
|
||||
variant = 'icons',
|
||||
timeline = defaultTimeline,
|
||||
activity = defaultCommentActivity,
|
||||
multipleActivity = defaultMultipleActivity,
|
||||
currentUserAvatar = defaultAvatar,
|
||||
showCommentForm = true,
|
||||
onCommentSubmit,
|
||||
className,
|
||||
}: FeedProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{variant === 'icons' && <FeedWithIcons timeline={timeline} />}
|
||||
|
||||
{variant === 'comments' && (
|
||||
<FeedWithComments
|
||||
activity={activity}
|
||||
currentUserAvatar={currentUserAvatar}
|
||||
showCommentForm={showCommentForm}
|
||||
onCommentSubmit={onCommentSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variant === 'multiple' && <FeedWithMultipleTypes activity={multipleActivity} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
frontend/src/components/LoginForm.tsx
Normal file
144
frontend/src/components/LoginForm.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
// frontend\src\components\LoginForm.tsx
|
||||
|
||||
import type { FormEvent } from 'react'
|
||||
import Button from './Button'
|
||||
import Card from './Card'
|
||||
|
||||
type LoginFormProps = {
|
||||
title?: string
|
||||
submitText?: string
|
||||
isSubmitting?: boolean
|
||||
forgotPasswordHref?: string
|
||||
registerHref?: string
|
||||
googleHref?: string
|
||||
githubHref?: string
|
||||
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
|
||||
}
|
||||
|
||||
export default function LoginForm({
|
||||
title = 'Sign in to your account',
|
||||
submitText = 'Sign in',
|
||||
isSubmitting = false,
|
||||
forgotPasswordHref = '#',
|
||||
onSubmit,
|
||||
}: LoginFormProps) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||
className="mx-auto h-10 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="mx-auto h-10 w-auto not-dark:hidden"
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||
<Card variant="edge-to-edge-mobile">
|
||||
<form action="#" method="POST" onSubmit={onSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="identifier" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Benutzername oder E-Mail
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="identifier"
|
||||
name="identifier"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-6 shrink-0 items-center">
|
||||
<div className="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 forced-colors:appearance-auto"
|
||||
/>
|
||||
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
|
||||
>
|
||||
<path
|
||||
d="M3 8L6 11L11 3.5"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-0 group-has-checked:opacity-100"
|
||||
/>
|
||||
<path
|
||||
d="M3 7H11"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label htmlFor="remember-me" className="block text-sm/6 text-gray-900 dark:text-white">
|
||||
Angemeldet bleiben
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm/6">
|
||||
<a
|
||||
href={forgotPasswordHref}
|
||||
className="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
259
frontend/src/components/Modal.tsx
Normal file
259
frontend/src/components/Modal.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||
import {
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
type ModalVariant =
|
||||
| 'success-single'
|
||||
| 'success-wide'
|
||||
| 'alert'
|
||||
| 'alert-dismiss'
|
||||
| 'alert-gray-footer'
|
||||
| 'alert-left-buttons'
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
variant?: ModalVariant
|
||||
title?: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
open,
|
||||
setOpen,
|
||||
variant = 'success-single',
|
||||
title,
|
||||
description,
|
||||
confirmText,
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
}: ModalProps) {
|
||||
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
||||
const isGrayFooter = variant === 'alert-gray-footer'
|
||||
const isDismiss = variant === 'alert-dismiss'
|
||||
const isLeftButtons = variant === 'alert-left-buttons'
|
||||
const isWideSuccess = variant === 'success-wide'
|
||||
|
||||
const Icon = isSuccess ? CheckIcon : ExclamationTriangleIcon
|
||||
|
||||
const resolvedTitle =
|
||||
title ?? (isSuccess ? 'Payment successful' : 'Deactivate account')
|
||||
|
||||
const resolvedDescription =
|
||||
description ??
|
||||
(isSuccess
|
||||
? 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur amet labore.'
|
||||
: 'Are you sure you want to deactivate your account? All of your data will be permanently removed from our servers forever. This action cannot be undone.')
|
||||
|
||||
const resolvedConfirmText =
|
||||
confirmText ?? (isSuccess ? 'Go back to dashboard' : 'Deactivate')
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm?.()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (isGrayFooter) {
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex size-12 shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:size-10 dark:bg-red-500/10">
|
||||
<Icon aria-hidden="true" className="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{resolvedTitle}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 sm:ml-3 sm:w-auto dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
|
||||
>
|
||||
{resolvedConfirmText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:p-6 data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||
isSuccess && !isWideSuccess ? 'sm:max-w-sm' : 'sm:max-w-lg',
|
||||
)}
|
||||
>
|
||||
{isDismiss && (
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classNames(!isSuccess && 'sm:flex sm:items-start')}>
|
||||
<div
|
||||
className={classNames(
|
||||
'mx-auto flex size-12 shrink-0 items-center justify-center rounded-full',
|
||||
isSuccess
|
||||
? 'bg-green-100 dark:bg-green-500/10'
|
||||
: 'bg-red-100 sm:mx-0 sm:size-10 dark:bg-red-500/10',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
'size-6',
|
||||
isSuccess ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-3 text-center',
|
||||
isSuccess ? 'sm:mt-5' : 'sm:mt-0 sm:ml-4 sm:text-left',
|
||||
)}
|
||||
>
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{resolvedTitle}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{resolvedDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variant === 'success-single' && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||
>
|
||||
{resolvedConfirmText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'success-wide' && (
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:col-start-2 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||
>
|
||||
{resolvedConfirmText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSuccess && (
|
||||
<div
|
||||
className={classNames(
|
||||
'mt-5 sm:mt-4',
|
||||
isLeftButtons ? 'sm:ml-10 sm:flex sm:pl-4' : 'sm:flex sm:flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className={classNames(
|
||||
'inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400',
|
||||
isLeftButtons ? 'sm:w-auto' : 'sm:ml-3 sm:w-auto',
|
||||
)}
|
||||
>
|
||||
{resolvedConfirmText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
onClick={() => setOpen(false)}
|
||||
className={classNames(
|
||||
'mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
||||
isLeftButtons && 'sm:ml-3',
|
||||
)}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/Search.tsx
Normal file
20
frontend/src/components/Search.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// frontend\src\components\Search.tsx
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
export default function Search() {
|
||||
return (
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
206
frontend/src/components/Sidebar.tsx
Normal file
206
frontend/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
// frontend\src\components\Sidebar.tsx
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChartPieIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
DocumentDuplicateIcon,
|
||||
FireIcon,
|
||||
HomeIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Geräte', href: '/geraete', icon: ComputerDesktopIcon },
|
||||
{ name: 'Einsätze', href: '/einsaetze', icon: FireIcon },
|
||||
{ name: 'Kalender', href: '/kalender', icon: CalendarIcon },
|
||||
{ name: 'Dokumente', href: '/dokumente', icon: DocumentDuplicateIcon },
|
||||
{ name: 'Berichte', href: '/berichte', icon: ChartPieIcon },
|
||||
]
|
||||
|
||||
type Team = {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
href: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: Team[]
|
||||
}
|
||||
|
||||
type SidebarProps = {
|
||||
user: User
|
||||
sidebarOpen: boolean
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getTeamInitial(team: Team) {
|
||||
if (team.initial) {
|
||||
return team.initial
|
||||
}
|
||||
|
||||
return team.name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
user,
|
||||
setSidebarOpen,
|
||||
}: {
|
||||
user: User
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
}) {
|
||||
const teams = user.teams ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
isActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon aria-hidden="true" className="size-6 shrink-0" />
|
||||
{item.name}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-400">
|
||||
Zugeordnete Teams
|
||||
</div>
|
||||
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.length > 0 ? (
|
||||
teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<a
|
||||
href={team.href || '#'}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-gray-400 group-hover:border-white/20 group-hover:text-white">
|
||||
{getTeamInitial(team)}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-2 py-1 text-sm/6 text-gray-500">
|
||||
Keine Teams zugeordnet
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li className="mt-auto">
|
||||
<NavLink
|
||||
to="/einstellungen"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
isActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Cog6ToothIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
673
frontend/src/components/Tables.tsx
Normal file
673
frontend/src/components/Tables.tsx
Normal file
@ -0,0 +1,673 @@
|
||||
// frontend/src/components/Tables.tsx
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import Button from './Button'
|
||||
|
||||
export type TableVariant =
|
||||
| 'simple'
|
||||
| 'card'
|
||||
| 'full-width'
|
||||
| 'constrained'
|
||||
| 'striped'
|
||||
| 'uppercase'
|
||||
| 'stacked-mobile'
|
||||
| 'hidden-mobile'
|
||||
| 'avatars'
|
||||
| 'sticky-header'
|
||||
| 'vertical-lines'
|
||||
| 'condensed'
|
||||
| 'sortable'
|
||||
| 'grouped'
|
||||
| 'summary'
|
||||
| 'border'
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string
|
||||
header: string
|
||||
render: (row: T) => ReactNode
|
||||
mobileRender?: (row: T) => ReactNode
|
||||
isPrimary?: boolean
|
||||
sortable?: boolean
|
||||
hideOnMobile?: 'sm' | 'md' | 'lg'
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}
|
||||
|
||||
export type TableGroup<T> = {
|
||||
id: string | number
|
||||
name: string
|
||||
rows: T[]
|
||||
}
|
||||
|
||||
export type TableSummaryRow = {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
emphasized?: boolean
|
||||
colSpan?: number
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
type TablesProps<T> = {
|
||||
title?: string
|
||||
description?: string
|
||||
|
||||
rows?: T[]
|
||||
groups?: TableGroup<T>[]
|
||||
|
||||
columns: TableColumn<T>[]
|
||||
getRowId: (row: T) => string | number
|
||||
|
||||
variant?: TableVariant
|
||||
emptyText?: string
|
||||
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
headerAction?: ReactNode
|
||||
|
||||
rowActions?: (row: T) => ReactNode
|
||||
|
||||
selectable?: boolean
|
||||
selectedRowIds?: Array<string | number>
|
||||
onSelectedRowIdsChange?: (ids: Array<string | number>) => void
|
||||
|
||||
sortKey?: string
|
||||
sortDirection?: SortDirection
|
||||
onSort?: (column: TableColumn<T>) => void
|
||||
|
||||
summaryRows?: TableSummaryRow[]
|
||||
|
||||
hideHeadings?: boolean
|
||||
stickyTopClassName?: string
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getAlignmentClass(align?: TableColumn<unknown>['align']) {
|
||||
if (align === 'right') {
|
||||
return 'text-right'
|
||||
}
|
||||
|
||||
if (align === 'center') {
|
||||
return 'text-center'
|
||||
}
|
||||
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
function getHiddenClass(hideOnMobile?: TableColumn<unknown>['hideOnMobile']) {
|
||||
if (hideOnMobile === 'sm') {
|
||||
return 'hidden sm:table-cell'
|
||||
}
|
||||
|
||||
if (hideOnMobile === 'md') {
|
||||
return 'hidden md:table-cell'
|
||||
}
|
||||
|
||||
if (hideOnMobile === 'lg') {
|
||||
return 'hidden lg:table-cell'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
inputRef,
|
||||
value,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
inputRef?: React.Ref<HTMLInputElement>
|
||||
value?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="group absolute top-1/2 left-4 -mt-2 grid size-4 grid-cols-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="checkbox"
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-gray-800 dark:disabled:checked:bg-gray-800 forced-colors:appearance-auto"
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
|
||||
>
|
||||
<path
|
||||
d="M3 8L6 11L11 3.5"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-0 group-has-checked:opacity-100"
|
||||
/>
|
||||
<path
|
||||
d="M3 7H11"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Tables<T>({
|
||||
title,
|
||||
description,
|
||||
|
||||
rows = [],
|
||||
groups,
|
||||
|
||||
columns,
|
||||
getRowId,
|
||||
|
||||
variant = 'simple',
|
||||
emptyText = 'Keine Einträge vorhanden.',
|
||||
|
||||
actionLabel,
|
||||
onAction,
|
||||
headerAction,
|
||||
|
||||
rowActions,
|
||||
|
||||
selectable = false,
|
||||
selectedRowIds,
|
||||
onSelectedRowIdsChange,
|
||||
|
||||
sortKey,
|
||||
sortDirection = 'asc',
|
||||
onSort,
|
||||
|
||||
summaryRows,
|
||||
|
||||
hideHeadings = false,
|
||||
stickyTopClassName = 'top-0',
|
||||
|
||||
className,
|
||||
}: TablesProps<T>) {
|
||||
const headerCheckboxRef = useRef<HTMLInputElement>(null)
|
||||
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
|
||||
|
||||
const rowGroups =
|
||||
groups && groups.length > 0
|
||||
? groups
|
||||
: [
|
||||
{
|
||||
id: 'default',
|
||||
name: '',
|
||||
rows,
|
||||
},
|
||||
]
|
||||
|
||||
const allRows = rowGroups.flatMap((group) => group.rows)
|
||||
const allRowIds = allRows.map(getRowId)
|
||||
|
||||
const selectedIds = selectedRowIds ?? internalSelectedRowIds
|
||||
const selectedSet = new Set(selectedIds)
|
||||
|
||||
const checked = allRowIds.length > 0 && selectedIds.length === allRowIds.length
|
||||
const indeterminate = selectedIds.length > 0 && selectedIds.length < allRowIds.length
|
||||
|
||||
const isCard = variant === 'card'
|
||||
const isBorder = variant === 'border'
|
||||
const isFullWidth = variant === 'full-width'
|
||||
const isConstrained = variant === 'constrained'
|
||||
const isStriped = variant === 'striped'
|
||||
const isUppercase = variant === 'uppercase'
|
||||
const isStackedMobile = variant === 'stacked-mobile'
|
||||
const isStickyHeader = variant === 'sticky-header'
|
||||
const isVerticalLines = variant === 'vertical-lines'
|
||||
const isCondensed = variant === 'condensed'
|
||||
const isSortable = variant === 'sortable'
|
||||
const isGrouped = variant === 'grouped' || Boolean(groups?.length)
|
||||
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
|
||||
|
||||
const totalColumns =
|
||||
columns.length +
|
||||
(selectable ? 1 : 0) +
|
||||
(rowActions ? 1 : 0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!headerCheckboxRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
headerCheckboxRef.current.indeterminate = indeterminate
|
||||
}, [indeterminate])
|
||||
|
||||
function updateSelected(nextSelectedIds: Array<string | number>) {
|
||||
if (!selectedRowIds) {
|
||||
setInternalSelectedRowIds(nextSelectedIds)
|
||||
}
|
||||
|
||||
onSelectedRowIdsChange?.(nextSelectedIds)
|
||||
}
|
||||
|
||||
function toggleAll(nextChecked: boolean) {
|
||||
updateSelected(nextChecked ? allRowIds : [])
|
||||
}
|
||||
|
||||
function toggleRow(rowId: string | number, nextChecked: boolean) {
|
||||
if (nextChecked) {
|
||||
updateSelected(Array.from(new Set([...selectedIds, rowId])))
|
||||
return
|
||||
}
|
||||
|
||||
updateSelected(selectedIds.filter((id) => id !== rowId))
|
||||
}
|
||||
|
||||
function renderHeaderAction() {
|
||||
if (headerAction) {
|
||||
return headerAction
|
||||
}
|
||||
|
||||
if (!actionLabel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!title && !description && !actionLabel && !headerAction) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
isConstrained && 'mx-auto max-w-7xl',
|
||||
!isFullWidth && 'px-4 sm:px-6 lg:px-8',
|
||||
isFullWidth && 'px-4 sm:px-6 lg:px-8',
|
||||
'sm:flex sm:items-center',
|
||||
)}
|
||||
>
|
||||
<div className="sm:flex-auto">
|
||||
{title && (
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(actionLabel || headerAction) && (
|
||||
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
{renderHeaderAction()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderColumnHeader(column: TableColumn<T>) {
|
||||
const activeSortColumn = sortKey === column.key
|
||||
const showSortIcon = isSortable || column.sortable
|
||||
|
||||
if (!showSortIcon) {
|
||||
return column.header
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort?.(column)}
|
||||
className="group inline-flex"
|
||||
>
|
||||
{column.header}
|
||||
|
||||
<span
|
||||
className={classNames(
|
||||
activeSortColumn
|
||||
? 'ml-2 flex-none rounded-sm bg-gray-100 text-gray-900 group-hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:group-hover:bg-gray-700'
|
||||
: 'invisible ml-2 flex-none rounded-sm text-gray-400 group-hover:visible group-focus:visible dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
'size-5',
|
||||
activeSortColumn && sortDirection === 'asc' && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTableHead() {
|
||||
return (
|
||||
<thead
|
||||
className={classNames(
|
||||
hideHeadings && 'sr-only',
|
||||
isCard && 'bg-gray-50 dark:bg-gray-800/75',
|
||||
!isCard && 'bg-white dark:bg-gray-900',
|
||||
)}
|
||||
>
|
||||
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
|
||||
{selectable && (
|
||||
<th scope="col" className="relative px-7 sm:w-12 sm:px-6">
|
||||
<Checkbox
|
||||
inputRef={headerCheckboxRef}
|
||||
checked={checked}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{columns.map((column, columnIndex) => {
|
||||
const isFirst = columnIndex === 0
|
||||
const isLast = columnIndex === columns.length - 1 && !rowActions
|
||||
const hiddenClass = getHiddenClass(column.hideOnMobile)
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={classNames(
|
||||
isStickyHeader &&
|
||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||
isUppercase
|
||||
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
|
||||
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
|
||||
isCondensed ? 'px-2' : 'px-3',
|
||||
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||
isCard && isFirst && 'sm:pl-6',
|
||||
isLast && !isCard && 'pr-4 sm:pr-0',
|
||||
isLast && isCard && 'sm:pr-6',
|
||||
getAlignmentClass(column.align),
|
||||
hiddenClass,
|
||||
column.headerClassName,
|
||||
)}
|
||||
>
|
||||
{renderColumnHeader(column)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
|
||||
{rowActions && (
|
||||
<th
|
||||
scope="col"
|
||||
className={classNames(
|
||||
isStickyHeader &&
|
||||
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||
'py-3.5 pr-4 pl-3 sm:pr-0',
|
||||
isCard && 'sm:pr-6',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Aktionen</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
|
||||
function renderRow(row: T, rowIndex: number) {
|
||||
const rowId = getRowId(row)
|
||||
const selected = selectedSet.has(rowId)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowId}
|
||||
className={classNames(
|
||||
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="relative px-7 sm:w-12 sm:px-6">
|
||||
{selected && (
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
value={String(rowId)}
|
||||
onChange={(nextChecked) => toggleRow(rowId, nextChecked)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{columns.map((column, columnIndex) => {
|
||||
const isFirst = columnIndex === 0
|
||||
const isLast = columnIndex === columns.length - 1 && !rowActions
|
||||
const hiddenClass = getHiddenClass(column.hideOnMobile)
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={classNames(
|
||||
isCondensed ? 'py-2' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||
isCondensed ? 'px-2' : 'px-3',
|
||||
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||
isCard && isFirst && 'sm:pl-6',
|
||||
isLast && !isCard && 'pr-4 sm:pr-0',
|
||||
isLast && isCard && 'sm:pr-6',
|
||||
'text-sm text-gray-500 dark:text-gray-400',
|
||||
!isStackedMobile && 'whitespace-nowrap',
|
||||
column.isPrimary && 'font-medium text-gray-900 dark:text-white',
|
||||
selected && column.isPrimary && 'text-indigo-600 dark:text-indigo-400',
|
||||
getAlignmentClass(column.align),
|
||||
hiddenClass,
|
||||
column.className,
|
||||
)}
|
||||
>
|
||||
{column.render(row)}
|
||||
|
||||
{isStackedMobile && column.isPrimary && column.mobileRender && (
|
||||
<dl className="font-normal lg:hidden">
|
||||
{column.mobileRender(row)}
|
||||
</dl>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
{rowActions && (
|
||||
<td
|
||||
className={classNames(
|
||||
isCondensed ? 'py-2' : 'py-4',
|
||||
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
|
||||
isCard && 'sm:pr-6',
|
||||
)}
|
||||
>
|
||||
{rowActions(row)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function renderGroupedRows() {
|
||||
return rowGroups.map((group) => (
|
||||
<Fragment key={group.id}>
|
||||
{isGrouped && group.name && (
|
||||
<tr className="border-t border-gray-200 dark:border-white/10">
|
||||
<th
|
||||
scope="colgroup"
|
||||
colSpan={totalColumns}
|
||||
className="bg-gray-50 py-2 pr-3 pl-4 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:bg-gray-800/50 dark:text-white"
|
||||
>
|
||||
{group.name}
|
||||
</th>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{group.rows.map(renderRow)}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
function renderEmptyRow() {
|
||||
if (allRows.length > 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={totalColumns}
|
||||
className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSummaryRows() {
|
||||
if (!hasSummary || !summaryRows?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<tfoot>
|
||||
{summaryRows.map((row, index) => {
|
||||
const colSpan = row.colSpan ?? Math.max(totalColumns - 1, 1)
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<th
|
||||
scope="row"
|
||||
colSpan={colSpan}
|
||||
className={classNames(
|
||||
index === 0 ? 'pt-6' : 'pt-4',
|
||||
'pr-3 pl-4 text-right text-sm sm:pl-0',
|
||||
row.emphasized
|
||||
? 'font-semibold text-gray-900 dark:text-white'
|
||||
: 'font-normal text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
{row.label}
|
||||
</th>
|
||||
|
||||
<td
|
||||
className={classNames(
|
||||
index === 0 ? 'pt-6' : 'pt-4',
|
||||
'pr-4 pl-3 text-right text-sm sm:pr-0',
|
||||
row.emphasized
|
||||
? 'font-semibold text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
{row.value}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tfoot>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
return (
|
||||
<table
|
||||
className={classNames(
|
||||
isStickyHeader
|
||||
? 'min-w-full border-separate border-spacing-0'
|
||||
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
|
||||
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
||||
)}
|
||||
>
|
||||
{renderTableHead()}
|
||||
|
||||
<tbody
|
||||
className={classNames(
|
||||
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10',
|
||||
isCard
|
||||
? 'bg-white dark:bg-gray-800/50'
|
||||
: 'bg-white dark:bg-gray-900',
|
||||
)}
|
||||
>
|
||||
{renderEmptyRow()}
|
||||
{renderGroupedRows()}
|
||||
</tbody>
|
||||
|
||||
{renderSummaryRows()}
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{renderHeader()}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
title || description || actionLabel || headerAction ? 'mt-8' : '',
|
||||
'flow-root',
|
||||
isFullWidth && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
isConstrained && 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||
!isConstrained && '-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
isConstrained
|
||||
? ''
|
||||
: 'inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8',
|
||||
isFullWidth && 'sm:px-0 lg:px-0',
|
||||
)}
|
||||
>
|
||||
{isCard || isBorder ? (
|
||||
<div
|
||||
className={classNames(
|
||||
isCard &&
|
||||
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
|
||||
isBorder &&
|
||||
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
|
||||
)}
|
||||
>
|
||||
{renderTable()}
|
||||
</div>
|
||||
) : (
|
||||
renderTable()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isAvatarVariant(variant: TableVariant) {
|
||||
return variant === 'avatars'
|
||||
}
|
||||
398
frontend/src/components/Tabs.tsx
Normal file
398
frontend/src/components/Tabs.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
// frontend\src\components\Tabs.tsx
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
||||
|
||||
type TabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export type TabItem = {
|
||||
name: string
|
||||
href: string
|
||||
current?: boolean
|
||||
icon?: TabIcon
|
||||
count?: string | number
|
||||
}
|
||||
|
||||
type TabsVariant =
|
||||
| 'underline'
|
||||
| 'underline-icons'
|
||||
| 'pills'
|
||||
| 'pills-gray'
|
||||
| 'pills-brand'
|
||||
| 'full-width-underline'
|
||||
| 'bar-underline'
|
||||
| 'underline-badges'
|
||||
| 'simple'
|
||||
|
||||
type TabsAlign = 'left' | 'center' | 'right'
|
||||
|
||||
type TabsProps = {
|
||||
tabs: TabItem[]
|
||||
variant?: TabsVariant
|
||||
align?: TabsAlign
|
||||
sticky?: boolean
|
||||
stickyTopClassName?: string
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const alignClassMap: Record<TabsAlign, string> = {
|
||||
left: 'justify-start',
|
||||
center: 'justify-center',
|
||||
right: 'justify-end',
|
||||
}
|
||||
|
||||
function isTabActive(tab: TabItem, pathname: string) {
|
||||
if (typeof tab.current === 'boolean') {
|
||||
return tab.current
|
||||
}
|
||||
|
||||
if (tab.href === '/') {
|
||||
return pathname === '/'
|
||||
}
|
||||
|
||||
return pathname.startsWith(tab.href)
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
variant = 'underline',
|
||||
align = 'left',
|
||||
sticky = false,
|
||||
stickyTopClassName = 'top-16',
|
||||
ariaLabel = 'Tabs',
|
||||
className,
|
||||
}: TabsProps) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeTab = tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
||||
const alignClassName = alignClassMap[align]
|
||||
|
||||
function handleSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
|
||||
|
||||
if (!selectedTab) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTab.href === '#') {
|
||||
return
|
||||
}
|
||||
|
||||
navigate(selectedTab.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
sticky && 'sticky z-30 bg-white dark:bg-gray-900',
|
||||
sticky && stickyTopClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<select
|
||||
value={activeTab?.name}
|
||||
onChange={handleSelectChange}
|
||||
aria-label="Tab auswählen"
|
||||
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.name}>{tab.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
{variant === 'underline' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
'border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'underline-icons' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
const Icon = tab.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-indigo-500 dark:text-indigo-400'
|
||||
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
|
||||
'mr-2 -ml-0.5 size-5',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{tab.name}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'pills' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'pills-gray' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'pills-brand' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'full-width-underline' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className="-mb-px flex">
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||
'flex-1 border-b-2 px-1 py-4 text-center text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'bar-underline' && (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className="isolate flex divide-x divide-gray-200 rounded-lg bg-white shadow-sm dark:divide-white/10 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
{tabs.map((tab, tabIndex) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
|
||||
tabIndex === 0 && 'rounded-l-lg',
|
||||
tabIndex === tabs.length - 1 && 'rounded-r-lg',
|
||||
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
|
||||
'absolute inset-x-0 bottom-0 h-0.5',
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{variant === 'underline-badges' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-white',
|
||||
'flex border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
|
||||
{tab.count ? (
|
||||
<span
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant === 'simple' && (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className="flex overflow-x-auto border-b border-gray-200 dark:border-white/10"
|
||||
>
|
||||
<ul
|
||||
role="list"
|
||||
className={classNames(
|
||||
'flex min-w-full flex-none gap-x-6 px-4 py-3 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400',
|
||||
alignClassName,
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
return (
|
||||
<li key={tab.name}>
|
||||
<Link
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={
|
||||
active
|
||||
? 'text-indigo-600 dark:text-indigo-400'
|
||||
: 'hover:text-gray-700 dark:hover:text-white'
|
||||
}
|
||||
>
|
||||
{tab.name}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/Topbar.tsx
Normal file
132
frontend/src/components/Topbar.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
// frontend/src/components/Topbar.tsx
|
||||
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
Bars3Icon,
|
||||
BellIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { Link } from 'react-router'
|
||||
import Search from './Search'
|
||||
import Camera from './Camera'
|
||||
import type { User } from './types'
|
||||
|
||||
type TopbarProps = {
|
||||
user: User
|
||||
onLogout: () => void | Promise<void>
|
||||
onOpenSidebar: () => void
|
||||
}
|
||||
|
||||
const userNavigation = [
|
||||
{ name: 'Mein Account', href: '/einstellungen' },
|
||||
]
|
||||
|
||||
export default function Topbar({ user, onLogout, onOpenSidebar }: TopbarProps) {
|
||||
function handleQrScan(value: string) {
|
||||
console.log('QR-Code erkannt:', value)
|
||||
}
|
||||
|
||||
const avatarUrl =
|
||||
user.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSidebar}
|
||||
className="-m-2.5 p-2.5 text-gray-700 hover:text-gray-900 lg:hidden dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
|
||||
<div aria-hidden="true" className="h-6 w-px bg-gray-900/10 lg:hidden dark:bg-white/10" />
|
||||
|
||||
<div className="flex flex-1 gap-x-3 self-stretch lg:gap-x-4">
|
||||
<div className="flex items-center">
|
||||
<Camera onScan={handleQrScan} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="hidden h-6 w-px self-center bg-gray-900/10 lg:block dark:bg-white/10"
|
||||
/>
|
||||
|
||||
<Search />
|
||||
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="sr-only">View notifications</span>
|
||||
<BellIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10 dark:lg:bg-gray-100/10"
|
||||
/>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex items-center">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-8 rounded-full bg-gray-50 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<span className="hidden lg:flex lg:items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{user.displayName || user.username}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="ml-2 size-5 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</span>
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
{userNavigation.map((item) => (
|
||||
<MenuItem key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/types.ts
Normal file
38
frontend/src/components/types.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// frontend\src\components\types.ts
|
||||
|
||||
export type Team = {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
href: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: Team[]
|
||||
}
|
||||
|
||||
export type DeviceHistoryChange = {
|
||||
field: string
|
||||
label: string
|
||||
oldValue: string
|
||||
newValue: string
|
||||
}
|
||||
|
||||
export type DeviceHistoryEntry = {
|
||||
id: string
|
||||
deviceId: string
|
||||
userId: string
|
||||
userDisplayName: string
|
||||
action: 'created' | 'updated'
|
||||
changes: DeviceHistoryChange[]
|
||||
createdAt: string
|
||||
}
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
/* frontend\src\index.css */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// main.tsx
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
11
frontend/src/pages/CalendarPage.tsx
Normal file
11
frontend/src/pages/CalendarPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// frontend/src/pages/CalendarPage.tsx
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Kalender
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
frontend/src/pages/DashboardPage.tsx
Normal file
19
frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
// frontend\src\pages\DashboardPage.tsx
|
||||
|
||||
type DashboardPageProps = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Willkommen zurück, {username}.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
698
frontend/src/pages/DevicesPage.tsx
Normal file
698
frontend/src/pages/DevicesPage.tsx
Normal file
@ -0,0 +1,698 @@
|
||||
// frontend/src/pages/DevicesPage.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import Feed, { type FeedTimelineItem } from '../components/Feed'
|
||||
import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
|
||||
import Tables, { type TableColumn } from '../components/Tables'
|
||||
import Button from '../components/Button'
|
||||
import type { DeviceHistoryChange, DeviceHistoryEntry } from '../components/types'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type RelatedDevice = {
|
||||
id: string
|
||||
inventoryNumber: string
|
||||
manufacturer: string
|
||||
model: string
|
||||
}
|
||||
|
||||
type Device = {
|
||||
id: string
|
||||
inventoryNumber: string
|
||||
manufacturer: string
|
||||
model: string
|
||||
serialNumber: string
|
||||
macAddress: string
|
||||
location: string
|
||||
loanStatus: string
|
||||
comment: string
|
||||
relatedDevices: RelatedDevice[]
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
const selectClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
|
||||
|
||||
function getLoanStatusClassName(status: string) {
|
||||
const normalizedStatus = status.toLowerCase()
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('ausgeliehen') ||
|
||||
normalizedStatus.includes('verliehen') ||
|
||||
normalizedStatus.includes('reserviert')
|
||||
) {
|
||||
return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-500/30'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('defekt') ||
|
||||
normalizedStatus.includes('verloren') ||
|
||||
normalizedStatus.includes('gesperrt')
|
||||
) {
|
||||
return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('wartung') ||
|
||||
normalizedStatus.includes('prüfung')
|
||||
) {
|
||||
return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-500/30'
|
||||
}
|
||||
|
||||
return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
|
||||
}
|
||||
|
||||
function formatHistoryDate(value: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatHistoryChange(change: DeviceHistoryChange) {
|
||||
return `${change.label}: ${change.oldValue} → ${change.newValue}`
|
||||
}
|
||||
|
||||
function getHistoryContent(entry: DeviceHistoryEntry) {
|
||||
if (entry.action === 'created') {
|
||||
return 'Gerät erstellt durch'
|
||||
}
|
||||
|
||||
if (!entry.changes.length) {
|
||||
return 'Gerät bearbeitet durch'
|
||||
}
|
||||
|
||||
return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||
const [currentDeviceId, setCurrentDeviceId] = useState<string | null>(null)
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
|
||||
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = editingDevice !== null
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
}, [])
|
||||
|
||||
async function loadDevices() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Geräte konnten nicht geladen werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDevices(data.devices ?? [])
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeviceHistory(deviceId: string) {
|
||||
setIsHistoryLoading(true)
|
||||
setHistoryError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/${deviceId}/history`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Bearbeitungsverlauf konnte nicht geladen werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDeviceHistory(data.history ?? [])
|
||||
} catch (error) {
|
||||
setHistoryError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Bearbeitungsverlauf konnte nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
setCreateError(null)
|
||||
setEditingDevice(null)
|
||||
setRelatedDeviceIds([])
|
||||
setCurrentDeviceId(null)
|
||||
setIsCreateModalOpen(true)
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
if (isCreating) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreateModalOpen(false)
|
||||
setEditingDevice(null)
|
||||
setCurrentDeviceId(null)
|
||||
setRelatedDeviceIds([])
|
||||
setDeviceHistory([])
|
||||
setHistoryError(null)
|
||||
setIsHistoryLoading(false)
|
||||
}
|
||||
|
||||
function openEditModal(device: Device) {
|
||||
setCreateError(null)
|
||||
setHistoryError(null)
|
||||
setDeviceHistory([])
|
||||
setEditingDevice(device)
|
||||
setCurrentDeviceId(device.id)
|
||||
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
|
||||
setIsCreateModalOpen(true)
|
||||
|
||||
void loadDeviceHistory(device.id)
|
||||
}
|
||||
|
||||
function toggleRelatedDevice(deviceId: string) {
|
||||
setRelatedDeviceIds((currentDeviceIds) => {
|
||||
if (currentDeviceIds.includes(deviceId)) {
|
||||
return currentDeviceIds.filter((currentDeviceId) => currentDeviceId !== deviceId)
|
||||
}
|
||||
|
||||
return [...currentDeviceIds, deviceId]
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveDevice(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
|
||||
const payload = {
|
||||
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
|
||||
manufacturer: String(formData.get('manufacturer') ?? ''),
|
||||
model: String(formData.get('model') ?? ''),
|
||||
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||
macAddress: String(formData.get('macAddress') ?? ''),
|
||||
location: String(formData.get('location') ?? ''),
|
||||
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||
comment: String(formData.get('comment') ?? ''),
|
||||
relatedDeviceIds,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
isEditing
|
||||
? `${API_URL}/devices/${editingDevice.id}`
|
||||
: `${API_URL}/devices`,
|
||||
{
|
||||
method: isEditing ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(
|
||||
errorData?.error ??
|
||||
(isEditing ? 'Gerät konnte nicht aktualisiert werden' : 'Gerät konnte nicht erstellt werden'),
|
||||
)
|
||||
}
|
||||
|
||||
form.reset()
|
||||
setRelatedDeviceIds([])
|
||||
setEditingDevice(null)
|
||||
setCurrentDeviceId(null)
|
||||
setIsCreateModalOpen(false)
|
||||
|
||||
await loadDevices()
|
||||
} catch (error) {
|
||||
setCreateError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: isEditing
|
||||
? 'Gerät konnte nicht aktualisiert werden'
|
||||
: 'Gerät konnte nicht erstellt werden',
|
||||
)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumn<Device>[] = [
|
||||
{
|
||||
key: 'inventoryNumber',
|
||||
header: 'Inventur-Nr.',
|
||||
isPrimary: true,
|
||||
render: (device) => device.inventoryNumber,
|
||||
},
|
||||
{
|
||||
key: 'manufacturer',
|
||||
header: 'Hersteller',
|
||||
render: (device) => device.manufacturer || '-',
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modell',
|
||||
render: (device) => device.model || '-',
|
||||
},
|
||||
{
|
||||
key: 'serialNumber',
|
||||
header: 'Seriennummer',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => device.serialNumber || '-',
|
||||
},
|
||||
{
|
||||
key: 'macAddress',
|
||||
header: 'MAC-Adresse',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => device.macAddress || '-',
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Standort',
|
||||
render: (device) => device.location || '-',
|
||||
},
|
||||
{
|
||||
key: 'loanStatus',
|
||||
header: 'Verleih-Status',
|
||||
render: (device) => (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
getLoanStatusClassName(device.loanStatus),
|
||||
].join(' ')}
|
||||
>
|
||||
{device.loanStatus || 'Verfügbar'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'relatedDevices',
|
||||
header: 'Zubehör',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => {
|
||||
if (!device.relatedDevices?.length) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-xs flex-wrap gap-1">
|
||||
{device.relatedDevices.map((relatedDevice) => (
|
||||
<span
|
||||
key={relatedDevice.id}
|
||||
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"
|
||||
>
|
||||
{relatedDevice.inventoryNumber}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'comment',
|
||||
header: 'Kommentar',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => (
|
||||
<span className="block max-w-xs truncate">
|
||||
{device.comment || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const assignableDevices = devices.filter((device) => device.id !== currentDeviceId)
|
||||
|
||||
const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
|
||||
id: entry.id,
|
||||
content: getHistoryContent(entry),
|
||||
target: entry.userDisplayName || 'Unbekannt',
|
||||
href: '#',
|
||||
date: formatHistoryDate(entry.createdAt),
|
||||
datetime: entry.createdAt,
|
||||
icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
|
||||
iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tables
|
||||
title="Geräte"
|
||||
description={
|
||||
isLoading
|
||||
? 'Geräte werden geladen...'
|
||||
: 'Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, Standort, Verleih-Status und Zubehör.'
|
||||
}
|
||||
actionLabel="Gerät hinzufügen"
|
||||
onAction={openCreateModal}
|
||||
rows={devices}
|
||||
columns={columns}
|
||||
getRowId={(device) => device.id}
|
||||
variant="card"
|
||||
emptyText={isLoading ? 'Geräte werden geladen...' : 'Keine Geräte vorhanden.'}
|
||||
rowActions={(device) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(device)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
Bearbeiten<span className="sr-only">, {device.inventoryNumber}</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Dialog open={isCreateModalOpen} onClose={closeCreateModal} className="relative z-50">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/50 transition-opacity data-closed:opacity-0 dark:bg-gray-950/70"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={[
|
||||
'w-full transform overflow-hidden rounded-lg bg-white shadow-xl outline outline-gray-900/5 transition data-closed:scale-95 data-closed:opacity-0 dark:bg-gray-900 dark:outline-white/10',
|
||||
isEditing ? 'max-w-6xl' : 'max-w-3xl',
|
||||
].join(' ')}
|
||||
>
|
||||
<form key={editingDevice?.id ?? 'create'} onSubmit={handleSaveDevice}>
|
||||
<div className="flex items-start justify-between border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||||
<div>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{isEditing ? 'Gerät bearbeiten' : 'Gerät hinzufügen'}
|
||||
</DialogTitle>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{isEditing
|
||||
? 'Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.'
|
||||
: 'Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeCreateModal}
|
||||
className="-m-2.5 rounded-md p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
isEditing
|
||||
? 'grid max-h-[calc(100vh-12rem)] grid-cols-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_24rem]'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 px-4 py-6 sm:grid-cols-6 sm:px-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="inventoryNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Inventur-Nr. *
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="inventoryNumber"
|
||||
name="inventoryNumber"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={editingDevice?.inventoryNumber ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="loanStatus" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Verleih-Status
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
id="loanStatus"
|
||||
name="loanStatus"
|
||||
defaultValue={editingDevice?.loanStatus || 'Verfügbar'}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option>Verfügbar</option>
|
||||
<option>Ausgeliehen</option>
|
||||
<option>Reserviert</option>
|
||||
<option>Wartung</option>
|
||||
<option>Defekt</option>
|
||||
<option>Verloren</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="manufacturer" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Hersteller
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="manufacturer"
|
||||
name="manufacturer"
|
||||
type="text"
|
||||
defaultValue={editingDevice?.manufacturer ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="model" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Modell
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="model"
|
||||
name="model"
|
||||
type="text"
|
||||
defaultValue={editingDevice?.model ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="serialNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Seriennummer
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="serialNumber"
|
||||
name="serialNumber"
|
||||
type="text"
|
||||
defaultValue={editingDevice?.serialNumber ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="macAddress" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
MAC-Adresse
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="macAddress"
|
||||
name="macAddress"
|
||||
type="text"
|
||||
placeholder="00:11:22:33:44:55"
|
||||
defaultValue={editingDevice?.macAddress ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label htmlFor="location" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Standort
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
defaultValue={editingDevice?.location ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label htmlFor="comment" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Kommentar
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
rows={3}
|
||||
defaultValue={editingDevice?.comment ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<fieldset>
|
||||
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Zugeordnete Geräte / Zubehör
|
||||
</legend>
|
||||
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
|
||||
{assignableDevices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{assignableDevices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-center gap-x-3 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={relatedDeviceIds.includes(device.id)}
|
||||
onChange={() => toggleRelatedDevice(device.id)}
|
||||
className="size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
|
||||
/>
|
||||
<span>
|
||||
{device.inventoryNumber}
|
||||
{(device.manufacturer || device.model) && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{' '}
|
||||
— {[device.manufacturer, device.model].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Noch keine anderen Geräte vorhanden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<aside className="border-t border-gray-200 px-4 py-6 sm:px-6 lg:border-t-0 lg:border-l dark:border-white/10">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Bearbeitungsverlauf
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Änderungen an diesem Gerät.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
{isHistoryLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Verlauf wird geladen...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{historyError && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isHistoryLoading && !historyError && historyTimeline.length > 0 && (
|
||||
<Feed
|
||||
variant="icons"
|
||||
timeline={historyTimeline}
|
||||
showCommentForm={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isHistoryLoading && !historyError && historyTimeline.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Noch keine Änderungen erfasst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
onClick={closeCreateModal}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isCreating}
|
||||
>
|
||||
{isEditing ? 'Änderungen speichern' : 'Gerät speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/pages/DocumentsPage.tsx
Normal file
11
frontend/src/pages/DocumentsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// frontend/src/pages/DocumentsPage.tsx
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Dokumente
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/pages/IncidentsPage.tsx
Normal file
11
frontend/src/pages/IncidentsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// frontend/src/pages/IncidentsPage.tsx
|
||||
|
||||
export default function IncidentsPage() {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Einsätze
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/pages/ReportsPage.tsx
Normal file
11
frontend/src/pages/ReportsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// frontend/src/pages/ReportsPage.tsx
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Berichte
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
357
frontend/src/pages/SettingsPage.tsx
Normal file
357
frontend/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,357 @@
|
||||
// frontend\src\pages\SettingsPage.tsx
|
||||
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import {
|
||||
BellIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Tabs from '../components/Tabs'
|
||||
import Button from '../components/Button'
|
||||
import Container from '../components/Container'
|
||||
import type { User } from '../components/types'
|
||||
|
||||
type SettingsPageProps = {
|
||||
user?: User
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Container variant="constrained" className="py-16">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderSection({ title }: { title: string }) {
|
||||
return (
|
||||
<Section
|
||||
title={title}
|
||||
description="Dieser Bereich kann später mit eigenen Einstellungen erweitert werden."
|
||||
>
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Noch keine Einstellungen vorhanden.
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage({ user }: SettingsPageProps) {
|
||||
const location = useLocation()
|
||||
|
||||
const pathname = location.pathname.replace(/\/$/, '')
|
||||
const section = pathname.split('/')[2] ?? 'konto'
|
||||
|
||||
const settingsTabs = [
|
||||
{
|
||||
name: 'Konto',
|
||||
href: '/einstellungen',
|
||||
icon: UserIcon,
|
||||
current: pathname === '/einstellungen',
|
||||
},
|
||||
{
|
||||
name: 'Benachrichtigungen',
|
||||
href: '/einstellungen/benachrichtigungen',
|
||||
icon: BellIcon,
|
||||
current: pathname === '/einstellungen/benachrichtigungen',
|
||||
},
|
||||
{
|
||||
name: 'Abrechnung',
|
||||
href: '/einstellungen/abrechnung',
|
||||
icon: CreditCardIcon,
|
||||
current: pathname === '/einstellungen/abrechnung',
|
||||
},
|
||||
{
|
||||
name: 'Teams',
|
||||
href: '/einstellungen/teams',
|
||||
icon: UsersIcon,
|
||||
current: pathname === '/einstellungen/teams',
|
||||
},
|
||||
{
|
||||
name: 'Integrationen',
|
||||
href: '/einstellungen/integrationen',
|
||||
icon: PuzzlePieceIcon,
|
||||
current: pathname === '/einstellungen/integrationen',
|
||||
},
|
||||
]
|
||||
|
||||
const avatarUrl =
|
||||
user?.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1 className="sr-only">Einstellungen</h1>
|
||||
|
||||
<Tabs
|
||||
tabs={settingsTabs}
|
||||
variant="underline-icons"
|
||||
align="center"
|
||||
sticky
|
||||
stickyTopClassName="top-16"
|
||||
ariaLabel="Einstellungen"
|
||||
/>
|
||||
|
||||
{section === 'konto' && (
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Persönliche Informationen"
|
||||
description="Verwalte deine Profildaten und Kontoinformationen."
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="button" variant="secondary" size="md">
|
||||
Avatar ändern
|
||||
</Button>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
JPG, GIF oder PNG. Maximal 1 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="displayName" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Anzeigename
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
defaultValue={user?.displayName ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="username" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Benutzername
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
defaultValue={user?.username ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label htmlFor="email" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
defaultValue={user?.email ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="unit" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einheit
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="unit"
|
||||
name="unit"
|
||||
type="text"
|
||||
defaultValue={user?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="group" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Gruppe
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="group"
|
||||
name="group"
|
||||
type="text"
|
||||
defaultValue={user?.group ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button type="submit" color="indigo" size="md">
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Passwort ändern"
|
||||
description="Aktualisiere das Passwort deines Benutzerkontos."
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label htmlFor="current-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="current-password"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label htmlFor="new-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="new-password"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label htmlFor="confirm-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button type="submit" color="indigo" size="md">
|
||||
Passwort speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Andere Sitzungen abmelden"
|
||||
description="Melde dich auf anderen Geräten ab, falls du dein Konto absichern möchtest."
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label htmlFor="logout-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Dein Passwort
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="logout-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button type="submit" color="indigo" size="md">
|
||||
Andere Sitzungen abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Konto löschen"
|
||||
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex items-start">
|
||||
<Button type="submit" color="red" size="md">
|
||||
Konto löschen
|
||||
</Button>
|
||||
</form>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === 'benachrichtigungen' && (
|
||||
<PlaceholderSection title="Benachrichtigungen" />
|
||||
)}
|
||||
|
||||
{section === 'abrechnung' && (
|
||||
<PlaceholderSection title="Abrechnung" />
|
||||
)}
|
||||
|
||||
{section === 'teams' && (
|
||||
<PlaceholderSection title="Teams" />
|
||||
)}
|
||||
|
||||
{section === 'integrationen' && (
|
||||
<PlaceholderSection title="Integrationen" />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user