update
This commit is contained in:
commit
3b2ae530a5
14
backend/.env
Normal file
14
backend/.env
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
PORT=8080
|
||||||
|
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
|
||||||
|
JWT_SECRET=tegvideo7010!
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_DISPLAY_NAME=Administrator
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_UNIT=Administration
|
||||||
|
ADMIN_GROUP=admin
|
||||||
|
ADMIN_RIGHTS=admin,users:read,users:write
|
||||||
|
ADMIN_TEAM_NAME=Administration
|
||||||
|
ADMIN_TEAM_INITIAL=A
|
||||||
|
ADMIN_TEAM_HREF=#
|
||||||
25
backend/cors.go
Normal file
25
backend/cors.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// backend\cors.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func (s *Server) cors(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
if origin == s.frontendOrigin {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
561
backend/devices.go
Normal file
561
backend/devices.go
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
// backend/devices.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InventoryNumber string `json:"inventoryNumber"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
SerialNumber string `json:"serialNumber"`
|
||||||
|
MacAddress string `json:"macAddress"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
LoanStatus string `json:"loanStatus"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceShort struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InventoryNumber string `json:"inventoryNumber"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateDeviceRequest struct {
|
||||||
|
InventoryNumber string `json:"inventoryNumber"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
SerialNumber string `json:"serialNumber"`
|
||||||
|
MacAddress string `json:"macAddress"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
LoanStatus string `json:"loanStatus"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateDeviceRequest = CreateDeviceRequest
|
||||||
|
|
||||||
|
var errDeviceNotFound = errors.New("device does not exist")
|
||||||
|
|
||||||
|
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleListDevices(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
s.handleCreateDevice(w, r)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
inventory_number,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial_number,
|
||||||
|
mac_address,
|
||||||
|
location,
|
||||||
|
loan_status,
|
||||||
|
comment,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM devices
|
||||||
|
ORDER BY inventory_number ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not load devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
devices := make([]Device, 0)
|
||||||
|
deviceMap := map[string]*Device{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var device Device
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&device.ID,
|
||||||
|
&device.InventoryNumber,
|
||||||
|
&device.Manufacturer,
|
||||||
|
&device.Model,
|
||||||
|
&device.SerialNumber,
|
||||||
|
&device.MacAddress,
|
||||||
|
&device.Location,
|
||||||
|
&device.LoanStatus,
|
||||||
|
&device.Comment,
|
||||||
|
&device.CreatedAt,
|
||||||
|
&device.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device.RelatedDevices = []DeviceShort{}
|
||||||
|
|
||||||
|
devices = append(devices, device)
|
||||||
|
deviceMap[device.ID] = &devices[len(devices)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read devices")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relationRows, err := s.db.Query(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
device_relations.device_id,
|
||||||
|
related_devices.id,
|
||||||
|
related_devices.inventory_number,
|
||||||
|
related_devices.manufacturer,
|
||||||
|
related_devices.model
|
||||||
|
FROM device_relations
|
||||||
|
INNER JOIN devices AS related_devices
|
||||||
|
ON related_devices.id = device_relations.related_device_id
|
||||||
|
ORDER BY related_devices.inventory_number ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not load device relations")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer relationRows.Close()
|
||||||
|
|
||||||
|
for relationRows.Next() {
|
||||||
|
var deviceID string
|
||||||
|
var relatedDevice DeviceShort
|
||||||
|
|
||||||
|
if err := relationRows.Scan(
|
||||||
|
&deviceID,
|
||||||
|
&relatedDevice.ID,
|
||||||
|
&relatedDevice.InventoryNumber,
|
||||||
|
&relatedDevice.Manufacturer,
|
||||||
|
&relatedDevice.Model,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device, exists := deviceMap[deviceID]
|
||||||
|
if exists {
|
||||||
|
device.RelatedDevices = append(device.RelatedDevices, relatedDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := relationRows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read device relations")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"devices": devices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input CreateDeviceRequest
|
||||||
|
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDeviceInput(&input)
|
||||||
|
|
||||||
|
if input.InventoryNumber == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var device Device
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO devices (
|
||||||
|
inventory_number,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial_number,
|
||||||
|
mac_address,
|
||||||
|
location,
|
||||||
|
loan_status,
|
||||||
|
comment
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
inventory_number,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial_number,
|
||||||
|
mac_address,
|
||||||
|
location,
|
||||||
|
loan_status,
|
||||||
|
comment,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
`,
|
||||||
|
input.InventoryNumber,
|
||||||
|
input.Manufacturer,
|
||||||
|
input.Model,
|
||||||
|
input.SerialNumber,
|
||||||
|
input.MacAddress,
|
||||||
|
input.Location,
|
||||||
|
input.LoanStatus,
|
||||||
|
input.Comment,
|
||||||
|
).Scan(
|
||||||
|
&device.ID,
|
||||||
|
&device.InventoryNumber,
|
||||||
|
&device.Manufacturer,
|
||||||
|
&device.Model,
|
||||||
|
&device.SerialNumber,
|
||||||
|
&device.MacAddress,
|
||||||
|
&device.Location,
|
||||||
|
&device.LoanStatus,
|
||||||
|
&device.Comment,
|
||||||
|
&device.CreatedAt,
|
||||||
|
&device.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
|
||||||
|
if errors.Is(err, errDeviceNotFound) {
|
||||||
|
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not assign related device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := buildDeviceCreateHistoryChanges(r.Context(), tx, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "created", changes); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device.RelatedDevices = []DeviceShort{}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"device": device,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
|
if deviceID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input UpdateDeviceRequest
|
||||||
|
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeDeviceInput(&input)
|
||||||
|
|
||||||
|
if input.InventoryNumber == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Inventur-Nr. ist erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, ok := userFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not load device history state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var device Device
|
||||||
|
|
||||||
|
err = tx.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
UPDATE devices
|
||||||
|
SET
|
||||||
|
inventory_number = $2,
|
||||||
|
manufacturer = $3,
|
||||||
|
model = $4,
|
||||||
|
serial_number = $5,
|
||||||
|
mac_address = $6,
|
||||||
|
location = $7,
|
||||||
|
loan_status = $8,
|
||||||
|
comment = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
inventory_number,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial_number,
|
||||||
|
mac_address,
|
||||||
|
location,
|
||||||
|
loan_status,
|
||||||
|
comment,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
input.InventoryNumber,
|
||||||
|
input.Manufacturer,
|
||||||
|
input.Model,
|
||||||
|
input.SerialNumber,
|
||||||
|
input.MacAddress,
|
||||||
|
input.Location,
|
||||||
|
input.LoanStatus,
|
||||||
|
input.Comment,
|
||||||
|
).Scan(
|
||||||
|
&device.ID,
|
||||||
|
&device.InventoryNumber,
|
||||||
|
&device.Manufacturer,
|
||||||
|
&device.Model,
|
||||||
|
&device.SerialNumber,
|
||||||
|
&device.MacAddress,
|
||||||
|
&device.Location,
|
||||||
|
&device.LoanStatus,
|
||||||
|
&device.Comment,
|
||||||
|
&device.CreatedAt,
|
||||||
|
&device.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
writeError(w, http.StatusConflict, "Gerät mit dieser Inventur-Nr., Seriennummer oder MAC-Adresse existiert bereits")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syncDeviceRelations(r.Context(), tx, device.ID, input.RelatedDeviceIDs); err != nil {
|
||||||
|
if errors.Is(err, errDeviceNotFound) {
|
||||||
|
writeError(w, http.StatusBadRequest, "Zugeordnetes Gerät wurde nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not update device relations")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := buildDeviceUpdateHistoryChanges(r.Context(), tx, oldDevice, oldRelatedDeviceIDs, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) > 0 {
|
||||||
|
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not update device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device.RelatedDevices = []DeviceShort{}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"device": device,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error {
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err := tx.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM devices
|
||||||
|
WHERE id = $1
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return errDeviceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceInput(input *CreateDeviceRequest) {
|
||||||
|
input.InventoryNumber = strings.TrimSpace(input.InventoryNumber)
|
||||||
|
input.Manufacturer = strings.TrimSpace(input.Manufacturer)
|
||||||
|
input.Model = strings.TrimSpace(input.Model)
|
||||||
|
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||||
|
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||||
|
input.Location = strings.TrimSpace(input.Location)
|
||||||
|
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
|
||||||
|
input.Comment = strings.TrimSpace(input.Comment)
|
||||||
|
|
||||||
|
if input.LoanStatus == "" {
|
||||||
|
input.LoanStatus = "Verfügbar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
DELETE FROM device_relations
|
||||||
|
WHERE device_id = $1 OR related_device_id = $1
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
|
for _, relatedDeviceID := range relatedDeviceIDs {
|
||||||
|
relatedDeviceID = strings.TrimSpace(relatedDeviceID)
|
||||||
|
|
||||||
|
if relatedDeviceID == "" || relatedDeviceID == deviceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen[relatedDeviceID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[relatedDeviceID] = true
|
||||||
|
|
||||||
|
if err := ensureDeviceExists(ctx, tx, relatedDeviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := assignRelatedDevice(ctx, tx, deviceID, relatedDeviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := assignRelatedDevice(ctx, tx, relatedDeviceID, deviceID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignRelatedDevice(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceID string) error {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO device_relations (
|
||||||
|
device_id,
|
||||||
|
related_device_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (device_id, related_device_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
relatedDeviceID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceTransaction interface {
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
}
|
||||||
25
backend/go.mod
Normal file
25
backend/go.mod
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module main
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgconn v1.14.3
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
)
|
||||||
43
backend/go.sum
Normal file
43
backend/go.sum
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
|
||||||
|
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
|
||||||
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
377
backend/history.go
Normal file
377
backend/history.go
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceHistoryEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
UserDisplayName string `json:"userDisplayName"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Changes []DeviceHistoryChange `json:"changes"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceHistoryChange struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
OldValue string `json:"oldValue"`
|
||||||
|
NewValue string `json:"newValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
|
||||||
|
if deviceID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.device_id,
|
||||||
|
COALESCE(h.user_id::TEXT, ''),
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'),
|
||||||
|
h.action,
|
||||||
|
h.changes,
|
||||||
|
h.created_at
|
||||||
|
FROM device_history h
|
||||||
|
LEFT JOIN users u ON u.id = h.user_id
|
||||||
|
WHERE h.device_id = $1
|
||||||
|
ORDER BY h.created_at DESC
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not load device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
history := []DeviceHistoryEntry{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var entry DeviceHistoryEntry
|
||||||
|
var changesRaw []byte
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&entry.ID,
|
||||||
|
&entry.DeviceID,
|
||||||
|
&entry.UserID,
|
||||||
|
&entry.UserDisplayName,
|
||||||
|
&entry.Action,
|
||||||
|
&changesRaw,
|
||||||
|
&entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changesRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not parse device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history = append(history, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not read device history")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"history": history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFromContext(ctx context.Context) (User, bool) {
|
||||||
|
user, ok := ctx.Value(userContextKey).(User)
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDeviceHistory(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx,
|
||||||
|
deviceID string,
|
||||||
|
user User,
|
||||||
|
action string,
|
||||||
|
changes []DeviceHistoryChange,
|
||||||
|
) error {
|
||||||
|
changesJSON, err := json.Marshal(changes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO device_history (
|
||||||
|
device_id,
|
||||||
|
user_id,
|
||||||
|
action,
|
||||||
|
changes
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4::JSONB)
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
user.ID,
|
||||||
|
action,
|
||||||
|
string(changesJSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Device, []string, error) {
|
||||||
|
var device Device
|
||||||
|
|
||||||
|
err := tx.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
inventory_number,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
serial_number,
|
||||||
|
mac_address,
|
||||||
|
location,
|
||||||
|
loan_status,
|
||||||
|
comment,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM devices
|
||||||
|
WHERE id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
).Scan(
|
||||||
|
&device.ID,
|
||||||
|
&device.InventoryNumber,
|
||||||
|
&device.Manufacturer,
|
||||||
|
&device.Model,
|
||||||
|
&device.SerialNumber,
|
||||||
|
&device.MacAddress,
|
||||||
|
&device.Location,
|
||||||
|
&device.LoanStatus,
|
||||||
|
&device.Comment,
|
||||||
|
&device.CreatedAt,
|
||||||
|
&device.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return device, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT related_device_id
|
||||||
|
FROM device_relations
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY related_device_id ASC
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return device, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
relatedDeviceIDs := []string{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var relatedDeviceID string
|
||||||
|
|
||||||
|
if err := rows.Scan(&relatedDeviceID); err != nil {
|
||||||
|
return device, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedDeviceIDs = append(relatedDeviceIDs, relatedDeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return device, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return device, relatedDeviceIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDeviceCreateHistoryChanges(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx,
|
||||||
|
input CreateDeviceRequest,
|
||||||
|
) ([]DeviceHistoryChange, error) {
|
||||||
|
changes := []DeviceHistoryChange{}
|
||||||
|
|
||||||
|
changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", "", input.InventoryNumber)
|
||||||
|
changes = appendHistoryChange(changes, "manufacturer", "Hersteller", "", input.Manufacturer)
|
||||||
|
changes = appendHistoryChange(changes, "model", "Modell", "", input.Model)
|
||||||
|
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber)
|
||||||
|
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress)
|
||||||
|
changes = appendHistoryChange(changes, "location", "Standort", "", input.Location)
|
||||||
|
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus)
|
||||||
|
changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment)
|
||||||
|
|
||||||
|
relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changes = appendHistoryChange(changes, "relatedDeviceIds", "Zugeordnete Geräte / Zubehör", "", relatedDevices)
|
||||||
|
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDeviceUpdateHistoryChanges(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx,
|
||||||
|
oldDevice Device,
|
||||||
|
oldRelatedDeviceIDs []string,
|
||||||
|
input UpdateDeviceRequest,
|
||||||
|
) ([]DeviceHistoryChange, error) {
|
||||||
|
changes := []DeviceHistoryChange{}
|
||||||
|
|
||||||
|
changes = appendHistoryChange(changes, "inventoryNumber", "Inventur-Nr.", oldDevice.InventoryNumber, input.InventoryNumber)
|
||||||
|
changes = appendHistoryChange(changes, "manufacturer", "Hersteller", oldDevice.Manufacturer, input.Manufacturer)
|
||||||
|
changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model)
|
||||||
|
changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber)
|
||||||
|
changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress)
|
||||||
|
changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location)
|
||||||
|
changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus)
|
||||||
|
changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment)
|
||||||
|
|
||||||
|
oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changes = appendHistoryChange(
|
||||||
|
changes,
|
||||||
|
"relatedDeviceIds",
|
||||||
|
"Zugeordnete Geräte / Zubehör",
|
||||||
|
oldRelatedDevices,
|
||||||
|
newRelatedDevices,
|
||||||
|
)
|
||||||
|
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHistoryChange(
|
||||||
|
changes []DeviceHistoryChange,
|
||||||
|
field string,
|
||||||
|
label string,
|
||||||
|
oldValue string,
|
||||||
|
newValue string,
|
||||||
|
) []DeviceHistoryChange {
|
||||||
|
oldValue = strings.TrimSpace(oldValue)
|
||||||
|
newValue = strings.TrimSpace(newValue)
|
||||||
|
|
||||||
|
if oldValue == newValue {
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(changes, DeviceHistoryChange{
|
||||||
|
Field: field,
|
||||||
|
Label: label,
|
||||||
|
OldValue: displayHistoryValue(oldValue),
|
||||||
|
NewValue: displayHistoryValue(newValue),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayHistoryValue(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []string) (string, error) {
|
||||||
|
normalizedDeviceIDs := normalizeDeviceIDs(deviceIDs)
|
||||||
|
|
||||||
|
if len(normalizedDeviceIDs) == 0 {
|
||||||
|
return "-", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{}
|
||||||
|
|
||||||
|
for _, deviceID := range normalizedDeviceIDs {
|
||||||
|
var label string
|
||||||
|
|
||||||
|
err := tx.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT TRIM(CONCAT_WS(' ', inventory_number, NULLIF(manufacturer, ''), NULLIF(model, '')))
|
||||||
|
FROM devices
|
||||||
|
WHERE id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
deviceID,
|
||||||
|
).Scan(&label)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", errDeviceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
|
||||||
|
if label == "" {
|
||||||
|
label = deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = append(labels, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(labels, ", "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceIDs(deviceIDs []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := []string{}
|
||||||
|
|
||||||
|
for _, deviceID := range deviceIDs {
|
||||||
|
deviceID = strings.TrimSpace(deviceID)
|
||||||
|
|
||||||
|
if deviceID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen[deviceID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[deviceID] = true
|
||||||
|
result = append(result, deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
76
backend/main.go
Normal file
76
backend/main.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// backend\main.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
port := env("PORT", "8080")
|
||||||
|
databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable")
|
||||||
|
jwtSecret := env("JWT_SECRET", "dev-secret-change-me")
|
||||||
|
frontendOrigin := env("FRONTEND_ORIGIN", "http://localhost:5173")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
db, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Ping(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := NewServer(db, jwtSecret, frontendOrigin)
|
||||||
|
|
||||||
|
log.Printf("Backend läuft auf http://localhost:%s", port)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(":"+port, server.Routes()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(r *http.Request, target any) error {
|
||||||
|
return json.NewDecoder(r.Body).Decode(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{
|
||||||
|
"error": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key string, fallback string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
23
backend/routes.go
Normal file
23
backend/routes.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// backend\routes.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func (s *Server) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /auth/register", s.handleRegister)
|
||||||
|
mux.HandleFunc("POST /auth/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /auth/logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||||
|
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||||
|
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||||
|
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||||
|
|
||||||
|
return s.cors(mux)
|
||||||
|
}
|
||||||
461
backend/server.go
Normal file
461
backend/server.go
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
// backend\server.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const userContextKey contextKey = "user"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
jwtSecret []byte
|
||||||
|
frontendOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Rights []string `json:"rights"`
|
||||||
|
Teams []Team `json:"teams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Initial string `json:"initial"`
|
||||||
|
Href string `json:"href"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Rights []string `json:"rights"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Server {
|
||||||
|
return &Server{
|
||||||
|
db: db,
|
||||||
|
jwtSecret: []byte(jwtSecret),
|
||||||
|
frontendOrigin: frontendOrigin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input RegisterRequest
|
||||||
|
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := normalizeUsername(input.Username)
|
||||||
|
email := normalizeEmail(input.Email)
|
||||||
|
|
||||||
|
if username == "" || email == "" || len(input.Password) < 8 {
|
||||||
|
writeError(w, http.StatusBadRequest, "Benutzername, E-Mail und Passwort mit mindestens 8 Zeichen sind erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := strings.TrimSpace(input.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = username
|
||||||
|
}
|
||||||
|
|
||||||
|
group := strings.TrimSpace(input.Group)
|
||||||
|
if group == "" {
|
||||||
|
group = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
rights := input.Rights
|
||||||
|
if len(rights) == 0 {
|
||||||
|
rights = []string{"user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
r.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
avatar,
|
||||||
|
unit,
|
||||||
|
user_group,
|
||||||
|
rights
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
COALESCE(username, ''),
|
||||||
|
COALESCE(display_name, ''),
|
||||||
|
email,
|
||||||
|
COALESCE(avatar, ''),
|
||||||
|
COALESCE(unit, ''),
|
||||||
|
COALESCE(user_group, ''),
|
||||||
|
COALESCE(rights, '{}'::TEXT[])
|
||||||
|
`,
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
email,
|
||||||
|
string(passwordHash),
|
||||||
|
strings.TrimSpace(input.Avatar),
|
||||||
|
strings.TrimSpace(input.Unit),
|
||||||
|
group,
|
||||||
|
rights,
|
||||||
|
).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Avatar,
|
||||||
|
&user.Unit,
|
||||||
|
&user.Group,
|
||||||
|
&user.Rights,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "User already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.setSessionCookie(w, user); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input LoginRequest
|
||||||
|
|
||||||
|
if err := readJSON(r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier := strings.ToLower(strings.TrimSpace(input.Identifier))
|
||||||
|
|
||||||
|
if identifier == "" || input.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort sind erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, passwordHash, err := s.getUserByIdentifier(r.Context(), identifier)
|
||||||
|
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(input.Password)); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Invalid username/email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.setSessionCookie(w, user); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"message": "Logged out",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := r.Context().Value(userContextKey).(User)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: user.ID,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
tokenString, err := token.SignedString(s.jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: tokenString,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 60 * 60 * 24,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
|
||||||
|
// In Produktion auf true setzen, wenn HTTPS aktiv ist.
|
||||||
|
Secure: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(token.Method.Alg()), []byte(jwt.SigningMethodHS256.Alg())) != 1 {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.getUserByID(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (User, string, error) {
|
||||||
|
var user User
|
||||||
|
var passwordHash string
|
||||||
|
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(username, ''),
|
||||||
|
COALESCE(display_name, ''),
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
COALESCE(avatar, ''),
|
||||||
|
COALESCE(unit, ''),
|
||||||
|
COALESCE(user_group, ''),
|
||||||
|
COALESCE(rights, '{}'::TEXT[])
|
||||||
|
FROM users
|
||||||
|
WHERE lower(email) = $1 OR lower(username) = $1
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
identifier,
|
||||||
|
).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&passwordHash,
|
||||||
|
&user.Avatar,
|
||||||
|
&user.Unit,
|
||||||
|
&user.Group,
|
||||||
|
&user.Rights,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return user, passwordHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := s.getTeamsForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return user, passwordHash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Teams = teams
|
||||||
|
|
||||||
|
return user, passwordHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
|
||||||
|
var user User
|
||||||
|
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(username, ''),
|
||||||
|
COALESCE(display_name, ''),
|
||||||
|
email,
|
||||||
|
COALESCE(avatar, ''),
|
||||||
|
COALESCE(unit, ''),
|
||||||
|
COALESCE(user_group, ''),
|
||||||
|
COALESCE(rights, '{}'::TEXT[])
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
userID,
|
||||||
|
).Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.Username,
|
||||||
|
&user.DisplayName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Avatar,
|
||||||
|
&user.Unit,
|
||||||
|
&user.Group,
|
||||||
|
&user.Rights,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
teams, err := s.getTeamsForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Teams = teams
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUsername(username string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTeamsForUser(ctx context.Context, userID string) ([]Team, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.name,
|
||||||
|
t.initial,
|
||||||
|
t.href
|
||||||
|
FROM teams t
|
||||||
|
INNER JOIN user_teams ut ON ut.team_id = t.id
|
||||||
|
WHERE ut.user_id = $1
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
teams := []Team{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var team Team
|
||||||
|
|
||||||
|
if err := rows.Scan(&team.ID, &team.Name, &team.Initial, &team.Href); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
team.Current = false
|
||||||
|
teams = append(teams, team)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return teams, nil
|
||||||
|
}
|
||||||
550
backend/setup/main.go
Normal file
550
backend/setup/main.go
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
// backend\setup\main.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
reset := flag.Bool("reset", false, "Datenbank komplett löschen und neu anlegen")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := godotenv.Load(".env", "backend/.env"); err != nil {
|
||||||
|
log.Println("Keine .env Datei gefunden, nutze System-Environment oder Fallbacks")
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseURL := env("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teg?sslmode=disable")
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseName := strings.TrimPrefix(parsedURL.Path, "/")
|
||||||
|
if databaseName == "" {
|
||||||
|
log.Fatal("DATABASE_URL enthält keinen Datenbanknamen")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if *reset {
|
||||||
|
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createDatabaseIfNotExists(ctx, parsedURL, databaseName); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Ping(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createSchema(ctx, db); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, created, err := createAdminIfNotExists(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printAdminResult(password, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
|
||||||
|
if databaseName == "postgres" || databaseName == "template0" || databaseName == "template1" {
|
||||||
|
return fmt.Errorf("datenbank %q darf nicht per -reset gelöscht werden", databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenanceURL := *parsedURL
|
||||||
|
maintenanceURL.Path = "/postgres"
|
||||||
|
|
||||||
|
db, err := pgxpool.New(ctx, maintenanceURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||||
|
databaseName,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
fmt.Printf("Datenbank %q existiert nicht, Reset übersprungen\n", databaseName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
|
||||||
|
|
||||||
|
_, err = db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT pg_terminate_backend(pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = $1
|
||||||
|
AND pid <> pg_backend_pid()
|
||||||
|
`,
|
||||||
|
databaseName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdentifier(databaseName))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Datenbank %q wurde gelöscht\n", databaseName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDatabaseIfNotExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
|
||||||
|
maintenanceURL := *parsedURL
|
||||||
|
maintenanceURL.Path = "/postgres"
|
||||||
|
|
||||||
|
db, err := pgxpool.New(ctx, maintenanceURL.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||||
|
databaseName,
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
fmt.Printf("Datenbank %q existiert bereits\n", databaseName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, `CREATE DATABASE `+quoteIdentifier(databaseName))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Datenbank %q wurde erstellt\n", databaseName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||||
|
queries := []string{
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS pgcrypto`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
|
||||||
|
avatar TEXT,
|
||||||
|
unit TEXT,
|
||||||
|
user_group TEXT,
|
||||||
|
rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
initial TEXT NOT NULL,
|
||||||
|
href TEXT NOT NULL DEFAULT '#',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_teams (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
PRIMARY KEY (user_id, team_id)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
inventory_number TEXT NOT NULL,
|
||||||
|
manufacturer TEXT NOT NULL DEFAULT '',
|
||||||
|
model TEXT NOT NULL DEFAULT '',
|
||||||
|
serial_number TEXT NOT NULL DEFAULT '',
|
||||||
|
mac_address TEXT NOT NULL DEFAULT '',
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS device_relations (
|
||||||
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
PRIMARY KEY (device_id, related_device_id),
|
||||||
|
CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS device_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
changes JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`,
|
||||||
|
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`,
|
||||||
|
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := db.Exec(ctx, query); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Tabellen und Indizes wurden erstellt oder existieren bereits")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool, error) {
|
||||||
|
adminUsername := env("ADMIN_USERNAME", "admin")
|
||||||
|
adminDisplayName := env("ADMIN_DISPLAY_NAME", "Administrator")
|
||||||
|
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
|
||||||
|
adminAvatar := env("ADMIN_AVATAR", "")
|
||||||
|
adminUnit := env("ADMIN_UNIT", "Administration")
|
||||||
|
adminGroup := env("ADMIN_GROUP", "admin")
|
||||||
|
adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write"))
|
||||||
|
|
||||||
|
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
|
||||||
|
adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A")
|
||||||
|
adminTeamHref := env("ADMIN_TEAM_HREF", "#")
|
||||||
|
|
||||||
|
teamID, err := ensureTeam(ctx, db, adminTeamName, adminTeamInitial, adminTeamHref)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingUserID string
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1 OR email = $2
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
adminUsername,
|
||||||
|
adminEmail,
|
||||||
|
).Scan(&existingUserID)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
if err := assignUserToTeam(ctx, db, existingUserID, teamID); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
password, err := generatePassword(24)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
avatar,
|
||||||
|
unit,
|
||||||
|
user_group,
|
||||||
|
rights
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::TEXT[])
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
adminUsername,
|
||||||
|
adminDisplayName,
|
||||||
|
adminEmail,
|
||||||
|
string(passwordHash),
|
||||||
|
adminAvatar,
|
||||||
|
adminUnit,
|
||||||
|
adminGroup,
|
||||||
|
toPostgresTextArray(adminRights),
|
||||||
|
).Scan(&userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := assignUserToTeam(ctx, db, userID, teamID); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return password, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTeam(ctx context.Context, db *pgxpool.Pool, name string, initial string, href string) (string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
initial = strings.TrimSpace(initial)
|
||||||
|
href = strings.TrimSpace(href)
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = "Administration"
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial == "" {
|
||||||
|
initial = strings.ToUpper(string([]rune(name)[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if href == "" {
|
||||||
|
href = "#"
|
||||||
|
}
|
||||||
|
|
||||||
|
var teamID string
|
||||||
|
|
||||||
|
err := db.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO teams (name, initial, href)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (name)
|
||||||
|
DO UPDATE SET
|
||||||
|
initial = EXCLUDED.initial,
|
||||||
|
href = EXCLUDED.href,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
name,
|
||||||
|
initial,
|
||||||
|
href,
|
||||||
|
).Scan(&teamID)
|
||||||
|
|
||||||
|
return teamID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignUserToTeam(ctx context.Context, db *pgxpool.Pool, userID string, teamID string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO user_teams (user_id, team_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, team_id) DO NOTHING
|
||||||
|
`,
|
||||||
|
userID,
|
||||||
|
teamID,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePassword(length int) (string, error) {
|
||||||
|
randomBytes := make([]byte, length)
|
||||||
|
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
password := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||||
|
|
||||||
|
if len(password) > length {
|
||||||
|
password = password[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printAdminResult(password string, created bool) {
|
||||||
|
adminUsername := env("ADMIN_USERNAME", "admin")
|
||||||
|
adminEmail := env("ADMIN_EMAIL", "admin@example.com")
|
||||||
|
adminTeamName := env("ADMIN_TEAM_NAME", "Administration")
|
||||||
|
|
||||||
|
fmt.Println("")
|
||||||
|
|
||||||
|
if created {
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Println("Admin-User wurde erstellt")
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Printf("Benutzername: %s\n", adminUsername)
|
||||||
|
fmt.Printf("E-Mail: %s\n", adminEmail)
|
||||||
|
fmt.Printf("Team: %s\n", adminTeamName)
|
||||||
|
fmt.Printf("Passwort: %s\n", password)
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Println("Bitte Passwort sicher speichern. Es wird nicht erneut angezeigt.")
|
||||||
|
fmt.Println("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Println("Admin-User existiert bereits")
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Printf("Benutzername: %s\n", adminUsername)
|
||||||
|
fmt.Printf("E-Mail: %s\n", adminEmail)
|
||||||
|
fmt.Printf("Team: %s\n", adminTeamName)
|
||||||
|
fmt.Println("Passwort: wird nicht angezeigt, da nur der Hash gespeichert wird")
|
||||||
|
fmt.Println("=================================================")
|
||||||
|
fmt.Println("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPostgresTextArray(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped := make([]string, 0, len(values))
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||||
|
|
||||||
|
escaped = append(escaped, `"`+value+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(escaped) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{" + strings.Join(escaped, ",") + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
|
||||||
|
if part != "" {
|
||||||
|
result = append(result, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdentifier(value string) string {
|
||||||
|
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key string, fallback string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>teg</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3348
frontend/package-lock.json
generated
Normal file
3348
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "teg",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.10",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router": "^7.15.0",
|
||||||
|
"tailwindcss": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.59.2",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
1
frontend/src/App.css
Normal file
1
frontend/src/App.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* frontend\src\App.css */
|
||||||
130
frontend/src/App.tsx
Normal file
130
frontend/src/App.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// frontend\src\App.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { Navigate, Route, Routes } from 'react-router'
|
||||||
|
import LoginForm from './components/LoginForm'
|
||||||
|
import AppLayout from './components/AppLayout'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import DevicesPage from './pages/DevicesPage'
|
||||||
|
import IncidentsPage from './pages/IncidentsPage'
|
||||||
|
import CalendarPage from './pages/CalendarPage'
|
||||||
|
import DocumentsPage from './pages/DocumentsPage'
|
||||||
|
import ReportsPage from './pages/ReportsPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import type { Team, User } from './components/types'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setUser(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setUser(data.user)
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
setIsLoggingIn(true)
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
const identifier = String(formData.get('identifier'))
|
||||||
|
const password = String(formData.get('password'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ identifier, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
alert(errorData?.error ?? 'Login fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setUser(data.user)
|
||||||
|
} catch {
|
||||||
|
alert('Backend ist nicht erreichbar')
|
||||||
|
} finally {
|
||||||
|
setIsLoggingIn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<LoginForm
|
||||||
|
onSubmit={handleLogin}
|
||||||
|
submitText="Anmelden"
|
||||||
|
isSubmitting={isLoggingIn}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout user={user} onLogout={handleLogout}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage username={user.username} />} />
|
||||||
|
<Route path="/geraete" element={<DevicesPage />} />
|
||||||
|
<Route path="/einsaetze" element={<IncidentsPage />} />
|
||||||
|
<Route path="/kalender" element={<CalendarPage />} />
|
||||||
|
<Route path="/dokumente" element={<DocumentsPage />} />
|
||||||
|
<Route path="/berichte" element={<ReportsPage />} />
|
||||||
|
<Route path="/einstellungen/*" element={<SettingsPage user={user} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
38
frontend/src/components/AppLayout.tsx
Normal file
38
frontend/src/components/AppLayout.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// frontend\src\components\AppLayout.tsx
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import Topbar from './Topbar'
|
||||||
|
import type { User } from './types'
|
||||||
|
|
||||||
|
type AppLayoutProps = {
|
||||||
|
user: User
|
||||||
|
onLogout: () => void | Promise<void>
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ user, onLogout, children }: AppLayoutProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Sidebar
|
||||||
|
user={user}
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
setSidebarOpen={setSidebarOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="lg:pl-72">
|
||||||
|
<Topbar
|
||||||
|
user={user}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onOpenSidebar={() => setSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
frontend/src/components/Button.tsx
Normal file
200
frontend/src/components/Button.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
// frontend\src\components\ui\Button.tsx
|
||||||
|
|
||||||
|
import { type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'soft'
|
||||||
|
type Size = 'xs' | 'sm' | 'md' | 'lg'
|
||||||
|
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
|
||||||
|
|
||||||
|
export type ButtonProps = Omit<
|
||||||
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
'children'
|
||||||
|
> & {
|
||||||
|
children: ReactNode
|
||||||
|
variant?: Variant
|
||||||
|
size?: Size
|
||||||
|
color?: Color
|
||||||
|
rounded?: 'sm' | 'md' | 'full'
|
||||||
|
leadingIcon?: ReactNode
|
||||||
|
trailingIcon?: ReactNode
|
||||||
|
isLoading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 ' +
|
||||||
|
'disabled:cursor-not-allowed disabled:saturate-0 disabled:opacity-60 disabled:hover:brightness-100'
|
||||||
|
|
||||||
|
const disabledPrimary =
|
||||||
|
'disabled:!bg-gray-300 disabled:!text-gray-600 disabled:shadow-none disabled:hover:!bg-gray-300 ' +
|
||||||
|
'dark:disabled:!bg-white/10 dark:disabled:!text-white/40 dark:disabled:hover:!bg-white/10'
|
||||||
|
|
||||||
|
const disabledSecondary =
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
|
||||||
|
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5'
|
||||||
|
|
||||||
|
const disabledSoft =
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:inset-ring-gray-200 disabled:shadow-none disabled:hover:bg-gray-100 ' +
|
||||||
|
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:hover:bg-white/5'
|
||||||
|
|
||||||
|
const roundedMap = {
|
||||||
|
sm: 'rounded-sm',
|
||||||
|
md: 'rounded-md',
|
||||||
|
full: 'rounded-full',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const sizeMap: Record<Size, string> = {
|
||||||
|
xs: 'px-2 py-1 text-xs',
|
||||||
|
sm: 'px-2.5 py-1.5 text-sm',
|
||||||
|
md: 'px-3 py-2 text-sm',
|
||||||
|
lg: 'px-3.5 py-2.5 text-sm',
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<Color, Record<Variant, string>> = {
|
||||||
|
indigo: {
|
||||||
|
primary:
|
||||||
|
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' +
|
||||||
|
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
|
secondary:
|
||||||
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' +
|
||||||
|
disabledSecondary,
|
||||||
|
soft:
|
||||||
|
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/20 hover:inset-ring-indigo-500/25 ' +
|
||||||
|
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
|
},
|
||||||
|
|
||||||
|
blue: {
|
||||||
|
primary:
|
||||||
|
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-500 focus-visible:outline-blue-600 ' +
|
||||||
|
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
|
secondary:
|
||||||
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' +
|
||||||
|
disabledSecondary,
|
||||||
|
soft:
|
||||||
|
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/20 hover:inset-ring-blue-500/25 ' +
|
||||||
|
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
|
},
|
||||||
|
|
||||||
|
emerald: {
|
||||||
|
primary:
|
||||||
|
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-500 focus-visible:outline-emerald-600 ' +
|
||||||
|
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
|
secondary:
|
||||||
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' +
|
||||||
|
disabledSecondary,
|
||||||
|
soft:
|
||||||
|
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/20 hover:inset-ring-emerald-500/25 ' +
|
||||||
|
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
|
},
|
||||||
|
|
||||||
|
red: {
|
||||||
|
primary:
|
||||||
|
'!bg-red-600 !text-white shadow-sm hover:!bg-red-500 focus-visible:outline-red-600 ' +
|
||||||
|
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
|
secondary:
|
||||||
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' +
|
||||||
|
disabledSecondary,
|
||||||
|
soft:
|
||||||
|
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/20 hover:inset-ring-red-500/25 ' +
|
||||||
|
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
|
},
|
||||||
|
|
||||||
|
amber: {
|
||||||
|
primary:
|
||||||
|
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-400 focus-visible:outline-amber-500 ' +
|
||||||
|
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
|
secondary:
|
||||||
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' +
|
||||||
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' +
|
||||||
|
disabledSecondary,
|
||||||
|
soft:
|
||||||
|
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/18 hover:inset-ring-amber-500/25 ' +
|
||||||
|
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true">
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
opacity="0.25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22 12a10 10 0 0 1-10 10"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
color = 'indigo',
|
||||||
|
size = 'md',
|
||||||
|
rounded = 'md',
|
||||||
|
leadingIcon,
|
||||||
|
trailingIcon,
|
||||||
|
isLoading = false,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const iconGap = leadingIcon || trailingIcon || isLoading ? 'gap-x-1.5' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={cn(
|
||||||
|
base,
|
||||||
|
roundedMap[rounded],
|
||||||
|
sizeMap[size],
|
||||||
|
colorMap[color][variant],
|
||||||
|
iconGap,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="-ml-0.5">
|
||||||
|
<Spinner />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
leadingIcon && <span className="-ml-0.5">{leadingIcon}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{children}</span>
|
||||||
|
|
||||||
|
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/components/ButtonGroup.tsx
Normal file
116
frontend/src/components/ButtonGroup.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
// components/ui/ButtonGroup.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Size = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
export type ButtonGroupItem = {
|
||||||
|
id: string
|
||||||
|
label?: ReactNode
|
||||||
|
icon?: ReactNode
|
||||||
|
srLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ButtonGroupProps = {
|
||||||
|
items: ButtonGroupItem[]
|
||||||
|
value: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
size?: Size
|
||||||
|
className?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
|
return parts.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
||||||
|
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
|
||||||
|
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
|
||||||
|
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ButtonGroup({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
ariaLabel = 'Optionen',
|
||||||
|
}: ButtonGroupProps) {
|
||||||
|
const s = sizeMap[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'isolate inline-flex overflow-hidden rounded-md shadow-xs ring-1 ring-gray-300 dark:shadow-none dark:ring-gray-700',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="group"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{items.map((it, idx) => {
|
||||||
|
const active = it.id === value
|
||||||
|
const isFirst = idx === 0
|
||||||
|
const isLast = idx === items.length - 1
|
||||||
|
const iconOnly = !it.label && !!it.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
disabled={it.disabled}
|
||||||
|
onClick={() => onChange(it.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center font-semibold leading-none',
|
||||||
|
'transition-[background-color,color,box-shadow] duration-150',
|
||||||
|
'focus:z-20 hover:z-10',
|
||||||
|
!isFirst && 'before:absolute before:bottom-0 before:left-0 before:top-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
|
||||||
|
isFirst && 'rounded-l-md',
|
||||||
|
isLast && 'rounded-r-md',
|
||||||
|
|
||||||
|
active
|
||||||
|
? [
|
||||||
|
'bg-indigo-100 text-indigo-800',
|
||||||
|
'hover:bg-indigo-200',
|
||||||
|
'hover:shadow-[inset_0_0_0_1px_rgba(99,102,241,0.28),inset_0_0_0_9999px_rgba(99,102,241,0.10)]',
|
||||||
|
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50 dark:hover:shadow-none',
|
||||||
|
].join(' ')
|
||||||
|
: [
|
||||||
|
'bg-white text-gray-900',
|
||||||
|
'hover:bg-gray-50',
|
||||||
|
'hover:shadow-[inset_0_0_0_1px_rgba(148,163,184,0.35),inset_0_0_0_9999px_rgba(15,23,42,0.05)]',
|
||||||
|
'dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:hover:shadow-none',
|
||||||
|
].join(' '),
|
||||||
|
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
||||||
|
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
||||||
|
)}
|
||||||
|
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||||
|
>
|
||||||
|
{iconOnly && it.srLabel ? <span className="sr-only">{it.srLabel}</span> : null}
|
||||||
|
|
||||||
|
{it.icon ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 transition-colors duration-150',
|
||||||
|
iconOnly ? '' : '-ml-0.5',
|
||||||
|
active
|
||||||
|
? 'text-indigo-700 dark:text-indigo-200'
|
||||||
|
: 'text-gray-500 dark:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{it.label ? <span className={it.icon ? 'ml-1.5' : ''}>{it.label}</span> : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
235
frontend/src/components/Camera.tsx
Normal file
235
frontend/src/components/Camera.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// frontend\src\components\Camera.tsx
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||||
|
import { CameraIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type BarcodeDetectorResult = {
|
||||||
|
rawValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BarcodeDetectorInstance = {
|
||||||
|
detect: (source: HTMLVideoElement) => Promise<BarcodeDetectorResult[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type BarcodeDetectorConstructor = new (options?: {
|
||||||
|
formats?: string[]
|
||||||
|
}) => BarcodeDetectorInstance
|
||||||
|
|
||||||
|
type CameraProps = {
|
||||||
|
onScan?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Camera({ onScan }: CameraProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [scannedValue, setScannedValue] = useState<string | null>(null)
|
||||||
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const frameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
stopCamera()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startCamera()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopCamera()
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
setError(null)
|
||||||
|
setScannedValue(null)
|
||||||
|
setIsStarting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
setError('Dein Browser unterstützt keinen Kamera-Zugriff.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: {
|
||||||
|
ideal: 'environment',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
streamRef.current = stream
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream
|
||||||
|
await videoRef.current.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
startQrScanner()
|
||||||
|
} catch {
|
||||||
|
setError('Kamera konnte nicht geöffnet werden. Bitte Berechtigung prüfen.')
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (frameRef.current) {
|
||||||
|
cancelAnimationFrame(frameRef.current)
|
||||||
|
frameRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((track) => track.stop())
|
||||||
|
streamRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startQrScanner() {
|
||||||
|
const BarcodeDetector = (
|
||||||
|
window as Window & {
|
||||||
|
BarcodeDetector?: BarcodeDetectorConstructor
|
||||||
|
}
|
||||||
|
).BarcodeDetector
|
||||||
|
|
||||||
|
if (!BarcodeDetector) {
|
||||||
|
setError('QR-Code-Erkennung wird in diesem Browser nicht unterstützt. Die Kamera-Vorschau funktioniert trotzdem.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const detector = new BarcodeDetector({
|
||||||
|
formats: ['qr_code'],
|
||||||
|
})
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (videoRef.current.readyState >= 2) {
|
||||||
|
const codes = await detector.detect(videoRef.current)
|
||||||
|
|
||||||
|
if (codes.length > 0) {
|
||||||
|
const value = codes[0].rawValue
|
||||||
|
|
||||||
|
setScannedValue(value)
|
||||||
|
onScan?.(value)
|
||||||
|
stopCamera()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Weiter versuchen, falls ein einzelner Frame nicht gelesen werden kann.
|
||||||
|
}
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="-m-2.5 flex items-center justify-center p-2.5 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<span className="sr-only">QR-Code scannen</span>
|
||||||
|
<CameraIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={closeModal} className="relative z-50">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/70"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 pt-4 pr-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pt-5 pb-4 sm:p-6">
|
||||||
|
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
QR-Code scannen
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Richte die Kamera auf einen QR-Code.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 overflow-hidden rounded-lg bg-gray-950">
|
||||||
|
{scannedValue ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
QR-Code erkannt:
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 break-all rounded-md bg-white/10 p-3 text-sm text-white">
|
||||||
|
{scannedValue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
autoPlay
|
||||||
|
className="aspect-video w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStarting && (
|
||||||
|
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Kamera wird gestartet...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 sm:w-auto dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
frontend/src/components/Card.tsx
Normal file
102
frontend/src/components/Card.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// frontend\src\components\Card.tsx
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type CardVariant =
|
||||||
|
| 'basic'
|
||||||
|
| 'edge-to-edge-mobile'
|
||||||
|
| 'with-header'
|
||||||
|
| 'with-footer'
|
||||||
|
| 'with-header-footer'
|
||||||
|
| 'gray-footer'
|
||||||
|
| 'gray-body'
|
||||||
|
| 'well'
|
||||||
|
| 'well-on-gray'
|
||||||
|
| 'well-edge-to-edge-mobile'
|
||||||
|
|
||||||
|
type CardProps = {
|
||||||
|
children?: ReactNode
|
||||||
|
header?: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
variant?: CardVariant
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
variant = 'basic',
|
||||||
|
className,
|
||||||
|
}: CardProps) {
|
||||||
|
const isEdgeToEdgeMobile =
|
||||||
|
variant === 'edge-to-edge-mobile' || variant === 'well-edge-to-edge-mobile'
|
||||||
|
|
||||||
|
const isWell =
|
||||||
|
variant === 'well' ||
|
||||||
|
variant === 'well-on-gray' ||
|
||||||
|
variant === 'well-edge-to-edge-mobile'
|
||||||
|
|
||||||
|
const hasDivider =
|
||||||
|
variant === 'with-header' ||
|
||||||
|
variant === 'with-footer' ||
|
||||||
|
variant === 'with-header-footer'
|
||||||
|
|
||||||
|
const hasHeader =
|
||||||
|
header &&
|
||||||
|
(variant === 'with-header' ||
|
||||||
|
variant === 'with-header-footer' ||
|
||||||
|
variant === 'gray-body')
|
||||||
|
|
||||||
|
const hasFooter =
|
||||||
|
footer &&
|
||||||
|
(variant === 'with-footer' ||
|
||||||
|
variant === 'with-header-footer' ||
|
||||||
|
variant === 'gray-footer')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'overflow-hidden',
|
||||||
|
isEdgeToEdgeMobile ? 'sm:rounded-lg' : 'rounded-lg',
|
||||||
|
isWell
|
||||||
|
? variant === 'well-on-gray'
|
||||||
|
? 'bg-gray-200 dark:bg-gray-800/50'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800/50'
|
||||||
|
: 'bg-white shadow-sm dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
hasDivider && 'divide-y divide-gray-200 dark:divide-white/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasHeader && (
|
||||||
|
<div className="px-4 py-5 sm:px-6">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-5 sm:p-6',
|
||||||
|
variant === 'gray-body' && 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFooter && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'px-4 py-4 sm:px-6',
|
||||||
|
variant === 'gray-footer' && 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
frontend/src/components/Container.tsx
Normal file
48
frontend/src/components/Container.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type ContainerVariant =
|
||||||
|
| 'full-width-mobile'
|
||||||
|
| 'constrained'
|
||||||
|
| 'breakpoint-full-width-mobile'
|
||||||
|
| 'breakpoint-constrained'
|
||||||
|
| 'narrow'
|
||||||
|
|
||||||
|
type ContainerProps = {
|
||||||
|
children: ReactNode
|
||||||
|
variant?: ContainerVariant
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ContainerVariant, string> = {
|
||||||
|
'full-width-mobile': 'mx-auto max-w-7xl sm:px-6 lg:px-8',
|
||||||
|
constrained: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||||
|
'breakpoint-full-width-mobile': 'container mx-auto sm:px-6 lg:px-8',
|
||||||
|
'breakpoint-constrained': 'container mx-auto px-4 sm:px-6 lg:px-8',
|
||||||
|
narrow: 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Container({
|
||||||
|
children,
|
||||||
|
variant = 'constrained',
|
||||||
|
className,
|
||||||
|
}: ContainerProps) {
|
||||||
|
if (variant === 'narrow') {
|
||||||
|
return (
|
||||||
|
<div className={classNames(variantClasses[variant], className)}>
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(variantClasses[variant], className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
689
frontend/src/components/Feed.tsx
Normal file
689
frontend/src/components/Feed.tsx
Normal file
@ -0,0 +1,689 @@
|
|||||||
|
// frontend/src/components/Feed.tsx
|
||||||
|
|
||||||
|
import { Fragment, useState, type ComponentType, type FormEvent, type SVGProps } from 'react'
|
||||||
|
import {
|
||||||
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
CheckIcon,
|
||||||
|
FaceFrownIcon,
|
||||||
|
FaceSmileIcon,
|
||||||
|
FireIcon,
|
||||||
|
HandThumbUpIcon,
|
||||||
|
HeartIcon,
|
||||||
|
PaperClipIcon,
|
||||||
|
TagIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
UserIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
|
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
||||||
|
import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
type FeedIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
|
type FeedVariant = 'icons' | 'comments' | 'multiple'
|
||||||
|
|
||||||
|
export type FeedTimelineItem = {
|
||||||
|
id: string | number
|
||||||
|
content: string
|
||||||
|
target: string
|
||||||
|
href: string
|
||||||
|
date: string
|
||||||
|
datetime: string
|
||||||
|
icon: FeedIcon
|
||||||
|
iconBackground: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedCommentActivityItem =
|
||||||
|
| {
|
||||||
|
id: string | number
|
||||||
|
type: 'created' | 'edited' | 'sent' | 'viewed' | 'paid'
|
||||||
|
person: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
date: string
|
||||||
|
dateTime: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string | number
|
||||||
|
type: 'commented'
|
||||||
|
person: {
|
||||||
|
name: string
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
comment: string
|
||||||
|
date: string
|
||||||
|
dateTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedMultipleActivityItem =
|
||||||
|
| {
|
||||||
|
id: string | number
|
||||||
|
type: 'comment'
|
||||||
|
person: {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
imageUrl: string
|
||||||
|
comment: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string | number
|
||||||
|
type: 'assignment'
|
||||||
|
person: {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
assigned: {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string | number
|
||||||
|
type: 'tags'
|
||||||
|
person: {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
tags: Array<{
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
color: string
|
||||||
|
}>
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mood = {
|
||||||
|
name: string
|
||||||
|
value: string | null
|
||||||
|
icon: FeedIcon
|
||||||
|
iconColor: string
|
||||||
|
bgColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedProps = {
|
||||||
|
variant?: FeedVariant
|
||||||
|
timeline?: FeedTimelineItem[]
|
||||||
|
activity?: FeedCommentActivityItem[]
|
||||||
|
multipleActivity?: FeedMultipleActivityItem[]
|
||||||
|
currentUserAvatar?: string
|
||||||
|
showCommentForm?: boolean
|
||||||
|
onCommentSubmit?: (comment: string, mood: Mood) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTimeline: FeedTimelineItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: 'Applied to',
|
||||||
|
target: 'Front End Developer',
|
||||||
|
href: '#',
|
||||||
|
date: 'Sep 20',
|
||||||
|
datetime: '2020-09-20',
|
||||||
|
icon: UserIcon,
|
||||||
|
iconBackground: 'bg-gray-400 dark:bg-gray-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: 'Advanced to phone screening by',
|
||||||
|
target: 'Bethany Blake',
|
||||||
|
href: '#',
|
||||||
|
date: 'Sep 22',
|
||||||
|
datetime: '2020-09-22',
|
||||||
|
icon: HandThumbUpIcon,
|
||||||
|
iconBackground: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: 'Completed phone screening with',
|
||||||
|
target: 'Martha Gardner',
|
||||||
|
href: '#',
|
||||||
|
date: 'Sep 28',
|
||||||
|
datetime: '2020-09-28',
|
||||||
|
icon: CheckIcon,
|
||||||
|
iconBackground: 'bg-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
content: 'Advanced to interview by',
|
||||||
|
target: 'Bethany Blake',
|
||||||
|
href: '#',
|
||||||
|
date: 'Sep 30',
|
||||||
|
datetime: '2020-09-30',
|
||||||
|
icon: HandThumbUpIcon,
|
||||||
|
iconBackground: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
content: 'Completed interview with',
|
||||||
|
target: 'Katherine Snyder',
|
||||||
|
href: '#',
|
||||||
|
date: 'Oct 4',
|
||||||
|
datetime: '2020-10-04',
|
||||||
|
icon: CheckIcon,
|
||||||
|
iconBackground: 'bg-green-500',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultCommentActivity: FeedCommentActivityItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'created',
|
||||||
|
person: { name: 'Chelsea Hagon' },
|
||||||
|
date: '7d ago',
|
||||||
|
dateTime: '2023-01-23T10:32',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'edited',
|
||||||
|
person: { name: 'Chelsea Hagon' },
|
||||||
|
date: '6d ago',
|
||||||
|
dateTime: '2023-01-23T11:03',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'sent',
|
||||||
|
person: { name: 'Chelsea Hagon' },
|
||||||
|
date: '6d ago',
|
||||||
|
dateTime: '2023-01-23T11:24',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: 'commented',
|
||||||
|
person: {
|
||||||
|
name: 'Chelsea Hagon',
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||||
|
},
|
||||||
|
comment: 'Called client, they reassured me the invoice would be paid by the 25th.',
|
||||||
|
date: '3d ago',
|
||||||
|
dateTime: '2023-01-23T15:56',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: 'viewed',
|
||||||
|
person: { name: 'Alex Curren' },
|
||||||
|
date: '2d ago',
|
||||||
|
dateTime: '2023-01-24T09:12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
type: 'paid',
|
||||||
|
person: { name: 'Alex Curren' },
|
||||||
|
date: '1d ago',
|
||||||
|
dateTime: '2023-01-24T09:20',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultMultipleActivity: FeedMultipleActivityItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'comment',
|
||||||
|
person: { name: 'Eduardo Benz', href: '#' },
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1520785643438-5bf77931f493?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
|
||||||
|
comment:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
|
||||||
|
date: '6d ago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'assignment',
|
||||||
|
person: { name: 'Hilary Mahy', href: '#' },
|
||||||
|
assigned: { name: 'Kristin Watson', href: '#' },
|
||||||
|
date: '2d ago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'tags',
|
||||||
|
person: { name: 'Hilary Mahy', href: '#' },
|
||||||
|
tags: [
|
||||||
|
{ name: 'Bug', href: '#', color: 'fill-red-500' },
|
||||||
|
{ name: 'Accessibility', href: '#', color: 'fill-indigo-500' },
|
||||||
|
],
|
||||||
|
date: '6h ago',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: 'comment',
|
||||||
|
person: { name: 'Jason Meyers', href: '#' },
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80',
|
||||||
|
comment:
|
||||||
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tincidunt nunc ipsum tempor purus vitae id.',
|
||||||
|
date: '2h ago',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const moods: Mood[] = [
|
||||||
|
{ name: 'Excited', value: 'excited', icon: FireIcon, iconColor: 'text-white', bgColor: 'bg-red-500' },
|
||||||
|
{ name: 'Loved', value: 'loved', icon: HeartIcon, iconColor: 'text-white', bgColor: 'bg-pink-400' },
|
||||||
|
{ name: 'Happy', value: 'happy', icon: FaceSmileIcon, iconColor: 'text-white', bgColor: 'bg-green-400' },
|
||||||
|
{ name: 'Sad', value: 'sad', icon: FaceFrownIcon, iconColor: 'text-white', bgColor: 'bg-yellow-400' },
|
||||||
|
{ name: 'Thumbsy', value: 'thumbsy', icon: HandThumbUpIcon, iconColor: 'text-white', bgColor: 'bg-blue-500' },
|
||||||
|
{
|
||||||
|
name: 'I feel nothing',
|
||||||
|
value: null,
|
||||||
|
icon: XMarkIcon,
|
||||||
|
iconColor: 'text-gray-400 dark:text-gray-500',
|
||||||
|
bgColor: 'bg-transparent',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultAvatar =
|
||||||
|
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||||
|
|
||||||
|
function FeedWithIcons({ timeline }: { timeline: FeedTimelineItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flow-root">
|
||||||
|
<ul role="list" className="-mb-8">
|
||||||
|
{timeline.map((event, eventIndex) => (
|
||||||
|
<li key={event.id}>
|
||||||
|
<div className="relative pb-8">
|
||||||
|
{eventIndex !== timeline.length - 1 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex space-x-3">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
event.iconBackground,
|
||||||
|
'flex size-8 items-center justify-center rounded-full ring-8 ring-white dark:ring-gray-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<event.icon aria-hidden="true" className="size-5 text-white" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{event.content}{' '}
|
||||||
|
<a href={event.href} className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{event.target}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||||
|
<time dateTime={event.datetime}>{event.date}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedWithComments({
|
||||||
|
activity,
|
||||||
|
currentUserAvatar,
|
||||||
|
showCommentForm,
|
||||||
|
onCommentSubmit,
|
||||||
|
}: {
|
||||||
|
activity: FeedCommentActivityItem[]
|
||||||
|
currentUserAvatar: string
|
||||||
|
showCommentForm: boolean
|
||||||
|
onCommentSubmit?: (comment: string, mood: Mood) => void
|
||||||
|
}) {
|
||||||
|
const [selectedMood, setSelectedMood] = useState(moods[5])
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget)
|
||||||
|
const comment = String(formData.get('comment') ?? '').trim()
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onCommentSubmit?.(comment, selectedMood)
|
||||||
|
event.currentTarget.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul role="list" className="space-y-6">
|
||||||
|
{activity.map((activityItem, activityIndex) => (
|
||||||
|
<li key={activityItem.id} className="relative flex gap-x-4">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
activityIndex === activity.length - 1 ? 'h-6' : '-bottom-6',
|
||||||
|
'absolute top-0 left-0 flex w-6 justify-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-px bg-gray-200 dark:bg-white/15" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activityItem.type === 'commented' ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={activityItem.person.imageUrl}
|
||||||
|
className="relative mt-3 size-6 flex-none rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-auto rounded-md p-3 ring-1 ring-gray-200 ring-inset dark:ring-white/15">
|
||||||
|
<div className="flex justify-between gap-x-4">
|
||||||
|
<div className="py-0.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.person.name}
|
||||||
|
</span>{' '}
|
||||||
|
commented
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<time
|
||||||
|
dateTime={activityItem.dateTime}
|
||||||
|
className="flex-none py-0.5 text-xs/5 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{activityItem.date}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm/6 text-gray-500 dark:text-gray-400">
|
||||||
|
{activityItem.comment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative flex size-6 flex-none items-center justify-center bg-white dark:bg-gray-900">
|
||||||
|
{activityItem.type === 'paid' ? (
|
||||||
|
<CheckCircleIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6 text-indigo-600 dark:text-indigo-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="size-1.5 rounded-full bg-gray-100 ring ring-gray-300 dark:bg-white/10 dark:ring-white/20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="flex-auto py-0.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.person.name}
|
||||||
|
</span>{' '}
|
||||||
|
{activityItem.type} the invoice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<time
|
||||||
|
dateTime={activityItem.dateTime}
|
||||||
|
className="flex-none py-0.5 text-xs/5 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{activityItem.date}
|
||||||
|
</time>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{showCommentForm && (
|
||||||
|
<div className="mt-6 flex gap-x-3">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={currentUserAvatar}
|
||||||
|
className="size-6 flex-none rounded-full bg-gray-50 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="relative flex-auto">
|
||||||
|
<div className="overflow-hidden rounded-lg pb-12 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-indigo-500">
|
||||||
|
<label htmlFor="comment" className="sr-only">
|
||||||
|
Kommentar hinzufügen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="comment"
|
||||||
|
name="comment"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Kommentar hinzufügen..."
|
||||||
|
className="block w-full resize-none bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pr-2 pl-3">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<PaperClipIcon aria-hidden="true" className="size-5" />
|
||||||
|
<span className="sr-only">Datei anhängen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Listbox value={selectedMood} onChange={setSelectedMood}>
|
||||||
|
<Label className="sr-only">Stimmung</Label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<ListboxButton className="relative -m-2.5 flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
{selectedMood.value === null ? (
|
||||||
|
<span>
|
||||||
|
<FaceSmileIcon aria-hidden="true" className="size-5 shrink-0" />
|
||||||
|
<span className="sr-only">Stimmung hinzufügen</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
selectedMood.bgColor,
|
||||||
|
'flex size-8 items-center justify-center rounded-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<selectedMood.icon aria-hidden="true" className="size-5 shrink-0 text-white" />
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">{selectedMood.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
|
||||||
|
<ListboxOptions
|
||||||
|
transition
|
||||||
|
className="absolute bottom-10 z-10 -ml-6 w-60 rounded-lg bg-white py-3 text-base shadow-sm outline-1 outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:ml-auto sm:w-64 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
{moods.map((mood) => (
|
||||||
|
<ListboxOption
|
||||||
|
key={mood.value ?? 'none'}
|
||||||
|
value={mood}
|
||||||
|
className="relative cursor-default bg-white px-3 py-2 text-gray-900 select-none data-focus:bg-gray-100 dark:bg-transparent dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
mood.bgColor,
|
||||||
|
'flex size-8 items-center justify-center rounded-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<mood.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(mood.iconColor, 'size-5 shrink-0')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="ml-3 block truncate font-medium">
|
||||||
|
{mood.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ListboxOption>
|
||||||
|
))}
|
||||||
|
</ListboxOptions>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
|
Kommentieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedWithMultipleTypes({ activity }: { activity: FeedMultipleActivityItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flow-root">
|
||||||
|
<ul role="list" className="-mb-8">
|
||||||
|
{activity.map((activityItem, activityIndex) => (
|
||||||
|
<li key={activityItem.id}>
|
||||||
|
<div className="relative pb-8">
|
||||||
|
{activityIndex !== activity.length - 1 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
{activityItem.type === 'comment' && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={activityItem.imageUrl}
|
||||||
|
className="flex size-10 items-center justify-center rounded-full bg-gray-400 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:ring-gray-900 dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="absolute -right-1 -bottom-0.5 rounded-tl bg-white px-0.5 py-px dark:bg-gray-900">
|
||||||
|
<ChatBubbleLeftEllipsisIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.person.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Commented {activityItem.date}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<p>{activityItem.comment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activityItem.type === 'assignment' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="relative px-1">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
||||||
|
<UserCircleIcon aria-hidden="true" className="size-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 py-1.5">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.person.name}
|
||||||
|
</a>{' '}
|
||||||
|
assigned{' '}
|
||||||
|
<a href={activityItem.assigned.href} className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.assigned.name}
|
||||||
|
</a>{' '}
|
||||||
|
<span className="whitespace-nowrap">{activityItem.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activityItem.type === 'tags' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="relative px-1">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
|
||||||
|
<TagIcon aria-hidden="true" className="size-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 py-0">
|
||||||
|
<div className="text-sm/8 text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="mr-0.5">
|
||||||
|
<a href={activityItem.person.href} className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activityItem.person.name}
|
||||||
|
</a>{' '}
|
||||||
|
added tags
|
||||||
|
</span>{' '}
|
||||||
|
|
||||||
|
<span className="mr-0.5">
|
||||||
|
{activityItem.tags.map((tag) => (
|
||||||
|
<Fragment key={tag.name}>
|
||||||
|
<a
|
||||||
|
href={tag.href}
|
||||||
|
className="inline-flex items-center gap-x-1.5 rounded-full px-2 py-1 text-xs font-medium text-gray-900 inset-ring inset-ring-gray-200 dark:bg-white/5 dark:text-gray-100 dark:inset-ring-white/10"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 6 6" aria-hidden="true" className={classNames(tag.color, 'size-1.5')}>
|
||||||
|
<circle r={3} cx={3} cy={3} />
|
||||||
|
</svg>
|
||||||
|
{tag.name}
|
||||||
|
</a>{' '}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="whitespace-nowrap">{activityItem.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Feed({
|
||||||
|
variant = 'icons',
|
||||||
|
timeline = defaultTimeline,
|
||||||
|
activity = defaultCommentActivity,
|
||||||
|
multipleActivity = defaultMultipleActivity,
|
||||||
|
currentUserAvatar = defaultAvatar,
|
||||||
|
showCommentForm = true,
|
||||||
|
onCommentSubmit,
|
||||||
|
className,
|
||||||
|
}: FeedProps) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{variant === 'icons' && <FeedWithIcons timeline={timeline} />}
|
||||||
|
|
||||||
|
{variant === 'comments' && (
|
||||||
|
<FeedWithComments
|
||||||
|
activity={activity}
|
||||||
|
currentUserAvatar={currentUserAvatar}
|
||||||
|
showCommentForm={showCommentForm}
|
||||||
|
onCommentSubmit={onCommentSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'multiple' && <FeedWithMultipleTypes activity={multipleActivity} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
frontend/src/components/LoginForm.tsx
Normal file
144
frontend/src/components/LoginForm.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// frontend\src\components\LoginForm.tsx
|
||||||
|
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import Button from './Button'
|
||||||
|
import Card from './Card'
|
||||||
|
|
||||||
|
type LoginFormProps = {
|
||||||
|
title?: string
|
||||||
|
submitText?: string
|
||||||
|
isSubmitting?: boolean
|
||||||
|
forgotPasswordHref?: string
|
||||||
|
registerHref?: string
|
||||||
|
googleHref?: string
|
||||||
|
githubHref?: string
|
||||||
|
onSubmit?: (event: FormEvent<HTMLFormElement>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginForm({
|
||||||
|
title = 'Sign in to your account',
|
||||||
|
submitText = 'Sign in',
|
||||||
|
isSubmitting = false,
|
||||||
|
forgotPasswordHref = '#',
|
||||||
|
onSubmit,
|
||||||
|
}: LoginFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<img
|
||||||
|
alt="Your Company"
|
||||||
|
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||||
|
className="mx-auto h-10 w-auto dark:hidden"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt="Your Company"
|
||||||
|
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||||
|
className="mx-auto h-10 w-auto not-dark:hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 className="mt-6 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||||
|
<Card variant="edge-to-edge-mobile">
|
||||||
|
<form action="#" method="POST" onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="identifier" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Benutzername oder E-Mail
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="identifier"
|
||||||
|
name="identifier"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex h-6 shrink-0 items-center">
|
||||||
|
<div className="group grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 forced-colors:appearance-auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-0 group-has-checked:opacity-100"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label htmlFor="remember-me" className="block text-sm/6 text-gray-900 dark:text-white">
|
||||||
|
Angemeldet bleiben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm/6">
|
||||||
|
<a
|
||||||
|
href={forgotPasswordHref}
|
||||||
|
className="font-semibold text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
259
frontend/src/components/Modal.tsx
Normal file
259
frontend/src/components/Modal.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type ModalVariant =
|
||||||
|
| 'success-single'
|
||||||
|
| 'success-wide'
|
||||||
|
| 'alert'
|
||||||
|
| 'alert-dismiss'
|
||||||
|
| 'alert-gray-footer'
|
||||||
|
| 'alert-left-buttons'
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
variant?: ModalVariant
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
variant = 'success-single',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText,
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
}: ModalProps) {
|
||||||
|
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
||||||
|
const isGrayFooter = variant === 'alert-gray-footer'
|
||||||
|
const isDismiss = variant === 'alert-dismiss'
|
||||||
|
const isLeftButtons = variant === 'alert-left-buttons'
|
||||||
|
const isWideSuccess = variant === 'success-wide'
|
||||||
|
|
||||||
|
const Icon = isSuccess ? CheckIcon : ExclamationTriangleIcon
|
||||||
|
|
||||||
|
const resolvedTitle =
|
||||||
|
title ?? (isSuccess ? 'Payment successful' : 'Deactivate account')
|
||||||
|
|
||||||
|
const resolvedDescription =
|
||||||
|
description ??
|
||||||
|
(isSuccess
|
||||||
|
? 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur amet labore.'
|
||||||
|
: 'Are you sure you want to deactivate your account? All of your data will be permanently removed from our servers forever. This action cannot be undone.')
|
||||||
|
|
||||||
|
const resolvedConfirmText =
|
||||||
|
confirmText ?? (isSuccess ? 'Go back to dashboard' : 'Deactivate')
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
onConfirm?.()
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGrayFooter) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mx-auto flex size-12 shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:size-10 dark:bg-red-500/10">
|
||||||
|
<Icon aria-hidden="true" className="size-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{resolvedTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{resolvedDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 dark:bg-gray-700/25">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 sm:ml-3 sm:w-auto dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
|
||||||
|
>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-autofocus
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className={classNames(
|
||||||
|
'relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:p-6 data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
isSuccess && !isWideSuccess ? 'sm:max-w-sm' : 'sm:max-w-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDismiss && (
|
||||||
|
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={classNames(!isSuccess && 'sm:flex sm:items-start')}>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'mx-auto flex size-12 shrink-0 items-center justify-center rounded-full',
|
||||||
|
isSuccess
|
||||||
|
? 'bg-green-100 dark:bg-green-500/10'
|
||||||
|
: 'bg-red-100 sm:mx-0 sm:size-10 dark:bg-red-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
'size-6',
|
||||||
|
isSuccess ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'mt-3 text-center',
|
||||||
|
isSuccess ? 'sm:mt-5' : 'sm:mt-0 sm:ml-4 sm:text-left',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogTitle as="h3" className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{resolvedTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{resolvedDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variant === 'success-single' && (
|
||||||
|
<div className="mt-5 sm:mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||||
|
>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'success-wide' && (
|
||||||
|
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:col-start-2 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||||||
|
>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-autofocus
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 sm:col-start-1 sm:mt-0 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSuccess && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'mt-5 sm:mt-4',
|
||||||
|
isLeftButtons ? 'sm:ml-10 sm:flex sm:pl-4' : 'sm:flex sm:flex-row-reverse',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={classNames(
|
||||||
|
'inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400',
|
||||||
|
isLeftButtons ? 'sm:w-auto' : 'sm:ml-3 sm:w-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolvedConfirmText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-autofocus
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={classNames(
|
||||||
|
'mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
||||||
|
isLeftButtons && 'sm:ml-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/Search.tsx
Normal file
20
frontend/src/components/Search.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// frontend\src\components\Search.tsx
|
||||||
|
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
|
export default function Search() {
|
||||||
|
return (
|
||||||
|
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||||
|
<input
|
||||||
|
name="search"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search"
|
||||||
|
className="col-start-1 row-start-1 block size-full bg-white pl-8 text-base text-gray-900 outline-hidden placeholder:text-gray-400 sm:text-sm/6 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
frontend/src/components/Sidebar.tsx
Normal file
206
frontend/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
// frontend\src\components\Sidebar.tsx
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
TransitionChild,
|
||||||
|
} from '@headlessui/react'
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
ChartPieIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
FireIcon,
|
||||||
|
HomeIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { NavLink } from 'react-router'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||||
|
{ name: 'Geräte', href: '/geraete', icon: ComputerDesktopIcon },
|
||||||
|
{ name: 'Einsätze', href: '/einsaetze', icon: FireIcon },
|
||||||
|
{ name: 'Kalender', href: '/kalender', icon: CalendarIcon },
|
||||||
|
{ name: 'Dokumente', href: '/dokumente', icon: DocumentDuplicateIcon },
|
||||||
|
{ name: 'Berichte', href: '/berichte', icon: ChartPieIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
initial: string
|
||||||
|
href: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
unit: string
|
||||||
|
group: string
|
||||||
|
rights: string[]
|
||||||
|
teams: Team[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
user: User
|
||||||
|
sidebarOpen: boolean
|
||||||
|
setSidebarOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamInitial(team: Team) {
|
||||||
|
if (team.initial) {
|
||||||
|
return team.initial
|
||||||
|
}
|
||||||
|
|
||||||
|
return team.name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({
|
||||||
|
user,
|
||||||
|
setSidebarOpen,
|
||||||
|
}: {
|
||||||
|
user: User
|
||||||
|
setSidebarOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const teams = user.teams ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex h-16 shrink-0 items-center">
|
||||||
|
<img
|
||||||
|
alt="Your Company"
|
||||||
|
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||||
|
className="h-8 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col">
|
||||||
|
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||||
|
<li>
|
||||||
|
<ul role="list" className="-mx-2 space-y-1">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<li key={item.name}>
|
||||||
|
<NavLink
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
classNames(
|
||||||
|
isActive
|
||||||
|
? 'bg-white/5 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||||
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon aria-hidden="true" className="size-6 shrink-0" />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<div className="text-xs/6 font-semibold text-gray-400">
|
||||||
|
Zugeordnete Teams
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||||
|
{teams.length > 0 ? (
|
||||||
|
teams.map((team) => (
|
||||||
|
<li key={team.id}>
|
||||||
|
<a
|
||||||
|
href={team.href || '#'}
|
||||||
|
className={classNames(
|
||||||
|
team.current
|
||||||
|
? 'bg-white/5 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||||
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-gray-400 group-hover:border-white/20 group-hover:text-white">
|
||||||
|
{getTeamInitial(team)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{team.name}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="px-2 py-1 text-sm/6 text-gray-500">
|
||||||
|
Keine Teams zugeordnet
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mt-auto">
|
||||||
|
<NavLink
|
||||||
|
to="/einstellungen"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
classNames(
|
||||||
|
isActive
|
||||||
|
? 'bg-white/5 text-white'
|
||||||
|
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||||
|
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Cog6ToothIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||||
|
Einstellungen
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 flex">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<TransitionChild>
|
||||||
|
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
|
||||||
|
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||||
|
<span className="sr-only">Close sidebar</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||||
|
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||||
|
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||||
|
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
673
frontend/src/components/Tables.tsx
Normal file
673
frontend/src/components/Tables.tsx
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
// frontend/src/components/Tables.tsx
|
||||||
|
|
||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
type ReactNode,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
export type TableVariant =
|
||||||
|
| 'simple'
|
||||||
|
| 'card'
|
||||||
|
| 'full-width'
|
||||||
|
| 'constrained'
|
||||||
|
| 'striped'
|
||||||
|
| 'uppercase'
|
||||||
|
| 'stacked-mobile'
|
||||||
|
| 'hidden-mobile'
|
||||||
|
| 'avatars'
|
||||||
|
| 'sticky-header'
|
||||||
|
| 'vertical-lines'
|
||||||
|
| 'condensed'
|
||||||
|
| 'sortable'
|
||||||
|
| 'grouped'
|
||||||
|
| 'summary'
|
||||||
|
| 'border'
|
||||||
|
|
||||||
|
export type TableColumn<T> = {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
render: (row: T) => ReactNode
|
||||||
|
mobileRender?: (row: T) => ReactNode
|
||||||
|
isPrimary?: boolean
|
||||||
|
sortable?: boolean
|
||||||
|
hideOnMobile?: 'sm' | 'md' | 'lg'
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
className?: string
|
||||||
|
headerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableGroup<T> = {
|
||||||
|
id: string | number
|
||||||
|
name: string
|
||||||
|
rows: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableSummaryRow = {
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
emphasized?: boolean
|
||||||
|
colSpan?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
type TablesProps<T> = {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
rows?: T[]
|
||||||
|
groups?: TableGroup<T>[]
|
||||||
|
|
||||||
|
columns: TableColumn<T>[]
|
||||||
|
getRowId: (row: T) => string | number
|
||||||
|
|
||||||
|
variant?: TableVariant
|
||||||
|
emptyText?: string
|
||||||
|
|
||||||
|
actionLabel?: string
|
||||||
|
onAction?: () => void
|
||||||
|
headerAction?: ReactNode
|
||||||
|
|
||||||
|
rowActions?: (row: T) => ReactNode
|
||||||
|
|
||||||
|
selectable?: boolean
|
||||||
|
selectedRowIds?: Array<string | number>
|
||||||
|
onSelectedRowIdsChange?: (ids: Array<string | number>) => void
|
||||||
|
|
||||||
|
sortKey?: string
|
||||||
|
sortDirection?: SortDirection
|
||||||
|
onSort?: (column: TableColumn<T>) => void
|
||||||
|
|
||||||
|
summaryRows?: TableSummaryRow[]
|
||||||
|
|
||||||
|
hideHeadings?: boolean
|
||||||
|
stickyTopClassName?: string
|
||||||
|
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlignmentClass(align?: TableColumn<unknown>['align']) {
|
||||||
|
if (align === 'right') {
|
||||||
|
return 'text-right'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (align === 'center') {
|
||||||
|
return 'text-center'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-left'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHiddenClass(hideOnMobile?: TableColumn<unknown>['hideOnMobile']) {
|
||||||
|
if (hideOnMobile === 'sm') {
|
||||||
|
return 'hidden sm:table-cell'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOnMobile === 'md') {
|
||||||
|
return 'hidden md:table-cell'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOnMobile === 'lg') {
|
||||||
|
return 'hidden lg:table-cell'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
inputRef,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
inputRef?: React.Ref<HTMLInputElement>
|
||||||
|
value?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="group absolute top-1/2 left-4 -mt-2 grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="checkbox"
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 indeterminate:border-indigo-600 indeterminate:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 dark:border-white/20 dark:bg-gray-800/50 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500 dark:focus-visible:outline-indigo-500 dark:disabled:border-white/10 dark:disabled:bg-gray-800 dark:disabled:checked:bg-gray-800 forced-colors:appearance-auto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-0 group-has-checked:opacity-100"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="opacity-0 group-has-indeterminate:opacity-100"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tables<T>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
|
||||||
|
rows = [],
|
||||||
|
groups,
|
||||||
|
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
|
||||||
|
variant = 'simple',
|
||||||
|
emptyText = 'Keine Einträge vorhanden.',
|
||||||
|
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
headerAction,
|
||||||
|
|
||||||
|
rowActions,
|
||||||
|
|
||||||
|
selectable = false,
|
||||||
|
selectedRowIds,
|
||||||
|
onSelectedRowIdsChange,
|
||||||
|
|
||||||
|
sortKey,
|
||||||
|
sortDirection = 'asc',
|
||||||
|
onSort,
|
||||||
|
|
||||||
|
summaryRows,
|
||||||
|
|
||||||
|
hideHeadings = false,
|
||||||
|
stickyTopClassName = 'top-0',
|
||||||
|
|
||||||
|
className,
|
||||||
|
}: TablesProps<T>) {
|
||||||
|
const headerCheckboxRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [internalSelectedRowIds, setInternalSelectedRowIds] = useState<Array<string | number>>([])
|
||||||
|
|
||||||
|
const rowGroups =
|
||||||
|
groups && groups.length > 0
|
||||||
|
? groups
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
name: '',
|
||||||
|
rows,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const allRows = rowGroups.flatMap((group) => group.rows)
|
||||||
|
const allRowIds = allRows.map(getRowId)
|
||||||
|
|
||||||
|
const selectedIds = selectedRowIds ?? internalSelectedRowIds
|
||||||
|
const selectedSet = new Set(selectedIds)
|
||||||
|
|
||||||
|
const checked = allRowIds.length > 0 && selectedIds.length === allRowIds.length
|
||||||
|
const indeterminate = selectedIds.length > 0 && selectedIds.length < allRowIds.length
|
||||||
|
|
||||||
|
const isCard = variant === 'card'
|
||||||
|
const isBorder = variant === 'border'
|
||||||
|
const isFullWidth = variant === 'full-width'
|
||||||
|
const isConstrained = variant === 'constrained'
|
||||||
|
const isStriped = variant === 'striped'
|
||||||
|
const isUppercase = variant === 'uppercase'
|
||||||
|
const isStackedMobile = variant === 'stacked-mobile'
|
||||||
|
const isStickyHeader = variant === 'sticky-header'
|
||||||
|
const isVerticalLines = variant === 'vertical-lines'
|
||||||
|
const isCondensed = variant === 'condensed'
|
||||||
|
const isSortable = variant === 'sortable'
|
||||||
|
const isGrouped = variant === 'grouped' || Boolean(groups?.length)
|
||||||
|
const hasSummary = variant === 'summary' || Boolean(summaryRows?.length)
|
||||||
|
|
||||||
|
const totalColumns =
|
||||||
|
columns.length +
|
||||||
|
(selectable ? 1 : 0) +
|
||||||
|
(rowActions ? 1 : 0)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!headerCheckboxRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCheckboxRef.current.indeterminate = indeterminate
|
||||||
|
}, [indeterminate])
|
||||||
|
|
||||||
|
function updateSelected(nextSelectedIds: Array<string | number>) {
|
||||||
|
if (!selectedRowIds) {
|
||||||
|
setInternalSelectedRowIds(nextSelectedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectedRowIdsChange?.(nextSelectedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(nextChecked: boolean) {
|
||||||
|
updateSelected(nextChecked ? allRowIds : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(rowId: string | number, nextChecked: boolean) {
|
||||||
|
if (nextChecked) {
|
||||||
|
updateSelected(Array.from(new Set([...selectedIds, rowId])))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelected(selectedIds.filter((id) => id !== rowId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderAction() {
|
||||||
|
if (headerAction) {
|
||||||
|
return headerAction
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actionLabel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="indigo"
|
||||||
|
size="md"
|
||||||
|
onClick={onAction}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader() {
|
||||||
|
if (!title && !description && !actionLabel && !headerAction) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isConstrained && 'mx-auto max-w-7xl',
|
||||||
|
!isFullWidth && 'px-4 sm:px-6 lg:px-8',
|
||||||
|
isFullWidth && 'px-4 sm:px-6 lg:px-8',
|
||||||
|
'sm:flex sm:items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="sm:flex-auto">
|
||||||
|
{title && (
|
||||||
|
<h1 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(actionLabel || headerAction) && (
|
||||||
|
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
|
{renderHeaderAction()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumnHeader(column: TableColumn<T>) {
|
||||||
|
const activeSortColumn = sortKey === column.key
|
||||||
|
const showSortIcon = isSortable || column.sortable
|
||||||
|
|
||||||
|
if (!showSortIcon) {
|
||||||
|
return column.header
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort?.(column)}
|
||||||
|
className="group inline-flex"
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
activeSortColumn
|
||||||
|
? 'ml-2 flex-none rounded-sm bg-gray-100 text-gray-900 group-hover:bg-gray-200 dark:bg-gray-800 dark:text-white dark:group-hover:bg-gray-700'
|
||||||
|
: 'invisible ml-2 flex-none rounded-sm text-gray-400 group-hover:visible group-focus:visible dark:text-gray-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
'size-5',
|
||||||
|
activeSortColumn && sortDirection === 'asc' && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTableHead() {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
className={classNames(
|
||||||
|
hideHeadings && 'sr-only',
|
||||||
|
isCard && 'bg-gray-50 dark:bg-gray-800/75',
|
||||||
|
!isCard && 'bg-white dark:bg-gray-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tr className={classNames(isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10')}>
|
||||||
|
{selectable && (
|
||||||
|
<th scope="col" className="relative px-7 sm:w-12 sm:px-6">
|
||||||
|
<Checkbox
|
||||||
|
inputRef={headerCheckboxRef}
|
||||||
|
checked={checked}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((column, columnIndex) => {
|
||||||
|
const isFirst = columnIndex === 0
|
||||||
|
const isLast = columnIndex === columns.length - 1 && !rowActions
|
||||||
|
const hiddenClass = getHiddenClass(column.hideOnMobile)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
scope="col"
|
||||||
|
className={classNames(
|
||||||
|
isStickyHeader &&
|
||||||
|
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||||
|
isUppercase
|
||||||
|
? 'py-3 text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400'
|
||||||
|
: 'py-3.5 text-sm font-semibold text-gray-900 dark:text-white',
|
||||||
|
isCondensed ? 'px-2' : 'px-3',
|
||||||
|
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||||
|
isCard && isFirst && 'sm:pl-6',
|
||||||
|
isLast && !isCard && 'pr-4 sm:pr-0',
|
||||||
|
isLast && isCard && 'sm:pr-6',
|
||||||
|
getAlignmentClass(column.align),
|
||||||
|
hiddenClass,
|
||||||
|
column.headerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderColumnHeader(column)}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{rowActions && (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className={classNames(
|
||||||
|
isStickyHeader &&
|
||||||
|
`sticky ${stickyTopClassName} z-10 bg-white/75 backdrop-blur-sm backdrop-filter dark:bg-gray-900/75`,
|
||||||
|
'py-3.5 pr-4 pl-3 sm:pr-0',
|
||||||
|
isCard && 'sm:pr-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Aktionen</span>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(row: T, rowIndex: number) {
|
||||||
|
const rowId = getRowId(row)
|
||||||
|
const selected = selectedSet.has(rowId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowId}
|
||||||
|
className={classNames(
|
||||||
|
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||||
|
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectable && (
|
||||||
|
<td className="relative px-7 sm:w-12 sm:px-6">
|
||||||
|
{selected && (
|
||||||
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-indigo-600 dark:bg-indigo-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
value={String(rowId)}
|
||||||
|
onChange={(nextChecked) => toggleRow(rowId, nextChecked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((column, columnIndex) => {
|
||||||
|
const isFirst = columnIndex === 0
|
||||||
|
const isLast = columnIndex === columns.length - 1 && !rowActions
|
||||||
|
const hiddenClass = getHiddenClass(column.hideOnMobile)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={classNames(
|
||||||
|
isCondensed ? 'py-2' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||||
|
isCondensed ? 'px-2' : 'px-3',
|
||||||
|
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||||
|
isCard && isFirst && 'sm:pl-6',
|
||||||
|
isLast && !isCard && 'pr-4 sm:pr-0',
|
||||||
|
isLast && isCard && 'sm:pr-6',
|
||||||
|
'text-sm text-gray-500 dark:text-gray-400',
|
||||||
|
!isStackedMobile && 'whitespace-nowrap',
|
||||||
|
column.isPrimary && 'font-medium text-gray-900 dark:text-white',
|
||||||
|
selected && column.isPrimary && 'text-indigo-600 dark:text-indigo-400',
|
||||||
|
getAlignmentClass(column.align),
|
||||||
|
hiddenClass,
|
||||||
|
column.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.render(row)}
|
||||||
|
|
||||||
|
{isStackedMobile && column.isPrimary && column.mobileRender && (
|
||||||
|
<dl className="font-normal lg:hidden">
|
||||||
|
{column.mobileRender(row)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{rowActions && (
|
||||||
|
<td
|
||||||
|
className={classNames(
|
||||||
|
isCondensed ? 'py-2' : 'py-4',
|
||||||
|
'pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0',
|
||||||
|
isCard && 'sm:pr-6',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rowActions(row)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupedRows() {
|
||||||
|
return rowGroups.map((group) => (
|
||||||
|
<Fragment key={group.id}>
|
||||||
|
{isGrouped && group.name && (
|
||||||
|
<tr className="border-t border-gray-200 dark:border-white/10">
|
||||||
|
<th
|
||||||
|
scope="colgroup"
|
||||||
|
colSpan={totalColumns}
|
||||||
|
className="bg-gray-50 py-2 pr-3 pl-4 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:bg-gray-800/50 dark:text-white"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.rows.map(renderRow)}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyRow() {
|
||||||
|
if (allRows.length > 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={totalColumns}
|
||||||
|
className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryRows() {
|
||||||
|
if (!hasSummary || !summaryRows?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tfoot>
|
||||||
|
{summaryRows.map((row, index) => {
|
||||||
|
const colSpan = row.colSpan ?? Math.max(totalColumns - 1, 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
colSpan={colSpan}
|
||||||
|
className={classNames(
|
||||||
|
index === 0 ? 'pt-6' : 'pt-4',
|
||||||
|
'pr-3 pl-4 text-right text-sm sm:pl-0',
|
||||||
|
row.emphasized
|
||||||
|
? 'font-semibold text-gray-900 dark:text-white'
|
||||||
|
: 'font-normal text-gray-500 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td
|
||||||
|
className={classNames(
|
||||||
|
index === 0 ? 'pt-6' : 'pt-4',
|
||||||
|
'pr-4 pl-3 text-right text-sm sm:pr-0',
|
||||||
|
row.emphasized
|
||||||
|
? 'font-semibold text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-500 dark:text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tfoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
className={classNames(
|
||||||
|
isStickyHeader
|
||||||
|
? 'min-w-full border-separate border-spacing-0'
|
||||||
|
: 'relative min-w-full divide-y divide-gray-300 dark:divide-white/15',
|
||||||
|
isBorder && 'divide-y divide-gray-300 dark:divide-white/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderTableHead()}
|
||||||
|
|
||||||
|
<tbody
|
||||||
|
className={classNames(
|
||||||
|
!isStickyHeader && 'divide-y divide-gray-200 dark:divide-white/10',
|
||||||
|
isCard
|
||||||
|
? 'bg-white dark:bg-gray-800/50'
|
||||||
|
: 'bg-white dark:bg-gray-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderEmptyRow()}
|
||||||
|
{renderGroupedRows()}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
{renderSummaryRows()}
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{renderHeader()}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
title || description || actionLabel || headerAction ? 'mt-8' : '',
|
||||||
|
'flow-root',
|
||||||
|
isFullWidth && 'overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isConstrained && 'mx-auto max-w-7xl px-4 sm:px-6 lg:px-8',
|
||||||
|
!isConstrained && '-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isConstrained
|
||||||
|
? ''
|
||||||
|
: 'inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8',
|
||||||
|
isFullWidth && 'sm:px-0 lg:px-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCard || isBorder ? (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isCard &&
|
||||||
|
'overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
isBorder &&
|
||||||
|
'overflow-hidden ring-1 ring-gray-300 sm:rounded-lg dark:ring-white/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderTable()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderTable()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvatarVariant(variant: TableVariant) {
|
||||||
|
return variant === 'avatars'
|
||||||
|
}
|
||||||
398
frontend/src/components/Tabs.tsx
Normal file
398
frontend/src/components/Tabs.tsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
// frontend\src\components\Tabs.tsx
|
||||||
|
|
||||||
|
import type { ComponentType, SVGProps } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router'
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
||||||
|
|
||||||
|
type TabIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
|
export type TabItem = {
|
||||||
|
name: string
|
||||||
|
href: string
|
||||||
|
current?: boolean
|
||||||
|
icon?: TabIcon
|
||||||
|
count?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabsVariant =
|
||||||
|
| 'underline'
|
||||||
|
| 'underline-icons'
|
||||||
|
| 'pills'
|
||||||
|
| 'pills-gray'
|
||||||
|
| 'pills-brand'
|
||||||
|
| 'full-width-underline'
|
||||||
|
| 'bar-underline'
|
||||||
|
| 'underline-badges'
|
||||||
|
| 'simple'
|
||||||
|
|
||||||
|
type TabsAlign = 'left' | 'center' | 'right'
|
||||||
|
|
||||||
|
type TabsProps = {
|
||||||
|
tabs: TabItem[]
|
||||||
|
variant?: TabsVariant
|
||||||
|
align?: TabsAlign
|
||||||
|
sticky?: boolean
|
||||||
|
stickyTopClassName?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignClassMap: Record<TabsAlign, string> = {
|
||||||
|
left: 'justify-start',
|
||||||
|
center: 'justify-center',
|
||||||
|
right: 'justify-end',
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTabActive(tab: TabItem, pathname: string) {
|
||||||
|
if (typeof tab.current === 'boolean') {
|
||||||
|
return tab.current
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.href === '/') {
|
||||||
|
return pathname === '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname.startsWith(tab.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tabs({
|
||||||
|
tabs,
|
||||||
|
variant = 'underline',
|
||||||
|
align = 'left',
|
||||||
|
sticky = false,
|
||||||
|
stickyTopClassName = 'top-16',
|
||||||
|
ariaLabel = 'Tabs',
|
||||||
|
className,
|
||||||
|
}: TabsProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const activeTab = tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
||||||
|
const alignClassName = alignClassMap[align]
|
||||||
|
|
||||||
|
function handleSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
|
||||||
|
|
||||||
|
if (!selectedTab) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTab.href === '#') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(selectedTab.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
sticky && 'sticky z-30 bg-white dark:bg-gray-900',
|
||||||
|
sticky && stickyTopClassName,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:hidden">
|
||||||
|
<select
|
||||||
|
value={activeTab?.name}
|
||||||
|
onChange={handleSelectChange}
|
||||||
|
aria-label="Tab auswählen"
|
||||||
|
className="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<option key={tab.name}>{tab.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end fill-gray-500 dark:fill-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
{variant === 'underline' && (
|
||||||
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||||
|
'border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'underline-icons' && (
|
||||||
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
const Icon = tab.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||||
|
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'text-indigo-500 dark:text-indigo-400'
|
||||||
|
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
|
||||||
|
'mr-2 -ml-0.5 size-5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{tab.name}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'pills' && (
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||||
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'pills-gray' && (
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
|
||||||
|
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
|
||||||
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'pills-brand' && (
|
||||||
|
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||||
|
'rounded-md px-3 py-2 text-sm font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'full-width-underline' && (
|
||||||
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className="-mb-px flex">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||||
|
'flex-1 border-b-2 px-1 py-4 text-center text-sm font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'bar-underline' && (
|
||||||
|
<nav
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="isolate flex divide-x divide-gray-200 rounded-lg bg-white shadow-sm dark:divide-white/10 dark:bg-gray-800/50 dark:shadow-none dark:outline dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
{tabs.map((tab, tabIndex) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white',
|
||||||
|
tabIndex === 0 && 'rounded-l-lg',
|
||||||
|
tabIndex === tabs.length - 1 && 'rounded-r-lg',
|
||||||
|
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{tab.name}</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
active ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
|
||||||
|
'absolute inset-x-0 bottom-0 h-0.5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'underline-badges' && (
|
||||||
|
<div className="border-b border-gray-200 dark:border-white/10">
|
||||||
|
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-white',
|
||||||
|
'flex border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
|
||||||
|
{tab.count ? (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||||
|
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||||
|
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'simple' && (
|
||||||
|
<nav
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex overflow-x-auto border-b border-gray-200 dark:border-white/10"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
className={classNames(
|
||||||
|
'flex min-w-full flex-none gap-x-6 px-4 py-3 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400',
|
||||||
|
alignClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = isTabActive(tab, location.pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={tab.name}>
|
||||||
|
<Link
|
||||||
|
to={tab.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-indigo-600 dark:text-indigo-400'
|
||||||
|
: 'hover:text-gray-700 dark:hover:text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/components/Topbar.tsx
Normal file
132
frontend/src/components/Topbar.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// frontend/src/components/Topbar.tsx
|
||||||
|
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItems,
|
||||||
|
} from '@headlessui/react'
|
||||||
|
import {
|
||||||
|
Bars3Icon,
|
||||||
|
BellIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
import Search from './Search'
|
||||||
|
import Camera from './Camera'
|
||||||
|
import type { User } from './types'
|
||||||
|
|
||||||
|
type TopbarProps = {
|
||||||
|
user: User
|
||||||
|
onLogout: () => void | Promise<void>
|
||||||
|
onOpenSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const userNavigation = [
|
||||||
|
{ name: 'Mein Account', href: '/einstellungen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Topbar({ user, onLogout, onOpenSidebar }: TopbarProps) {
|
||||||
|
function handleQrScan(value: string) {
|
||||||
|
console.log('QR-Code erkannt:', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl =
|
||||||
|
user.avatar ||
|
||||||
|
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-xs sm:gap-x-6 sm:px-6 lg:px-8 dark:border-white/10 dark:bg-gray-900">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSidebar}
|
||||||
|
className="-m-2.5 p-2.5 text-gray-700 hover:text-gray-900 lg:hidden dark:text-gray-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open sidebar</span>
|
||||||
|
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div aria-hidden="true" className="h-6 w-px bg-gray-900/10 lg:hidden dark:bg-white/10" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 gap-x-3 self-stretch lg:gap-x-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Camera onScan={handleQrScan} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden h-6 w-px self-center bg-gray-900/10 lg:block dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Search />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<span className="sr-only">View notifications</span>
|
||||||
|
<BellIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10 dark:lg:bg-gray-100/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Menu as="div" className="relative">
|
||||||
|
<MenuButton className="relative flex items-center">
|
||||||
|
<span className="absolute -inset-1.5" />
|
||||||
|
<span className="sr-only">Open user menu</span>
|
||||||
|
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={avatarUrl}
|
||||||
|
className="size-8 rounded-full bg-gray-50 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="hidden lg:flex lg:items-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-4 text-sm/6 font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-2 size-5 text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuItems
|
||||||
|
transition
|
||||||
|
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-gray-900/5 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||||
|
>
|
||||||
|
{userNavigation.map((item) => (
|
||||||
|
<MenuItem key={item.name}>
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItems>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
frontend/src/components/types.ts
Normal file
38
frontend/src/components/types.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// frontend\src\components\types.ts
|
||||||
|
|
||||||
|
export type Team = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
initial: string
|
||||||
|
href: string
|
||||||
|
current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
unit: string
|
||||||
|
group: string
|
||||||
|
rights: string[]
|
||||||
|
teams: Team[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceHistoryChange = {
|
||||||
|
field: string
|
||||||
|
label: string
|
||||||
|
oldValue: string
|
||||||
|
newValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceHistoryEntry = {
|
||||||
|
id: string
|
||||||
|
deviceId: string
|
||||||
|
userId: string
|
||||||
|
userDisplayName: string
|
||||||
|
action: 'created' | 'updated'
|
||||||
|
changes: DeviceHistoryChange[]
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/* frontend\src\index.css */
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// main.tsx
|
||||||
|
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
11
frontend/src/pages/CalendarPage.tsx
Normal file
11
frontend/src/pages/CalendarPage.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// frontend/src/pages/CalendarPage.tsx
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Kalender
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
frontend/src/pages/DashboardPage.tsx
Normal file
19
frontend/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// frontend\src\pages\DashboardPage.tsx
|
||||||
|
|
||||||
|
type DashboardPageProps = {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage({ username }: DashboardPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Willkommen zurück, {username}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
698
frontend/src/pages/DevicesPage.tsx
Normal file
698
frontend/src/pages/DevicesPage.tsx
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
// frontend/src/pages/DevicesPage.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBackdrop,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@headlessui/react'
|
||||||
|
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
import Feed, { type FeedTimelineItem } from '../components/Feed'
|
||||||
|
import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
|
||||||
|
import Tables, { type TableColumn } from '../components/Tables'
|
||||||
|
import Button from '../components/Button'
|
||||||
|
import type { DeviceHistoryChange, DeviceHistoryEntry } from '../components/types'
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
type RelatedDevice = {
|
||||||
|
id: string
|
||||||
|
inventoryNumber: string
|
||||||
|
manufacturer: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device = {
|
||||||
|
id: string
|
||||||
|
inventoryNumber: string
|
||||||
|
manufacturer: string
|
||||||
|
model: string
|
||||||
|
serialNumber: string
|
||||||
|
macAddress: string
|
||||||
|
location: string
|
||||||
|
loanStatus: string
|
||||||
|
comment: string
|
||||||
|
relatedDevices: RelatedDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
|
const selectClassName =
|
||||||
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
|
function getLoanStatusClassName(status: string) {
|
||||||
|
const normalizedStatus = status.toLowerCase()
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedStatus.includes('ausgeliehen') ||
|
||||||
|
normalizedStatus.includes('verliehen') ||
|
||||||
|
normalizedStatus.includes('reserviert')
|
||||||
|
) {
|
||||||
|
return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedStatus.includes('defekt') ||
|
||||||
|
normalizedStatus.includes('verloren') ||
|
||||||
|
normalizedStatus.includes('gesperrt')
|
||||||
|
) {
|
||||||
|
return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedStatus.includes('wartung') ||
|
||||||
|
normalizedStatus.includes('prüfung')
|
||||||
|
) {
|
||||||
|
return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoryDate(value: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value))
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoryChange(change: DeviceHistoryChange) {
|
||||||
|
return `${change.label}: ${change.oldValue} → ${change.newValue}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryContent(entry: DeviceHistoryEntry) {
|
||||||
|
if (entry.action === 'created') {
|
||||||
|
return 'Gerät erstellt durch'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.changes.length) {
|
||||||
|
return 'Gerät bearbeitet durch'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||||
|
const [currentDeviceId, setCurrentDeviceId] = useState<string | null>(null)
|
||||||
|
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||||
|
|
||||||
|
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
|
||||||
|
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const isEditing = editingDevice !== null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevices()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/devices`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.error ?? 'Geräte konnten nicht geladen werden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setDevices(data.devices ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeviceHistory(deviceId: string) {
|
||||||
|
setIsHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/devices/${deviceId}/history`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorData?.error ?? 'Bearbeitungsverlauf konnte nicht geladen werden')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setDeviceHistory(data.history ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
setHistoryError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Bearbeitungsverlauf konnte nicht geladen werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
setCreateError(null)
|
||||||
|
setEditingDevice(null)
|
||||||
|
setRelatedDeviceIds([])
|
||||||
|
setCurrentDeviceId(null)
|
||||||
|
setIsCreateModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateModal() {
|
||||||
|
if (isCreating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreateModalOpen(false)
|
||||||
|
setEditingDevice(null)
|
||||||
|
setCurrentDeviceId(null)
|
||||||
|
setRelatedDeviceIds([])
|
||||||
|
setDeviceHistory([])
|
||||||
|
setHistoryError(null)
|
||||||
|
setIsHistoryLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(device: Device) {
|
||||||
|
setCreateError(null)
|
||||||
|
setHistoryError(null)
|
||||||
|
setDeviceHistory([])
|
||||||
|
setEditingDevice(device)
|
||||||
|
setCurrentDeviceId(device.id)
|
||||||
|
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
|
||||||
|
setIsCreateModalOpen(true)
|
||||||
|
|
||||||
|
void loadDeviceHistory(device.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRelatedDevice(deviceId: string) {
|
||||||
|
setRelatedDeviceIds((currentDeviceIds) => {
|
||||||
|
if (currentDeviceIds.includes(deviceId)) {
|
||||||
|
return currentDeviceIds.filter((currentDeviceId) => currentDeviceId !== deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...currentDeviceIds, deviceId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveDevice(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const form = event.currentTarget
|
||||||
|
const formData = new FormData(form)
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
setCreateError(null)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
inventoryNumber: String(formData.get('inventoryNumber') ?? ''),
|
||||||
|
manufacturer: String(formData.get('manufacturer') ?? ''),
|
||||||
|
model: String(formData.get('model') ?? ''),
|
||||||
|
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||||
|
macAddress: String(formData.get('macAddress') ?? ''),
|
||||||
|
location: String(formData.get('location') ?? ''),
|
||||||
|
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||||
|
comment: String(formData.get('comment') ?? ''),
|
||||||
|
relatedDeviceIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
isEditing
|
||||||
|
? `${API_URL}/devices/${editingDevice.id}`
|
||||||
|
: `${API_URL}/devices`,
|
||||||
|
{
|
||||||
|
method: isEditing ? 'PUT' : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => null)
|
||||||
|
throw new Error(
|
||||||
|
errorData?.error ??
|
||||||
|
(isEditing ? 'Gerät konnte nicht aktualisiert werden' : 'Gerät konnte nicht erstellt werden'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset()
|
||||||
|
setRelatedDeviceIds([])
|
||||||
|
setEditingDevice(null)
|
||||||
|
setCurrentDeviceId(null)
|
||||||
|
setIsCreateModalOpen(false)
|
||||||
|
|
||||||
|
await loadDevices()
|
||||||
|
} catch (error) {
|
||||||
|
setCreateError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: isEditing
|
||||||
|
? 'Gerät konnte nicht aktualisiert werden'
|
||||||
|
: 'Gerät konnte nicht erstellt werden',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<Device>[] = [
|
||||||
|
{
|
||||||
|
key: 'inventoryNumber',
|
||||||
|
header: 'Inventur-Nr.',
|
||||||
|
isPrimary: true,
|
||||||
|
render: (device) => device.inventoryNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manufacturer',
|
||||||
|
header: 'Hersteller',
|
||||||
|
render: (device) => device.manufacturer || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
header: 'Modell',
|
||||||
|
render: (device) => device.model || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serialNumber',
|
||||||
|
header: 'Seriennummer',
|
||||||
|
hideOnMobile: 'lg',
|
||||||
|
render: (device) => device.serialNumber || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'macAddress',
|
||||||
|
header: 'MAC-Adresse',
|
||||||
|
hideOnMobile: 'lg',
|
||||||
|
render: (device) => device.macAddress || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'location',
|
||||||
|
header: 'Standort',
|
||||||
|
render: (device) => device.location || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loanStatus',
|
||||||
|
header: 'Verleih-Status',
|
||||||
|
render: (device) => (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||||
|
getLoanStatusClassName(device.loanStatus),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{device.loanStatus || 'Verfügbar'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'relatedDevices',
|
||||||
|
header: 'Zubehör',
|
||||||
|
hideOnMobile: 'lg',
|
||||||
|
render: (device) => {
|
||||||
|
if (!device.relatedDevices?.length) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-xs flex-wrap gap-1">
|
||||||
|
{device.relatedDevices.map((relatedDevice) => (
|
||||||
|
<span
|
||||||
|
key={relatedDevice.id}
|
||||||
|
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"
|
||||||
|
>
|
||||||
|
{relatedDevice.inventoryNumber}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comment',
|
||||||
|
header: 'Kommentar',
|
||||||
|
hideOnMobile: 'lg',
|
||||||
|
render: (device) => (
|
||||||
|
<span className="block max-w-xs truncate">
|
||||||
|
{device.comment || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const assignableDevices = devices.filter((device) => device.id !== currentDeviceId)
|
||||||
|
|
||||||
|
const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
content: getHistoryContent(entry),
|
||||||
|
target: entry.userDisplayName || 'Unbekannt',
|
||||||
|
href: '#',
|
||||||
|
date: formatHistoryDate(entry.createdAt),
|
||||||
|
datetime: entry.createdAt,
|
||||||
|
icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
|
||||||
|
iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tables
|
||||||
|
title="Geräte"
|
||||||
|
description={
|
||||||
|
isLoading
|
||||||
|
? 'Geräte werden geladen...'
|
||||||
|
: 'Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, Standort, Verleih-Status und Zubehör.'
|
||||||
|
}
|
||||||
|
actionLabel="Gerät hinzufügen"
|
||||||
|
onAction={openCreateModal}
|
||||||
|
rows={devices}
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(device) => device.id}
|
||||||
|
variant="card"
|
||||||
|
emptyText={isLoading ? 'Geräte werden geladen...' : 'Keine Geräte vorhanden.'}
|
||||||
|
rowActions={(device) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditModal(device)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Bearbeiten<span className="sr-only">, {device.inventoryNumber}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={isCreateModalOpen} onClose={closeCreateModal} className="relative z-50">
|
||||||
|
<DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-gray-900/50 transition-opacity data-closed:opacity-0 dark:bg-gray-950/70"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<DialogPanel
|
||||||
|
transition
|
||||||
|
className={[
|
||||||
|
'w-full transform overflow-hidden rounded-lg bg-white shadow-xl outline outline-gray-900/5 transition data-closed:scale-95 data-closed:opacity-0 dark:bg-gray-900 dark:outline-white/10',
|
||||||
|
isEditing ? 'max-w-6xl' : 'max-w-3xl',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<form key={editingDevice?.id ?? 'create'} onSubmit={handleSaveDevice}>
|
||||||
|
<div className="flex items-start justify-between border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{isEditing ? 'Gerät bearbeiten' : 'Gerät hinzufügen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{isEditing
|
||||||
|
? 'Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.'
|
||||||
|
: 'Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeCreateModal}
|
||||||
|
className="-m-2.5 rounded-md p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Schließen</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{createError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isEditing
|
||||||
|
? 'grid max-h-[calc(100vh-12rem)] grid-cols-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_24rem]'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 px-4 py-6 sm:grid-cols-6 sm:px-6">
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="inventoryNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Inventur-Nr. *
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="inventoryNumber"
|
||||||
|
name="inventoryNumber"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue={editingDevice?.inventoryNumber ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="loanStatus" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Verleih-Status
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<select
|
||||||
|
id="loanStatus"
|
||||||
|
name="loanStatus"
|
||||||
|
defaultValue={editingDevice?.loanStatus || 'Verfügbar'}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<option>Verfügbar</option>
|
||||||
|
<option>Ausgeliehen</option>
|
||||||
|
<option>Reserviert</option>
|
||||||
|
<option>Wartung</option>
|
||||||
|
<option>Defekt</option>
|
||||||
|
<option>Verloren</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="manufacturer" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Hersteller
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="manufacturer"
|
||||||
|
name="manufacturer"
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingDevice?.manufacturer ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="model" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Modell
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="model"
|
||||||
|
name="model"
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingDevice?.model ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="serialNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Seriennummer
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="serialNumber"
|
||||||
|
name="serialNumber"
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingDevice?.serialNumber ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="macAddress" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
MAC-Adresse
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="macAddress"
|
||||||
|
name="macAddress"
|
||||||
|
type="text"
|
||||||
|
placeholder="00:11:22:33:44:55"
|
||||||
|
defaultValue={editingDevice?.macAddress ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-6">
|
||||||
|
<label htmlFor="location" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Standort
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="location"
|
||||||
|
name="location"
|
||||||
|
type="text"
|
||||||
|
defaultValue={editingDevice?.location ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-6">
|
||||||
|
<label htmlFor="comment" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Kommentar
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<textarea
|
||||||
|
id="comment"
|
||||||
|
name="comment"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={editingDevice?.comment ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-6">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Zugeordnete Geräte / Zubehör
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
|
||||||
|
{assignableDevices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{assignableDevices.map((device) => (
|
||||||
|
<label
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-x-3 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={relatedDeviceIds.includes(device.id)}
|
||||||
|
onChange={() => toggleRelatedDevice(device.id)}
|
||||||
|
className="size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{device.inventoryNumber}
|
||||||
|
{(device.manufacturer || device.model) && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{' '}
|
||||||
|
— {[device.manufacturer, device.model].filter(Boolean).join(' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine anderen Geräte vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<aside className="border-t border-gray-200 px-4 py-6 sm:px-6 lg:border-t-0 lg:border-l dark:border-white/10">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Bearbeitungsverlauf
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Änderungen an diesem Gerät.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{isHistoryLoading && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Verlauf wird geladen...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{historyError && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
{historyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isHistoryLoading && !historyError && historyTimeline.length > 0 && (
|
||||||
|
<Feed
|
||||||
|
variant="icons"
|
||||||
|
timeline={historyTimeline}
|
||||||
|
showCommentForm={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isHistoryLoading && !historyError && historyTimeline.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Änderungen erfasst.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
color="indigo"
|
||||||
|
onClick={closeCreateModal}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
isLoading={isCreating}
|
||||||
|
>
|
||||||
|
{isEditing ? 'Änderungen speichern' : 'Gerät speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
frontend/src/pages/DocumentsPage.tsx
Normal file
11
frontend/src/pages/DocumentsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// frontend/src/pages/DocumentsPage.tsx
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Dokumente
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
frontend/src/pages/IncidentsPage.tsx
Normal file
11
frontend/src/pages/IncidentsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// frontend/src/pages/IncidentsPage.tsx
|
||||||
|
|
||||||
|
export default function IncidentsPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Einsätze
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
frontend/src/pages/ReportsPage.tsx
Normal file
11
frontend/src/pages/ReportsPage.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// frontend/src/pages/ReportsPage.tsx
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Berichte
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
357
frontend/src/pages/SettingsPage.tsx
Normal file
357
frontend/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
// frontend\src\pages\SettingsPage.tsx
|
||||||
|
|
||||||
|
import type { FormEvent, ReactNode } from 'react'
|
||||||
|
import { useLocation } from 'react-router'
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
PuzzlePieceIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
|
import Tabs from '../components/Tabs'
|
||||||
|
import Button from '../components/Button'
|
||||||
|
import Container from '../components/Container'
|
||||||
|
import type { User } from '../components/types'
|
||||||
|
|
||||||
|
type SettingsPageProps = {
|
||||||
|
user?: User
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClassName =
|
||||||
|
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Container variant="constrained" className="py-16">
|
||||||
|
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderSection({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={title}
|
||||||
|
description="Dieser Bereich kann später mit eigenen Einstellungen erweitert werden."
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||||
|
Noch keine Einstellungen vorhanden.
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage({ user }: SettingsPageProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const pathname = location.pathname.replace(/\/$/, '')
|
||||||
|
const section = pathname.split('/')[2] ?? 'konto'
|
||||||
|
|
||||||
|
const settingsTabs = [
|
||||||
|
{
|
||||||
|
name: 'Konto',
|
||||||
|
href: '/einstellungen',
|
||||||
|
icon: UserIcon,
|
||||||
|
current: pathname === '/einstellungen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Benachrichtigungen',
|
||||||
|
href: '/einstellungen/benachrichtigungen',
|
||||||
|
icon: BellIcon,
|
||||||
|
current: pathname === '/einstellungen/benachrichtigungen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Abrechnung',
|
||||||
|
href: '/einstellungen/abrechnung',
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
current: pathname === '/einstellungen/abrechnung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Teams',
|
||||||
|
href: '/einstellungen/teams',
|
||||||
|
icon: UsersIcon,
|
||||||
|
current: pathname === '/einstellungen/teams',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Integrationen',
|
||||||
|
href: '/einstellungen/integrationen',
|
||||||
|
icon: PuzzlePieceIcon,
|
||||||
|
current: pathname === '/einstellungen/integrationen',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const avatarUrl =
|
||||||
|
user?.avatar ||
|
||||||
|
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1 className="sr-only">Einstellungen</h1>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
tabs={settingsTabs}
|
||||||
|
variant="underline-icons"
|
||||||
|
align="center"
|
||||||
|
sticky
|
||||||
|
stickyTopClassName="top-16"
|
||||||
|
ariaLabel="Einstellungen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{section === 'konto' && (
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||||
|
<Section
|
||||||
|
title="Persönliche Informationen"
|
||||||
|
description="Verwalte deine Profildaten und Kontoinformationen."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||||
|
<div className="col-span-full flex items-center gap-x-8">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={avatarUrl}
|
||||||
|
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="secondary" size="md">
|
||||||
|
Avatar ändern
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||||
|
JPG, GIF oder PNG. Maximal 1 MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="displayName" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Anzeigename
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
name="displayName"
|
||||||
|
type="text"
|
||||||
|
defaultValue={user?.displayName ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="username" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
defaultValue={user?.username ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label htmlFor="email" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
defaultValue={user?.email ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="unit" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Einheit
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="unit"
|
||||||
|
name="unit"
|
||||||
|
type="text"
|
||||||
|
defaultValue={user?.unit ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label htmlFor="group" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Gruppe
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="group"
|
||||||
|
name="group"
|
||||||
|
type="text"
|
||||||
|
defaultValue={user?.group ?? ''}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex">
|
||||||
|
<Button type="submit" color="indigo" size="md">
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Passwort ändern"
|
||||||
|
description="Aktualisiere das Passwort deines Benutzerkontos."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label htmlFor="current-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Aktuelles Passwort
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label htmlFor="new-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label htmlFor="confirm-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Neues Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex">
|
||||||
|
<Button type="submit" color="indigo" size="md">
|
||||||
|
Passwort speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Andere Sitzungen abmelden"
|
||||||
|
description="Melde dich auf anderen Geräten ab, falls du dein Konto absichern möchtest."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label htmlFor="logout-password" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Dein Passwort
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
id="logout-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex">
|
||||||
|
<Button type="submit" color="indigo" size="md">
|
||||||
|
Andere Sitzungen abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Konto löschen"
|
||||||
|
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-start">
|
||||||
|
<Button type="submit" color="red" size="md">
|
||||||
|
Konto löschen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'benachrichtigungen' && (
|
||||||
|
<PlaceholderSection title="Benachrichtigungen" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'abrechnung' && (
|
||||||
|
<PlaceholderSection title="Abrechnung" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'teams' && (
|
||||||
|
<PlaceholderSection title="Teams" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'integrationen' && (
|
||||||
|
<PlaceholderSection title="Integrationen" />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user