This commit is contained in:
Linrador 2026-05-12 15:29:56 +02:00
commit 3b2ae530a5
48 changed files with 10337 additions and 0 deletions

14
backend/.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View 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"
}
}

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
View 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
View File

@ -0,0 +1 @@
/* frontend\src\App.css */

130
frontend/src/App.tsx Normal file
View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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'
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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(),
],
})