This commit is contained in:
Linrador 2026-06-13 10:15:57 +02:00
parent b62c1273c3
commit b3c07355a8
57 changed files with 11109 additions and 1134 deletions

View File

@ -1,7 +1,7 @@
PORT=8080
DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable
JWT_SECRET=tegvideo7010!
FRONTEND_ORIGIN=http://localhost:5173
FRONTEND_ORIGIN=http://10.0.1.25:5173
ADDRESS_SEARCH_PROVIDER=photon
ADDRESS_SEARCH_URL=https://photon.komoot.io/api
ADDRESS_SEARCH_LANGUAGE=de

View File

@ -3,8 +3,10 @@
package main
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"strconv"
@ -13,12 +15,13 @@ import (
)
type reverseAddressSuggestion struct {
ID string `json:"id"`
Label string `json:"label"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type,omitempty"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
ID string `json:"id"`
Label string `json:"label"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type,omitempty"`
HasHouseNumber bool `json:"hasHouseNumber"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
type nominatimReverseAddress struct {
@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string {
return strings.TrimSpace(result.DisplayName)
}
func isPolygonGeoJSON(value json.RawMessage) bool {
if len(value) == 0 || string(value) == "null" {
return false
}
var object struct {
Type string `json:"type"`
Geometry json.RawMessage `json:"geometry"`
Features []json.RawMessage `json:"features"`
Geometries []json.RawMessage `json:"geometries"`
}
if err := json.Unmarshal(value, &object); err != nil {
return false
}
switch object.Type {
case "Polygon", "MultiPolygon":
return true
case "Feature":
return isPolygonGeoJSON(object.Geometry)
case "FeatureCollection":
for _, feature := range object.Features {
if isPolygonGeoJSON(feature) {
return true
}
}
case "GeometryCollection":
for _, geometry := range object.Geometries {
if isPolygonGeoJSON(geometry) {
return true
}
}
}
return false
}
func coordinateDistanceMeters(firstLat, firstLon, secondLat, secondLon float64) float64 {
const earthRadius = 6371000
firstLatitude := firstLat * math.Pi / 180
secondLatitude := secondLat * math.Pi / 180
latitudeDifference := (secondLat - firstLat) * math.Pi / 180
longitudeDifference := (secondLon - firstLon) * math.Pi / 180
a := math.Sin(latitudeDifference/2)*math.Sin(latitudeDifference/2) +
math.Cos(firstLatitude)*math.Cos(secondLatitude)*
math.Sin(longitudeDifference/2)*math.Sin(longitudeDifference/2)
return earthRadius * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
type addressPolygonLookupResult struct {
GeoJSON json.RawMessage
HasHouseNumber bool
}
func lookupAddressPolygon(
ctx context.Context,
query string,
latitude float64,
longitude float64,
) addressPolygonLookupResult {
query = strings.TrimSpace(query)
if query == "" {
return addressPolygonLookupResult{}
}
params := url.Values{}
params.Set("format", "jsonv2")
params.Set("addressdetails", "1")
params.Set("polygon_geojson", "1")
params.Set("limit", "10")
params.Set("accept-language", "de")
params.Set("q", query)
searchURL := fmt.Sprintf(
"https://nominatim.openstreetmap.org/search?%s",
params.Encode(),
)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return addressPolygonLookupResult{}
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", addressSearchUserAgent())
response, err := httpClient().Do(request)
if err != nil {
return addressPolygonLookupResult{}
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return addressPolygonLookupResult{}
}
var results []nominatimSearchResult
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
return addressPolygonLookupResult{}
}
var bestResult addressPolygonLookupResult
bestDistance := math.Inf(1)
for _, result := range results {
if !isPolygonGeoJSON(result.GeoJSON) {
continue
}
resultLatitude, latitudeErr := strconv.ParseFloat(result.Lat, 64)
resultLongitude, longitudeErr := strconv.ParseFloat(result.Lon, 64)
if latitudeErr != nil || longitudeErr != nil {
continue
}
distance := coordinateDistanceMeters(
latitude,
longitude,
resultLatitude,
resultLongitude,
)
if distance > 250 || distance >= bestDistance {
continue
}
bestDistance = distance
bestResult = addressPolygonLookupResult{
GeoJSON: result.GeoJSON,
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
}
}
return bestResult
}
func normalizeOSMIdentifier(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
value = strings.ReplaceAll(value, ":", "")
if len(value) < 2 {
return ""
}
if value[0] != 'N' && value[0] != 'W' && value[0] != 'R' {
return ""
}
if _, err := strconv.ParseInt(value[1:], 10, 64); err != nil {
return ""
}
return value
}
func lookupOSMPolygon(ctx context.Context, osmIdentifier string) addressPolygonLookupResult {
osmIdentifier = normalizeOSMIdentifier(osmIdentifier)
if osmIdentifier == "" {
return addressPolygonLookupResult{}
}
params := url.Values{}
params.Set("format", "jsonv2")
params.Set("polygon_geojson", "1")
params.Set("osm_ids", osmIdentifier)
lookupURL := fmt.Sprintf(
"https://nominatim.openstreetmap.org/lookup?%s",
params.Encode(),
)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil)
if err != nil {
return addressPolygonLookupResult{}
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", addressSearchUserAgent())
response, err := httpClient().Do(request)
if err != nil {
return addressPolygonLookupResult{}
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return addressPolygonLookupResult{}
}
var results []nominatimSearchResult
if err := json.NewDecoder(response.Body).Decode(&results); err != nil {
return addressPolygonLookupResult{}
}
for _, result := range results {
if isPolygonGeoJSON(result.GeoJSON) {
return addressPolygonLookupResult{
GeoJSON: result.GeoJSON,
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
}
}
}
return addressPolygonLookupResult{}
}
func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
return
}
geoJSON := result.GeoJSON
hasHouseNumber := strings.TrimSpace(result.Address.HouseNumber) != ""
if !isPolygonGeoJSON(geoJSON) {
lookupResult := lookupOSMPolygon(
r.Context(),
r.URL.Query().Get("osm_id"),
)
geoJSON = lookupResult.GeoJSON
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
}
if !isPolygonGeoJSON(geoJSON) {
searchQuery := strings.TrimSpace(r.URL.Query().Get("q"))
if searchQuery == "" {
searchQuery = label
}
lookupResult := lookupAddressPolygon(
r.Context(),
searchQuery,
lat,
lon,
)
geoJSON = lookupResult.GeoJSON
hasHouseNumber = hasHouseNumber || lookupResult.HasHouseNumber
}
writeJSON(w, http.StatusOK, map[string]any{
"suggestion": reverseAddressSuggestion{
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
Label: label,
Lat: lat,
Lon: lon,
Type: result.Type,
GeoJSON: result.GeoJSON,
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
Label: label,
Lat: lat,
Lon: lon,
Type: result.Type,
HasHouseNumber: hasHouseNumber,
GeoJSON: geoJSON,
},
})
}

View File

@ -13,11 +13,13 @@ import (
)
type AddressSuggestion struct {
ID string `json:"id"`
Label string `json:"label"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type"`
ID string `json:"id"`
Label string `json:"label"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type"`
HasHouseNumber bool `json:"hasHouseNumber"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
type photonResponse struct {
@ -55,6 +57,7 @@ type nominatimSearchResult struct {
Lon string `json:"lon"`
Type string `json:"type"`
Address nominatimReverseAddress `json:"address"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
}
func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) {
@ -164,11 +167,12 @@ func (s *Server) handlePhotonAddressSearch(
}
suggestions = append(suggestions, AddressSuggestion{
ID: buildPhotonID(feature.Properties, label),
Label: label,
Lat: lat,
Lon: lon,
Type: buildPhotonType(feature.Properties),
ID: buildPhotonID(feature.Properties, label),
Label: label,
Lat: lat,
Lon: lon,
Type: buildPhotonType(feature.Properties),
HasHouseNumber: strings.TrimSpace(feature.Properties.HouseNumber) != "",
})
}
@ -196,6 +200,7 @@ func (s *Server) handleNominatimAddressSearch(
values.Set("q", query)
values.Set("format", "jsonv2")
values.Set("addressdetails", "1")
values.Set("polygon_geojson", "1")
values.Set("limit", strconv.Itoa(limit))
if countryCode != "" && values.Get("countrycodes") == "" {
@ -252,11 +257,13 @@ func (s *Server) handleNominatimAddressSearch(
}
suggestions = append(suggestions, AddressSuggestion{
ID: strconv.FormatInt(result.PlaceID, 10),
Label: label,
Lat: lat,
Lon: lon,
Type: result.Type,
ID: strconv.FormatInt(result.PlaceID, 10),
Label: label,
Lat: lat,
Lon: lon,
Type: result.Type,
HasHouseNumber: strings.TrimSpace(result.Address.HouseNumber) != "",
GeoJSON: result.GeoJSON,
})
}

View File

@ -24,17 +24,20 @@ type AdminTeam struct {
}
type AdminUser 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 []AdminTeam `json:"teams"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
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 []AdminTeam `json:"teams"`
Online bool `json:"onlineStatus"`
PresenceStatus string `json:"presenceStatus"`
LastSeenAt *time.Time `json:"lastSeenAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AdminUserRequest struct {
@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
COALESCE(unit, ''),
COALESCE(user_group, ''),
rights,
`+presenceStatusSQL+`,
last_seen_at,
created_at,
updated_at
FROM users
@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
&user.Unit,
&user.Group,
&user.Rights,
&user.PresenceStatus,
&user.LastSeenAt,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
}
user.Teams = []AdminTeam{}
user.Online = user.PresenceStatus == "online"
users = append(users, user)
userIndexByID[user.ID] = len(users) - 1

612
backend/channels.go Normal file
View File

@ -0,0 +1,612 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const zabbixWebhookScript = `var params = JSON.parse(value),
request = new HttpRequest(),
payload = {
subject: params.Subject,
message: params.Message,
host: params.Host,
severity: params.Severity,
status: params.Status,
eventId: params.EventId,
eventUrl: params.EventUrl
};
request.addHeader('Content-Type: application/json');
request.addHeader('Authorization: Bearer ' + params.Token);
var response = request.post(params.URL, JSON.stringify(payload)),
status = request.getStatus();
if (status < 200 || status >= 300) {
throw 'Webhook failed with HTTP status ' + status + ': ' + response;
}
return response;`
var channelSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
type AdminChannel struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
SourceName string `json:"sourceName"`
Image string `json:"image"`
AllUsers bool `json:"allUsers"`
UserIDs []string `json:"userIds"`
WebhookEnabled bool `json:"webhookEnabled"`
TokenConfigured bool `json:"tokenConfigured"`
WebhookTokenUpdatedAt *time.Time `json:"webhookTokenUpdatedAt"`
WebhookPath string `json:"webhookPath"`
}
type AdminChannelUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Unit string `json:"unit"`
}
type saveAdminChannelRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
SourceName string `json:"sourceName"`
Image string `json:"image"`
AllUsers bool `json:"allUsers"`
UserIDs []string `json:"userIds"`
WebhookEnabled bool `json:"webhookEnabled"`
}
type channelWebhookPayload struct {
Subject string `json:"subject"`
Message string `json:"message"`
Host string `json:"host"`
Severity string `json:"severity"`
Status string `json:"status"`
EventID string `json:"eventId"`
EventURL string `json:"eventUrl"`
}
func (s *Server) ensureAllUserChannels(ctx context.Context, userID string) error {
_, err := s.db.Exec(
ctx,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, $1
FROM conversations c
WHERE c.type = 'channel'
AND c.channel_all_users
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
userID,
)
return err
}
func normalizeChannelSlug(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validateAdminChannelInput(input saveAdminChannelRequest) (saveAdminChannelRequest, error) {
input.Name = strings.TrimSpace(input.Name)
input.Slug = normalizeChannelSlug(input.Slug)
input.SourceName = strings.TrimSpace(input.SourceName)
input.Image = strings.TrimSpace(input.Image)
userIDs := input.UserIDs
uniqueUserIDs := make(map[string]struct{}, len(userIDs))
input.UserIDs = make([]string, 0, len(userIDs))
for _, userID := range userIDs {
userID = strings.TrimSpace(userID)
if userID == "" {
continue
}
if _, exists := uniqueUserIDs[userID]; exists {
continue
}
uniqueUserIDs[userID] = struct{}{}
input.UserIDs = append(input.UserIDs, userID)
}
if input.Name == "" {
return input, errors.New("Channelname ist erforderlich")
}
if !channelSlugPattern.MatchString(input.Slug) {
return input, errors.New("Der Webhook-Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten")
}
if input.SourceName == "" {
input.SourceName = input.Name
}
if len(input.Image) > 3*1024*1024 {
return input, errors.New("Das Channel-Bild ist zu groß")
}
return input, nil
}
func (s *Server) handleAdminListChannels(w http.ResponseWriter, r *http.Request) {
channels, err := s.listAdminChannels(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Channels konnten nicht geladen werden")
return
}
users, err := s.listAdminChannelUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"channels": channels,
"users": users,
"zabbixScript": zabbixWebhookScript,
})
}
func (s *Server) listAdminChannels(ctx context.Context) ([]AdminChannel, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
c.id::TEXT,
c.name,
c.slug,
c.channel_source_name,
c.channel_image,
c.channel_all_users,
c.webhook_enabled,
c.webhook_token_hash <> '',
c.webhook_token_updated_at,
COALESCE(array_agg(cm.user_id::TEXT) FILTER (WHERE cm.user_id IS NOT NULL), '{}')
FROM conversations c
LEFT JOIN conversation_members cm ON cm.conversation_id = c.id
WHERE c.type = 'channel'
GROUP BY c.id
ORDER BY lower(c.name)
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
channels := []AdminChannel{}
for rows.Next() {
var channel AdminChannel
if err := rows.Scan(
&channel.ID,
&channel.Name,
&channel.Slug,
&channel.SourceName,
&channel.Image,
&channel.AllUsers,
&channel.WebhookEnabled,
&channel.TokenConfigured,
&channel.WebhookTokenUpdatedAt,
&channel.UserIDs,
); err != nil {
return nil, err
}
channel.WebhookPath = "/webhooks/channels/" + channel.Slug
channels = append(channels, channel)
}
return channels, rows.Err()
}
func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
id::TEXT,
COALESCE(username, ''),
COALESCE(display_name, ''),
email,
COALESCE(unit, '')
FROM users
ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email))
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := []AdminChannelUser{}
for rows.Next() {
var user AdminChannelUser
if err := rows.Scan(
&user.ID,
&user.Username,
&user.DisplayName,
&user.Email,
&user.Unit,
); err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s *Server) handleAdminCreateChannel(w http.ResponseWriter, r *http.Request) {
var input saveAdminChannelRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
input, err := validateAdminChannelInput(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Channel konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var channelID string
err = tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (
type,
name,
slug,
channel_source_name,
channel_image,
channel_all_users,
webhook_enabled
)
VALUES ('channel', $1, $2, $3, $4, $5, $6)
RETURNING id::TEXT
`,
input.Name,
input.Slug,
input.SourceName,
input.Image,
input.AllUsers,
input.WebhookEnabled,
).Scan(&channelID)
if err != nil {
writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet")
return
}
if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil {
writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": channelID})
}
func (s *Server) handleAdminUpdateChannel(w http.ResponseWriter, r *http.Request) {
channelID := strings.TrimSpace(r.PathValue("id"))
var input saveAdminChannelRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
input, err := validateAdminChannelInput(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Channel konnte nicht aktualisiert werden")
return
}
defer tx.Rollback(r.Context())
commandTag, err := tx.Exec(
r.Context(),
`
UPDATE conversations
SET
name = $2,
slug = $3,
channel_source_name = $4,
channel_image = $5,
channel_all_users = $6,
webhook_enabled = $7,
updated_at = now()
WHERE id = $1
AND type = 'channel'
`,
channelID,
input.Name,
input.Slug,
input.SourceName,
input.Image,
input.AllUsers,
input.WebhookEnabled,
)
if err != nil {
writeError(w, http.StatusConflict, "Channelname oder Webhook-Slug wird bereits verwendet")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden")
return
}
if err := syncChannelMembers(r.Context(), tx, channelID, input.AllUsers, input.UserIDs); err != nil {
writeError(w, http.StatusInternalServerError, "Channelmitglieder konnten nicht gespeichert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Channel konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": channelID})
}
func syncChannelMembers(
ctx context.Context,
tx pgx.Tx,
channelID string,
allUsers bool,
userIDs []string,
) error {
if allUsers {
_, err := tx.Exec(
ctx,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
channelID,
)
return err
}
if _, err := tx.Exec(
ctx,
`
DELETE FROM conversation_members
WHERE conversation_id = $1
AND NOT (
user_id::TEXT = ANY(
COALESCE($2::TEXT[], ARRAY[]::TEXT[])
)
)
`,
channelID,
userIDs,
); err != nil {
return err
}
for _, userID := range userIDs {
if _, err := tx.Exec(
ctx,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users WHERE id = $2
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
channelID,
strings.TrimSpace(userID),
); err != nil {
return err
}
}
return nil
}
func generateChannelWebhookToken() (string, string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
hash := sha256.Sum256([]byte(token))
return token, hex.EncodeToString(hash[:]), nil
}
func (s *Server) handleAdminRotateChannelToken(w http.ResponseWriter, r *http.Request) {
channelID := strings.TrimSpace(r.PathValue("id"))
token, tokenHash, err := generateChannelWebhookToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht erzeugt werden")
return
}
commandTag, err := s.db.Exec(
r.Context(),
`
UPDATE conversations
SET
webhook_token_hash = $2,
webhook_token_updated_at = now(),
updated_at = now()
WHERE id = $1
AND type = 'channel'
`,
channelID,
tokenHash,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Webhook-Token konnte nicht gespeichert werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Channel wurde nicht gefunden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": token})
}
func (s *Server) handleChannelWebhook(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
slug := normalizeChannelSlug(r.PathValue("slug"))
var channelID string
var tokenHash string
var enabled bool
err := s.db.QueryRow(
r.Context(),
`
SELECT id::TEXT, webhook_token_hash, webhook_enabled
FROM conversations
WHERE type = 'channel'
AND slug = $1
`,
slug,
).Scan(&channelID, &tokenHash, &enabled)
if err != nil || !enabled || tokenHash == "" {
writeError(w, http.StatusNotFound, "Webhook wurde nicht gefunden")
return
}
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if token == "" {
token = strings.TrimSpace(r.Header.Get("X-Webhook-Token"))
}
hash := sha256.Sum256([]byte(token))
providedHash := hex.EncodeToString(hash[:])
if subtle.ConstantTimeCompare([]byte(providedHash), []byte(tokenHash)) != 1 {
writeError(w, http.StatusUnauthorized, "Ungültiges Webhook-Token")
return
}
var input channelWebhookPayload
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Webhook-Nachricht")
return
}
body := formatChannelWebhookMessage(input)
if body == "" {
writeError(w, http.StatusBadRequest, "Nachricht darf nicht leer sein")
return
}
message, err := s.createChannelMessage(r.Context(), channelID, body)
if err != nil {
writeError(w, http.StatusInternalServerError, "Channel-Nachricht konnte nicht gespeichert werden")
return
}
s.publishChatMessage(r.Context(), message, "")
writeJSON(w, http.StatusCreated, map[string]any{
"messageId": message.ID,
"status": "accepted",
})
}
func formatChannelWebhookMessage(input channelWebhookPayload) string {
parts := []string{}
if subject := strings.TrimSpace(input.Subject); subject != "" {
parts = append(parts, subject)
}
if message := strings.TrimSpace(input.Message); message != "" {
parts = append(parts, message)
}
details := []string{}
for _, detail := range []struct {
label string
value string
}{
{"Host", input.Host},
{"Status", input.Status},
{"Schweregrad", input.Severity},
{"Event-ID", input.EventID},
{"Link", input.EventURL},
} {
if value := strings.TrimSpace(detail.value); value != "" {
details = append(details, detail.label+": "+value)
}
}
if len(details) > 0 {
parts = append(parts, strings.Join(details, "\n"))
}
body := strings.Join(parts, "\n\n")
runes := []rune(body)
if len(runes) > 4000 {
body = string(runes[:4000])
}
return body
}
func (s *Server) createChannelMessage(
ctx context.Context,
conversationID string,
body string,
) (ChatMessage, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return ChatMessage{}, err
}
defer tx.Rollback(ctx)
var messageID string
err = tx.QueryRow(
ctx,
`
INSERT INTO messages (conversation_id, body)
SELECT id, $2
FROM conversations
WHERE id = $1
AND type = 'channel'
RETURNING id::TEXT
`,
conversationID,
body,
).Scan(&messageID)
if err != nil {
return ChatMessage{}, err
}
if _, err := tx.Exec(
ctx,
`
UPDATE conversations
SET last_message_at = now(), updated_at = now()
WHERE id = $1
`,
conversationID,
); err != nil {
return ChatMessage{}, err
}
if err := tx.Commit(ctx); err != nil {
return ChatMessage{}, err
}
return s.getChatMessage(ctx, messageID)
}

1366
backend/chat.go Normal file

File diff suppressed because it is too large Load Diff

160
backend/chat_attachments.go Normal file
View File

@ -0,0 +1,160 @@
package main
import (
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
)
const maxChatAttachmentSize = 10 << 20
func (s *Server) handleUploadChatAttachment(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxChatAttachmentSize+(1<<20))
if err := r.ParseMultipartForm(maxChatAttachmentSize); err != nil {
writeError(w, http.StatusBadRequest, "Datei ist zu groß oder ungültig")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "Datei fehlt")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxChatAttachmentSize+1))
if err != nil {
writeError(w, http.StatusBadRequest, "Datei konnte nicht gelesen werden")
return
}
if len(data) == 0 {
writeError(w, http.StatusBadRequest, "Datei ist leer")
return
}
if len(data) > maxChatAttachmentSize {
writeError(w, http.StatusBadRequest, "Datei darf höchstens 10 MB groß sein")
return
}
fileName := strings.TrimSpace(filepath.Base(header.Filename))
if fileName == "" || fileName == "." {
fileName = "Anhang"
}
contentType := strings.TrimSpace(header.Header.Get("Content-Type"))
if contentType == "" {
contentType = http.DetectContentType(data)
}
var attachment ChatAttachment
err = s.db.QueryRow(
r.Context(),
`
INSERT INTO message_attachments (
uploader_id,
file_name,
content_type,
size_bytes,
data
)
VALUES ($1, $2, $3, $4, $5)
RETURNING id::TEXT, file_name, content_type, size_bytes
`,
user.ID,
fileName,
contentType,
len(data),
data,
).Scan(
&attachment.ID,
&attachment.FileName,
&attachment.ContentType,
&attachment.SizeBytes,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Datei konnte nicht gespeichert werden")
return
}
attachment.URL = "/chat/attachments/" + attachment.ID
writeJSON(w, http.StatusCreated, map[string]any{"attachment": attachment})
}
func (s *Server) handleDownloadChatAttachment(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
attachmentID := strings.TrimSpace(r.PathValue("id"))
var uploaderID string
var messageID string
var conversationID string
var fileName string
var contentType string
var data []byte
err := s.db.QueryRow(
r.Context(),
`
SELECT
a.uploader_id::TEXT,
COALESCE(a.message_id::TEXT, ''),
COALESCE(m.conversation_id::TEXT, ''),
a.file_name,
a.content_type,
a.data
FROM message_attachments a
LEFT JOIN messages m ON m.id = a.message_id
WHERE a.id = $1
`,
attachmentID,
).Scan(
&uploaderID,
&messageID,
&conversationID,
&fileName,
&contentType,
&data,
)
if err != nil {
writeError(w, http.StatusNotFound, "Datei wurde nicht gefunden")
return
}
allowed := messageID == "" && uploaderID == user.ID
if messageID != "" {
allowed = s.canAccessConversation(r.Context(), conversationID, user.ID)
}
if !allowed {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diese Datei")
return
}
if contentType == "" {
contentType = "application/octet-stream"
}
dispositionType := "attachment"
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
dispositionType = "inline"
}
disposition := mime.FormatMediaType(dispositionType, map[string]string{
"filename": fileName,
})
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", disposition)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}

View File

@ -0,0 +1,361 @@
package main
import (
"context"
"errors"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
)
const (
maxLinkPreviewBytes = 512 * 1024
linkPreviewUserAgent = "Mozilla/5.0 (compatible; TEG-LinkPreview/1.0)"
)
var (
errLinkPreviewBlocked = errors.New("zieladresse ist nicht erlaubt")
chatURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
metaTagPattern = regexp.MustCompile(`(?is)<meta\b[^>]*>`)
metaAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z:_-]+)\s*=\s*"([^"]*)"|([a-zA-Z:_-]+)\s*=\s*'([^']*)'`)
titleTagPattern = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
trailingTrimSet = ".,!?;:)]}'\""
)
// firstHTTPURL liefert die erste http(s)-URL aus dem Nachrichtentext.
func firstHTTPURL(body string) string {
match := chatURLPattern.FindString(body)
if match == "" {
return ""
}
return strings.TrimRight(match, trailingTrimSet)
}
// isBlockedIP verhindert SSRF: interne/lokale Adressbereiche sind tabu.
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast()
}
// safeDialControl wird unmittelbar vor dem Verbindungsaufbau mit der bereits
// aufgelösten Ziel-IP aufgerufen das blockt auch DNS-Rebinding.
func safeDialControl(network, address string, _ syscall.RawConn) error {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return errLinkPreviewBlocked
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return errLinkPreviewBlocked
}
if isBlockedIP(net.ParseIP(host)) {
return errLinkPreviewBlocked
}
return nil
}
func newLinkPreviewClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
Control: safeDialControl,
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
DisableKeepAlives: true,
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errLinkPreviewBlocked
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return errLinkPreviewBlocked
}
return nil
},
}
}
func fetchLinkPreview(ctx context.Context, rawURL string) (*ChatLinkPreview, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, errLinkPreviewBlocked
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", linkPreviewUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("Accept-Language", "de,en;q=0.8")
resp, err := newLinkPreviewClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, errLinkPreviewBlocked
}
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if contentType != "" && !strings.Contains(contentType, "html") {
return nil, errLinkPreviewBlocked
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxLinkPreviewBytes))
if err != nil {
return nil, err
}
preview := parseOpenGraph(string(body), resp.Request.URL)
preview.URL = rawURL
return preview, nil
}
func parseOpenGraph(htmlBody string, baseURL *url.URL) *ChatLinkPreview {
meta := map[string]string{}
for _, tag := range metaTagPattern.FindAllString(htmlBody, 300) {
var key, content string
hasContent := false
for _, attr := range metaAttrPattern.FindAllStringSubmatch(tag, -1) {
name := strings.ToLower(attr[1])
value := attr[2]
if name == "" {
name = strings.ToLower(attr[3])
value = attr[4]
}
value = html.UnescapeString(value)
switch name {
case "property", "name":
key = strings.ToLower(strings.TrimSpace(value))
case "content":
content = value
hasContent = true
}
}
if key != "" && hasContent {
if _, exists := meta[key]; !exists {
meta[key] = content
}
}
}
pick := func(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(meta[key]); value != "" {
return value
}
}
return ""
}
preview := &ChatLinkPreview{}
preview.Title = pick("og:title", "twitter:title")
if preview.Title == "" {
if match := titleTagPattern.FindStringSubmatch(htmlBody); len(match) > 1 {
preview.Title = strings.TrimSpace(
html.UnescapeString(htmlTagPattern.ReplaceAllString(match[1], "")),
)
}
}
preview.Description = pick("og:description", "twitter:description", "description")
preview.SiteName = pick("og:site_name")
if preview.SiteName == "" && baseURL != nil {
preview.SiteName = baseURL.Hostname()
}
if image := pick("og:image", "og:image:url", "og:image:secure_url", "twitter:image", "twitter:image:src"); image != "" {
preview.ImageURL = resolveImageURL(image, baseURL)
}
preview.Title = truncateRunes(preview.Title, 200)
preview.Description = truncateRunes(preview.Description, 400)
preview.SiteName = truncateRunes(preview.SiteName, 100)
return preview
}
func resolveImageURL(image string, baseURL *url.URL) string {
parsed, err := url.Parse(strings.TrimSpace(image))
if err != nil {
return ""
}
if baseURL != nil {
parsed = baseURL.ResolveReference(parsed)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ""
}
return parsed.String()
}
func truncateRunes(value string, max int) string {
value = strings.TrimSpace(value)
if runes := []rune(value); len(runes) > max {
return strings.TrimSpace(string(runes[:max]))
}
return value
}
func (s *Server) getChatLinkPreview(ctx context.Context, messageID string) (*ChatLinkPreview, error) {
var preview ChatLinkPreview
err := s.db.QueryRow(
ctx,
`
SELECT url, title, description, image_url, site_name
FROM message_link_previews
WHERE message_id = $1
`,
messageID,
).Scan(
&preview.URL,
&preview.Title,
&preview.Description,
&preview.ImageURL,
&preview.SiteName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &preview, nil
}
type chatLinkPreviewRequest struct {
URL string `json:"url"`
}
// handleChatLinkPreview liefert eine Vorschau für eine einzelne URL on demand,
// damit der Absender sie schon vor dem Senden über dem Eingabefeld sieht.
func (s *Server) handleChatLinkPreview(w http.ResponseWriter, r *http.Request) {
if _, ok := userFromContext(r.Context()); !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input chatLinkPreviewRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
rawURL := firstHTTPURL(input.URL)
if rawURL == "" {
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
preview, err := fetchLinkPreview(ctx, rawURL)
if err != nil ||
preview == nil ||
(preview.Title == "" && preview.Description == "" && preview.ImageURL == "") {
writeJSON(w, http.StatusOK, map[string]any{"preview": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"preview": preview})
}
// scheduleLinkPreview lädt sofern die Nachricht eine URL enthält die
// Vorschau asynchron, damit der Versand nicht durch externe Requests blockiert.
func (s *Server) scheduleLinkPreview(message ChatMessage) {
rawURL := firstHTTPURL(message.Body)
if rawURL == "" {
return
}
go s.generateLinkPreview(message.ID, rawURL)
}
func (s *Server) generateLinkPreview(messageID string, rawURL string) {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
preview, err := fetchLinkPreview(ctx, rawURL)
if err != nil || preview == nil {
return
}
if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" {
return
}
_, err = s.db.Exec(
ctx,
`
INSERT INTO message_link_previews (
message_id, url, title, description, image_url, site_name
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (message_id) DO UPDATE SET
url = EXCLUDED.url,
title = EXCLUDED.title,
description = EXCLUDED.description,
image_url = EXCLUDED.image_url,
site_name = EXCLUDED.site_name
`,
messageID,
preview.URL,
preview.Title,
preview.Description,
preview.ImageURL,
preview.SiteName,
)
if err != nil {
return
}
message, err := s.getChatMessage(ctx, messageID)
if err != nil {
return
}
s.publishChatMessageUpdate(ctx, message)
}

View File

@ -0,0 +1,65 @@
package main
import (
"net/http"
"strings"
)
type updateConversationMuteRequest struct {
Muted bool `json:"muted"`
}
func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return
}
var conversationType string
if err := s.db.QueryRow(
r.Context(),
`SELECT type FROM conversations WHERE id = $1`,
conversationID,
).Scan(&conversationType); err != nil {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if conversationType == "direct" {
writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden")
return
}
var input updateConversationMuteRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
_, err := s.db.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id, muted)
VALUES ($1, $2, $3)
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET muted = EXCLUDED.muted
`,
conversationID,
user.ID,
input.Muted,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Stummschaltung konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"muted": input.Muted,
})
}

109
backend/chat_search.go Normal file
View File

@ -0,0 +1,109 @@
package main
import (
"net/http"
"strings"
"time"
)
type ChatSearchResult struct {
ConversationID string `json:"conversationId"`
MessageID string `json:"messageId"`
Body string `json:"body"`
SenderDisplayName string `json:"senderDisplayName"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
writeJSON(w, http.StatusOK, map[string]any{
"results": []ChatSearchResult{},
})
return
}
rows, err := s.db.Query(
r.Context(),
`
SELECT DISTINCT ON (m.conversation_id)
m.conversation_id::TEXT,
m.id::TEXT,
m.body,
COALESCE(
NULLIF(u.display_name, ''),
NULLIF(u.username, ''),
u.email,
NULLIF(c.channel_source_name, ''),
c.name,
''
),
m.created_at
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.deleted_at IS NULL
AND strpos(lower(m.body), lower($2)) > 0
AND (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $1
)
)
OR (
c.type <> 'team'
AND EXISTS (
SELECT 1
FROM conversation_members cm
WHERE cm.conversation_id = c.id
AND cm.user_id = $1
)
)
)
ORDER BY m.conversation_id, m.created_at DESC, m.id DESC
LIMIT 50
`,
user.ID,
query,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Chatnachrichten konnten nicht durchsucht werden")
return
}
defer rows.Close()
results := []ChatSearchResult{}
for rows.Next() {
var result ChatSearchResult
if err := rows.Scan(
&result.ConversationID,
&result.MessageID,
&result.Body,
&result.SenderDisplayName,
&result.CreatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht gelesen werden")
return
}
results = append(results, result)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Suchergebnisse konnten nicht vollständig geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"results": results,
})
}

121
backend/chat_unread.go Normal file
View File

@ -0,0 +1,121 @@
package main
import (
"context"
"net/http"
"strings"
)
func (s *Server) markChatConversationRead(
ctx context.Context,
conversationID string,
userID string,
) error {
_, err := s.db.Exec(
ctx,
`
INSERT INTO conversation_members (
conversation_id,
user_id,
last_read_at
)
VALUES ($1, $2, now())
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET last_read_at = EXCLUDED.last_read_at
`,
conversationID,
userID,
)
return err
}
func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, error) {
var unreadCount int
err := s.db.QueryRow(
ctx,
`
SELECT COUNT(*)::INT
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN conversation_members cm
ON cm.conversation_id = c.id
AND cm.user_id = $1
WHERE m.deleted_at IS NULL
AND m.sender_id IS DISTINCT FROM $1::UUID
AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ)
AND (
(
c.type = 'team'
AND EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = $1
)
)
OR (
c.type <> 'team'
AND cm.user_id IS NOT NULL
)
)
`,
userID,
).Scan(&unreadCount)
return unreadCount, err
}
func (s *Server) handleGetChatUnreadCount(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
if err := s.ensureTeamConversations(r.Context(), user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Team-Chats konnten nicht vorbereitet werden")
return
}
if err := s.ensureAllUserChannels(r.Context(), user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Channels konnten nicht vorbereitet werden")
return
}
unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"unreadCount": unreadCount,
})
}
func (s *Server) handleMarkChatConversationRead(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return
}
if err := s.markChatConversationRead(r.Context(), conversationID, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Lesestatus konnte nicht gespeichert werden")
return
}
unreadCount, err := s.getChatUnreadCount(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Ungelesene Nachrichten konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"unreadCount": unreadCount,
})
}

464
backend/chat_websocket.go Normal file
View File

@ -0,0 +1,464 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
type chatSocketCommand struct {
Type string `json:"type"`
ClientID string `json:"clientId"`
ConversationID string `json:"conversationId"`
Body string `json:"body"`
AttachmentIDs []string `json:"attachmentIds"`
ReplyToMessageID string `json:"replyToMessageId"`
ForwardedFromMessageID string `json:"forwardedFromMessageId"`
Location *ChatLocation `json:"location"`
}
type chatSocketEvent struct {
Type string `json:"type"`
ClientID string `json:"clientId,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
UserID string `json:"userId,omitempty"`
UserDisplayName string `json:"userDisplayName,omitempty"`
Typing bool `json:"typing,omitempty"`
Message *ChatMessage `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type chatSocketClient struct {
userID string
ch chan chatSocketEvent
}
type chatSocketBroker struct {
mu sync.RWMutex
clients map[*chatSocketClient]struct{}
}
var chatSockets = &chatSocketBroker{
clients: map[*chatSocketClient]struct{}{},
}
func (b *chatSocketBroker) subscribe(userID string) *chatSocketClient {
client := &chatSocketClient{
userID: userID,
ch: make(chan chatSocketEvent, 32),
}
b.mu.Lock()
b.clients[client] = struct{}{}
b.mu.Unlock()
return client
}
func (b *chatSocketBroker) unsubscribe(client *chatSocketClient) {
b.mu.Lock()
delete(b.clients, client)
b.mu.Unlock()
}
func (b *chatSocketBroker) publish(userID string, event chatSocketEvent) {
b.mu.RLock()
defer b.mu.RUnlock()
for client := range b.clients {
if client.userID != userID {
continue
}
select {
case client.ch <- event:
default:
}
}
}
func (c *chatSocketClient) send(event chatSocketEvent) {
select {
case c.ch <- event:
default:
}
}
func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
originPatterns := []string{}
if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" {
originPatterns = append(originPatterns, frontendOrigin)
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: originPatterns,
})
if err != nil {
return
}
defer conn.CloseNow()
conn.SetReadLimit(8 * 1024)
client := chatSockets.subscribe(user.ID)
defer chatSockets.unsubscribe(client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.readChatSocket(ctx, cancel, conn, client, user)
client.send(chatSocketEvent{Type: "connected"})
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
return
case event, open := <-client.ch:
if !open {
return
}
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
err := wsjson.Write(writeCtx, conn, event)
writeCancel()
if err != nil {
return
}
case <-pingTicker.C:
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pingCtx)
pingCancel()
if err != nil {
return
}
}
}
}
func (s *Server) readChatSocket(
ctx context.Context,
cancel context.CancelFunc,
conn *websocket.Conn,
client *chatSocketClient,
user User,
) {
defer cancel()
for {
var command chatSocketCommand
if err := wsjson.Read(ctx, conn, &command); err != nil {
return
}
switch command.Type {
case "typing.start", "typing.stop":
if !s.canAccessConversation(ctx, command.ConversationID, user.ID) ||
!s.isConversationWritable(ctx, command.ConversationID) {
client.send(chatSocketEvent{
Type: "error",
Error: "Dieser Chat ist schreibgeschützt",
})
continue
}
s.publishChatTyping(
ctx,
user,
command.ConversationID,
command.Type == "typing.start",
)
continue
case "message.send":
message, err := s.createChatMessage(
ctx,
user,
command.ConversationID,
createChatMessageInput{
Body: command.Body,
AttachmentIDs: command.AttachmentIDs,
ReplyToMessageID: command.ReplyToMessageID,
ForwardedFromMessageID: command.ForwardedFromMessageID,
Location: command.Location,
},
)
if err != nil {
client.send(chatSocketEvent{
Type: "error",
ClientID: command.ClientID,
Error: chatSocketErrorMessage(err),
})
continue
}
s.publishChatMessage(ctx, message, command.ClientID)
continue
default:
client.send(chatSocketEvent{
Type: "error",
ClientID: command.ClientID,
Error: "Unbekannte Chat-Aktion",
})
}
}
}
func chatSocketErrorMessage(err error) string {
switch {
case errors.Is(err, errChatAccessDenied):
return "Kein Zugriff auf diesen Chat"
case errors.Is(err, errChatMessageEmpty):
return "Nachricht darf nicht leer sein"
case errors.Is(err, errChatMessageTooLong):
return "Nachricht darf höchstens 4000 Zeichen enthalten"
case errors.Is(err, errChatLocationInvalid):
return "Ungültiger Standort"
case errors.Is(err, errChatForwardSame):
return "Nachrichten können nicht in denselben Chat weitergeleitet werden"
case errors.Is(err, errChatReadOnly):
return "Channels sind schreibgeschützt"
default:
return "Nachricht konnte nicht gesendet werden"
}
}
func (s *Server) publishChatTyping(
ctx context.Context,
user User,
conversationID string,
typing bool,
) {
memberIDs, err := s.chatConversationMemberIDs(ctx, conversationID)
if err != nil {
return
}
event := chatSocketEvent{
Type: "typing.updated",
ConversationID: conversationID,
UserID: user.ID,
UserDisplayName: user.DisplayName,
Typing: typing,
}
if event.UserDisplayName == "" {
event.UserDisplayName = user.Username
}
if event.UserDisplayName == "" {
event.UserDisplayName = user.Email
}
for _, memberID := range memberIDs {
if memberID != user.ID {
chatSockets.publish(memberID, event)
}
}
}
func (s *Server) publishChatMessage(
ctx context.Context,
message ChatMessage,
clientID string,
) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT, cm.muted
FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1
AND (
c.type <> 'team'
OR EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
)
`,
message.ConversationID,
)
if err != nil {
return
}
defer rows.Close()
event := chatSocketEvent{
Type: "message.created",
ClientID: clientID,
Message: &message,
}
recipientIDs := []string{}
for rows.Next() {
var recipientID string
var muted bool
if err := rows.Scan(&recipientID, &muted); err != nil {
continue
}
chatSockets.publish(recipientID, event)
if recipientID != message.Sender.ID && !muted {
recipientIDs = append(recipientIDs, recipientID)
}
}
rows.Close()
title, conversationType, err := s.chatNotificationTitle(ctx, message)
if err != nil {
return
}
preview := strings.TrimSpace(message.Body)
if preview == "" && len(message.Attachments) > 0 {
preview = "Datei angehängt"
}
previewRunes := []rune(preview)
if len(previewRunes) > 160 {
preview = string(previewRunes[:160]) + "..."
}
data, err := json.Marshal(map[string]any{
"conversationId": message.ConversationID,
"messageId": message.ID,
"senderId": message.Sender.ID,
"senderAvatar": message.Sender.Avatar,
"senderName": chatUserDisplayName(message.Sender),
"conversationType": conversationType,
})
if err != nil {
return
}
for _, recipientID := range recipientIDs {
_ = s.createAndPublishNotification(
ctx,
recipientID,
"chat.message",
title,
preview,
"chat",
message.ConversationID,
data,
false,
)
}
s.scheduleLinkPreview(message)
}
func (s *Server) chatConversationMemberIDs(ctx context.Context, conversationID string) ([]string, error) {
rows, err := s.db.Query(
ctx,
`
SELECT cm.user_id::TEXT
FROM conversation_members cm
JOIN conversations c ON c.id = cm.conversation_id
WHERE cm.conversation_id = $1
AND (
c.type <> 'team'
OR EXISTS (
SELECT 1
FROM user_teams ut
WHERE ut.team_id = c.team_id
AND ut.user_id = cm.user_id
)
)
`,
conversationID,
)
if err != nil {
return nil, err
}
defer rows.Close()
memberIDs := []string{}
for rows.Next() {
var memberID string
if err := rows.Scan(&memberID); err != nil {
return nil, err
}
memberIDs = append(memberIDs, memberID)
}
return memberIDs, rows.Err()
}
func (s *Server) publishChatMessageUpdate(ctx context.Context, message ChatMessage) {
memberIDs, err := s.chatConversationMemberIDs(ctx, message.ConversationID)
if err != nil {
return
}
event := chatSocketEvent{
Type: "message.updated",
Message: &message,
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, event)
}
}
func chatUserDisplayName(user ChatUser) string {
if user.DisplayName != "" {
return user.DisplayName
}
if user.Username != "" {
return user.Username
}
return user.Email
}
func (s *Server) chatNotificationTitle(
ctx context.Context,
message ChatMessage,
) (string, string, error) {
var conversationType string
var conversationName string
err := s.db.QueryRow(
ctx,
`
SELECT type, name
FROM conversations
WHERE id = $1
`,
message.ConversationID,
).Scan(&conversationType, &conversationName)
if err != nil {
return "", "", err
}
senderName := chatUserDisplayName(message.Sender)
if conversationType == "team" && conversationName != "" {
return senderName + " in " + conversationName, conversationType, nil
}
if conversationType == "channel" && conversationName != "" {
return "Neue Meldung in " + conversationName, conversationType, nil
}
return "Neue Nachricht von " + senderName, conversationType, nil
}

View File

@ -15,8 +15,17 @@ import (
type CreateFeedbackRequest struct {
Message string `json:"message"`
// ClientLog ist das clientseitig erfasste Aktivitäts-/Fehlerprotokoll
// (Array). Client enthält Browser-/Geräte-Metadaten.
ClientLog json.RawMessage `json:"clientLog"`
Client json.RawMessage `json:"client"`
}
const (
maxFeedbackClientLogBytes = 512 * 1024
maxFeedbackClientMetaBytes = 16 * 1024
)
type FeedbackResponse struct {
ID string `json:"id"`
UserID string `json:"userId"`
@ -63,6 +72,16 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
return
}
var clientActivity any = json.RawMessage("[]")
if len(input.ClientLog) > 0 && len(input.ClientLog) <= maxFeedbackClientLogBytes {
clientActivity = input.ClientLog
}
var clientMeta any
if len(input.Client) > 0 && len(input.Client) <= maxFeedbackClientMetaBytes {
clientMeta = input.Client
}
feedbackLog := map[string]any{
"user": buildUserLogSnapshot(user),
"request": mergeLogFields(
@ -74,6 +93,8 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
"server": map[string]any{
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
},
"client": clientMeta,
"activity": clientActivity,
}
logJSON, err := json.Marshal(feedbackLog)

View File

@ -5,13 +5,12 @@ module main
go 1.26.2
require (
github.com/coder/websocket v1.8.14
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/coder/websocket v1.8.14 // indirect
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect

View File

@ -37,6 +37,7 @@ func main() {
}
server := NewServer(db, jwtSecret, frontendOrigin)
server.startPresenceMonitor(ctx)
log.Printf("Backend läuft auf http://localhost:%s", port)

View File

@ -21,6 +21,8 @@ type notificationPreferencesResponse struct {
DeviceUpdates bool `json:"deviceUpdates"`
DeviceJournals bool `json:"deviceJournals"`
SystemMessages bool `json:"systemMessages"`
ChatMessages bool `json:"chatMessages"`
DesktopEnabled bool `json:"desktopEnabled"`
}
func defaultNotificationPreferences() notificationPreferencesResponse {
@ -34,6 +36,8 @@ func defaultNotificationPreferences() notificationPreferencesResponse {
DeviceUpdates: true,
DeviceJournals: true,
SystemMessages: true,
ChatMessages: true,
DesktopEnabled: false,
}
}
@ -102,9 +106,11 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
operation_journals,
device_updates,
device_journals,
system_messages
system_messages,
chat_messages,
desktop_enabled
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (user_id)
DO UPDATE SET
in_app_enabled = EXCLUDED.in_app_enabled,
@ -116,6 +122,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
device_updates = EXCLUDED.device_updates,
device_journals = EXCLUDED.device_journals,
system_messages = EXCLUDED.system_messages,
chat_messages = EXCLUDED.chat_messages,
desktop_enabled = EXCLUDED.desktop_enabled,
updated_at = now()
`,
user.ID,
@ -128,6 +136,8 @@ func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *h
input.DeviceUpdates,
input.DeviceJournals,
input.SystemMessages,
input.ChatMessages,
input.DesktopEnabled,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
@ -160,7 +170,9 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
operation_journals,
device_updates,
device_journals,
system_messages
system_messages,
chat_messages,
desktop_enabled
FROM notification_preferences
WHERE user_id = $1
LIMIT 1
@ -176,6 +188,8 @@ func (s *Server) getNotificationPreferences(ctx context.Context, userID string)
&settings.DeviceUpdates,
&settings.DeviceJournals,
&settings.SystemMessages,
&settings.ChatMessages,
&settings.DesktopEnabled,
)
settings.SoundName = normalizeNotificationSoundName(settings.SoundName)

View File

@ -24,6 +24,8 @@ type Notification struct {
Data json.RawMessage `json:"data"`
Silent bool `json:"silent"`
SoundName string `json:"soundName,omitempty"`
Desktop bool `json:"desktop,omitempty"`
InApp bool `json:"inAppEnabled"`
ReadAt *time.Time `json:"readAt"`
CreatedAt time.Time `json:"createdAt"`
}
@ -95,11 +97,13 @@ func shouldDeliverNotification(
notificationType string,
entityType string,
) bool {
if !preferences.InAppEnabled {
return false
}
switch {
case strings.HasPrefix(notificationType, "chat.") || entityType == "chat":
return preferences.ChatMessages
case !preferences.InAppEnabled:
return false
case notificationType == "operation.journal":
return preferences.OperationJournals
@ -150,6 +154,8 @@ func (s *Server) createAndPublishNotification(
return nil
}
inAppVisible := silent || (preferences.InAppEnabled && entityType != "chat")
var notification Notification
var rawData []byte
@ -164,7 +170,8 @@ func (s *Server) createAndPublishNotification(
entity_type,
entity_id,
data,
silent
silent,
in_app_visible
)
VALUES (
$1,
@ -174,7 +181,8 @@ func (s *Server) createAndPublishNotification(
$5,
NULLIF($6, '')::UUID,
$7::JSONB,
$8
$8,
$9
)
RETURNING
id::TEXT,
@ -196,6 +204,7 @@ func (s *Server) createAndPublishNotification(
entityID,
string(dataJSON),
silent,
inAppVisible,
).Scan(
&notification.ID,
&notification.UserID,
@ -216,6 +225,11 @@ func (s *Server) createAndPublishNotification(
if !notification.Silent {
notification.SoundName = notificationSoundName(preferences)
notification.Desktop = preferences.DesktopEnabled
notification.InApp = preferences.InAppEnabled
if notification.EntityType == "chat" {
notification.InApp = preferences.ChatMessages
}
}
notificationsBroker.publish(notification)
@ -306,6 +320,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
FROM notifications
WHERE user_id = $1
AND silent = false
AND in_app_visible = true
AND entity_type <> 'chat'
ORDER BY created_at DESC
LIMIT 50
`,

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strings"
"time"
@ -16,16 +17,22 @@ import (
)
type Operation struct {
ID string `json:"id"`
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
ID string `json:"id"`
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
KwLatitude *float64 `json:"kwLatitude"`
KwLongitude *float64 `json:"kwLongitude"`
TargetLatitude *float64 `json:"targetLatitude"`
TargetLongitude *float64 `json:"targetLongitude"`
ViewConeFOV float64 `json:"viewConeFov"`
ViewConeLength float64 `json:"viewConeLength"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
Camera string `json:"camera"`
Router string `json:"router"`
Technology string `json:"technology"`
@ -47,23 +54,29 @@ type UpdateOperationJournalEntryRequest struct {
}
type CreateOperationRequest struct {
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
Camera string `json:"camera"`
Router string `json:"router"`
Technology string `json:"technology"`
Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"`
Remark string `json:"remark"`
MilestoneStorage string `json:"milestoneStorage"`
OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"`
Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"`
KwLatitude *float64 `json:"kwLatitude"`
KwLongitude *float64 `json:"kwLongitude"`
TargetLatitude *float64 `json:"targetLatitude"`
TargetLongitude *float64 `json:"targetLongitude"`
ViewConeFOV float64 `json:"viewConeFov"`
ViewConeLength float64 `json:"viewConeLength"`
OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"`
Camera string `json:"camera"`
Router string `json:"router"`
Technology string `json:"technology"`
Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"`
Remark string `json:"remark"`
MilestoneStorage string `json:"milestoneStorage"`
}
type UpdateOperationRequest = CreateOperationRequest
@ -119,6 +132,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
@ -156,6 +175,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
@ -201,6 +226,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return
}
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context())
if !ok {
@ -227,6 +256,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
@ -239,7 +274,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
remark,
milestone_storage
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
RETURNING
id,
operation_number,
@ -248,6 +283,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
@ -268,6 +309,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
input.CaseWorker,
input.TargetObject,
input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader,
input.OperationTeam,
input.Legend,
@ -287,6 +334,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
@ -380,6 +433,10 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return
}
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context())
if !ok {
@ -418,17 +475,23 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
case_worker = $5,
target_object = $6,
kw_address = $7,
operation_leader = $8,
operation_team = $9,
legend = $10,
camera = $11,
router = $12,
technology = $13,
switchbox = $14,
hard_drive = $15,
laptop = $16,
remark = $17,
milestone_storage = $18,
kw_latitude = $8,
kw_longitude = $9,
target_latitude = $10,
target_longitude = $11,
view_cone_fov = $12,
view_cone_length = $13,
operation_leader = $14,
operation_team = $15,
legend = $16,
camera = $17,
router = $18,
technology = $19,
switchbox = $20,
hard_drive = $21,
laptop = $22,
remark = $23,
milestone_storage = $24,
updated_at = now()
WHERE id = $1
RETURNING
@ -439,6 +502,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
@ -460,6 +529,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
input.CaseWorker,
input.TargetObject,
input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader,
input.OperationTeam,
input.Legend,
@ -479,6 +554,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
@ -644,6 +725,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
case_worker,
target_object,
kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader,
operation_team,
legend,
@ -670,6 +757,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
&operation.CaseWorker,
&operation.TargetObject,
&operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader,
&operation.OperationTeam,
&operation.Legend,
@ -730,6 +823,12 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress)
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", "", formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", "", formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
if input.KwLatitude != nil && input.TargetLatitude != nil {
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", "", fmt.Sprintf("%.0f°", input.ViewConeFOV))
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", "", formatOperationDistance(input.ViewConeLength))
}
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend)
@ -754,6 +853,10 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress)
changes = appendOperationHistoryChange(changes, "kwPosition", "KW-Position", formatOperationCoordinates(oldOperation.KwLatitude, oldOperation.KwLongitude), formatOperationCoordinates(input.KwLatitude, input.KwLongitude))
changes = appendOperationHistoryChange(changes, "targetPosition", "ZO-Position", formatOperationCoordinates(oldOperation.TargetLatitude, oldOperation.TargetLongitude), formatOperationCoordinates(input.TargetLatitude, input.TargetLongitude))
changes = appendOperationHistoryChange(changes, "viewConeFov", "Sichtfeld", fmt.Sprintf("%.0f°", oldOperation.ViewConeFOV), fmt.Sprintf("%.0f°", input.ViewConeFOV))
changes = appendOperationHistoryChange(changes, "viewConeLength", "Sichtweite", formatOperationDistance(oldOperation.ViewConeLength), formatOperationDistance(input.ViewConeLength))
changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
@ -809,6 +912,56 @@ func normalizeOperationInput(input *CreateOperationRequest) {
input.Laptop = strings.TrimSpace(input.Laptop)
input.Remark = strings.TrimSpace(input.Remark)
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
if input.ViewConeFOV == 0 {
input.ViewConeFOV = 36
}
}
func validateOperationGeometry(input CreateOperationRequest) error {
if err := validateOperationCoordinatePair("KW", input.KwLatitude, input.KwLongitude); err != nil {
return err
}
if err := validateOperationCoordinatePair("ZO", input.TargetLatitude, input.TargetLongitude); err != nil {
return err
}
if input.ViewConeFOV < 1 || input.ViewConeFOV > 180 {
return errors.New("Das Sichtfeld muss zwischen 1 und 180 Grad liegen")
}
if math.IsNaN(input.ViewConeLength) || math.IsInf(input.ViewConeLength, 0) ||
input.ViewConeLength < 0 || input.ViewConeLength > 100000 {
return errors.New("Die Sichtweite muss zwischen 0 und 100000 Metern liegen")
}
return nil
}
func validateOperationCoordinatePair(label string, latitude, longitude *float64) error {
if latitude == nil && longitude == nil {
return nil
}
if latitude == nil || longitude == nil {
return fmt.Errorf("%s-Koordinaten sind unvollständig", label)
}
if math.IsNaN(*latitude) || math.IsInf(*latitude, 0) ||
math.IsNaN(*longitude) || math.IsInf(*longitude, 0) ||
*latitude < -90 || *latitude > 90 ||
*longitude < -180 || *longitude > 180 {
return fmt.Errorf("%s-Koordinaten sind ungültig", label)
}
return nil
}
func formatOperationCoordinates(latitude, longitude *float64) string {
if latitude == nil || longitude == nil {
return ""
}
return fmt.Sprintf("%.6f, %.6f", *latitude, *longitude)
}
func formatOperationDistance(distance float64) string {
if distance <= 0 {
return "Automatisch bis ZO"
}
return fmt.Sprintf("%.0f m", distance)
}
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {

309
backend/presence.go Normal file
View File

@ -0,0 +1,309 @@
package main
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
const presenceStatusSQL = `
CASE
WHEN presence_mode = 'offline'
OR last_seen_at IS NULL
OR last_seen_at < now() - INTERVAL '2 minutes'
THEN 'offline'
WHEN presence_mode = 'away'
OR last_active_at IS NULL
OR last_active_at < now() - make_interval(mins => away_after_minutes)
THEN 'away'
ELSE 'online'
END
`
type presenceHeartbeatRequest struct {
Active bool `json:"active"`
}
type presenceSettingsRequest struct {
Mode string `json:"mode"`
AwayAfterMinutes int `json:"awayAfterMinutes"`
ShowLastSeen bool `json:"showLastSeen"`
}
type presenceStatusRequest struct {
Mode string `json:"mode"`
}
func normalizePresenceMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "away":
return "away"
case "offline":
return "offline"
default:
return "online"
}
}
func (s *Server) startPresenceMonitor(ctx context.Context) {
s.refreshPresenceStatuses(ctx, false)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.refreshPresenceStatuses(ctx, true)
}
}
}()
}
func (s *Server) refreshPresenceStatuses(ctx context.Context, publish bool) {
rows, err := s.db.Query(
ctx,
`
SELECT id::TEXT, `+presenceStatusSQL+`
FROM users
`,
)
if err != nil {
return
}
defer rows.Close()
currentStatuses := map[string]string{}
changedUserIDs := []string{}
for rows.Next() {
var userID string
var status string
if err := rows.Scan(&userID, &status); err != nil {
return
}
currentStatuses[userID] = status
s.presenceMu.Lock()
previousStatus, known := s.presenceStatus[userID]
s.presenceMu.Unlock()
if publish && known && previousStatus != status {
changedUserIDs = append(changedUserIDs, userID)
}
}
if rows.Err() != nil {
return
}
s.presenceMu.Lock()
s.presenceStatus = currentStatuses
s.presenceMu.Unlock()
for _, userID := range changedUserIDs {
user, err := s.getUserByID(ctx, userID)
if err == nil {
s.publishUserProfileEvent("user.presence", user)
}
}
}
func (s *Server) publishPresenceIfChanged(user User) {
s.presenceMu.Lock()
previousStatus, known := s.presenceStatus[user.ID]
s.presenceStatus[user.ID] = user.PresenceStatus
s.presenceMu.Unlock()
if !known || previousStatus != user.PresenceStatus {
s.publishUserProfileEvent("user.presence", user)
}
}
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceHeartbeatRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&input)
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
last_seen_at = now(),
last_active_at = CASE
WHEN $2 AND presence_mode = 'online' THEN now()
ELSE last_active_at
END
WHERE id = $1
`,
user.ID,
input.Active,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht aktualisiert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenz konnte nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{
"presenceStatus": updatedUser.PresenceStatus,
"lastSeenAt": updatedUser.LastSeenAt,
})
}
func (s *Server) handleGetPresenceSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": map[string]any{
"mode": user.PresenceMode,
"awayAfterMinutes": user.AwayAfterMinutes,
"currentStatus": user.PresenceStatus,
"showLastSeen": user.ShowLastSeen,
},
})
}
func (s *Server) handleUpdatePresenceSettings(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceSettingsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
mode := user.PresenceMode
if strings.TrimSpace(input.Mode) != "" {
mode = normalizePresenceMode(input.Mode)
}
if input.AwayAfterMinutes < 1 || input.AwayAfterMinutes > 120 {
writeError(w, http.StatusBadRequest, "Automatische Abwesenheit muss zwischen 1 und 120 Minuten liegen")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
presence_mode_updated_at = CASE
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
ELSE presence_mode_updated_at
END,
presence_mode = $2,
away_after_minutes = $3,
show_last_seen = $4,
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
updated_at = now()
WHERE id = $1
`,
user.ID,
mode,
input.AwayAfterMinutes,
input.ShowLastSeen,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht gespeichert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Präsenzeinstellungen konnten nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{
"settings": map[string]any{
"mode": updatedUser.PresenceMode,
"awayAfterMinutes": updatedUser.AwayAfterMinutes,
"currentStatus": updatedUser.PresenceStatus,
"showLastSeen": updatedUser.ShowLastSeen,
},
"user": updatedUser,
})
}
func (s *Server) handleUpdatePresenceStatus(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input presenceStatusRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
mode := strings.ToLower(strings.TrimSpace(input.Mode))
if mode != "online" && mode != "away" && mode != "offline" {
writeError(w, http.StatusBadRequest, "Ungültiger Status")
return
}
_, err := s.db.Exec(
r.Context(),
`
UPDATE users
SET
presence_mode_updated_at = CASE
WHEN presence_mode IS DISTINCT FROM $2 THEN now()
ELSE presence_mode_updated_at
END,
presence_mode = $2,
last_active_at = CASE WHEN $2 = 'online' THEN now() ELSE last_active_at END,
last_seen_at = now(),
updated_at = now()
WHERE id = $1
`,
user.ID,
mode,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Status konnte nicht gespeichert werden")
return
}
updatedUser, err := s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Status konnte nicht geladen werden")
return
}
s.publishPresenceIfChanged(updatedUser)
writeJSON(w, http.StatusOK, map[string]any{"user": updatedUser})
}

View File

@ -10,6 +10,7 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("POST /webhooks/channels/{slug}", s.handleChannelWebhook)
mux.HandleFunc("POST /auth/register", s.handleRegister)
mux.HandleFunc("POST /auth/login", s.handleLogin)
@ -81,6 +82,24 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead))
mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents))
mux.HandleFunc("POST /presence", s.requireAuth(s.handlePresence))
mux.HandleFunc("GET /settings/presence", s.requireAuth(s.handleGetPresenceSettings))
mux.HandleFunc("PUT /settings/presence", s.requireAuth(s.handleUpdatePresenceSettings))
mux.HandleFunc("PUT /settings/presence/status", s.requireAuth(s.handleUpdatePresenceStatus))
mux.HandleFunc("GET /chat/conversations", s.requireAuth(s.handleListChatConversations))
mux.HandleFunc("GET /chat/search", s.requireAuth(s.handleSearchChatMessages))
mux.HandleFunc("GET /chat/unread", s.requireAuth(s.handleGetChatUnreadCount))
mux.HandleFunc("POST /chat/direct", s.requireAuth(s.handleCreateDirectConversation))
mux.HandleFunc("GET /chat/users", s.requireAuth(s.handleListChatUsers))
mux.HandleFunc("GET /chat/ws", s.requireAuth(s.handleChatWebSocket))
mux.HandleFunc("POST /chat/attachments", s.requireAuth(s.handleUploadChatAttachment))
mux.HandleFunc("GET /chat/attachments/{id}", s.requireAuth(s.handleDownloadChatAttachment))
mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview))
mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages))
mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage))
mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead))
mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute))
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
@ -107,6 +126,10 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /admin/feedback", s.requireAuth(s.requireRight("administration:read", s.handleListFeedback)))
mux.HandleFunc("DELETE /admin/feedback/{id}", s.requireAuth(s.requireRight("administration:write", s.handleDeleteFeedback)))
mux.HandleFunc("GET /admin/channels", s.requireAuth(s.requireRight("administration:read", s.handleAdminListChannels)))
mux.HandleFunc("POST /admin/channels", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateChannel)))
mux.HandleFunc("PUT /admin/channels/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateChannel)))
mux.HandleFunc("POST /admin/channels/{id}/token", s.requireAuth(s.requireRight("administration:write", s.handleAdminRotateChannelToken)))
/* milestone settings */

View File

@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
@ -27,19 +28,27 @@ type Server struct {
db *pgxpool.Pool
jwtSecret []byte
frontendOrigin string
presenceMu sync.Mutex
presenceStatus map[string]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"`
TokenVersion int `json:"-"`
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"`
Online bool `json:"onlineStatus"`
PresenceMode string `json:"presenceMode"`
PresenceStatus string `json:"presenceStatus"`
AwayAfterMinutes int `json:"awayAfterMinutes"`
ShowLastSeen bool `json:"showLastSeen"`
LastSeenAt *time.Time `json:"lastSeenAt"`
TokenVersion int `json:"-"`
}
type Team struct {
@ -87,6 +96,7 @@ func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Serve
db: db,
jwtSecret: []byte(jwtSecret),
frontendOrigin: frontendOrigin,
presenceStatus: map[string]string{},
}
}
@ -196,6 +206,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
user.Online = true
user.PresenceMode = "online"
user.PresenceStatus = "online"
user.AwayAfterMinutes = 3
user.ShowLastSeen = false
now := time.Now()
user.LastSeenAt = &now
if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session")
return
@ -249,6 +267,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
user.PresenceStatus = user.PresenceMode
user.Online = user.PresenceStatus == "online"
now := time.Now()
user.LastSeenAt = &now
if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session")
return
@ -264,10 +287,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_, _ = s.db.Exec(
r.Context(),
`
UPDATE user_sessions
SET revoked_at = now()
WHERE id = $1
AND revoked_at IS NULL
WITH revoked_session AS (
UPDATE user_sessions
SET revoked_at = now()
WHERE id = $1
AND revoked_at IS NULL
RETURNING user_id
)
UPDATE users
SET last_seen_at = (
SELECT MAX(us.last_seen_at)
FROM user_sessions us
WHERE us.user_id = revoked_session.user_id
AND us.revoked_at IS NULL
)
FROM revoked_session
WHERE users.id = revoked_session.user_id
`,
sessionID,
)
@ -381,6 +416,11 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
}
_ = s.touchUserSession(r.Context(), user.ID, claims.SessionID)
user, err = s.getUserByID(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID)
@ -408,6 +448,11 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
COALESCE(unit, ''),
COALESCE(user_group, ''),
COALESCE(rights, '{}'::TEXT[]),
COALESCE(presence_mode, 'online'),
away_after_minutes,
show_last_seen,
`+presenceStatusSQL+`,
last_seen_at,
token_version
FROM users
WHERE lower(email) = $1 OR lower(username) = $1
@ -424,8 +469,14 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
&user.Unit,
&user.Group,
&user.Rights,
&user.PresenceMode,
&user.AwayAfterMinutes,
&user.ShowLastSeen,
&user.PresenceStatus,
&user.LastSeenAt,
&user.TokenVersion,
)
user.Online = user.PresenceStatus == "online"
if err != nil {
return user, passwordHash, err
@ -456,6 +507,11 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
COALESCE(unit, ''),
COALESCE(user_group, ''),
COALESCE(rights, '{}'::TEXT[]),
COALESCE(presence_mode, 'online'),
away_after_minutes,
show_last_seen,
`+presenceStatusSQL+`,
last_seen_at,
token_version
FROM users
WHERE id = $1
@ -471,8 +527,14 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
&user.Unit,
&user.Group,
&user.Rights,
&user.PresenceMode,
&user.AwayAfterMinutes,
&user.ShowLastSeen,
&user.PresenceStatus,
&user.LastSeenAt,
&user.TokenVersion,
)
user.Online = user.PresenceStatus == "online"
if err != nil {
return user, err

View File

@ -229,6 +229,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
token_version INTEGER NOT NULL DEFAULT 1,
last_seen_at TIMESTAMPTZ,
last_active_at TIMESTAMPTZ,
presence_mode TEXT NOT NULL DEFAULT 'online'
CHECK (presence_mode IN ('online', 'away', 'offline')),
presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
away_after_minutes INTEGER NOT NULL DEFAULT 3
CHECK (away_after_minutes BETWEEN 1 AND 120),
show_last_seen BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@ -239,6 +246,58 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
// aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb
// der letzten Minuten).
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode TEXT NOT NULL DEFAULT 'online'`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS presence_mode_updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS away_after_minutes INTEGER NOT NULL DEFAULT 3`,
`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'users'
AND column_name = 'away_after_minutes'
AND column_default LIKE '5%'
) THEN
UPDATE users
SET away_after_minutes = 3
WHERE away_after_minutes = 5;
ALTER TABLE users
ALTER COLUMN away_after_minutes SET DEFAULT 3;
END IF;
END
$$
`,
`ALTER TABLE IF EXISTS users ADD COLUMN IF NOT EXISTS show_last_seen BOOLEAN NOT NULL DEFAULT false`,
// Der Online-Status wird nicht dauerhaft als Boolean gespeichert, damit
// abgebrochene Browser-Sitzungen nicht für immer als online erscheinen.
// Die API liefert onlineStatus=true, wenn last_seen_at höchstens zwei
// Minuten zurückliegt; der Client aktualisiert den Wert per Heartbeat.
`
CREATE OR REPLACE VIEW user_presence AS
SELECT
id AS user_id,
last_seen_at,
last_active_at,
presence_mode,
away_after_minutes,
CASE
WHEN presence_mode = 'offline'
OR last_seen_at IS NULL
OR last_seen_at < now() - INTERVAL '2 minutes'
THEN 'offline'
WHEN presence_mode = 'away'
OR last_active_at IS NULL
OR last_active_at < now() - make_interval(mins => away_after_minutes)
THEN 'away'
ELSE 'online'
END AS status
FROM users
`,
`
CREATE TABLE IF NOT EXISTS user_sessions (
@ -436,6 +495,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
case_worker TEXT NOT NULL DEFAULT '',
target_object TEXT NOT NULL DEFAULT '',
kw_address TEXT NOT NULL DEFAULT '',
kw_latitude DOUBLE PRECISION,
kw_longitude DOUBLE PRECISION,
target_latitude DOUBLE PRECISION,
target_longitude DOUBLE PRECISION,
view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36,
view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0,
operation_leader TEXT NOT NULL DEFAULT '',
operation_team TEXT NOT NULL DEFAULT '',
legend TEXT NOT NULL DEFAULT '',
@ -454,6 +519,12 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_latitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS kw_longitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_latitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS target_longitude DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_fov DOUBLE PRECISION NOT NULL DEFAULT 36`,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS view_cone_length DOUBLE PRECISION NOT NULL DEFAULT 0`,
`
CREATE TABLE IF NOT EXISTS operation_history (
@ -485,12 +556,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
entity_id UUID,
data JSONB NOT NULL DEFAULT '{}'::JSONB,
in_app_visible BOOLEAN NOT NULL DEFAULT true,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS notifications ADD COLUMN IF NOT EXISTS in_app_visible BOOLEAN NOT NULL DEFAULT true`,
`
CREATE TABLE IF NOT EXISTS milestone_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
@ -548,12 +622,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
device_updates BOOLEAN NOT NULL DEFAULT true,
device_journals BOOLEAN NOT NULL DEFAULT true,
system_messages BOOLEAN NOT NULL DEFAULT true,
chat_messages BOOLEAN NOT NULL DEFAULT true,
desktop_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS chat_messages BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS notification_preferences ADD COLUMN IF NOT EXISTS desktop_enabled BOOLEAN NOT NULL DEFAULT false`,
`
CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -619,15 +698,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`,
// ── Chat ────────────────────────────────────────────────────────────
// Eine Konversation ist entweder ein Team-Gruppenchat (type='team',
// genau einer pro Team), ein Einzelchat zwischen zwei Personen
// (type='direct') oder ein freier Gruppenchat (type='group').
// Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
// freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
`
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL DEFAULT 'direct'
CHECK (type IN ('team', 'direct', 'group')),
CHECK (type IN ('team', 'direct', 'group', 'channel')),
name TEXT NOT NULL DEFAULT '',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
@ -635,6 +713,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
-- Stabiler Schlüssel für Einzelchats (sortierte User-IDs),
-- damit pro Personenpaar nur eine Konversation existiert.
direct_key TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL DEFAULT '',
channel_source_name TEXT NOT NULL DEFAULT '',
channel_image TEXT NOT NULL DEFAULT '',
channel_all_users BOOLEAN NOT NULL DEFAULT false,
webhook_enabled BOOLEAN NOT NULL DEFAULT true,
webhook_token_hash TEXT NOT NULL DEFAULT '',
webhook_token_updated_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
@ -644,6 +729,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
`ALTER TABLE IF EXISTS conversations DROP CONSTRAINT IF EXISTS conversations_type_check`,
`ALTER TABLE IF EXISTS conversations ADD CONSTRAINT conversations_type_check CHECK (type IN ('team', 'direct', 'group', 'channel'))`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS slug TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_source_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_image TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS channel_all_users BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN NOT NULL DEFAULT true`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_hash TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS webhook_token_updated_at TIMESTAMPTZ`,
`
CREATE TABLE IF NOT EXISTS conversation_members (
@ -651,6 +745,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ,
muted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@ -658,6 +753,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`,
`
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -666,6 +763,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL DEFAULT '',
reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ,
@ -673,6 +772,46 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
)
`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS reply_to_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS forwarded_from_message_id UUID REFERENCES messages(id) ON DELETE SET NULL`,
// Standort-Nachrichten: lat/lng sind NULL, wenn die Nachricht keinen Standort enthält.
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lat DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_lng DOUBLE PRECISION`,
`ALTER TABLE IF EXISTS messages ADD COLUMN IF NOT EXISTS location_label TEXT NOT NULL DEFAULT ''`,
`
CREATE TABLE IF NOT EXISTS message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uploader_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
// Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen.
// Eine Vorschau pro Nachricht (erste erkannte URL).
`
CREATE TABLE IF NOT EXISTS message_link_previews (
message_id UUID PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
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_users_last_seen_at ON users(last_seen_at)`,
@ -751,14 +890,41 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
// Genau eine Konversation pro Team bzw. pro Einzelchat-Paar.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_team_id_unique ON conversations(team_id) WHERE team_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_direct_key_unique ON conversations(direct_key) WHERE direct_key <> ''`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_conversations_channel_slug_unique ON conversations(slug) WHERE type = 'channel' AND slug <> ''`,
`CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`,
`
INSERT INTO conversations (
type,
name,
slug,
channel_source_name,
channel_all_users,
webhook_enabled
)
VALUES ('channel', 'Zabbix', 'zabbix', 'Zabbix', true, true)
ON CONFLICT (slug) WHERE type = 'channel' AND slug <> ''
DO NOTHING
`,
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT c.id, u.id
FROM conversations c
CROSS JOIN users u
WHERE c.type = 'channel'
AND c.channel_all_users
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
`CREATE INDEX IF NOT EXISTS idx_conversation_members_user_id ON conversation_members(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_conversation_members_conversation_id ON conversation_members(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_at ON messages(conversation_id, created_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_messages_sender_id ON messages(sender_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_reply_to_message_id ON messages(reply_to_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_forwarded_from_message_id ON messages(forwarded_from_message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`,
`CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`,
}
for _, query := range queries {

View File

@ -9,6 +9,10 @@ import (
)
func (s *Server) publishUserProfileEvent(eventType string, user User) {
// Detaillierte Aktivitätszeiten werden nur über autorisierte Direktchat-
// Abfragen ausgeliefert, nicht über das globale User-Event.
user.LastSeenAt = nil
data, err := json.Marshal(map[string]any{
"user": user,
})

View File

@ -54,6 +54,16 @@ func (s *Server) createUserSession(
err := s.db.QueryRow(
ctx,
`
WITH touched_user AS (
UPDATE users
SET
last_seen_at = now(),
last_active_at = CASE
WHEN presence_mode = 'online' THEN now()
ELSE last_active_at
END
WHERE id = $1
)
INSERT INTO user_sessions (
user_id,
browser,
@ -144,11 +154,18 @@ func (s *Server) touchUserSession(
_, err := s.db.Exec(
ctx,
`
UPDATE user_sessions
WITH touched_session AS (
UPDATE user_sessions
SET last_seen_at = now()
WHERE id = $1
AND user_id = $2
AND revoked_at IS NULL
RETURNING user_id
)
UPDATE users
SET last_seen_at = now()
WHERE id = $1
AND user_id = $2
AND revoked_at IS NULL
FROM touched_session
WHERE users.id = touched_session.user_id
`,
sessionID,
userID,

View File

@ -18,8 +18,6 @@ type updateSettingsAccountRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar"`
Unit string `json:"unit"`
Group string `json:"group"`
}
type updateSettingsPasswordRequest struct {
@ -49,8 +47,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
email := normalizeEmail(payload.Email)
displayName := strings.TrimSpace(payload.DisplayName)
avatar := strings.TrimSpace(payload.Avatar)
unit := strings.TrimSpace(payload.Unit)
group := strings.TrimSpace(payload.Group)
if username == "" {
writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich")
@ -66,10 +62,6 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
displayName = username
}
if group == "" {
group = "user"
}
commandTag, err := s.db.Exec(
r.Context(),
`
@ -79,17 +71,13 @@ func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Requ
display_name = $2,
email = $3,
avatar = $4,
unit = $5,
user_group = $6,
updated_at = now()
WHERE id = $7
WHERE id = $5
`,
username,
displayName,
email,
avatar,
unit,
group,
userID,
)

View File

@ -1,8 +1,8 @@
// frontend/src/App.tsx
import { useEffect, useState } from 'react'
import { Navigate, Route, Routes, useNavigate } from 'react-router'
import { NotificationProvider } from './components/Notifications'
import { useCallback, useEffect, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { useNotifications } from './components/Notifications'
import LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage'
@ -21,6 +21,8 @@ import Feedback from './components/Feedback'
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
import { hasRight } from './components/permissions'
import ChatPage from './pages/chat/ChatPage'
import { recordNavigation } from './utils/activityLog'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -65,6 +67,23 @@ function getUserFromNotification(notification: Notification) {
return user as User
}
function resolveNotificationImage(value: unknown) {
if (typeof value !== 'string' || !value.trim()) {
return ''
}
const image = value.trim()
if (image.startsWith('data:') || image.startsWith('blob:')) {
return image
}
try {
return new URL(image, API_URL).toString()
} catch {
return new URL('/favicon.svg', window.location.origin).toString()
}
}
function playNotificationSound(soundName?: string) {
if (!soundName) {
return
@ -96,31 +115,57 @@ function RequireRight({
function App() {
const navigate = useNavigate()
const location = useLocation()
const { showToast } = useNotifications()
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadChatCount, setUnreadChatCount] = useState(0)
const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>(
() => new Set(),
)
const currentUserId = user?.id
const awayAfterMinutes = user?.awayAfterMinutes
const unreadNotificationCount = notifications.filter(
(notification) => !notification.readAt,
).length
const loadUnreadChatCount = useCallback(async () => {
try {
const response = await fetch(`${API_URL}/chat/unread`, {
credentials: 'include',
})
if (!response.ok) {
return
}
const data = await response.json()
setUnreadChatCount(Number(data.unreadCount) || 0)
} catch {
// Der Zähler wird beim nächsten Chat- oder Notification-Ereignis erneut geladen.
}
}, [])
useEffect(() => {
void loadUser()
}, [])
useEffect(() => {
if (!user) {
recordNavigation(location.pathname)
}, [location.pathname])
useEffect(() => {
if (!currentUserId) {
setNotifications([])
setUnreadJournalOperationIds(new Set())
return
}
void loadNotifications()
void loadUnreadChatCount()
void loadUnreadJournalOperations()
const eventSource = new EventSource(`${API_URL}/events`, {
@ -132,6 +177,89 @@ function App() {
return
}
if (notification.entityType === 'chat') {
const data = getNotificationData(notification)
const senderAvatar = resolveNotificationImage(data.senderAvatar)
const desktopIcon =
senderAvatar ||
new URL('/favicon.svg', window.location.origin).toString()
const senderName =
typeof data.senderName === 'string' ? data.senderName : notification.title
const isChannel = data.conversationType === 'channel'
const chatPath = isChannel
? `/chat/channel/${notification.entityId}`
: `/chat/${notification.entityId}`
if (
notification.desktop &&
'Notification' in window &&
Notification.permission === 'granted'
) {
try {
const desktopNotification = new Notification(notification.title, {
body: notification.message,
icon: desktopIcon,
badge: desktopIcon,
tag: `chat-${notification.entityId}`,
requireInteraction: true,
})
desktopNotification.onclick = () => {
window.focus()
navigate(chatPath)
desktopNotification.close()
}
} catch {
// Das sichtbare Browser-Popup darunter bleibt als Fallback erhalten.
}
}
if (
notification.inAppEnabled !== false &&
document.visibilityState === 'visible'
) {
showToast({
title: notification.title,
message: notification.message,
imageUrl: senderAvatar,
imageName: senderName,
actionLabel: 'Öffnen',
onAction: () => navigate(chatPath),
...(isChannel
? {}
: {
replyPlaceholder: `An ${senderName} antworten...`,
onReply: async (message: string) => {
const response = await fetch(
`${API_URL}/chat/conversations/${notification.entityId}/messages`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: message }),
},
)
if (!response.ok) {
const responseData = await response.json().catch(() => null)
throw new Error(
responseData?.error ||
'Antwort konnte nicht gesendet werden.',
)
}
},
}),
autoClose: false,
})
}
playNotificationSound(notification.soundName)
return
}
if (notification.inAppEnabled === false) {
return
}
playNotificationSound(notification.soundName)
setNotifications((currentNotifications) => [
@ -167,6 +295,10 @@ function App() {
addVisibleNotification(notification)
dispatchEntityEvent(notification)
if (notification.entityType === 'chat') {
void loadUnreadChatCount()
}
}
function handleOperationEvent(event: MessageEvent) {
@ -248,7 +380,103 @@ function App() {
eventSource.removeEventListener('user-event', handleUserEvent)
eventSource.close()
}
}, [user])
}, [currentUserId, loadUnreadChatCount, navigate, showToast])
useEffect(() => {
if (!currentUserId) {
return
}
function handleUnreadChatChange() {
void loadUnreadChatCount()
}
window.addEventListener('chat-unread-changed', handleUnreadChatChange)
return () => {
window.removeEventListener('chat-unread-changed', handleUnreadChatChange)
}
}, [currentUserId, loadUnreadChatCount])
useEffect(() => {
if (!currentUserId) {
return
}
let idle = false
let lastActiveHeartbeat = 0
let idleTimer: number | undefined
const awayAfterMilliseconds =
Math.max(1, awayAfterMinutes || 3) * 60_000
function sendHeartbeat(active: boolean) {
void fetch(`${API_URL}/presence`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
})
}
function scheduleIdleTimer() {
if (idleTimer !== undefined) {
window.clearTimeout(idleTimer)
}
idleTimer = window.setTimeout(() => {
idle = true
sendHeartbeat(false)
}, awayAfterMilliseconds)
}
function handleActivity() {
const now = Date.now()
idle = false
scheduleIdleTimer()
if (now - lastActiveHeartbeat >= 15_000) {
lastActiveHeartbeat = now
sendHeartbeat(true)
}
}
sendHeartbeat(true)
lastActiveHeartbeat = Date.now()
scheduleIdleTimer()
const intervalId = window.setInterval(() => {
sendHeartbeat(!idle && document.visibilityState === 'visible')
}, 45_000)
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
handleActivity()
}
}
const activityEvents: Array<keyof WindowEventMap> = [
'pointerdown',
'keydown',
'mousemove',
'touchstart',
'scroll',
]
activityEvents.forEach((eventName) => {
window.addEventListener(eventName, handleActivity, { passive: true })
})
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.clearInterval(intervalId)
if (idleTimer !== undefined) {
window.clearTimeout(idleTimer)
}
activityEvents.forEach((eventName) => {
window.removeEventListener(eventName, handleActivity)
})
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [awayAfterMinutes, currentUserId])
async function loadUser() {
try {
@ -352,6 +580,16 @@ function App() {
}
function handleNotificationClick(notification: Notification) {
if (notification.entityType === 'chat' && notification.entityId) {
const data = getNotificationData(notification)
navigate(
data.conversationType === 'channel'
? `/chat/channel/${notification.entityId}`
: `/chat/${notification.entityId}`,
)
return
}
if (
notification.type === 'operation.created' ||
notification.entityType === 'operation'
@ -369,6 +607,7 @@ function App() {
} finally {
setUser(null)
setNotifications([])
setUnreadChatCount(0)
setUnreadJournalOperationIds(new Set())
}
}
@ -396,10 +635,11 @@ function App() {
}
return (
<NotificationProvider>
<AppLayout
<AppLayout
user={user}
onUserUpdated={setUser}
onLogout={handleLogout}
unreadChatCount={unreadChatCount}
notificationCenter={
<NotificationCenter
notifications={notifications}
@ -511,12 +751,24 @@ function App() {
/>
<Route path="/feedback" element={<Feedback />} />
<Route path="/chat" element={<ChatPage currentUser={user} />} />
<Route
path="/chat/team/:teamId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/channel/:channelId"
element={<ChatPage currentUser={user} />}
/>
<Route
path="/chat/:conversationId"
element={<ChatPage currentUser={user} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
</NotificationProvider>
</AppLayout>
)
}
export default App
export default App

View File

@ -0,0 +1,118 @@
// frontend/src/components/ActivityLogView.tsx
import {
ArrowLongRightIcon,
CursorArrowRaysIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline'
import type { ActivityLogEntry, ActivityLogType } from '../utils/activityLog'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function formatTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)
}
const typeMeta: Record<
ActivityLogType,
{ icon: typeof CursorArrowRaysIcon; iconClassName: string }
> = {
action: {
icon: CursorArrowRaysIcon,
iconClassName: 'text-indigo-500 dark:text-indigo-400',
},
navigation: {
icon: ArrowLongRightIcon,
iconClassName: 'text-sky-500 dark:text-sky-400',
},
error: {
icon: ExclamationTriangleIcon,
iconClassName: 'text-red-500 dark:text-red-400',
},
}
export default function ActivityLogView({
entries,
showPath = true,
emptyLabel = 'Keine Aktivitäten aufgezeichnet.',
}: {
entries: ActivityLogEntry[]
showPath?: boolean
emptyLabel?: string
}) {
if (entries.length === 0) {
return (
<p className="px-3 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{emptyLabel}
</p>
)
}
return (
<ol className="space-y-0.5">
{entries.map((entry) => {
const meta = typeMeta[entry.type] ?? typeMeta.action
const Icon = meta.icon
const isError = entry.type === 'error'
const stack =
entry.details && typeof entry.details.stack === 'string'
? entry.details.stack
: ''
return (
<li
key={entry.id}
className={classNames(
'flex items-start gap-3 rounded-lg px-3 py-2',
isError
? 'bg-red-50 dark:bg-red-500/10'
: 'hover:bg-gray-50 dark:hover:bg-white/5',
)}
>
<Icon className={classNames('mt-0.5 size-4 shrink-0', meta.iconClassName)} />
<div className="min-w-0 flex-1">
<p
className={classNames(
'text-sm break-words',
isError
? 'font-medium text-red-700 dark:text-red-300'
: 'text-gray-700 dark:text-gray-200',
)}
>
{entry.message}
</p>
{showPath && entry.path && (
<p className="mt-0.5 font-mono text-[0.625rem] text-gray-400">
{entry.path}
</p>
)}
{stack && (
<pre className="mt-1.5 max-h-40 overflow-auto rounded-md bg-gray-950 p-2 text-[0.625rem] leading-relaxed text-gray-300">
{stack}
</pre>
)}
</div>
<time className="shrink-0 font-mono text-[0.625rem] text-gray-400">
{formatTime(entry.timestamp)}
</time>
</li>
)
})}
</ol>
)
}

View File

@ -21,14 +21,21 @@ export type AddressSuggestion = {
lat: number
lon: number
type?: string
hasHouseNumber?: boolean
geojson?: GeoJsonObject | null
}
type Coordinates = {
export type AddressCoordinates = {
lat: number
lon: number
}
export type AddressArea = {
center: AddressCoordinates
hasHouseNumber: boolean
geojson: GeoJsonObject | null
}
type ReverseGeocodeResponse = {
suggestion?: AddressSuggestion
}
@ -39,8 +46,12 @@ type AddressComboboxProps = {
label: string
value: string
onChange: (value: string) => void
position?: AddressCoordinates | null
onPositionChange?: (position: AddressCoordinates | null) => void
onAreaChange?: (area: AddressArea | null) => void
placeholder?: string
mapVisible?: boolean
showMap?: boolean
}
const inputClassName =
@ -73,6 +84,18 @@ function normalizeAddressSuggestion(
...suggestion,
lat,
lon,
hasHouseNumber: suggestion.hasHouseNumber === true,
}
}
function createAddressArea(
suggestion: AddressSuggestion | null,
center: AddressCoordinates,
): AddressArea {
return {
center,
hasHouseNumber: suggestion?.hasHouseNumber === true,
geojson: suggestion?.geojson ?? null,
}
}
@ -82,8 +105,12 @@ export default function AddressCombobox({
label,
value,
onChange,
position,
onPositionChange,
onAreaChange,
placeholder = 'Adresse suchen',
mapVisible = true,
showMap = true,
}: AddressComboboxProps) {
const [query, setQuery] = useState(value)
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([])
@ -96,6 +123,21 @@ export default function AddressCombobox({
const [mapFocusKey, setMapFocusKey] = useState(0)
const [isSearching, setIsSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const controlledMarkerPosition =
position &&
Number.isFinite(position.lat) &&
Number.isFinite(position.lon)
? position
: null
const effectiveMarkerPosition =
position === undefined ? markerPosition : controlledMarkerPosition
const hasControlledMarkerPosition = controlledMarkerPosition !== null
const highlightedSuggestion =
selectedSuggestion?.geojson
? selectedSuggestion
: previewSuggestion?.geojson
? previewSuggestion
: null
const previousValueRef = useRef(value)
const initializedAddressRef = useRef('')
@ -104,6 +146,26 @@ export default function AddressCombobox({
const reverseGeocodeAbortRef = useRef<AbortController | null>(null)
const internalValueUpdateRef = useRef(false)
useEffect(() => {
if (
!highlightedSuggestion?.geojson ||
!effectiveMarkerPosition
) {
return
}
onAreaChange?.(
createAddressArea(highlightedSuggestion, {
lat: highlightedSuggestion.lat,
lon: highlightedSuggestion.lon,
}),
)
}, [
effectiveMarkerPosition,
highlightedSuggestion,
onAreaChange,
])
useEffect(() => {
if (value !== previousValueRef.current) {
setQuery(value)
@ -136,7 +198,7 @@ export default function AddressCombobox({
return
}
if (markerPosition) {
if (effectiveMarkerPosition) {
setMapFocusKey((currentKey) => currentKey + 1)
return
}
@ -144,7 +206,7 @@ export default function AddressCombobox({
initializedAddressRef.current = ''
void loadInitialMarkerForAddress(value)
}, [mapVisible, markerPosition, value])
}, [mapVisible, effectiveMarkerPosition, value])
useEffect(() => {
const trimmedValue = value.trim()
@ -154,7 +216,7 @@ export default function AddressCombobox({
return
}
if (markerPosition) {
if (effectiveMarkerPosition) {
return
}
@ -165,7 +227,63 @@ export default function AddressCombobox({
return () => {
abortController.abort()
}
}, [value, markerPosition])
}, [value, effectiveMarkerPosition])
useEffect(() => {
if (
!hasControlledMarkerPosition ||
value.trim().length < 3 ||
selectedSuggestion
) {
return
}
const coordinates = {
lat: controlledMarkerPosition.lat,
lon: controlledMarkerPosition.lon,
}
const abortController = new AbortController()
onAreaChange?.(createAddressArea(null, coordinates))
void (async () => {
try {
const response = await fetch(
`${API_URL}/address-reverse?lat=${encodeURIComponent(
coordinates.lat,
)}&lon=${encodeURIComponent(
coordinates.lon,
)}&q=${encodeURIComponent(value.trim())}`,
{
credentials: 'include',
signal: abortController.signal,
},
)
if (!response.ok) {
return
}
const data = (await response.json()) as ReverseGeocodeResponse
const suggestion = data.suggestion
? normalizeAddressSuggestion(data.suggestion)
: null
onAreaChange?.(createAddressArea(suggestion, coordinates))
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
}
})()
return () => abortController.abort()
}, [
hasControlledMarkerPosition,
value,
onAreaChange,
selectedSuggestion,
])
async function loadInitialMarkerForAddress(
address: string,
@ -177,7 +295,10 @@ export default function AddressCombobox({
return
}
if (initializedAddressRef.current === trimmedAddress && markerPosition) {
if (
initializedAddressRef.current === trimmedAddress &&
effectiveMarkerPosition
) {
return
}
@ -207,7 +328,7 @@ export default function AddressCombobox({
const reverseSuggestion = await reverseGeocodeCoordinates({
lat: firstSuggestion.lat,
lon: firstSuggestion.lon,
})
}, trimmedAddress, firstSuggestion.id)
const nextSuggestion: AddressSuggestion = {
...firstSuggestion,
@ -222,6 +343,16 @@ export default function AddressCombobox({
lat: nextSuggestion.lat,
lon: nextSuggestion.lon,
})
onPositionChange?.({
lat: nextSuggestion.lat,
lon: nextSuggestion.lon,
})
onAreaChange?.(
createAddressArea(nextSuggestion, {
lat: nextSuggestion.lat,
lon: nextSuggestion.lon,
}),
)
setMapFocusKey((currentKey) => currentKey + 1)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
@ -231,7 +362,9 @@ export default function AddressCombobox({
}
async function reverseGeocodeCoordinates(
coordinates: Coordinates,
coordinates: AddressCoordinates,
address = value,
osmIdentifier = '',
): Promise<AddressSuggestion | null> {
reverseGeocodeAbortRef.current?.abort()
@ -242,7 +375,11 @@ export default function AddressCombobox({
const response = await fetch(
`${API_URL}/address-reverse?lat=${encodeURIComponent(
coordinates.lat,
)}&lon=${encodeURIComponent(coordinates.lon)}`,
)}&lon=${encodeURIComponent(
coordinates.lon,
)}&q=${encodeURIComponent(
address.trim(),
)}&osm_id=${encodeURIComponent(osmIdentifier)}`,
{
credentials: 'include',
signal: abortController.signal,
@ -279,14 +416,16 @@ export default function AddressCombobox({
}
}
async function handleMapPositionChange(coordinates: Coordinates) {
async function handleMapPositionChange(coordinates: AddressCoordinates) {
setMarkerPosition(coordinates)
onPositionChange?.(coordinates)
setSelectedSuggestion(null)
setPreviewSuggestion(null)
const suggestion = await reverseGeocodeCoordinates(coordinates)
if (!suggestion) {
onAreaChange?.(createAddressArea(null, coordinates))
return
}
@ -296,6 +435,7 @@ export default function AddressCombobox({
setQuery(suggestion.label)
setSelectedSuggestion(suggestion)
setPreviewSuggestion(suggestion)
onAreaChange?.(createAddressArea(suggestion, coordinates))
onChange(suggestion.label)
}
@ -372,6 +512,8 @@ export default function AddressCombobox({
setSelectedSuggestion(null)
setPreviewSuggestion(null)
setMarkerPosition(null)
onPositionChange?.(null)
onAreaChange?.(null)
onChange(nextValue)
}
@ -390,12 +532,22 @@ export default function AddressCombobox({
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
})
onPositionChange?.({
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
})
onAreaChange?.(
createAddressArea(normalizedSuggestion, {
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
}),
)
setMapFocusKey((currentKey) => currentKey + 1)
const reverseSuggestion = await reverseGeocodeCoordinates({
lat: normalizedSuggestion.lat,
lon: normalizedSuggestion.lon,
})
}, normalizedSuggestion.label, normalizedSuggestion.id)
const nextSuggestion: AddressSuggestion = {
...normalizedSuggestion,
@ -409,6 +561,12 @@ export default function AddressCombobox({
setQuery(nextSuggestion.label)
setSelectedSuggestion(nextSuggestion)
setPreviewSuggestion(nextSuggestion)
onAreaChange?.(
createAddressArea(nextSuggestion, {
lat: nextSuggestion.lat,
lon: nextSuggestion.lon,
}),
)
onChange(nextSuggestion.label)
}
@ -472,14 +630,16 @@ export default function AddressCombobox({
</div>
</HeadlessCombobox>
<AddressMapPicker
key={mapKey}
value={markerPosition}
focusKey={mapFocusKey}
visible={mapVisible}
highlightGeoJson={previewSuggestion?.geojson ?? null}
onChange={handleMapPositionChange}
/>
{showMap && (
<AddressMapPicker
key={mapKey}
value={effectiveMarkerPosition}
focusKey={mapFocusKey}
visible={mapVisible}
highlightGeoJson={previewSuggestion?.geojson ?? null}
onChange={handleMapPositionChange}
/>
)}
</div>
)
}
}

View File

@ -10,16 +10,20 @@ import type { User } from './types'
type AppLayoutProps = {
user: User
onUserUpdated: (user: User) => void
onLogout: () => void | Promise<void>
children: ReactNode
notificationCenter?: ReactNode
unreadChatCount?: number
}
export default function AppLayout({
user,
onUserUpdated,
onLogout,
children,
notificationCenter,
unreadChatCount = 0,
}: AppLayoutProps) {
const location = useLocation()
@ -48,11 +52,13 @@ export default function AppLayout({
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
onNavigate={handleNavigation}
unreadChatCount={unreadChatCount}
/>
<div className="relative z-0 flex h-dvh min-w-0 flex-col lg:pl-72">
<Topbar
user={user}
onUserUpdated={onUserUpdated}
onLogout={onLogout}
onOpenSidebar={() => setSidebarOpen(true)}
notificationCenter={notificationCenter}
@ -85,4 +91,4 @@ export default function AppLayout({
</div>
</div>
)
}
}

View File

@ -4,7 +4,8 @@ import type { ImgHTMLAttributes } from 'react'
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
export type AvatarShape = 'circle' | 'rounded'
export type AvatarStatus = 'gray' | 'red' | 'green'
export type AvatarStatus = 'gray' | 'red' | 'green' | 'amber'
export type PresenceStatus = 'online' | 'away' | 'offline'
export type AvatarStatusPosition = 'top' | 'bottom'
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
@ -19,6 +20,9 @@ export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
* Online-Status der Person. true = online (grün), false = offline (grau).
* Wird ignoriert, wenn `status` explizit gesetzt ist.
*/
onlineStatus?: boolean
presenceStatus?: PresenceStatus
/** @deprecated Nutze `onlineStatus`. */
online?: boolean
showPlaceholderIcon?: boolean
wrapperClassName?: string
@ -62,6 +66,7 @@ const statusColorClassNames: Record<AvatarStatus, string> = {
gray: 'bg-gray-300 dark:bg-gray-500',
red: 'bg-red-400 dark:bg-red-500',
green: 'bg-green-400 dark:bg-green-500',
amber: 'bg-amber-400 dark:bg-amber-500',
}
function getInitials(name?: string, initials?: string) {
@ -104,6 +109,8 @@ export default function Avatar({
shape = 'circle',
status,
statusPosition = 'bottom',
onlineStatus,
presenceStatus,
online,
showPlaceholderIcon = true,
wrapperClassName,
@ -114,10 +121,35 @@ export default function Avatar({
const avatarInitials = getInitials(name, initials)
// `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab.
const resolvedOnlineStatus = onlineStatus ?? online
const presenceAvatarStatus: AvatarStatus | undefined =
presenceStatus === 'online'
? 'green'
: presenceStatus === 'away'
? 'amber'
: presenceStatus === 'offline'
? 'gray'
: undefined
const effectiveStatus: AvatarStatus | undefined =
status ?? (online === undefined ? undefined : online ? 'green' : 'gray')
status ??
presenceAvatarStatus ??
(resolvedOnlineStatus === undefined
? undefined
: resolvedOnlineStatus
? 'green'
: 'gray')
const statusLabel =
online === undefined ? undefined : online ? 'Online' : 'Offline'
presenceStatus === 'online'
? 'Online'
: presenceStatus === 'away'
? 'Abwesend'
: presenceStatus === 'offline'
? 'Offline'
: resolvedOnlineStatus === undefined
? undefined
: resolvedOnlineStatus
? 'Online'
: 'Offline'
const avatar = src ? (
<img
@ -171,7 +203,12 @@ export default function Avatar({
}
return (
<span className={classNames('relative inline-block', wrapperClassName)}>
<span
className={classNames(
'relative inline-flex shrink-0 align-middle leading-none',
wrapperClassName,
)}
>
{avatar}
<span
@ -193,4 +230,4 @@ export default function Avatar({
/>
</span>
)
}
}

View File

@ -1,54 +1,96 @@
// frontend/src/pages/Feedback.tsx
// frontend/src/components/Feedback.tsx
import { useState, type FormEvent } from 'react'
import Textarea from '../components/Textarea'
import { useMemo, useState, type FormEvent } from 'react'
import {
ChatBubbleBottomCenterTextIcon,
CheckCircleIcon,
CursorArrowRaysIcon,
ExclamationTriangleIcon,
ShieldCheckIcon,
} from '@heroicons/react/24/outline'
import Button from './Button'
import ActivityLogView from './ActivityLogView'
import { getActivityLog, type ActivityLogEntry } from '../utils/activityLog'
import { getCurrentSessionInfo } from '../utils/sessionInfo'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const MAX_LENGTH = 5000
async function collectClientMeta() {
try {
const sessionInfo = await getCurrentSessionInfo()
return {
...sessionInfo,
url: window.location.href,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
screen: `${window.screen.width}×${window.screen.height}`,
viewport: `${window.innerWidth}×${window.innerHeight}`,
collectedAt: new Date().toISOString(),
}
} catch {
return {
url: window.location.href,
userAgent: window.navigator.userAgent,
collectedAt: new Date().toISOString(),
}
}
}
export default function Feedback() {
const [logSnapshot, setLogSnapshot] = useState<ActivityLogEntry[]>(() =>
getActivityLog(),
)
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [submitted, setSubmitted] = useState(false)
const errorCount = useMemo(
() => logSnapshot.filter((entry) => entry.type === 'error').length,
[logSnapshot],
)
const actionCount = logSnapshot.length - errorCount
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const trimmedMessage = message.trim()
if (!trimmedMessage) {
setError('Bitte gib dein Feedback ein.')
setSuccessMessage(null)
return
}
setIsSubmitting(true)
setError(null)
setSuccessMessage(null)
try {
const client = await collectClientMeta()
const response = await fetch(`${API_URL}/feedback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
message: trimmedMessage,
clientLog: logSnapshot,
client,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden')
throw new Error(
errorData?.error ?? 'Feedback konnte nicht gesendet werden',
)
}
setMessage('')
setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
} catch (error) {
setSubmitted(true)
} catch (submitError) {
setError(
error instanceof Error
? error.message
submitError instanceof Error
? submitError.message
: 'Feedback konnte nicht gesendet werden',
)
} finally {
@ -56,56 +98,141 @@ export default function Feedback() {
}
}
return (
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
<div className="mx-auto max-w-3xl">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Feedback
</h1>
function startNewFeedback() {
setSubmitted(false)
setMessage('')
setError(null)
setLogSnapshot(getActivityLog())
}
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
</p>
return (
<div className="h-full min-h-0 overflow-y-auto bg-gray-50 px-4 py-10 sm:px-6 lg:px-8 dark:bg-gray-950 [scrollbar-gutter:stable]">
<div className="mx-auto max-w-3xl">
<div className="flex items-start gap-4">
<span className="inline-flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-600/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
<ChatBubbleBottomCenterTextIcon className="size-6" aria-hidden="true" />
</span>
<div>
<h1 className="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">
Feedback
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Fällt dir etwas auf, funktioniert etwas nicht oder hast du eine
Idee? Beim Absenden wird automatisch das unten sichtbare Protokoll
angehängt.
</p>
</div>
</div>
{successMessage && (
<div className="mt-6 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
{successMessage}
{submitted ? (
<div className="mt-8 rounded-2xl border border-green-200 bg-white p-8 text-center shadow-sm dark:border-green-500/20 dark:bg-gray-900">
<span className="mx-auto inline-flex size-14 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-500/15 dark:text-green-300">
<CheckCircleIcon className="size-8" aria-hidden="true" />
</span>
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
Danke für dein Feedback!
</h2>
<p className="mx-auto mt-1 max-w-md text-sm text-gray-500 dark:text-gray-400">
Dein Feedback wurde zusammen mit dem Protokoll gespeichert. Unser
Team schaut es sich an.
</p>
<div className="mt-6">
<Button type="button" variant="secondary" color="gray" onClick={startNewFeedback}>
Weiteres Feedback geben
</Button>
</div>
</div>
) : (
<>
{error && (
<div className="mt-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
<ExclamationTriangleIcon className="mt-0.5 size-5 shrink-0" />
<span>{error}</span>
</div>
)}
<form
onSubmit={handleSubmit}
className="mt-6 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-gray-900"
>
<label
htmlFor="feedback"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Dein Feedback
</label>
<textarea
id="feedback"
name="feedback"
rows={8}
value={message}
maxLength={MAX_LENGTH}
disabled={isSubmitting}
onChange={(event) => setMessage(event.target.value)}
placeholder="Beschreibe möglichst genau, was passiert ist oder was verbessert werden soll..."
className="mt-2 block w-full resize-none rounded-xl border-0 bg-gray-50 p-4 text-sm text-gray-900 outline-none ring-1 ring-gray-200 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 disabled:opacity-60 dark:bg-white/5 dark:text-white dark:ring-white/10"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-xs text-gray-400">
{message.length} / {MAX_LENGTH} Zeichen
</span>
<Button
type="submit"
isLoading={isSubmitting}
disabled={!message.trim() || isSubmitting}
>
Feedback senden
</Button>
</div>
</form>
<div className="mt-6 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-5 py-4 dark:border-white/10">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Angehängtes Protokoll
</h2>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Deine letzten Aktionen und Fehler in dieser Sitzung hilft
uns, Probleme nachzuvollziehen.
</p>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
<CursorArrowRaysIcon className="size-3.5" />
{actionCount} Aktionen
</span>
<span
className={
errorCount > 0
? 'inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300'
: 'inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400'
}
>
<ExclamationTriangleIcon className="size-3.5" />
{errorCount} Fehler
</span>
</div>
</div>
<div className="max-h-80 overflow-y-auto p-2">
<ActivityLogView
entries={logSnapshot}
showPath={false}
emptyLabel="Bisher wurden keine Aktionen oder Fehler aufgezeichnet."
/>
</div>
<div className="flex items-center gap-2 border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
<ShieldCheckIcon className="size-4 shrink-0 text-gray-400" />
Es werden keine Passwörter oder Eingabeinhalte erfasst nur,
welche Elemente du benutzt hast, und technische Fehlermeldungen.
</div>
</div>
</>
)}
{error && (
<div className="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
<form
onSubmit={handleSubmit}
className="mt-8 rounded-lg border border-gray-200 bg-white p-6 shadow-xs dark:border-white/10 dark:bg-gray-900"
>
<Textarea
id="feedback"
name="feedback"
label="Dein Feedback"
placeholder="Beschreibe möglichst genau, was passiert ist oder was verbessert werden soll..."
variant="avatar-actions"
rows={8}
value={message}
maxLength={5000}
disabled={isSubmitting}
submitLabel="Feedback senden"
showAttachButton={false}
onChange={(event) => setMessage(event.target.value)}
/>
<div className="mt-2 text-right text-xs text-gray-500 dark:text-gray-400">
{message.length} / 5000 Zeichen
</div>
</form>
</div>
</div>
)
}
}

View File

@ -2,11 +2,15 @@
import { type ComponentType, type SVGProps, useState } from 'react'
import {
ArrowRightIcon,
ChatBubbleLeftEllipsisIcon,
ClockIcon,
PaperAirplaneIcon,
PencilSquareIcon,
PlusCircleIcon,
UserCircleIcon,
} from '@heroicons/react/20/solid'
import Avatar from './Avatar'
import { Badge, type BadgeTone } from './Badge'
import Button from './Button'
import NotificationDot from './NotificationDot'
@ -126,6 +130,25 @@ function getJournalIconBackground(item: JournalItem) {
return 'bg-indigo-500'
}
function getJournalType(item: JournalItem): {
label: string
tone: BadgeTone
} {
if (item.type === 'created') {
return { label: 'Erstellt', tone: 'green' }
}
if (item.type === 'edited') {
return { label: 'Änderung', tone: 'blue' }
}
if (item.type === 'comment') {
return { label: 'Kommentar', tone: 'gray' }
}
return { label: 'Journal', tone: 'indigo' }
}
export default function Journal({
items = [],
currentUserAvatar,
@ -177,151 +200,189 @@ export default function Journal({
return (
<div className={className}>
{showEntryForm && (
<div className="mb-8 flex gap-x-3">
{currentUserAvatar ? (
<img
alt=""
src={currentUserAvatar}
className="size-8 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 size-8 flex-none items-center justify-center rounded-full bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10">
<UserCircleIcon
<div className="mb-8 rounded-2xl border border-indigo-100 bg-gradient-to-br from-indigo-50/80 via-white to-violet-50/50 p-4 shadow-sm dark:border-indigo-400/15 dark:from-indigo-500/10 dark:via-gray-900/60 dark:to-violet-500/5">
<div className="mb-3 flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 dark:bg-indigo-500">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-6 text-gray-500 dark:text-gray-400"
/>
</div>
)}
<div className="relative flex-auto">
<div className="overflow-hidden rounded-lg bg-white 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="journalEntry" className="sr-only">
Journal-Eintrag hinzufügen
</label>
<textarea
id="journalEntry"
name="journalEntry"
rows={3}
value={entry}
onChange={(event) => setEntry(event.target.value)}
placeholder={placeholder}
className="block w-full resize-none bg-transparent px-3 py-2 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
className="size-5"
/>
</div>
<div className="absolute inset-x-0 bottom-0 flex justify-end py-2 pr-2 pl-3">
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
isLoading={isSubmitting}
disabled={!entry.trim() || isSubmitting}
onClick={() => void handleSubmit()}
>
{submitLabel}
</Button>
<div>
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
Neuer Journal-Eintrag
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
Notizen und wichtige Informationen nachvollziehbar festhalten.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Avatar
src={currentUserAvatar}
name="Du"
size={9}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 transition focus-within:ring-2 focus-within:ring-indigo-600 dark:bg-white/5 dark:ring-white/10 dark:focus-within:ring-indigo-400">
<label htmlFor="journalEntry" className="sr-only">
Journal-Eintrag hinzufügen
</label>
<textarea
id="journalEntry"
name="journalEntry"
rows={3}
value={entry}
onChange={(event) => setEntry(event.target.value)}
placeholder={placeholder}
className="block min-h-24 w-full resize-y bg-transparent px-3.5 py-3 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 className="flex items-center justify-end border-t border-gray-100 bg-gray-50/70 px-2.5 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<Button
type="button"
variant="primary"
color="indigo"
size="sm"
rounded="full"
isLoading={isSubmitting}
disabled={!entry.trim() || isSubmitting}
onClick={() => void handleSubmit()}
leadingIcon={
<PaperAirplaneIcon aria-hidden="true" className="size-4" />
}
>
{submitLabel}
</Button>
</div>
</div>
</div>
</div>
</div>
)}
{items.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-6 py-10 text-center dark:border-white/15 dark:bg-white/[0.03]">
<div className="mx-auto flex size-11 items-center justify-center rounded-2xl bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-6"
/>
</div>
<p className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
Noch keine Aktivitäten
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
</div>
) : (
<div className="flow-root">
<ul role="list" className="-mb-8">
<ul role="list" className="space-y-4">
{items.map((item, itemIndex) => {
const personName = getItemPersonName(item)
const imageUrl = getItemImageUrl(item)
const dateTime = getItemDateTime(item)
const Icon = getJournalIcon(item)
const itemType = getJournalType(item)
const isManualEntry = item.type === 'journal' || item.type === 'comment'
const changes = item.changes ?? []
const hasChanges = !isManualEntry && changes.length > 0
return (
<li key={item.id}>
<div className="relative pb-8">
{itemIndex !== items.length - 1 && (
<span
aria-hidden="true"
className="absolute top-4 left-4 z-0 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
<li key={item.id} className="relative">
{itemIndex !== items.length - 1 && (
<span
aria-hidden="true"
className="absolute top-10 bottom-[-1rem] left-5 w-px bg-gradient-to-b from-gray-300 to-gray-200 dark:from-white/20 dark:to-white/5"
/>
)}
<div className="relative flex items-start gap-3 sm:gap-4">
<div className="relative z-10 size-10 shrink-0">
<Avatar
src={imageUrl}
name={personName}
size={10}
className="ring-4 ring-white dark:ring-gray-900"
/>
)}
<div className="relative z-10 flex space-x-3">
<div className="relative z-10 size-8 flex-none self-start">
{imageUrl ? (
<img
alt=""
src={imageUrl}
className="block size-8 rounded-full bg-gray-100 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:ring-gray-900 dark:outline-white/10"
/>
) : (
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:ring-gray-900 dark:outline-white/10">
<UserCircleIcon
aria-hidden="true"
className="size-6 text-gray-500 dark:text-gray-400"
/>
</div>
<span
className={classNames(
getJournalIconBackground(item),
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full shadow-sm ring-2 ring-white dark:ring-gray-900',
)}
>
<Icon aria-hidden="true" className="size-3 text-white" />
</span>
</div>
<span
className={classNames(
getJournalIconBackground(item),
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full ring-2 ring-white dark:ring-gray-900',
)}
>
<Icon aria-hidden="true" className="size-3.5 text-white" />
</span>
</div>
<div className="min-w-0 flex-1 pt-1.5">
<div className="flex justify-between gap-x-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
<span>{personName}</span>
{item.hasNotification && (
<NotificationDot
title="Neuer Journal-Eintrag"
label="Neuer Journal-Eintrag"
className="size-2"
/>
)}
</span>{' '}
{!isManualEntry && (
<span className="font-normal text-gray-700 dark:text-gray-300">
{getItemDescription(item)}
<article className="min-w-0 flex-1 rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm ring-1 ring-black/[0.02] transition hover:border-gray-300 hover:shadow-md dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5 dark:hover:border-white/20">
<header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{item.person?.href ? (
<a
href={item.person.href}
className="truncate text-sm font-semibold text-gray-950 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-300"
>
{personName}
</a>
) : (
<span className="truncate text-sm font-semibold text-gray-950 dark:text-white">
{personName}
</span>
)}
</p>
<div className="text-right whitespace-nowrap">
<time
dateTime={dateTime}
className="block text-sm text-gray-500 dark:text-gray-400"
<Badge
tone={itemType.tone}
variant="border"
shape="pill"
size="small"
>
{item.date}
</time>
{itemType.label}
</Badge>
{item.hasNotification && (
<NotificationDot
title="Neuer Journal-Eintrag"
label="Neuer Journal-Eintrag"
className="size-2"
/>
)}
</div>
{!isManualEntry && (
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
{getItemDescription(item)}
</p>
)}
</div>
{isManualEntry && editingItemId === item.id ? (
<div className="mt-2">
<time
dateTime={dateTime}
className="inline-flex w-fit shrink-0 items-center gap-1.5 rounded-full bg-gray-50 px-2.5 py-1 text-xs font-medium whitespace-nowrap text-gray-500 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:text-gray-400 dark:ring-white/10"
>
<ClockIcon aria-hidden="true" className="size-3.5" />
{item.date}
</time>
</header>
{isManualEntry && editingItemId === item.id ? (
<div className="mt-4 rounded-xl bg-gray-50/70 p-3 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.03] dark:ring-white/10">
<textarea
rows={3}
value={editEntry}
onChange={(event) => setEditEntry(event.target.value)}
className="block w-full resize-none rounded-md bg-white px-3 py-2 text-sm 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-white dark:outline-white/10 dark:focus:outline-indigo-500"
className="block min-h-24 w-full resize-y rounded-xl border-0 bg-white px-3.5 py-3 text-sm text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-600 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:focus:ring-indigo-400"
/>
<div className="mt-2 flex justify-end gap-x-2">
<div className="mt-3 flex justify-end gap-x-2">
<Button
type="button"
variant="secondary"
@ -345,77 +406,87 @@ export default function Journal({
Speichern
</Button>
</div>
</div>
) : isManualEntry ? (
<div className="mt-2 rounded-md bg-gray-50 p-2 text-sm text-gray-700 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:text-gray-200 dark:ring-white/10">
<div className="flex items-start gap-x-3">
<div className="min-w-0 flex-1 py-1 whitespace-pre-wrap break-words">
{getItemDescription(item)}
</div>
{item.canEdit && onEntryUpdate && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
disabled={isSubmitting}
onClick={() => startEditing(item)}
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
>
Bearbeiten
</Button>
)}
</div>
) : isManualEntry ? (
<div className="mt-4 rounded-xl bg-gray-50/80 p-3.5 text-sm text-gray-700 ring-1 ring-gray-200 ring-inset dark:bg-white/[0.04] dark:text-gray-200 dark:ring-white/10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words leading-6">
{getItemDescription(item)}
</div>
{item.editedDate && (
<div className="mt-2 flex items-center gap-x-1.5 border-t border-gray-200/70 pt-2 text-[11px] leading-4 text-gray-400 dark:border-white/10 dark:text-gray-500">
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
<span>
Zuletzt bearbeitet:{' '}
<time dateTime={getItemEditedDateTime(item)}>
{item.editedDate}
</time>
</span>
</div>
{item.canEdit && onEntryUpdate && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
disabled={isSubmitting}
onClick={() => startEditing(item)}
leadingIcon={
<PencilSquareIcon
aria-hidden="true"
className="size-4"
/>
}
>
Bearbeiten
</Button>
)}
</div>
) : hasChanges ? (
<div className="mt-2 space-y-2">
{changes.map((change, changeIndex) => (
<div
key={getChangeKey(change, changeIndex)}
className="rounded-md bg-gray-50 p-3 text-sm ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10"
>
<div className="text-xs font-semibold text-gray-900 dark:text-white">
{change.label}
{item.editedDate && (
<div className="mt-3 flex items-center gap-x-1.5 border-t border-gray-200/70 pt-3 text-[11px] leading-4 text-gray-400 dark:border-white/10 dark:text-gray-500">
<PencilSquareIcon aria-hidden="true" className="size-3.5" />
<span>
Zuletzt bearbeitet:{' '}
<time dateTime={getItemEditedDateTime(item)}>
{item.editedDate}
</time>
</span>
</div>
)}
</div>
) : hasChanges ? (
<div className="mt-4 space-y-3">
{changes.map((change, changeIndex) => (
<section
key={getChangeKey(change, changeIndex)}
className="rounded-xl border border-gray-200 bg-gray-50/70 p-3.5 dark:border-white/10 dark:bg-white/[0.03]"
>
<h5 className="text-xs font-semibold text-gray-900 dark:text-white">
{change.label}
</h5>
<div className="mt-2.5 grid grid-cols-1 items-stretch gap-2 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="min-w-0 rounded-lg bg-white p-3 ring-1 ring-gray-200 ring-inset dark:bg-gray-950/30 dark:ring-white/10">
<div className="text-[10px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Vorher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-300">
{getChangeValue(change.oldValue)}
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2">
<div className="min-w-0 rounded-md bg-white p-2 ring-1 ring-gray-200 ring-inset dark:bg-gray-950/30 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
Vorher
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-300">
{getChangeValue(change.oldValue)}
</div>
</div>
<div className="hidden items-center lg:flex">
<span className="flex size-7 items-center justify-center rounded-full bg-white text-gray-400 shadow-sm ring-1 ring-gray-200 dark:bg-white/5 dark:text-gray-500 dark:ring-white/10">
<ArrowRightIcon aria-hidden="true" className="size-3.5" />
</span>
</div>
<div className="min-w-0 rounded-md bg-white p-2 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:ring-indigo-400/20">
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-500 dark:text-indigo-300">
Nachher
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm font-medium text-gray-900 dark:text-white">
{getChangeValue(change.newValue)}
</div>
<div className="min-w-0 rounded-lg bg-indigo-50/80 p-3 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:ring-indigo-400/20">
<div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
Nachher
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-medium text-gray-950 dark:text-white">
{getChangeValue(change.newValue)}
</div>
</div>
</div>
))}
</div>
) : null}
</div>
</div>
</section>
))}
</div>
) : null}
</article>
</div>
</li>
)
@ -425,4 +496,4 @@ export default function Journal({
)}
</div>
)
}
}

View File

@ -7,6 +7,7 @@ import {
useEffect,
useMemo,
useState,
type FormEvent,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
@ -18,6 +19,7 @@ import {
XCircleIcon,
} from '@heroicons/react/24/outline'
import { XMarkIcon } from '@heroicons/react/20/solid'
import Avatar from './Avatar'
export type NotificationVariant =
| 'success'
@ -34,6 +36,11 @@ export type ToastNotification = {
onAction?: () => void
autoClose?: boolean
duration?: number
imageUrl?: string
imageName?: string
replyPlaceholder?: string
replyActionLabel?: string
onReply?: (message: string) => void | Promise<void>
}
type NotificationsProps = {
@ -119,9 +126,42 @@ function NotificationToast({
onAction,
autoClose = true,
duration = 3000,
imageUrl,
imageName,
replyPlaceholder,
replyActionLabel = 'Antworten',
onReply,
} = notification
const { icon: Icon, iconClassName } = getVariantStyles(variant)
const [reply, setReply] = useState('')
const [replying, setReplying] = useState(false)
const [replyError, setReplyError] = useState('')
async function handleReply(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const message = reply.trim()
if (!message || !onReply || replying) {
return
}
setReplying(true)
setReplyError('')
try {
await onReply(message)
onDismiss(id)
} catch (error) {
setReplyError(
error instanceof Error
? error.message
: 'Antwort konnte nicht gesendet werden.',
)
} finally {
setReplying(false)
}
}
useEffect(() => {
if (!autoClose) {
@ -143,10 +183,18 @@ function NotificationToast({
<div className="p-4">
<div className="flex items-start">
<div className="shrink-0">
<Icon
aria-hidden="true"
className={classNames('size-6', iconClassName)}
/>
{imageUrl || imageName ? (
<Avatar
src={imageUrl}
name={imageName || title}
size={10}
/>
) : (
<Icon
aria-hidden="true"
className={classNames('size-6', iconClassName)}
/>
)}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
@ -160,11 +208,40 @@ function NotificationToast({
</p>
)}
{onReply && (
<form onSubmit={(event) => void handleReply(event)} className="mt-3">
<div className="flex gap-2">
<input
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder={replyPlaceholder || 'Direkt antworten...'}
maxLength={4000}
className="min-w-0 flex-1 rounded-md border-0 bg-gray-100 px-3 py-2 text-sm text-gray-900 outline-none ring-1 ring-gray-200 placeholder:text-gray-500 focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<button
type="submit"
disabled={!reply.trim() || replying}
className="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{replying ? 'Sende...' : replyActionLabel}
</button>
</div>
{replyError && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{replyError}
</p>
)}
</form>
)}
{actionLabel && onAction && (
<div className="mt-3">
<button
type="button"
onClick={onAction}
onClick={() => {
onAction()
onDismiss(id)
}}
className="rounded-md text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus:outline-indigo-400"
>
{actionLabel}
@ -311,4 +388,4 @@ export function useNotifications() {
return context
}
export default NotificationsViewport
export default NotificationsViewport

View File

@ -10,6 +10,7 @@ import {
ExclamationTriangleIcon,
MagnifyingGlassIcon,
UserGroupIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import type { User } from './types'
import Checkbox from './Checkbox'
@ -139,7 +140,7 @@ function HighlightedText({
return (
<mark
key={`${part}-${index}`}
className="rounded bg-yellow-100 px-0.5 py-px font-semibold text-yellow-900 ring-1 ring-yellow-200 ring-inset"
className="rounded-md bg-amber-100 px-1 py-px font-semibold text-amber-950 ring-1 ring-amber-200 ring-inset dark:bg-amber-400/20 dark:text-amber-200 dark:ring-amber-300/20"
>
{part}
</mark>
@ -284,13 +285,18 @@ export default function SearchOverlay({
search.length >= 2 && !isLoading && !error && !hasResults
return (
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="relative h-full overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/70 [scrollbar-gutter:stable] dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/30">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-32 right-0 size-96 rounded-full bg-indigo-200/30 blur-3xl dark:bg-indigo-500/10" />
<div className="absolute bottom-0 -left-32 size-80 rounded-full bg-sky-100/50 blur-3xl dark:bg-sky-500/5" />
</div>
<div className="relative mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{/* Header */}
<div className="mb-8 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-8 overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-x-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/20 ring-1 ring-white/20">
<MagnifyingGlassIcon
aria-hidden="true"
className="size-6"
@ -298,11 +304,11 @@ export default function SearchOverlay({
</div>
<div>
<p className="text-base font-semibold text-gray-900">
<p className="text-base font-semibold text-gray-950 dark:text-white">
Suche
</p>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{search.length >= 2
? `${resultCount} Treffer für „${query}`
: 'Gib mindestens 2 Zeichen ein.'}
@ -312,16 +318,25 @@ export default function SearchOverlay({
<div className="flex flex-col gap-3 sm:items-end">
{search.length >= 2 && !error && (
<div className="inline-flex items-center gap-x-2 rounded-full bg-gray-50 px-3 py-1 text-sm font-medium text-gray-600 ring-1 ring-gray-200 ring-inset">
<div className="inline-flex items-center gap-x-2 rounded-full bg-slate-100/80 px-3 py-1 text-sm font-medium text-slate-600 ring-1 ring-slate-200 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10">
{isLoading && (
<span className="size-3 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
<span className="size-3 animate-spin rounded-full border-2 border-slate-300 border-t-indigo-600 dark:border-white/20 dark:border-t-indigo-400" />
)}
{isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="inline-flex size-8 items-center justify-center rounded-full text-gray-400 ring-1 ring-gray-200 transition hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-500 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white"
>
<span className="sr-only">Suche schließen</span>
<XMarkIcon aria-hidden="true" className="size-4" />
</button>
<Checkbox
checked={effectiveExactPhrase}
disabled={!canUseExactPhrase}
@ -330,8 +345,8 @@ export default function SearchOverlay({
wrapperClassName={[
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
canUseExactPhrase
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
: 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
? 'cursor-pointer bg-white/80 text-gray-700 ring-gray-200 hover:bg-gray-50 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10'
: 'cursor-not-allowed bg-gray-50/80 text-gray-400 ring-gray-200 dark:bg-white/[0.03] dark:text-gray-600 dark:ring-white/5',
].join(' ')}
labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4"
@ -341,7 +356,7 @@ export default function SearchOverlay({
checked={caseSensitive}
onChange={(event) => setCaseSensitive(event.target.checked)}
label="Groß-/Kleinschreibung"
wrapperClassName="items-center gap-2 rounded-full bg-white px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50"
wrapperClassName="items-center gap-2 rounded-full bg-white/80 px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4"
/>
@ -351,72 +366,72 @@ export default function SearchOverlay({
</div>
{search.length < 2 && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
<MagnifyingGlassIcon
aria-hidden="true"
className="mx-auto size-10 text-gray-300"
className="mx-auto size-10 text-gray-300 dark:text-gray-600"
/>
<p className="mt-4 text-sm font-medium text-gray-900">
<p className="mt-4 text-sm font-medium text-gray-900 dark:text-white">
Suche starten
</p>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gib mindestens 2 Zeichen ein.
</p>
</div>
)}
{showFullLoading && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600 dark:border-white/15 dark:border-t-indigo-400" />
<p className="mt-4 text-sm font-medium text-gray-900">
<p className="mt-4 text-sm font-medium text-gray-900 dark:text-white">
Suche läuft...
</p>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Ergebnisse werden geladen.
</p>
</div>
)}
{showError && (
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center shadow-sm">
<div className="rounded-3xl border border-red-200 bg-red-50/90 p-10 text-center shadow-sm dark:border-red-500/20 dark:bg-red-950/40">
<ExclamationTriangleIcon
aria-hidden="true"
className="mx-auto size-10 text-red-400"
className="mx-auto size-10 text-red-400 dark:text-red-300"
/>
<p className="mt-4 text-sm font-semibold text-red-900">
<p className="mt-4 text-sm font-semibold text-red-900 dark:text-red-200">
Suche fehlgeschlagen
</p>
<p className="mt-1 text-sm text-red-700">
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error}
</p>
</div>
)}
{showNoResults && (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<div className="rounded-3xl border border-dashed border-gray-300/80 bg-white/70 p-10 text-center shadow-sm backdrop-blur dark:border-white/15 dark:bg-gray-900/60">
<ExclamationTriangleIcon
aria-hidden="true"
className="mx-auto size-10 text-gray-300"
className="mx-auto size-10 text-gray-300 dark:text-gray-600"
/>
<p className="mt-4 text-sm font-semibold text-gray-900">
<p className="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
Keine Ergebnisse gefunden
</p>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Prüfe den Suchbegriff oder versuche es mit weniger Begriffen.
</p>
</div>
)}
{!error && groups.length > 0 && (
<div className="space-y-8">
<div className="space-y-10">
{groups.map((group) => {
const Icon = group.icon
@ -424,7 +439,7 @@ export default function SearchOverlay({
<section key={group.id}>
<div className="mb-4 flex items-center justify-between gap-x-4">
<div className="flex items-center gap-x-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-gray-50 text-gray-500 ring-1 ring-gray-200 ring-inset">
<div className="flex size-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<Icon
aria-hidden="true"
className="size-5"
@ -432,11 +447,11 @@ export default function SearchOverlay({
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{group.title}
</h3>
<p className="text-xs text-gray-500">
<p className="text-xs text-gray-500 dark:text-gray-400">
{group.items.length} Treffer
</p>
</div>
@ -449,15 +464,15 @@ export default function SearchOverlay({
key={`${group.id}-${item.id}`}
to={item.href}
onClick={onClose}
className="group relative flex min-h-28 flex-col justify-between rounded-xl border border-gray-200 bg-white p-4 pr-10 shadow-sm transition hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md"
className="group relative flex min-h-28 flex-col justify-between rounded-2xl border border-white/80 bg-white/85 p-4 pr-10 shadow-sm ring-1 ring-slate-900/5 backdrop-blur transition duration-200 hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-lg hover:shadow-indigo-900/5 dark:border-white/10 dark:bg-gray-900/75 dark:ring-white/5 dark:hover:border-indigo-400/30 dark:hover:bg-gray-900"
>
<ArrowRightIcon
aria-hidden="true"
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500"
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500 dark:text-gray-600 dark:group-hover:text-indigo-300"
/>
<div>
<p className="line-clamp-1 text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
<p className="line-clamp-1 text-sm font-semibold text-gray-900 transition group-hover:text-indigo-600 dark:text-white dark:group-hover:text-indigo-300">
<HighlightedText
value={item.title}
search={search}
@ -467,7 +482,7 @@ export default function SearchOverlay({
</p>
{item.subtitle && (
<p className="mt-1 line-clamp-2 text-xs text-gray-500">
<p className="mt-1 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
<HighlightedText
value={item.subtitle}
search={search}
@ -488,4 +503,4 @@ export default function SearchOverlay({
</div>
</div>
)
}
}

View File

@ -23,7 +23,7 @@ import {
import type { ComponentType, SVGProps } from 'react'
import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions'
import type { User, Team } from './types'
import type { User } from './types'
type NavigationItem = {
name: string
@ -82,6 +82,11 @@ const navigationSections: NavigationSection[] = [
{
name: 'Organisation',
items: [
{
name: 'Chats',
href: '/chat',
icon: ChatBubbleLeftRightIcon,
},
{
name: 'Kalender',
href: '/kalender',
@ -109,20 +114,13 @@ type SidebarProps = {
sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
unreadChatCount?: number
}
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 isPathActive(pathname: string, href: string) {
if (href === '/') {
return pathname === '/'
@ -170,12 +168,13 @@ function SidebarContent({
user,
setSidebarOpen,
onNavigate,
unreadChatCount = 0,
}: {
user: User
setSidebarOpen: (open: boolean) => void
onNavigate?: () => void
unreadChatCount?: number
}) {
const teams = user.teams ?? []
const location = useLocation()
const visibleNavigationSections = getVisibleNavigationSections(user)
@ -222,6 +221,11 @@ function SidebarContent({
)}
/>
<span className="truncate">{item.name}</span>
{item.href === '/chat' && unreadChatCount > 0 && (
<span className="ml-auto inline-flex min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 py-0.5 text-[0.6875rem] font-semibold leading-none text-white">
{unreadChatCount > 99 ? '99+' : unreadChatCount}
</span>
)}
</NavLink>
{showChildren && (
@ -298,42 +302,6 @@ function SidebarContent({
</div>
</li>
<li>
<div className="border-t border-white/10 pt-5">
<h2 className="px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Teams
</h2>
<ul role="list" className="mt-2 space-y-1">
{teams.length > 0 ? (
teams.map((team) => (
<li key={team.id}>
<a
href={team.href || '#'}
onClick={handleNavigate}
className={classNames(
team.current
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2 text-sm/6 font-semibold transition',
)}
>
<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-slate-300 group-hover:border-white/20 group-hover:text-white">
{getTeamInitial(team)}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))
) : (
<li className="px-3 py-1 text-sm/6 text-slate-500">
Keine Teams zugeordnet
</li>
)}
</ul>
</div>
</li>
<li className="mt-auto border-t border-white/10 pt-5">
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Hilfe & System
@ -413,6 +381,7 @@ export default function Sidebar({
sidebarOpen,
setSidebarOpen,
onNavigate,
unreadChatCount = 0,
}: SidebarProps) {
return (
<>
@ -449,6 +418,7 @@ export default function Sidebar({
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
unreadChatCount={unreadChatCount}
/>
</div>
</DialogPanel>
@ -461,6 +431,7 @@ export default function Sidebar({
user={user}
setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate}
unreadChatCount={unreadChatCount}
/>
</div>
</div>

View File

@ -1,6 +1,6 @@
// frontend/src/components/Topbar.tsx
import type { ReactNode } from 'react'
import { useState, type ReactNode } from 'react'
import {
Menu,
MenuButton,
@ -11,15 +11,19 @@ import {
Bars3Icon,
BellIcon,
} from '@heroicons/react/24/outline'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import { Link } from 'react-router'
import Search from './Search'
import Camera from './Camera'
import Avatar from './Avatar'
import type { User } from './types'
import { useNotifications } from './Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type TopbarProps = {
user: User
onUserUpdated: (user: User) => void
onLogout: () => void | Promise<void>
onOpenSidebar: () => void
notificationCenter?: ReactNode
@ -34,6 +38,7 @@ const userNavigation = [
export default function Topbar({
user,
onUserUpdated,
onLogout,
onOpenSidebar,
notificationCenter,
@ -41,10 +46,44 @@ export default function Topbar({
onSearchQueryChange,
onNavigate,
}: TopbarProps) {
const { showErrorToast } = useNotifications()
const [updatingPresenceMode, setUpdatingPresenceMode] = useState<
User['presenceMode'] | null
>(null)
function handleQrScan(value: string) {
console.log('QR-Code erkannt:', value)
}
async function updatePresenceMode(mode: User['presenceMode']) {
if (updatingPresenceMode) {
return
}
setUpdatingPresenceMode(mode)
try {
const response = await fetch(`${API_URL}/settings/presence/status`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
})
const data = await response.json().catch(() => null)
if (!response.ok || !data?.user) {
throw new Error(data?.error || 'Status konnte nicht gesetzt werden')
}
onUserUpdated(data.user as User)
} catch (error) {
showErrorToast({
title: 'Status konnte nicht gesetzt werden',
error,
fallback: 'Status konnte nicht gesetzt werden',
})
} finally {
setUpdatingPresenceMode(null)
}
}
return (
<div className="sticky top-0 z-[9500] 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
@ -98,6 +137,7 @@ export default function Topbar({
src={user.avatar}
name={user.displayName || user.username || user.email}
size={8}
presenceStatus={user.presenceStatus}
/>
<span className="hidden lg:flex lg:items-center">
@ -116,8 +156,36 @@ export default function Topbar({
<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"
className="absolute right-0 z-10 mt-2.5 w-52 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"
>
<div className="px-3 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
Chat-Status
</div>
{(
[
['online', 'Online', 'bg-green-500'],
['away', 'Abwesend', 'bg-amber-500'],
['offline', 'Offline', 'bg-gray-400'],
] as const
).map(([mode, label, colorClassName]) => (
<MenuItem key={mode}>
<button
type="button"
disabled={updatingPresenceMode !== null}
onClick={() => void updatePresenceMode(mode)}
className="flex w-full items-center gap-2.5 px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden disabled:opacity-60 dark:text-white dark:data-focus:bg-white/5"
>
<span className={`size-2.5 rounded-full ${colorClassName}`} />
<span className="flex-1">{label}</span>
{user.presenceMode === mode && (
<CheckIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
)}
</button>
</MenuItem>
))}
<div className="my-1 border-t border-gray-200 dark:border-white/10" />
{userNavigation.map((item) => (
<MenuItem key={item.name}>
<Link
@ -148,4 +216,4 @@ export default function Topbar({
</div>
</div>
)
}
}

View File

@ -18,6 +18,12 @@ export type User = {
group: string
rights: string[]
teams: Team[]
onlineStatus: boolean
presenceMode: 'online' | 'away' | 'offline'
presenceStatus: 'online' | 'away' | 'offline'
awayAfterMinutes: number
showLastSeen: boolean
lastSeenAt?: string | null
}
export type DeviceHistoryChange = {
@ -149,6 +155,12 @@ export type Operation = {
caseWorker: string
targetObject: string
kwAddress: string
kwLatitude?: number | null
kwLongitude?: number | null
targetLatitude?: number | null
targetLongitude?: number | null
viewConeFov?: number
viewConeLength?: number
operationLeader: string
operationTeam: string
legend: string
@ -194,6 +206,8 @@ export type Notification = {
data: unknown
silent: boolean
soundName?: string
desktop?: boolean
inAppEnabled?: boolean
readAt?: string | null
createdAt: string
}
}

View File

@ -5,11 +5,17 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import App from './App.tsx'
import { NotificationProvider } from './components/Notifications.tsx'
import { initActivityLog } from './utils/activityLog.ts'
initActivityLog()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
<NotificationProvider>
<App />
</NotificationProvider>
</BrowserRouter>
</StrictMode>,
)
)

View File

@ -3,6 +3,7 @@
import { useLocation, Navigate } from 'react-router'
import {
ChatBubbleLeftRightIcon,
HashtagIcon,
UserGroupIcon,
UsersIcon,
VideoCameraIcon,
@ -12,6 +13,7 @@ import UsersAdministration from './users/UsersAdministration'
import TeamsAdministration from './teams/TeamsAdministration'
import FeedbackAdministration from './feedback/FeedbackAdministration'
import Milestone from './milestone/Milestone'
import ChannelsAdministration from './channels/ChannelsAdministration'
export default function AdministrationPage() {
const location = useLocation()
@ -33,6 +35,12 @@ export default function AdministrationPage() {
icon: UserGroupIcon,
current: section === 'teams',
},
{
name: 'Channels',
href: '/administration/channels',
icon: HashtagIcon,
current: section === 'channels',
},
{
name: 'Milestone',
href: '/administration/milestone',
@ -47,7 +55,7 @@ export default function AdministrationPage() {
},
]
if (!['benutzer', 'teams', 'milestone', 'feedback'].includes(section)) {
if (!['benutzer', 'teams', 'channels', 'milestone', 'feedback'].includes(section)) {
return <Navigate to="/administration/benutzer" replace />
}
@ -78,10 +86,11 @@ export default function AdministrationPage() {
{section === 'benutzer' && <UsersAdministration />}
{section === 'teams' && <TeamsAdministration />}
{section === 'channels' && <ChannelsAdministration />}
{section === 'milestone' && <Milestone />}
{section === 'feedback' && <FeedbackAdministration />}
</div>
</div>
</main>
)
}
}

View File

@ -0,0 +1,666 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
} from 'react'
import {
ClipboardDocumentIcon,
HashtagIcon,
KeyIcon,
PlusIcon,
} from '@heroicons/react/20/solid'
import Button from '../../../components/Button'
import Avatar from '../../../components/Avatar'
import Checkbox from '../../../components/Checkbox'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Switch from '../../../components/Switch'
import { useNotifications } from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type AdminChannel = {
id: string
name: string
slug: string
sourceName: string
image: string
allUsers: boolean
userIds: string[]
webhookEnabled: boolean
tokenConfigured: boolean
webhookTokenUpdatedAt?: string | null
webhookPath: string
}
type AdminChannelUser = {
id: string
username: string
displayName: string
email: string
unit: string
}
type ChannelDraft = {
id?: string
name: string
slug: string
sourceName: string
image: string
allUsers: boolean
userIds: string[]
webhookEnabled: boolean
tokenConfigured: boolean
webhookTokenUpdatedAt?: string | null
webhookPath: string
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-sm 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 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500'
const emptyDraft: ChannelDraft = {
name: '',
slug: '',
sourceName: '',
image: '',
allUsers: true,
userIds: [],
webhookEnabled: true,
tokenConfigured: false,
webhookPath: '',
}
async function readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null)
return data?.error ?? fallback
}
function userDisplayName(user: AdminChannelUser) {
return user.displayName || user.username || user.email
}
function channelToDraft(channel: AdminChannel): ChannelDraft {
return {
...channel,
userIds: channel.userIds ?? [],
}
}
export default function ChannelsAdministration() {
const { showToast, showErrorToast } = useNotifications()
const [channels, setChannels] = useState<AdminChannel[]>([])
const [users, setUsers] = useState<AdminChannelUser[]>([])
const [draft, setDraft] = useState<ChannelDraft>(emptyDraft)
const [zabbixScript, setZabbixScript] = useState('')
const [newToken, setNewToken] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isRotatingToken, setIsRotatingToken] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
const webhookUrl = useMemo(() => {
const path = draft.webhookPath || `/webhooks/channels/${draft.slug}`
try {
return new URL(path, API_URL).toString()
} catch {
return `${API_URL.replace(/\/$/, '')}${path}`
}
}, [draft.slug, draft.webhookPath])
const loadChannels = useCallback(async (preferredId?: string) => {
setIsLoading(true)
try {
const response = await fetch(`${API_URL}/admin/channels`, {
credentials: 'include',
})
if (!response.ok) {
throw new Error(
await readApiError(response, 'Channels konnten nicht geladen werden'),
)
}
const data = await response.json()
const nextChannels = (data.channels ?? []) as AdminChannel[]
setChannels(nextChannels)
setUsers(data.users ?? [])
setZabbixScript(data.zabbixScript ?? '')
const selected =
nextChannels.find((channel) => channel.id === preferredId) ??
nextChannels[0]
setDraft(selected ? channelToDraft(selected) : emptyDraft)
} catch (error) {
showErrorToast({
title: 'Channels konnten nicht geladen werden',
error,
fallback: 'Channels konnten nicht geladen werden',
})
} finally {
setIsLoading(false)
}
}, [showErrorToast])
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void loadChannels()
}, 0)
return () => window.clearTimeout(timeoutId)
}, [loadChannels])
function selectChannel(channel: AdminChannel) {
setDraft(channelToDraft(channel))
setNewToken('')
}
function createChannel() {
setDraft(emptyDraft)
setNewToken('')
}
function updateDraft<Key extends keyof ChannelDraft>(
key: Key,
value: ChannelDraft[Key],
) {
setDraft((current) => ({ ...current, [key]: value }))
}
function toggleUser(userId: string, checked: boolean) {
setDraft((current) => ({
...current,
userIds: checked
? [...new Set([...current.userIds, userId])]
: current.userIds.filter((id) => id !== userId),
}))
}
function handleImageFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) {
return
}
if (!file.type.startsWith('image/')) {
showToast({
title: 'Ungültige Bilddatei',
message: 'Bitte wähle eine gültige Bilddatei aus.',
variant: 'error',
})
return
}
if (file.size > 2 * 1024 * 1024) {
showToast({
title: 'Channel-Bild zu groß',
message: 'Das Channel-Bild darf maximal 2 MB groß sein.',
variant: 'error',
})
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
updateDraft('image', reader.result)
}
}
reader.readAsDataURL(file)
}
async function saveChannel(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setIsSaving(true)
try {
const response = await fetch(
draft.id
? `${API_URL}/admin/channels/${draft.id}`
: `${API_URL}/admin/channels`,
{
method: draft.id ? 'PUT' : 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: draft.name,
slug: draft.slug,
sourceName: draft.sourceName,
image: draft.image,
allUsers: draft.allUsers,
userIds: draft.userIds,
webhookEnabled: draft.webhookEnabled,
}),
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Channel konnte nicht gespeichert werden'),
)
}
const data = await response.json()
await loadChannels(data.id ?? draft.id)
showToast({
title: 'Channel gespeichert',
message: 'Mitglieder und Webhook-Einstellungen wurden aktualisiert.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Channel konnte nicht gespeichert werden',
error,
fallback: 'Channel konnte nicht gespeichert werden',
})
} finally {
setIsSaving(false)
}
}
async function rotateToken() {
if (!draft.id) {
return
}
setIsRotatingToken(true)
try {
const response = await fetch(
`${API_URL}/admin/channels/${draft.id}/token`,
{
method: 'POST',
credentials: 'include',
},
)
if (!response.ok) {
throw new Error(
await readApiError(response, 'Webhook-Token konnte nicht erzeugt werden'),
)
}
const data = await response.json()
setNewToken(data.token ?? '')
setDraft((current) => ({ ...current, tokenConfigured: true }))
showToast({
title: 'Webhook-Token erzeugt',
message: 'Das Token wird nur jetzt vollständig angezeigt.',
variant: 'success',
autoClose: false,
})
await loadChannels(draft.id)
setNewToken(data.token ?? '')
} catch (error) {
showErrorToast({
title: 'Webhook-Token konnte nicht erzeugt werden',
error,
fallback: 'Webhook-Token konnte nicht erzeugt werden',
})
} finally {
setIsRotatingToken(false)
}
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value)
showToast({
title: `${label} kopiert`,
message: 'Der Wert liegt jetzt in der Zwischenablage.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: `${label} konnte nicht kopiert werden`,
error,
fallback: 'Die Zwischenablage ist nicht verfügbar.',
})
}
}
if (isLoading) {
return (
<div className="rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<LoadingSpinner label="Channels werden geladen..." center />
</div>
)
}
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-white/10">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Channels
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Schreibgeschützte Integrationen
</p>
</div>
<Button
type="button"
size="sm"
color="indigo"
leadingIcon={<PlusIcon className="size-4" />}
onClick={createChannel}
>
Neu
</Button>
</div>
<div className="space-y-1 p-2">
{channels.map((channel) => (
<button
key={channel.id}
type="button"
onClick={() => selectChannel(channel)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm ${
draft.id === channel.id
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5'
}`}
>
{channel.image ? (
<Avatar
src={channel.image}
name={channel.name}
size={9}
shape="rounded"
/>
) : (
<HashtagIcon className="size-5 shrink-0" />
)}
<span className="min-w-0">
<span className="block truncate font-medium">{channel.name}</span>
<span className="block truncate text-xs opacity-70">
/{channel.slug}
</span>
</span>
</button>
))}
</div>
</aside>
<div className="space-y-6">
<form
onSubmit={saveChannel}
className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
{draft.id ? 'Channel bearbeiten' : 'Channel anlegen'}
</h2>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium text-gray-900 dark:text-white">
Channelname
<input
required
value={draft.name}
onChange={(event) => updateDraft('name', event.target.value)}
placeholder="Zabbix"
className={`${inputClassName} mt-2`}
/>
</label>
<label className="text-sm font-medium text-gray-900 dark:text-white">
Webhook-Slug
<input
required
value={draft.slug}
onChange={(event) => updateDraft('slug', event.target.value)}
placeholder="zabbix"
pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
className={`${inputClassName} mt-2`}
/>
</label>
<label className="text-sm font-medium text-gray-900 dark:text-white sm:col-span-2">
Angezeigter Absender
<input
value={draft.sourceName}
onChange={(event) => updateDraft('sourceName', event.target.value)}
placeholder={draft.name || 'Zabbix'}
className={`${inputClassName} mt-2`}
/>
</label>
<div className="sm:col-span-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">
Channel-Bild
</p>
<div className="mt-2 flex items-center gap-4">
<Avatar
src={draft.image}
name={draft.name || 'Channel'}
size={14}
shape="rounded"
/>
<div>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleImageFileChange}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => imageInputRef.current?.click()}
>
Bild hochladen
</Button>
{draft.image && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={() => updateDraft('image', '')}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Optional: PNG, JPG, WEBP oder GIF bis maximal 2 MB.
</p>
</div>
</div>
</div>
</div>
<div className="mt-5 space-y-4 border-t border-gray-200 pt-5 dark:border-white/10">
<Switch
checked={draft.webhookEnabled}
onChange={(event) =>
updateDraft('webhookEnabled', event.target.checked)
}
label="Webhook aktiviert"
description="Nur aktivierte Channels nehmen externe Nachrichten an."
/>
<Switch
checked={draft.allUsers}
onChange={(event) => updateDraft('allUsers', event.target.checked)}
label="Für alle Benutzer"
description="Neue Benutzer werden automatisch Mitglied dieses Channels."
/>
</div>
{!draft.allUsers && (
<div className="mt-5">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Channelmitglieder
</h3>
<div className="mt-3 grid max-h-64 gap-2 overflow-y-auto rounded-lg border border-gray-200 p-3 sm:grid-cols-2 dark:border-white/10">
{users.map((user) => (
<Checkbox
key={user.id}
checked={draft.userIds.includes(user.id)}
onChange={(event) =>
toggleUser(user.id, event.target.checked)
}
label={userDisplayName(user)}
description={user.unit || user.email}
/>
))}
</div>
</div>
)}
<div className="mt-6 flex justify-end">
<Button type="submit" color="indigo" isLoading={isSaving}>
Channel speichern
</Button>
</div>
</form>
{draft.id && (
<section className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-gray-900 dark:text-white">
<KeyIcon className="size-5 text-indigo-500" />
Webhook für Zabbix 7.2.15
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verwende den Medientyp Webhook. Das Bearer-Token wird nur nach
dem Erzeugen vollständig angezeigt.
</p>
</div>
<Button
type="button"
variant="secondary"
color="indigo"
isLoading={isRotatingToken}
onClick={() => void rotateToken()}
>
{draft.tokenConfigured ? 'Token erneuern' : 'Token erzeugen'}
</Button>
</div>
<div className="mt-5 space-y-4">
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-gray-600 dark:text-gray-300">
<li>
Unter Alerts &gt; Media types einen Medientyp vom Typ
Webhook anlegen.
</li>
<li>
Die Parameter und das JavaScript unten übernehmen und den
Medientyp über Test prüfen.
</li>
<li>
Den Medientyp einem Zabbix-Benutzer als Medium zuweisen. Bei
Send to genügt beispielsweise teg-channel.
</li>
<li>
Diesen Benutzer in der gewünschten Action unter Send to
users als Empfänger auswählen.
</li>
</ol>
<CopyField
label="URL"
value={webhookUrl}
onCopy={() => void copyText(webhookUrl, 'URL')}
/>
{newToken && (
<CopyField
label="Token"
value={newToken}
onCopy={() => void copyText(newToken, 'Token')}
/>
)}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Parameter im Zabbix-Medientyp
</h3>
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200 text-sm dark:border-white/10">
{[
['URL', webhookUrl],
['Token', newToken || '<erzeugtes Token>'],
['To', '{ALERT.SENDTO}'],
['Subject', '{ALERT.SUBJECT}'],
['Message', '{ALERT.MESSAGE}'],
['Host', '{HOST.NAME}'],
['Severity', '{EVENT.SEVERITY}'],
['Status', '{EVENT.STATUS}'],
['EventId', '{EVENT.ID}'],
['EventUrl', '{TRIGGER.URL}'],
].map(([name, value]) => (
<div
key={name}
className="grid grid-cols-[7rem_minmax(0,1fr)] border-b border-gray-200 last:border-b-0 dark:border-white/10"
>
<span className="bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-white/5 dark:text-gray-300">
{name}
</span>
<code className="break-all px-3 py-2 text-gray-600 dark:text-gray-300">
{value}
</code>
</div>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
JavaScript
</h3>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
onClick={() => void copyText(zabbixScript, 'JavaScript')}
>
Kopieren
</Button>
</div>
<pre className="mt-2 max-h-96 overflow-auto rounded-lg bg-gray-950 p-4 text-xs leading-5 text-gray-100">
<code>{zabbixScript}</code>
</pre>
</div>
</div>
</section>
)}
</div>
</div>
)
}
function CopyField({
label,
value,
onCopy,
}: {
label: string
value: string
onCopy: () => void
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</label>
<div className="mt-2 flex gap-2">
<input readOnly value={value} className={inputClassName} />
<Button
type="button"
variant="secondary"
color="gray"
leadingIcon={<ClipboardDocumentIcon className="size-4" />}
onClick={onCopy}
>
Kopieren
</Button>
</div>
</div>
)
}

View File

@ -1,13 +1,22 @@
// frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import {
ArrowPathIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/20/solid'
import {
ComputerDesktopIcon,
CursorArrowRaysIcon,
ExclamationTriangleIcon,
GlobeAltIcon,
} from '@heroicons/react/24/outline'
import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner'
import Avatar from '../../../components/Avatar'
import ActivityLogView from '../../../components/ActivityLogView'
import type { ActivityLogEntry, ActivityLogType } from '../../../utils/activityLog'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -21,6 +30,14 @@ type FeedbackItem = {
createdAt: string
}
type ClientMeta = {
browser: string
operatingSystem: string
deviceType: string
url: string
viewport: string
}
type NotificationLike = {
type?: string
entityType?: string
@ -39,32 +56,82 @@ function formatDate(value: string) {
function getInitials(name: string, email: string) {
const source = name || email
if (!source) {
return '?'
}
const parts = source
return source
.replace(/@.*/, '')
.split(/[.\s_-]+/)
.filter(Boolean)
return parts
.slice(0, 2)
.map((part) => part.charAt(0))
.join('')
.toUpperCase()
}
const activityTypes: ActivityLogType[] = ['action', 'navigation', 'error']
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function readString(record: Record<string, unknown> | null, key: string) {
const value = record?.[key]
return typeof value === 'string' ? value : ''
}
function coerceActivity(value: unknown): ActivityLogEntry[] {
if (!Array.isArray(value)) {
return []
}
const entries: ActivityLogEntry[] = []
for (const item of value) {
const record = asRecord(item)
if (!record || typeof record.message !== 'string') {
continue
}
const type = activityTypes.includes(record.type as ActivityLogType)
? (record.type as ActivityLogType)
: 'action'
entries.push({
id: typeof record.id === 'string' ? record.id : `${entries.length}`,
type,
timestamp: readString(record, 'timestamp'),
path: readString(record, 'path'),
message: record.message,
details: asRecord(record.details) ?? undefined,
})
}
return entries
}
function parseClientMeta(record: Record<string, unknown> | null): ClientMeta | null {
const client = asRecord(record?.client)
if (!client) {
return null
}
return {
browser: readString(client, 'browser'),
operatingSystem: readString(client, 'operatingSystem'),
deviceType: readString(client, 'deviceType'),
url: readString(client, 'url'),
viewport: readString(client, 'viewport'),
}
}
function extractNotification(eventData: string): NotificationLike | null {
try {
const parsed = JSON.parse(eventData)
if (parsed?.notification) {
return parsed.notification
}
return parsed
return parsed?.notification ?? parsed
} catch {
return null
}
@ -83,6 +150,21 @@ function isFeedbackEvent(notification: NotificationLike | null) {
)
}
function MetaChip({
icon: Icon,
children,
}: {
icon: typeof GlobeAltIcon
children: ReactNode
}) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-300">
<Icon className="size-3.5 text-gray-400" />
{children}
</span>
)
}
export default function FeedbackAdministration() {
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
const [isLoading, setIsLoading] = useState(true)
@ -90,17 +172,28 @@ export default function FeedbackAdministration() {
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
() => new Set(),
)
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const sortedFeedback = useMemo(
() =>
[...feedback].sort(
(firstItem, secondItem) =>
new Date(secondItem.createdAt).getTime() -
new Date(firstItem.createdAt).getTime(),
),
[feedback],
)
const visibleFeedback = useMemo(() => {
const sorted = [...feedback].sort(
(firstItem, secondItem) =>
new Date(secondItem.createdAt).getTime() -
new Date(firstItem.createdAt).getTime(),
)
const normalizedSearch = search.trim().toLowerCase()
if (!normalizedSearch) {
return sorted
}
return sorted.filter((item) =>
[item.userName, item.userEmail, item.message]
.join(' ')
.toLowerCase()
.includes(normalizedSearch),
)
}, [feedback, search])
const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
if (options?.silent) {
@ -122,12 +215,11 @@ export default function FeedbackAdministration() {
}
const data = await response.json()
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
} catch (error) {
} catch (loadError) {
setError(
error instanceof Error
? error.message
loadError instanceof Error
? loadError.message
: 'Feedback konnte nicht geladen werden',
)
} finally {
@ -146,9 +238,7 @@ export default function FeedbackAdministration() {
})
events.onmessage = (event) => {
const notification = extractNotification(event.data)
if (isFeedbackEvent(notification)) {
if (isFeedbackEvent(extractNotification(event.data))) {
void loadFeedback({ silent: true })
}
}
@ -166,17 +256,11 @@ export default function FeedbackAdministration() {
const confirmed = window.confirm(
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
)
if (!confirmed) {
return
}
setDeletingFeedbackIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(feedbackItem.id)
return nextIds
})
setDeletingFeedbackIds((currentIds) => new Set(currentIds).add(feedbackItem.id))
setError(null)
try {
@ -193,10 +277,10 @@ export default function FeedbackAdministration() {
setFeedback((currentFeedback) =>
currentFeedback.filter((item) => item.id !== feedbackItem.id),
)
} catch (error) {
} catch (deleteError) {
setError(
error instanceof Error
? error.message
deleteError instanceof Error
? deleteError.message
: 'Feedback konnte nicht gelöscht werden',
)
} finally {
@ -223,75 +307,95 @@ export default function FeedbackAdministration() {
return (
<section>
<div className="mb-6 flex items-start justify-between gap-x-4">
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
Feedback
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-white/10 dark:text-gray-300">
{feedback.length}
</span>
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Eingereichtes Feedback inklusive technischem Log einsehen und verwalten.
Eingereichtes Feedback inklusive Aktivitäts- und Fehlerprotokoll.
</p>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isRefreshing}
isLoading={isRefreshing}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void loadFeedback({ silent: true })}
>
Aktualisieren
</Button>
<div className="flex items-center gap-2">
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
aria-hidden="true"
/>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Suchen..."
className="block w-44 rounded-lg border-0 bg-white py-2 pr-3 pl-9 text-sm text-gray-900 ring-1 ring-gray-200 outline-none focus:ring-2 focus:ring-indigo-500 sm:w-56 dark:bg-white/5 dark:text-white dark:ring-white/10"
/>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isRefreshing}
isLoading={isRefreshing}
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
onClick={() => void loadFeedback({ silent: true })}
>
Aktualisieren
</Button>
</div>
</div>
{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">
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
)}
{sortedFeedback.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-900">
{visibleFeedback.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center dark:border-white/10 dark:bg-gray-900">
<p className="text-sm text-gray-500 dark:text-gray-400">
Es wurde noch kein Feedback abgegeben.
{search.trim()
? 'Kein Feedback passt zur Suche.'
: 'Es wurde noch kein Feedback abgegeben.'}
</p>
</div>
) : (
<div className="space-y-4">
{sortedFeedback.map((feedbackItem) => {
{visibleFeedback.map((feedbackItem) => {
const isDeleting = deletingFeedbackIds.has(feedbackItem.id)
const logRecord = asRecord(feedbackItem.log)
const activity = coerceActivity(logRecord?.activity)
const client = parseClientMeta(logRecord)
const errorCount = activity.filter((entry) => entry.type === 'error').length
return (
<article
key={feedbackItem.id}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-gray-900"
className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900"
>
<div className="flex items-start justify-between gap-x-4">
<div className="flex min-w-0 items-start gap-x-3">
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5 dark:border-white/10">
<div className="flex min-w-0 items-start gap-3">
<Avatar
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
name={feedbackItem.userName || feedbackItem.userEmail}
size={10}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{feedbackItem.userName || 'Unbekannter Benutzer'}
</h3>
{feedbackItem.userEmail && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{feedbackItem.userEmail}
</span>
)}
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{formatDate(feedbackItem.createdAt)}
</p>
</div>
@ -311,19 +415,61 @@ export default function FeedbackAdministration() {
</Button>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
{feedbackItem.message}
</p>
<div className="p-5">
<p className="rounded-xl bg-gray-50 p-4 text-sm whitespace-pre-wrap text-gray-700 dark:bg-white/5 dark:text-gray-200">
{feedbackItem.message}
</p>
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5">
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
Technisches Log anzeigen
</summary>
{client && (
<div className="mt-4 flex flex-wrap gap-2">
{client.browser && (
<MetaChip icon={GlobeAltIcon}>{client.browser}</MetaChip>
)}
{client.operatingSystem && (
<MetaChip icon={ComputerDesktopIcon}>
{client.operatingSystem}
{client.deviceType ? ` · ${client.deviceType}` : ''}
</MetaChip>
)}
{client.viewport && (
<MetaChip icon={CursorArrowRaysIcon}>{client.viewport}</MetaChip>
)}
</div>
)}
<pre className="mt-3 max-h-96 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">
{JSON.stringify(feedbackItem.log, null, 2)}
</pre>
</details>
<details className="group mt-4 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 select-none hover:bg-gray-100 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
<span className="flex items-center gap-2">
<CursorArrowRaysIcon className="size-4 text-gray-400" />
Aktivitätsprotokoll
<span className="text-xs font-normal text-gray-400">
{activity.length} Einträge
</span>
</span>
{errorCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-500/15 dark:text-red-300">
<ExclamationTriangleIcon className="size-3.5" />
{errorCount}
</span>
)}
</summary>
<div className="max-h-96 overflow-y-auto p-2">
<ActivityLogView
entries={activity}
emptyLabel="Für dieses Feedback wurde kein clientseitiges Protokoll übermittelt."
/>
</div>
</details>
<details className="mt-2 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
<summary className="cursor-pointer list-none bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700 select-none hover:bg-gray-100 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10">
Rohdaten (technisches Log)
</summary>
<pre className="max-h-96 overflow-auto bg-gray-950 p-3 text-xs text-gray-100">
{JSON.stringify(feedbackItem.log, null, 2)}
</pre>
</details>
</div>
</article>
)
})}
@ -331,4 +477,4 @@ export default function FeedbackAdministration() {
)}
</section>
)
}
}

View File

@ -45,6 +45,9 @@ type AdminUser = {
group: string
rights: string[]
teams: AdminTeam[]
onlineStatus: boolean
presenceStatus: 'online' | 'away' | 'offline'
lastSeenAt?: string | null
}
const adminRightId = 'admin'
@ -201,13 +204,6 @@ function normalizeRights(rights: string[]) {
return [...nextRights]
}
function getSelectedValues(formData: FormData, name: string) {
return formData
.getAll(name)
.map((value) => String(value))
.filter(Boolean)
}
function getUserDisplayName(user: AdminUser) {
return user.displayName || user.username || user.email
}
@ -934,6 +930,8 @@ export default function UsersAdministration() {
name={displayName}
initials={getUserInitials(user)}
size={9}
presenceStatus={user.presenceStatus}
wrapperClassName="self-start"
className={isSelected && !user.avatar ? 'bg-indigo-600' : undefined}
/>
@ -1349,4 +1347,4 @@ export default function UsersAdministration() {
</div>
</>
)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -44,13 +44,13 @@ type DeviceFormFieldsProps = {
}
export 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'
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
export 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'
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:*:bg-gray-800 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
export const sectionClassName =
'flex min-h-0 flex-col rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
'flex min-h-0 flex-col rounded-2xl border border-gray-200/80 bg-white p-5 shadow-sm ring-1 ring-black/[0.02] dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
@ -64,8 +64,9 @@ export function SectionHeader({
description?: string
}) {
return (
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
<span className="absolute inset-y-0 left-0 w-1 rounded-full bg-indigo-500" />
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
{title}
</h3>
@ -402,7 +403,7 @@ export default function DeviceFormFields({
}
return (
<div className="grid min-h-full grid-cols-1 gap-4 sm:grid-cols-6">
<div className="grid min-h-full grid-cols-1 gap-5 sm:grid-cols-6">
{lookupError && (
<div className="sm:col-span-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{lookupError}
@ -589,7 +590,7 @@ export default function DeviceFormFields({
)}
{isMilestoneDevice && (
<section className="sm:col-span-6 rounded-lg border border-indigo-200 bg-indigo-50/50 p-4 dark:border-indigo-400/20 dark:bg-indigo-400/10">
<section className="sm:col-span-6 rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-50/80 to-violet-50/60 p-5 shadow-sm dark:border-indigo-400/20 dark:from-indigo-500/10 dark:to-violet-500/5">
<input type="hidden" name="isMilestoneDevice" value="true" />
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
@ -672,7 +673,7 @@ export default function DeviceFormFields({
Milestone-Status
</label>
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
<div className="mt-2 flex min-h-[42px] items-center gap-3 rounded-xl border border-gray-200 bg-white px-3.5 py-2 shadow-sm dark:border-white/10 dark:bg-white/5">
<VideoCameraIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
@ -838,7 +839,7 @@ export default function DeviceFormFields({
</div>
<div className="sm:col-span-6">
<div className="rounded-md border border-gray-200 px-3 py-3 dark:border-white/10">
<div className="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3.5 dark:border-white/10 dark:bg-white/[0.03]">
<Switch
id="isBaoDevice"
name="isBaoDevice"
@ -901,7 +902,7 @@ export default function DeviceFormFields({
</span>
</div>
<div className="mt-4 max-h-56 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
<div className="mt-4 max-h-64 overflow-y-auto rounded-xl border border-gray-200 bg-gray-50/60 p-3 dark:border-white/10 dark:bg-white/[0.03]">
{assignableDevices.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine anderen Geräte vorhanden.
@ -952,7 +953,7 @@ export default function DeviceFormFields({
/>
{!isMilestoneDevice ? (
<div className="mt-4 rounded-md border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
<div className="mt-4 rounded-xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm text-gray-500 dark:border-white/15 dark:bg-white/[0.03] dark:text-gray-400">
Dieses Gerät ist kein Milestone-Gerät.
</div>
) : (
@ -1003,4 +1004,4 @@ export default function DeviceFormFields({
</section>
</div>
)
}
}

View File

@ -34,10 +34,10 @@ export default function DeviceModalTabs({
onChange,
}: DeviceModalTabsProps) {
return (
<div className="border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<nav
aria-label="Geräteformular"
className="-mb-px flex gap-x-6 overflow-x-auto"
className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
@ -50,10 +50,10 @@ export default function DeviceModalTabs({
disabled={tab.disabled}
onClick={() => onChange(tab.id)}
className={classNames(
'inline-flex items-center gap-x-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium',
'inline-flex items-center gap-x-2 whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
isActive
? 'border-indigo-600 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',
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
: 'text-gray-500 hover:bg-white/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200',
tab.disabled && 'cursor-not-allowed opacity-50',
)}
>
@ -71,4 +71,4 @@ export default function DeviceModalTabs({
</nav>
</div>
)
}
}

View File

@ -6,7 +6,6 @@ import {
ArrowPathIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PlusIcon,
WrenchScrewdriverIcon,
XCircleIcon,
@ -110,20 +109,6 @@ function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.
return `in ${hours} Std. ${remainingMinutes} Min.`
}
function getLatestFirmwareCheckedAt(devices: Device[]) {
const timestamps = devices
.map((device) => device.firmwareUpdate?.checkedAt)
.filter(Boolean)
.map((value) => new Date(value as string).getTime())
.filter((value) => Number.isFinite(value))
if (timestamps.length === 0) {
return null
}
return new Date(Math.max(...timestamps)).toISOString()
}
type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
function getLoanStatusBadgeTone(status: string): BadgeTone {
@ -769,7 +754,7 @@ export default function DevicesPage() {
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
recordingStorageId:
String(formData.get('recordingStorageId') ?? '').trim() || null,
streams: cameraDetails.streams.map((stream, index) => ({
streams: cameraDetails.streams.map((_stream, index) => ({
index,
codec: String(formData.get(`streamCodec-${index}`) ?? ''),
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null,
@ -1028,9 +1013,6 @@ export default function DevicesPage() {
}
}
const latestFirmwareCheckedAt =
firmwareCheckState?.lastCheckedAt ?? getLatestFirmwareCheckedAt(devices)
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
const isFirmwareCheckLocked =
Boolean(firmwareCheckNextAllowedAt) &&
@ -1250,12 +1232,16 @@ export default function DevicesPage() {
]
return (
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
<div className="relative h-full min-h-0 overflow-y-auto bg-gradient-to-br from-slate-50 via-white to-indigo-50/50 px-4 py-8 sm:px-6 lg:px-8 dark:from-gray-950 dark:via-gray-950 dark:to-indigo-950/20 [scrollbar-gutter:stable]">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-36 right-0 size-96 rounded-full bg-indigo-200/25 blur-3xl dark:bg-indigo-500/10" />
</div>
<Tables
title="Geräte"
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
headerAction={
<div className="flex flex-col items-start gap-1.5">
<div className="flex flex-col items-start gap-2 sm:items-end">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
@ -1283,7 +1269,7 @@ export default function DevicesPage() {
</div>
{firmwareCheckNextAllowedLabel && (
<div className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
<ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span>{firmwareCheckNextAllowedLabel}</span>
</div>
@ -1291,6 +1277,7 @@ export default function DevicesPage() {
</div>
}
onAction={openCreateModal}
className="relative overflow-hidden rounded-3xl border border-white/80 bg-white/85 p-5 shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 backdrop-blur-xl sm:p-6 dark:border-white/10 dark:bg-gray-900/80 dark:shadow-black/20 dark:ring-white/5"
rows={devices}
columns={columns}
getRowId={(device) => device.id}
@ -1397,4 +1384,4 @@ export default function DevicesPage() {
</div>
)
}
}

View File

@ -7,7 +7,6 @@ import {
type ComponentType,
type FormEvent,
type MouseEvent,
type ReactNode,
type SVGProps,
} from 'react'
import { DialogTitle } from '@headlessui/react'
@ -25,14 +24,11 @@ import {
UserGroupIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import {
CircleMarker,
MapContainer,
TileLayer,
} from 'react-leaflet'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox'
import AddressCombobox, {
type AddressArea,
} from '../../components/AddressCombobox'
import Textarea from '../../components/Textarea'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox'
@ -43,6 +39,8 @@ import ServerFolderPicker, {
joinServerFolderPath,
} from '../../components/ServerFolderPicker'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
import type { OperationCoordinates } from '../../utils/operationGeometry'
import OperationGeometryEditor from './OperationGeometryEditor'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG'
@ -227,7 +225,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
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'
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
const readOnlyInputClassName =
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
@ -447,269 +445,6 @@ function ReviewTeamItem({
)
}
function ReviewSection({
icon: Icon,
title,
description,
children,
className,
}: {
icon: SetupStepIcon
title: string
description?: string
children: ReactNode
className?: string
}) {
return (
<section
className={classNames(
'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-white/[0.03]',
className,
)}
>
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="flex items-start gap-x-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<Icon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h4>
{description && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
</div>
<div className="p-4">
{children}
</div>
</section>
)
}
function ReviewField({
label,
value,
title,
className,
}: {
label: string
value: string
title?: string
className?: string
}) {
return (
<div
className={classNames(
'min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10',
className,
)}
>
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{label}
</dt>
<dd
title={title || value || undefined}
className="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
>
{getDisplayValue(value)}
</dd>
</div>
)
}
function ReviewLongField({
label,
value,
}: {
label: string
value: string
}) {
const displayValue = value.trim()
return (
<div className="min-w-0 rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
<dt className="text-[11px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{label}
</dt>
<dd className="mt-1">
{displayValue ? (
<p
title={displayValue}
className="line-clamp-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-white"
>
{displayValue}
</p>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500"></span>
)}
</dd>
</div>
)
}
type ReviewMapCoordinates = {
lat: number
lon: number
}
function toCoordinateNumber(value: unknown) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : NaN
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : NaN
}
return NaN
}
function ReviewAddressMap({ address }: { address: string }) {
const [coordinates, setCoordinates] = useState<ReviewMapCoordinates | null>(null)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const trimmedAddress = address.trim()
if (trimmedAddress.length < 3) {
setCoordinates(null)
return
}
const abortController = new AbortController()
async function loadCoordinates() {
setIsLoading(true)
try {
const response = await fetch(
`${API_URL}/address-search?q=${encodeURIComponent(trimmedAddress)}`,
{
credentials: 'include',
signal: abortController.signal,
},
)
if (!response.ok) {
setCoordinates(null)
return
}
const data = await response.json()
const firstSuggestion = data.suggestions?.[0]
if (!firstSuggestion) {
setCoordinates(null)
return
}
const lat = toCoordinateNumber(firstSuggestion.lat)
const lon = toCoordinateNumber(firstSuggestion.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setCoordinates(null)
return
}
setCoordinates({
lat,
lon,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setCoordinates(null)
} finally {
setIsLoading(false)
}
}
void loadCoordinates()
return () => {
abortController.abort()
}
}, [address])
if (!address.trim()) {
return null
}
if (isLoading) {
return (
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
Karte wird geladen...
</div>
)
}
if (!coordinates) {
return (
<div className="mt-2 flex h-28 items-center justify-center rounded-md border border-gray-200 bg-white text-xs text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
Keine Kartenposition gefunden.
</div>
)
}
return (
<div className="mt-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
<MapContainer
center={[coordinates.lat, coordinates.lon]}
zoom={16}
scrollWheelZoom={false}
dragging={false}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
className="h-28 w-full"
>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<CircleMarker
center={[coordinates.lat, coordinates.lon]}
radius={7}
pathOptions={{
fillOpacity: 0.9,
weight: 2,
}}
/>
</MapContainer>
</div>
)
}
function ReviewAddressItem({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="min-w-0">
<ReviewItem
label={label}
value={value}
/>
<ReviewAddressMap address={value} />
</div>
)
}
function StepHeader({
title,
description,
@ -718,8 +453,9 @@ function StepHeader({
description: string
}) {
return (
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
<div className="relative rounded-2xl border border-gray-200/80 bg-white p-5 pl-7 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<span className="absolute inset-y-5 left-4 w-1 rounded-full bg-indigo-500" />
<h3 className="text-base font-semibold text-gray-950 dark:text-white">
{title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
@ -739,6 +475,12 @@ export default function CreateOperationModal({
const [activeStep, setActiveStep] = useState<SetupStepId>('masterData')
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
const [localError, setLocalError] = useState<string | null>(null)
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
const [targetPosition, setTargetPosition] = useState<OperationCoordinates | null>(null)
const [kwArea, setKwArea] = useState<AddressArea | null>(null)
const [targetArea, setTargetArea] = useState<AddressArea | null>(null)
const [viewConeFov, setViewConeFov] = useState(36)
const [viewConeLength, setViewConeLength] = useState(0)
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
@ -781,6 +523,12 @@ export default function CreateOperationModal({
setFormValues(initialFormValues)
setSelectedOperationLeader(null)
setSelectedOperationTeam([])
setKwPosition(null)
setTargetPosition(null)
setKwArea(null)
setTargetArea(null)
setViewConeFov(36)
setViewConeLength(0)
setLocalError(null)
setMilestoneStorages([])
setSelectedMilestoneStorage(null)
@ -1074,7 +822,7 @@ export default function CreateOperationModal({
open={open}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl rounded-3xl bg-white ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
>
<form
onSubmit={handleSubmit}
@ -1086,6 +834,12 @@ export default function CreateOperationModal({
<input type="hidden" name="caseWorker" value={formValues.caseWorker} />
<input type="hidden" name="targetObject" value={formValues.targetObject} />
<input type="hidden" name="kwAddress" value={formValues.kwAddress} />
<input type="hidden" name="kwLatitude" value={kwPosition?.lat ?? ''} />
<input type="hidden" name="kwLongitude" value={kwPosition?.lon ?? ''} />
<input type="hidden" name="targetLatitude" value={targetPosition?.lat ?? ''} />
<input type="hidden" name="targetLongitude" value={targetPosition?.lon ?? ''} />
<input type="hidden" name="viewConeFov" value={viewConeFov} />
<input type="hidden" name="viewConeLength" value={viewConeLength} />
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
<input type="hidden" name="legend" value={formValues.legend} />
@ -1098,15 +852,21 @@ export default function CreateOperationModal({
<input type="hidden" name="remark" value={formValues.remark} />
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} />
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Einsatz anlegen
</DialogTitle>
<div className="relative flex items-start justify-between overflow-hidden border-b border-indigo-100 bg-gradient-to-r from-indigo-50 via-white to-violet-50 px-4 py-5 sm:px-6 dark:border-white/10 dark:from-indigo-950/50 dark:via-gray-900 dark:to-violet-950/30">
<div className="flex min-w-0 items-start gap-3.5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/20 dark:bg-indigo-500">
<ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<div className="min-w-0">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
Einsatz anlegen
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Folge den Schritten oder überspringe optionale Bereiche.
</p>
</p>
</div>
</div>
<Button
@ -1122,9 +882,10 @@ export default function CreateOperationModal({
</Button>
</div>
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<nav aria-label="Einrichtungsschritte" className="grid grid-cols-2 gap-2 md:grid-cols-7">
{setupSteps.map((step, index) => {
<div className="border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<nav aria-label="Einrichtungsschritte" className="overflow-x-auto pb-1">
<div className="grid min-w-[52rem] grid-cols-7 gap-2">
{setupSteps.map((step, index) => {
const isActive = step.id === activeStep
const isDone = index < activeStepIndex
const Icon = step.icon
@ -1135,12 +896,12 @@ export default function CreateOperationModal({
type="button"
onClick={() => goToStep(step.id)}
className={classNames(
'rounded-lg border px-3 py-2 text-left transition',
'rounded-xl border px-3 py-2.5 text-left transition',
isActive
? 'border-indigo-600 bg-indigo-50 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300'
? 'border-indigo-500 bg-indigo-50 text-indigo-700 shadow-sm ring-1 ring-indigo-500/10 dark:border-indigo-400/50 dark:bg-indigo-500/15 dark:text-indigo-200'
: isDone
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-500/20 dark:bg-green-500/10 dark:text-green-300'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300'
: 'border-transparent bg-gray-100/70 text-gray-600 hover:border-gray-200 hover:bg-white dark:bg-white/5 dark:text-gray-400 dark:hover:border-white/10 dark:hover:bg-white/10',
)}
>
<span className="flex items-center gap-x-1.5 text-[11px] font-medium">
@ -1153,17 +914,18 @@ export default function CreateOperationModal({
</span>
</button>
)
})}
})}
</div>
</nav>
</div>
{combinedError && (
<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">
<div className="mx-4 mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-300">
{combinedError}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
{activeStep === 'masterData' && (
<div className="space-y-6">
<StepHeader
@ -1231,14 +993,18 @@ export default function CreateOperationModal({
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
/>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-2">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AddressCombobox
id="createKwAddress"
name="createKwAddressDisplay"
label="KW"
value={formValues.kwAddress}
onChange={(value) => updateField('kwAddress', value)}
position={kwPosition}
onPositionChange={setKwPosition}
onAreaChange={setKwArea}
placeholder="KW-Adresse suchen oder eingeben"
showMap={false}
/>
<AddressCombobox
@ -1247,9 +1013,27 @@ export default function CreateOperationModal({
label="Zielobjekt"
value={formValues.targetObject}
onChange={(value) => updateField('targetObject', value)}
position={targetPosition}
onPositionChange={setTargetPosition}
onAreaChange={setTargetArea}
placeholder="Adresse suchen oder Zielobjekt eingeben"
showMap={false}
/>
</div>
<OperationGeometryEditor
kwPosition={kwPosition}
targetPosition={targetPosition}
kwArea={kwArea}
targetArea={targetArea}
fov={viewConeFov}
length={viewConeLength}
visible={activeStep === 'addresses'}
onKwPositionChange={setKwPosition}
onTargetPositionChange={setTargetPosition}
onFovChange={setViewConeFov}
onLengthChange={setViewConeLength}
/>
</div>
)}
@ -1775,16 +1559,28 @@ export default function CreateOperationModal({
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ReviewAddressItem
label="KW"
value={formValues.kwAddress}
/>
<ReviewAddressItem
<ReviewItem label="KW" value={formValues.kwAddress} />
<ReviewItem
label="Zielobjekt"
value={formValues.targetObject}
/>
</div>
<div className="mt-4">
<OperationGeometryEditor
kwPosition={kwPosition}
targetPosition={targetPosition}
kwArea={kwArea}
targetArea={targetArea}
fov={viewConeFov}
length={viewConeLength}
visible={activeStep === 'review'}
onKwPositionChange={setKwPosition}
onTargetPositionChange={setTargetPosition}
onFovChange={setViewConeFov}
onLengthChange={setViewConeLength}
/>
</div>
</div>
<div>
@ -1855,9 +1651,18 @@ export default function CreateOperationModal({
)}
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10">
<div className="text-sm text-gray-500 dark:text-gray-400">
Schritt {activeStepIndex + 1} von {setupSteps.length}
<div className="flex flex-col gap-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<div className="min-w-36">
<div className="flex items-center justify-between gap-3 text-xs font-medium text-gray-500 dark:text-gray-400">
<span>Schritt {activeStepIndex + 1} von {setupSteps.length}</span>
<span>{Math.round(((activeStepIndex + 1) / setupSteps.length) * 100)}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all"
style={{ width: `${((activeStepIndex + 1) / setupSteps.length) * 100}%` }}
/>
</div>
</div>
<div className="flex justify-end gap-x-3">
@ -1922,4 +1727,4 @@ export default function CreateOperationModal({
</form>
</Modal>
)
}
}

View File

@ -0,0 +1,858 @@
import { useEffect, useMemo, useRef } from 'react'
import {
Circle,
GeoJSON,
MapContainer,
Marker,
Polygon,
Polyline,
TileLayer,
Tooltip,
useMap,
} from 'react-leaflet'
import type {
Feature,
FeatureCollection,
GeoJsonObject,
Geometry,
GeometryCollection,
MultiPolygon,
Polygon as GeoJsonPolygon,
} from 'geojson'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { AddressArea } from '../../components/AddressCombobox'
import {
createOperationViewCone,
getOperationBearingDegrees,
getOperationDestinationPoint,
getOperationDistanceMeters,
getOperationSignedAngleDifferenceDegrees,
isValidOperationCoordinates,
type OperationCoordinates,
} from '../../utils/operationGeometry'
type MarkerType = 'kw' | 'target'
type OperationGeometryEditorProps = {
kwPosition: OperationCoordinates | null
targetPosition: OperationCoordinates | null
kwArea: AddressArea | null
targetArea: AddressArea | null
fov: number
length: number
visible?: boolean
onKwPositionChange: (position: OperationCoordinates) => void
onTargetPositionChange: (position: OperationCoordinates) => void
onFovChange: (value: number) => void
onLengthChange: (value: number) => void
}
type PolygonCoordinates = GeoJsonPolygon['coordinates']
const defaultCenter: OperationCoordinates = {
lat: 51.1657,
lon: 10.4515,
}
const addressFallbackRadiusMeters = 60
function createMarkerIcon(color: string, label: string) {
return L.divIcon({
className: '',
iconSize: [36, 42],
iconAnchor: [18, 42],
tooltipAnchor: [0, -38],
html: `
<span style="
display:flex;
align-items:center;
justify-content:center;
width:36px;
height:36px;
border-radius:9999px 9999px 9999px 0;
transform:rotate(-45deg);
background:${color};
border:3px solid white;
box-shadow:0 8px 18px rgb(15 23 42 / .28);
">
<strong style="
transform:rotate(45deg);
color:white;
font:700 11px/1 system-ui,sans-serif;
">${label}</strong>
</span>
`,
})
}
function createControlHandleIcon(color: string, size: number) {
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `
<span style="
display:flex;
align-items:center;
justify-content:center;
width:${size}px;
height:${size}px;
border-radius:9999px;
background:white;
border:3px solid ${color};
box-shadow:0 4px 12px rgb(15 23 42 / .3);
cursor:grab;
">
<span style="
width:6px;
height:6px;
border-radius:9999px;
background:${color};
"></span>
</span>
`,
})
}
const kwIcon = createMarkerIcon('#4f46e5', 'KW')
const targetIcon = createMarkerIcon('#e11d48', 'ZO')
const fovHandleIcon = createControlHandleIcon('#7c3aed', 20)
const lengthHandleIcon = createControlHandleIcon('#4f46e5', 24)
function collectPolygonCoordinates(
value: GeoJsonObject | Geometry | null | undefined,
result: PolygonCoordinates[],
) {
if (!value) {
return
}
if (value.type === 'Polygon') {
result.push((value as GeoJsonPolygon).coordinates)
return
}
if (value.type === 'MultiPolygon') {
result.push(...(value as MultiPolygon).coordinates)
return
}
if (value.type === 'Feature') {
collectPolygonCoordinates((value as Feature).geometry, result)
return
}
if (value.type === 'FeatureCollection') {
for (const feature of (value as FeatureCollection).features) {
collectPolygonCoordinates(feature.geometry, result)
}
return
}
if (value.type === 'GeometryCollection') {
for (const geometry of (value as GeometryCollection).geometries) {
collectPolygonCoordinates(geometry, result)
}
}
}
function getPolygonCoordinates(value: GeoJsonObject | null) {
const result: PolygonCoordinates[] = []
collectPolygonCoordinates(value, result)
return result
}
function isPointOnSegment(
pointX: number,
pointY: number,
startX: number,
startY: number,
endX: number,
endY: number,
) {
const crossProduct =
(pointY - startY) * (endX - startX) -
(pointX - startX) * (endY - startY)
if (Math.abs(crossProduct) > 1e-10) {
return false
}
return (
pointX >= Math.min(startX, endX) - 1e-10 &&
pointX <= Math.max(startX, endX) + 1e-10 &&
pointY >= Math.min(startY, endY) - 1e-10 &&
pointY <= Math.max(startY, endY) + 1e-10
)
}
function isPointInRing(
position: OperationCoordinates,
ring: PolygonCoordinates[number],
) {
let inside = false
const pointX = position.lon
const pointY = position.lat
for (
let currentIndex = 0, previousIndex = ring.length - 1;
currentIndex < ring.length;
previousIndex = currentIndex, currentIndex += 1
) {
const current = ring[currentIndex]
const previous = ring[previousIndex]
const currentX = current[0]
const currentY = current[1]
const previousX = previous[0]
const previousY = previous[1]
if (
isPointOnSegment(
pointX,
pointY,
previousX,
previousY,
currentX,
currentY,
)
) {
return true
}
const intersects =
currentY > pointY !== previousY > pointY &&
pointX <
((previousX - currentX) * (pointY - currentY)) /
(previousY - currentY) +
currentX
if (intersects) {
inside = !inside
}
}
return inside
}
function isPointInPolygon(
position: OperationCoordinates,
polygon: PolygonCoordinates,
) {
if (polygon.length === 0 || !isPointInRing(position, polygon[0])) {
return false
}
return !polygon
.slice(1)
.some((hole) => isPointInRing(position, hole))
}
function getAreaPolygons(area: AddressArea | null) {
if (!area?.geojson) {
return []
}
return getPolygonCoordinates(area.geojson)
}
function getPolygonInteriorPosition(polygon: PolygonCoordinates) {
const outerRing = polygon[0]
if (!outerRing || outerRing.length === 0) {
return null
}
const uniqueCoordinates =
outerRing.length > 1 &&
outerRing[0][0] === outerRing[outerRing.length - 1][0] &&
outerRing[0][1] === outerRing[outerRing.length - 1][1]
? outerRing.slice(0, -1)
: outerRing
const average = uniqueCoordinates.reduce(
(result, coordinate) => ({
lat: result.lat + coordinate[1],
lon: result.lon + coordinate[0],
}),
{ lat: 0, lon: 0 },
)
const candidate = {
lat: average.lat / uniqueCoordinates.length,
lon: average.lon / uniqueCoordinates.length,
}
if (isPointInPolygon(candidate, polygon)) {
return candidate
}
const boundaryPosition = {
lat: outerRing[0][1],
lon: outerRing[0][0],
}
for (let step = 1; step <= 20; step += 1) {
const factor = step / 20
const position = {
lat:
boundaryPosition.lat +
(candidate.lat - boundaryPosition.lat) * factor,
lon:
boundaryPosition.lon +
(candidate.lon - boundaryPosition.lon) * factor,
}
if (isPointInPolygon(position, polygon)) {
return position
}
}
return boundaryPosition
}
function getInitialPositionInsideAddressArea(
position: OperationCoordinates,
area: AddressArea | null,
) {
if (!area?.hasHouseNumber) {
return position
}
const polygons = getAreaPolygons(area)
if (
polygons.length === 0 ||
polygons.some((polygon) => isPointInPolygon(position, polygon))
) {
return position
}
return polygons
.map(getPolygonInteriorPosition)
.filter(isValidOperationCoordinates)
.sort(
(first, second) =>
getOperationDistanceMeters(position, first) -
getOperationDistanceMeters(position, second),
)[0] ?? position
}
function isPositionInsideAddressArea(
position: OperationCoordinates,
area: AddressArea | null,
) {
if (!area) {
return false
}
if (!area.hasHouseNumber) {
return (
getOperationDistanceMeters(area.center, position) <=
addressFallbackRadiusMeters
)
}
const polygons = getAreaPolygons(area)
if (polygons.length > 0) {
return polygons.some((polygon) => isPointInPolygon(position, polygon))
}
return false
}
function MapViewport({
kwPosition,
targetPosition,
visible,
}: {
kwPosition: OperationCoordinates | null
targetPosition: OperationCoordinates | null
visible: boolean
}) {
const map = useMap()
const previousPositionCountRef = useRef(0)
useEffect(() => {
if (!visible) {
return
}
const frame = window.requestAnimationFrame(() => {
map.invalidateSize({ pan: false })
const positions = [kwPosition, targetPosition].filter(
isValidOperationCoordinates,
)
if (positions.length > previousPositionCountRef.current) {
if (positions.length === 2) {
map.fitBounds(
positions.map((position) => [position.lat, position.lon]),
{ padding: [64, 64], maxZoom: 19 },
)
} else if (positions.length === 1) {
map.setView([positions[0].lat, positions[0].lon], 18)
}
}
previousPositionCountRef.current = positions.length
})
return () => window.cancelAnimationFrame(frame)
}, [kwPosition, map, targetPosition, visible])
return null
}
function AddressAreaOverlay({
area,
color,
}: {
area: AddressArea | null
color: string
}) {
if (!area) {
return null
}
if (
area.hasHouseNumber &&
getAreaPolygons(area).length > 0 &&
area.geojson
) {
return (
<GeoJSON
key={`${color}-${JSON.stringify(area.geojson)}`}
data={area.geojson}
interactive={false}
style={{
color,
fillColor: color,
fillOpacity: 0.18,
opacity: 0.9,
weight: 3,
}}
/>
)
}
if (!area.hasHouseNumber) {
return (
<Circle
center={[area.center.lat, area.center.lon]}
radius={addressFallbackRadiusMeters}
interactive={false}
pathOptions={{
color,
fillColor: color,
fillOpacity: 0.06,
opacity: 0.6,
weight: 1.5,
dashArray: '5 5',
}}
/>
)
}
return null
}
function DraggableOperationMarker({
type,
position,
area,
onChange,
}: {
type: MarkerType
position: OperationCoordinates
area: AddressArea | null
onChange: (type: MarkerType, position: OperationCoordinates) => void
}) {
const markerRef = useRef<L.Marker | null>(null)
const lastValidPositionRef = useRef(position)
useEffect(() => {
const constrainedPosition = getInitialPositionInsideAddressArea(
position,
area,
)
lastValidPositionRef.current = constrainedPosition
markerRef.current?.setLatLng([
constrainedPosition.lat,
constrainedPosition.lon,
])
if (
constrainedPosition.lat !== position.lat ||
constrainedPosition.lon !== position.lon
) {
onChange(type, constrainedPosition)
}
}, [area, onChange, position, type])
const eventHandlers = useMemo(() => {
function handleDrag() {
const marker = markerRef.current
const latLng = marker?.getLatLng()
if (!marker || !latLng) {
return
}
const nextPosition = {
lat: latLng.lat,
lon: latLng.lng,
}
if (!isPositionInsideAddressArea(nextPosition, area)) {
const previousPosition = lastValidPositionRef.current
marker.setLatLng([previousPosition.lat, previousPosition.lon])
return
}
lastValidPositionRef.current = nextPosition
onChange(type, nextPosition)
}
return {
drag: handleDrag,
dragend: handleDrag,
}
}, [area, onChange, type])
const canDrag =
area !== null &&
(!area.hasHouseNumber || getAreaPolygons(area).length > 0)
return (
<Marker
ref={markerRef}
draggable={canDrag}
autoPan
position={[position.lat, position.lon]}
icon={type === 'kw' ? kwIcon : targetIcon}
eventHandlers={eventHandlers}
>
<Tooltip permanent direction="top">
{type === 'kw' ? 'KW' : 'Zielobjekt'}
</Tooltip>
</Marker>
)
}
function ConeControlMarker({
position,
icon,
label,
onDrag,
}: {
position: [number, number]
icon: L.DivIcon
label: string
onDrag: (position: OperationCoordinates, marker: L.Marker) => void
}) {
const markerRef = useRef<L.Marker | null>(null)
const eventHandlers = useMemo(() => {
function handleDrag() {
const marker = markerRef.current
const latLng = marker?.getLatLng()
if (!marker || !latLng) {
return
}
onDrag(
{
lat: latLng.lat,
lon: latLng.lng,
},
marker,
)
}
return {
drag: handleDrag,
dragend: handleDrag,
}
}, [onDrag])
return (
<Marker
ref={markerRef}
draggable
autoPan
position={position}
icon={icon}
eventHandlers={eventHandlers}
zIndexOffset={500}
>
<Tooltip direction="top">{label}</Tooltip>
</Marker>
)
}
function formatDistance(value: number) {
if (value >= 1000) {
return `${(value / 1000).toLocaleString('de-DE', {
maximumFractionDigits: 2,
})} km`
}
return `${Math.round(value)} m`
}
export default function OperationGeometryEditor({
kwPosition,
targetPosition,
kwArea,
targetArea,
fov,
length,
visible = true,
onKwPositionChange,
onTargetPositionChange,
onFovChange,
onLengthChange,
}: OperationGeometryEditorProps) {
const validKwPosition = isValidOperationCoordinates(kwPosition)
? kwPosition
: null
const validTargetPosition = isValidOperationCoordinates(targetPosition)
? targetPosition
: null
const geometry =
validKwPosition && validTargetPosition
? createOperationViewCone({
from: validKwPosition,
to: validTargetPosition,
fov,
length,
})
: null
const center = validKwPosition ?? validTargetPosition ?? defaultCenter
useEffect(() => {
if (
geometry &&
length > 0 &&
length > geometry.targetDistance
) {
onLengthChange(geometry.targetDistance)
}
}, [geometry, length, onLengthChange])
function handlePositionChange(
type: MarkerType,
position: OperationCoordinates,
) {
onLengthChange(0)
if (type === 'kw') {
onKwPositionChange(position)
return
}
onTargetPositionChange(position)
}
function handleFovDrag(
position: OperationCoordinates,
marker: L.Marker,
side: -1 | 1,
) {
if (!geometry || !validKwPosition) {
return
}
const handleBearing = getOperationBearingDegrees(
validKwPosition,
position,
)
const halfAngle = Math.min(
90,
Math.max(
0.5,
Math.abs(
getOperationSignedAngleDifferenceDegrees(
handleBearing,
geometry.bearing,
),
),
),
)
const projectedPosition = getOperationDestinationPoint(
validKwPosition,
geometry.coneLength,
geometry.bearing + side * halfAngle,
)
marker.setLatLng(projectedPosition)
onFovChange(halfAngle * 2)
}
function handleLengthDrag(
position: OperationCoordinates,
marker: L.Marker,
) {
if (!geometry || !validKwPosition) {
return
}
const nextLength = Math.min(
geometry.targetDistance,
Math.max(1, getOperationDistanceMeters(validKwPosition, position)),
)
const projectedPosition = getOperationDestinationPoint(
validKwPosition,
nextLength,
geometry.bearing,
)
marker.setLatLng(projectedPosition)
onLengthChange(nextLength)
}
const leftCorner = geometry?.positions[1]
const rightCorner = geometry?.positions[geometry.positions.length - 2]
const centerEndpoint = geometry?.centerLine[1]
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<h4 className="text-sm font-semibold text-gray-950 dark:text-white">
Position und Sichtwinkel
</h4>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
KW und ZO innerhalb ihrer markierten Adressfläche ziehen. Die
Eckgriffe ändern das FOV, der mittlere Griff die Länge.
</p>
</div>
<div className="relative z-0 h-96">
<MapContainer
center={[center.lat, center.lon]}
zoom={validKwPosition || validTargetPosition ? 18 : 6}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution="© OpenStreetMap"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapViewport
kwPosition={validKwPosition}
targetPosition={validTargetPosition}
visible={visible}
/>
<AddressAreaOverlay area={kwArea} color="#4f46e5" />
<AddressAreaOverlay area={targetArea} color="#e11d48" />
{geometry && (
<>
<Polygon
positions={geometry.positions}
interactive={false}
pathOptions={{
color: '#4f46e5',
fillColor: '#6366f1',
fillOpacity: 0.18,
opacity: 0.9,
weight: 1.5,
}}
/>
<Polyline
positions={geometry.centerLine}
interactive={false}
pathOptions={{
color: '#4f46e5',
opacity: 0.95,
weight: 2.5,
dashArray: '7 6',
}}
/>
<Polyline
positions={geometry.targetLine}
interactive={false}
pathOptions={{
color: '#e11d48',
opacity: 0.7,
weight: 1.5,
dashArray: '3 7',
}}
/>
</>
)}
{validKwPosition && (
<DraggableOperationMarker
type="kw"
position={validKwPosition}
area={kwArea}
onChange={handlePositionChange}
/>
)}
{validTargetPosition && (
<DraggableOperationMarker
type="target"
position={validTargetPosition}
area={targetArea}
onChange={handlePositionChange}
/>
)}
{geometry && leftCorner && (
<ConeControlMarker
position={leftCorner}
icon={fovHandleIcon}
label="Linke Kegelkante ziehen"
onDrag={(position, marker) =>
handleFovDrag(position, marker, -1)
}
/>
)}
{geometry && rightCorner && (
<ConeControlMarker
position={rightCorner}
icon={fovHandleIcon}
label="Rechte Kegelkante ziehen"
onDrag={(position, marker) =>
handleFovDrag(position, marker, 1)
}
/>
)}
{geometry && centerEndpoint && (
<ConeControlMarker
position={centerEndpoint}
icon={lengthHandleIcon}
label="Kegellänge ziehen"
onDrag={handleLengthDrag}
/>
)}
</MapContainer>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-gray-200 px-4 py-3 text-xs text-gray-500 dark:border-white/10 dark:text-gray-400">
<span>
FOV:{' '}
<strong className="font-semibold text-gray-800 dark:text-gray-200">
{Math.round(fov)}°
</strong>
</span>
<span>
Kegellänge:{' '}
<strong className="font-semibold text-gray-800 dark:text-gray-200">
{geometry ? formatDistance(geometry.coneLength) : ''}
</strong>
</span>
<span>
KWZO:{' '}
<strong className="font-semibold text-gray-800 dark:text-gray-200">
{geometry ? formatDistance(geometry.targetDistance) : ''}
</strong>
</span>
</div>
</div>
)
}

View File

@ -8,6 +8,11 @@ import AppMap, { type MapHeatPoint, type MapMarker } from '../../components/Map'
import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner'
import type { Operation } from '../../components/types'
import {
createOperationViewCone,
isValidOperationCoordinates,
type OperationCoordinates,
} from '../../utils/operationGeometry'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -21,8 +26,6 @@ type AddressSuggestion = {
type OperationMarkerType = 'kwAddress' | 'targetObject'
type OperationMapMode = 'markers' | 'heatmap'
type OperationMarker = {
id: string
operation: Operation
@ -40,6 +43,7 @@ type OperationViewCone = {
color: string
positions: Array<[number, number]>
centerLine: [[number, number], [number, number]]
targetLine: [[number, number], [number, number]]
}
type OperationMapProps = {
@ -218,22 +222,40 @@ function getOperationAddresses(operation: Operation) {
const addresses: Array<{
type: OperationMarkerType
address: string
position: OperationCoordinates | null
}> = []
const kwAddress = operation.kwAddress?.trim()
const targetObject = operation.targetObject?.trim()
if (kwAddress) {
const kwPosition =
operation.kwLatitude != null && operation.kwLongitude != null
? {
lat: operation.kwLatitude,
lon: operation.kwLongitude,
}
: null
const targetPosition =
operation.targetLatitude != null && operation.targetLongitude != null
? {
lat: operation.targetLatitude,
lon: operation.targetLongitude,
}
: null
if (kwAddress || isValidOperationCoordinates(kwPosition)) {
addresses.push({
type: 'kwAddress',
address: kwAddress,
address: kwAddress || 'KW',
position: kwPosition,
})
}
if (targetObject) {
if (targetObject || isValidOperationCoordinates(targetPosition)) {
addresses.push({
type: 'targetObject',
address: targetObject,
address: targetObject || 'Zielobjekt',
position: targetPosition,
})
}
@ -315,110 +337,6 @@ function renderOperationPopup(
)
}
function toRadians(value: number) {
return (value * Math.PI) / 180
}
function toDegrees(value: number) {
return (value * 180) / Math.PI
}
function getDistanceMeters(
start: { lat: number; lon: number },
end: { lat: number; lon: number },
) {
const earthRadius = 6371000
const startLat = toRadians(start.lat)
const endLat = toRadians(end.lat)
const deltaLat = toRadians(end.lat - start.lat)
const deltaLon = toRadians(end.lon - start.lon)
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(startLat) *
Math.cos(endLat) *
Math.sin(deltaLon / 2) *
Math.sin(deltaLon / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return earthRadius * c
}
function getBearingDegrees(
start: { lat: number; lon: number },
end: { lat: number; lon: number },
) {
const startLat = toRadians(start.lat)
const endLat = toRadians(end.lat)
const deltaLon = toRadians(end.lon - start.lon)
const y = Math.sin(deltaLon) * Math.cos(endLat)
const x =
Math.cos(startLat) * Math.sin(endLat) -
Math.sin(startLat) * Math.cos(endLat) * Math.cos(deltaLon)
return (toDegrees(Math.atan2(y, x)) + 360) % 360
}
function getDestinationPoint(
start: { lat: number; lon: number },
distanceMeters: number,
bearingDegrees: number,
): [number, number] {
const earthRadius = 6371000
const bearing = toRadians(bearingDegrees)
const startLat = toRadians(start.lat)
const startLon = toRadians(start.lon)
const angularDistance = distanceMeters / earthRadius
const lat = Math.asin(
Math.sin(startLat) * Math.cos(angularDistance) +
Math.cos(startLat) * Math.sin(angularDistance) * Math.cos(bearing),
)
const lon =
startLon +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(startLat),
Math.cos(angularDistance) - Math.sin(startLat) * Math.sin(lat),
)
return [toDegrees(lat), ((toDegrees(lon) + 540) % 360) - 180]
}
function createViewConePositions({
from,
to,
halfAngle = 18,
steps = 16,
}: {
from: { lat: number; lon: number }
to: { lat: number; lon: number }
halfAngle?: number
steps?: number
}) {
const distance = getDistanceMeters(from, to)
if (distance < 2) {
return []
}
const bearing = getBearingDegrees(from, to)
const positions: Array<[number, number]> = [[from.lat, from.lon]]
for (let step = 0; step <= steps; step += 1) {
const angle = bearing - halfAngle + (halfAngle * 2 * step) / steps
positions.push(getDestinationPoint(from, distance, angle))
}
positions.push([from.lat, from.lon])
return positions
}
function getOperationViewCones(markers: OperationMarker[]) {
const markersByOperation = new globalThis.Map<string, OperationMarker[]>()
@ -438,12 +356,14 @@ function getOperationViewCones(markers: OperationMarker[]) {
return
}
const positions = createViewConePositions({
const geometry = createOperationViewCone({
from: kwMarker,
to: targetMarker,
fov: kwMarker.operation.viewConeFov || 36,
length: kwMarker.operation.viewConeLength || 0,
})
if (positions.length === 0) {
if (!geometry) {
return
}
@ -451,11 +371,9 @@ function getOperationViewCones(markers: OperationMarker[]) {
id: `${operationId}-view-cone`,
operation: kwMarker.operation,
color: kwMarker.color,
positions,
centerLine: [
[kwMarker.lat, kwMarker.lon],
[targetMarker.lat, targetMarker.lon],
],
positions: geometry.positions,
centerLine: geometry.centerLine,
targetLine: geometry.targetLine,
})
})
@ -503,7 +421,12 @@ export default function OperationMap({
new Set(
operationsWithAddresses
.flatMap((operation) =>
getOperationAddresses(operation).map((item) => item.address),
getOperationAddresses(operation)
.filter(
(item) =>
!isValidOperationCoordinates(item.position),
)
.map((item) => item.address),
)
.filter(Boolean),
),
@ -537,7 +460,15 @@ export default function OperationMap({
const nextMarkers = operationsWithAddresses
.flatMap((operation) =>
getOperationAddresses(operation).map((addressItem) => {
const suggestion = geocodedByAddress.get(addressItem.address)
const suggestion = isValidOperationCoordinates(
addressItem.position,
)
? {
lat: addressItem.position.lat,
lon: addressItem.position.lon,
label: addressItem.address,
}
: geocodedByAddress.get(addressItem.address)
if (!suggestion) {
return null
@ -713,8 +644,21 @@ export default function OperationMap({
}}
/>
))}
{visibleViewCones.map((cone) => (
<Polyline
key={`${cone.id}-target-line`}
positions={cone.targetLine}
pathOptions={{
color: cone.color,
opacity: 0.55,
weight: 1.5,
dashArray: '3 8',
}}
/>
))}
</AppMap>
</div>
</div>
)
}
}

View File

@ -28,13 +28,17 @@ import Modal from '../../components/Modal'
import Journal, { type JournalItem } from '../../components/Journal'
import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner'
import AddressCombobox from '../../components/AddressCombobox'
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types'
import AddressCombobox, {
type AddressArea,
} from '../../components/AddressCombobox'
import type { Operation, OperationHistoryEntry } from '../../components/types'
import { formatOperationNumberInput } from '../../components/formatter'
import NotificationDot from '../../components/NotificationDot'
import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox'
import Tabs from '../../components/Tabs'
import type { OperationCoordinates } from '../../utils/operationGeometry'
import OperationGeometryEditor from './OperationGeometryEditor'
type OperationModalProps = {
open: boolean
@ -65,10 +69,10 @@ type OperationTabId =
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
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'
'block w-full rounded-xl border-0 bg-gray-50 px-3.5 py-2 text-base text-gray-900 shadow-sm ring-1 ring-gray-200 ring-inset placeholder:text-gray-400 transition focus:bg-white focus:ring-2 focus:ring-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:placeholder:text-gray-500 dark:focus:bg-white/10 dark:focus:ring-indigo-400'
const sectionClassName =
'rounded-lg border border-gray-200 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-white/5'
'rounded-2xl border border-gray-200/80 bg-white p-5 shadow-sm ring-1 ring-black/[0.02] dark:border-white/10 dark:bg-gray-900/70 dark:ring-white/5'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG'
@ -354,8 +358,9 @@ function SectionHeader({
description?: string
}) {
return (
<div className="border-b border-gray-200 pb-3 dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
<span className="absolute inset-y-0 left-0 w-1 rounded-full bg-indigo-500" />
<h3 className="text-sm font-semibold text-gray-950 dark:text-white">
{title}
</h3>
@ -415,6 +420,12 @@ export default function OperationModal({
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
const [targetObject, setTargetObject] = useState('')
const [kwAddress, setKwAddress] = useState('')
const [kwPosition, setKwPosition] = useState<OperationCoordinates | null>(null)
const [targetPosition, setTargetPosition] = useState<OperationCoordinates | null>(null)
const [kwArea, setKwArea] = useState<AddressArea | null>(null)
const [targetArea, setTargetArea] = useState<AddressArea | null>(null)
const [viewConeFov, setViewConeFov] = useState(36)
const [viewConeLength, setViewConeLength] = useState(0)
const [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
@ -458,8 +469,30 @@ export default function OperationModal({
}
setActiveTab('masterData')
setKwArea(null)
setTargetArea(null)
setTargetObject(editingOperation?.targetObject ?? '')
setKwAddress(editingOperation?.kwAddress ?? '')
setKwPosition(
editingOperation?.kwLatitude != null &&
editingOperation.kwLongitude != null
? {
lat: editingOperation.kwLatitude,
lon: editingOperation.kwLongitude,
}
: null,
)
setTargetPosition(
editingOperation?.targetLatitude != null &&
editingOperation.targetLongitude != null
? {
lat: editingOperation.targetLatitude,
lon: editingOperation.targetLongitude,
}
: null,
)
setViewConeFov(editingOperation?.viewConeFov || 36)
setViewConeLength(editingOperation?.viewConeLength || 0)
setMilestoneStorages([])
setSelectedMilestoneStorage(null)
setShowCreateStorageForm(false)
@ -471,6 +504,12 @@ export default function OperationModal({
editingOperation?.id,
editingOperation?.targetObject,
editingOperation?.kwAddress,
editingOperation?.kwLatitude,
editingOperation?.kwLongitude,
editingOperation?.targetLatitude,
editingOperation?.targetLongitude,
editingOperation?.viewConeFov,
editingOperation?.viewConeLength,
])
useEffect(() => {
@ -856,7 +895,7 @@ export default function OperationModal({
open={open}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl rounded-3xl bg-white ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10"
>
<form
key={editingOperation?.id ?? 'create'}
@ -898,18 +937,30 @@ export default function OperationModal({
name="milestoneStorage"
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''}
/>
<input type="hidden" name="kwLatitude" value={kwPosition?.lat ?? ''} />
<input type="hidden" name="kwLongitude" value={kwPosition?.lon ?? ''} />
<input type="hidden" name="targetLatitude" value={targetPosition?.lat ?? ''} />
<input type="hidden" name="targetLongitude" value={targetPosition?.lon ?? ''} />
<input type="hidden" name="viewConeFov" value={viewConeFov} />
<input type="hidden" name="viewConeLength" value={viewConeLength} />
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
<div className="relative flex items-start justify-between overflow-hidden border-b border-indigo-100 bg-gradient-to-r from-indigo-50 via-white to-violet-50 px-4 py-5 sm:px-6 dark:border-white/10 dark:from-indigo-950/50 dark:via-gray-900 dark:to-violet-950/30">
<div className="flex min-w-0 items-start gap-3.5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/20 dark:bg-indigo-500">
<ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
</div>
<div className="min-w-0">
<DialogTitle className="text-base font-semibold text-gray-950 dark:text-white">
{isEditing ? 'Einsatz bearbeiten' : 'Einsatz hinzufügen'}
</DialogTitle>
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{isEditing
? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.'
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
</p>
</p>
</div>
</div>
<Button
@ -924,8 +975,8 @@ export default function OperationModal({
</Button>
</div>
<div className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 dark:border-white/10 dark:bg-gray-900">
<div className="-mb-px flex gap-x-6 overflow-x-auto">
<div className="shrink-0 border-b border-gray-200 bg-white/90 px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<div className="flex gap-1.5 overflow-x-auto rounded-2xl bg-gray-100/80 p-1.5 dark:bg-white/5">
{visibleTabs.map((tab) => {
const Icon = tab.icon
@ -941,10 +992,10 @@ export default function OperationModal({
}
}}
className={classNames(
'whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium',
'whitespace-nowrap rounded-xl px-3 py-2 text-sm font-medium transition',
activeTab === tab.id
? 'border-indigo-600 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',
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
: 'text-gray-500 hover:bg-white/70 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200',
)}
>
<span className="inline-flex items-center gap-x-2">
@ -966,13 +1017,13 @@ export default function OperationModal({
</div>
{error && (
<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">
<div className="mx-4 mt-4 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:border-red-500/20 dark:bg-red-950/30 dark:text-red-300">
{error}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-4 py-6 sm:px-6">
<div className="min-h-full bg-gray-50/70 px-4 py-6 sm:px-6 dark:bg-gray-950/40">
{/* Stammdaten */}
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
@ -1045,25 +1096,51 @@ export default function OperationModal({
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
/>
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2">
<AddressCombobox
id="targetObject"
name="targetObject"
label="Zielobjekt"
value={targetObject}
onChange={setTargetObject}
placeholder="Adresse suchen oder Zielobjekt eingeben"
mapVisible={activeTab === 'addresses'}
/>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<AddressCombobox
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
id="kwAddress"
name="kwAddress"
label="KW"
value={kwAddress}
onChange={setKwAddress}
position={kwPosition}
onPositionChange={setKwPosition}
onAreaChange={setKwArea}
placeholder="KW-Adresse suchen oder eingeben"
mapVisible={activeTab === 'addresses'}
showMap={false}
/>
<AddressCombobox
key={`target-${editingOperation?.id ?? 'new'}-${open}`}
id="targetObject"
name="targetObject"
label="Zielobjekt"
value={targetObject}
onChange={setTargetObject}
position={targetPosition}
onPositionChange={setTargetPosition}
onAreaChange={setTargetArea}
placeholder="Adresse suchen oder Zielobjekt eingeben"
mapVisible={activeTab === 'addresses'}
showMap={false}
/>
</div>
<div className="mt-4">
<OperationGeometryEditor
kwPosition={kwPosition}
targetPosition={targetPosition}
kwArea={kwArea}
targetArea={targetArea}
fov={viewConeFov}
length={viewConeLength}
visible={activeTab === 'addresses'}
onKwPositionChange={setKwPosition}
onTargetPositionChange={setTargetPosition}
onFovChange={setViewConeFov}
onLengthChange={setViewConeLength}
/>
</div>
</section>
@ -1796,7 +1873,7 @@ export default function OperationModal({
</div>
</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">
<div className="flex justify-end gap-x-3 border-t border-gray-200 bg-white/90 px-4 py-4 backdrop-blur sm:px-6 dark:border-white/10 dark:bg-gray-900/90">
<Button
type="button"
variant="secondary"

View File

@ -908,6 +908,15 @@ export default function OperationsPage({
const form = event.currentTarget
const formData = new FormData(form)
const operationToEdit = editingOperation
const optionalNumber = (name: string) => {
const rawValue = String(formData.get(name) ?? '').trim()
if (!rawValue) {
return null
}
const value = Number(rawValue)
return Number.isFinite(value) ? value : null
}
setIsSaving(true)
setSaveError(null)
@ -919,6 +928,12 @@ export default function OperationsPage({
caseWorker: String(formData.get('caseWorker') ?? ''),
targetObject: String(formData.get('targetObject') ?? ''),
kwAddress: String(formData.get('kwAddress') ?? ''),
kwLatitude: optionalNumber('kwLatitude'),
kwLongitude: optionalNumber('kwLongitude'),
targetLatitude: optionalNumber('targetLatitude'),
targetLongitude: optionalNumber('targetLongitude'),
viewConeFov: optionalNumber('viewConeFov') ?? 36,
viewConeLength: optionalNumber('viewConeLength') ?? 0,
operationLeader: String(formData.get('operationLeader') ?? ''),
operationTeam: String(formData.get('operationTeam') ?? ''),
legend: String(formData.get('legend') ?? ''),
@ -929,6 +944,7 @@ export default function OperationsPage({
hardDrive: String(formData.get('hardDrive') ?? ''),
laptop: String(formData.get('laptop') ?? ''),
remark: String(formData.get('remark') ?? ''),
milestoneStorage: String(formData.get('milestoneStorage') ?? ''),
}
try {
@ -1934,4 +1950,4 @@ export default function OperationsPage({
/>
</div>
)
}
}

View File

@ -4,8 +4,7 @@ import type { ReactNode } from 'react'
import { useLocation } from 'react-router'
import {
BellIcon,
CreditCardIcon,
PuzzlePieceIcon,
ShieldCheckIcon,
UserIcon,
UsersIcon,
} from '@heroicons/react/20/solid'
@ -14,6 +13,7 @@ import Container from '../../components/Container'
import type { User } from '../../components/types'
import Konto from './konto/Konto'
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
import PresenceSettings from './presence/PresenceSettings'
type SettingsPageProps = {
user?: User
@ -79,6 +79,14 @@ export default function SettingsPage({
icon: UserIcon,
current: pathname === '/einstellungen/konto',
},
{
name: 'Datenschutz',
href: '/einstellungen/datenschutz',
icon: ShieldCheckIcon,
current:
pathname === '/einstellungen/datenschutz' ||
pathname === '/einstellungen/praesenz',
},
{
name: 'Benachrichtigungen',
href: '/einstellungen/benachrichtigungen',
@ -118,10 +126,17 @@ export default function SettingsPage({
<Benachrichtigungen />
)}
{(section === 'datenschutz' || section === 'praesenz') && (
<PresenceSettings
user={user}
onUserUpdated={onUserUpdated}
/>
)}
{section === 'teams' && (
<PlaceholderSection title="Teams" />
)}
</div>
</main>
)
}
}

View File

@ -32,6 +32,8 @@ type NotificationSettings = {
deviceUpdates: boolean
deviceJournals: boolean
systemMessages: boolean
chatMessages: boolean
desktopEnabled: boolean
}
const defaultSettings: NotificationSettings = {
@ -44,6 +46,8 @@ const defaultSettings: NotificationSettings = {
deviceUpdates: true,
deviceJournals: true,
systemMessages: true,
chatMessages: true,
desktopEnabled: false,
}
const notificationSounds: Array<{
@ -267,6 +271,32 @@ export default function Benachrichtigungen() {
}
}
async function enableDesktopNotifications() {
if (!('Notification' in window)) {
showToast({
title: 'Nicht unterstützt',
message: 'Dieser Browser unterstützt keine Windows-Benachrichtigungen.',
variant: 'warning',
})
return
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
showToast({
title: 'Berechtigung fehlt',
message: 'Windows-Benachrichtigungen wurden nicht freigegeben.',
variant: 'warning',
})
return
}
updateSetting('desktopEnabled', true)
new Notification('TEG-Benachrichtigungen aktiviert', {
body: 'Neue Chatnachrichten können jetzt auf Windows-Ebene angezeigt werden.',
})
}
if (isLoading) {
return (
<Section
@ -293,11 +323,29 @@ export default function Benachrichtigungen() {
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
>
<div className="space-y-6 sm:max-w-2xl">
{isSaving && (
<p
role="status"
className="text-sm text-gray-500 dark:text-gray-400"
>
Einstellungen werden gespeichert...
</p>
)}
<NotificationCard
icon={<BellIcon aria-hidden="true" className="size-5" />}
title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
>
<Switch
checked={settings.inAppEnabled}
onChange={(event) =>
updateSetting('inAppEnabled', event.target.checked)
}
label="In der Anwendung anzeigen"
description="Zeigt Hinweise im Benachrichtigungscenter an."
/>
<Switch
checked={settings.soundEnabled}
onChange={(event) =>
@ -355,11 +403,52 @@ export default function Benachrichtigungen() {
</div>
</NotificationCard>
<NotificationCard
icon={<EnvelopeIcon aria-hidden="true" className="size-5" />}
title="Windows-Benachrichtigungen"
description="Zeigt neue Chatnachrichten außerhalb des Browserfensters an."
>
<Switch
checked={settings.desktopEnabled}
onChange={(event) => {
if (event.target.checked) {
void enableDesktopNotifications()
return
}
updateSetting('desktopEnabled', false)
}}
label="Auf Windows-Ebene anzeigen"
description="Benötigt die Benachrichtigungsfreigabe des Browsers."
/>
{typeof Notification !== 'undefined' &&
Notification.permission !== 'granted' && (
<Button
type="button"
variant="secondary"
color="indigo"
size="sm"
onClick={() => void enableDesktopNotifications()}
>
Berechtigung erteilen
</Button>
)}
</NotificationCard>
<NotificationCard
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
title="Ereignisse"
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest."
>
<Switch
checked={settings.chatMessages}
onChange={(event) =>
updateSetting('chatMessages', event.target.checked)
}
label="Chatnachrichten"
description="Zeigt eine Browser-Schnellantwort bei neuen Team- und Direktnachrichten."
/>
<Switch
checked={settings.operationUpdates}
onChange={(event) =>
@ -380,26 +469,6 @@ export default function Benachrichtigungen() {
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
/>
<Switch
checked={settings.deviceUpdates}
onChange={(event) =>
updateSetting('deviceUpdates', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Geräteänderungen"
description="Benachrichtigt dich, wenn Geräte erstellt oder geändert werden."
/>
<Switch
checked={settings.deviceJournals}
onChange={(event) =>
updateSetting('deviceJournals', event.target.checked)
}
disabled={!settings.inAppEnabled && !settings.emailEnabled}
label="Geräte-Journal"
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Geräten."
/>
<Switch
checked={settings.systemMessages}
onChange={(event) =>
@ -415,4 +484,4 @@ export default function Benachrichtigungen() {
</div>
</>
)
}
}

View File

@ -17,6 +17,7 @@ import {
} from '@heroicons/react/20/solid'
import { useNotifications } from '../../../components/Notifications'
import Avatar from '../../../components/Avatar'
import Combobox, { type ComboboxItem } from '../../../components/Combobox'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -232,8 +233,6 @@ export default function Konto({
username: String(formData.get('username') ?? ''),
email: String(formData.get('email') ?? ''),
avatar: String(formData.get('avatar') ?? ''),
unit: String(formData.get('unit') ?? ''),
group: String(formData.get('group') ?? ''),
}),
})
@ -540,42 +539,29 @@ export default function Konto({
</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={currentUser?.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={currentUser?.group ?? ''}
className={inputClassName}
/>
</div>
<div className="col-span-full">
<Combobox
label="Einheit"
items={
currentUser?.unit
? [{ id: currentUser.unit, name: currentUser.unit }]
: []
}
value={
currentUser?.unit
? ({
id: currentUser.unit,
name: currentUser.unit,
} satisfies ComboboxItem)
: null
}
onChange={() => undefined}
placeholder="Keine Einheit zugeordnet"
disabled
/>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Die Einheit kann nur in der Benutzeradministration geändert werden.
</p>
</div>
</div>
@ -841,4 +827,4 @@ export default function Konto({
</div>
</>
)
}
}

View File

@ -0,0 +1,151 @@
import { useEffect, useState, type FormEvent } from 'react'
import Container from '../../../components/Container'
import Button from '../../../components/Button'
import Switch from '../../../components/Switch'
import type { User } from '../../../components/types'
import { useNotifications } from '../../../components/Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type PresenceSettingsProps = {
user?: User
onUserUpdated?: (user: User) => void
}
type PresenceSettingsData = {
awayAfterMinutes: number
showLastSeen: boolean
}
export default function PresenceSettings({
user,
onUserUpdated,
}: PresenceSettingsProps) {
const { showToast, showErrorToast } = useNotifications()
const [settings, setSettings] = useState<PresenceSettingsData>({
awayAfterMinutes: user?.awayAfterMinutes ?? 3,
showLastSeen: user?.showLastSeen ?? false,
})
const [saving, setSaving] = useState(false)
useEffect(() => {
void fetch(`${API_URL}/settings/presence`, {
credentials: 'include',
})
.then((response) => response.json())
.then((data) => {
if (data.settings) {
setSettings(data.settings)
}
})
.catch(() => undefined)
}, [])
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSaving(true)
try {
const response = await fetch(`${API_URL}/settings/presence`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
awayAfterMinutes: settings.awayAfterMinutes,
showLastSeen: settings.showLastSeen,
}),
})
const data = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(data?.error || 'Datenschutz konnte nicht gespeichert werden')
}
setSettings(data.settings)
if (data.user) {
onUserUpdated?.(data.user)
}
showToast({
title: 'Datenschutz gespeichert',
message: 'Deine Datenschutz- und Inaktivitätseinstellungen wurden aktualisiert.',
variant: 'success',
})
} catch (error) {
showErrorToast({
title: 'Datenschutz konnte nicht gespeichert werden',
error,
fallback: 'Datenschutz konnte nicht gespeichert werden',
})
} finally {
setSaving(false)
}
}
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">
Datenschutz
</h2>
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Bestimme, welche Statusinformationen andere Personen im Chat sehen.
</p>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 md:col-span-2 sm:max-w-xl"
>
<div>
<label
htmlFor="away-after"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Automatisch abwesend nach
</label>
<div className="mt-2 flex items-center gap-3">
<input
id="away-after"
type="number"
min={1}
max={120}
value={settings.awayAfterMinutes}
onChange={(event) =>
setSettings((current) => ({
...current,
awayAfterMinutes: Number(event.target.value),
}))
}
className="w-28 rounded-md bg-white px-3 py-2 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 disabled:opacity-50 focus:outline-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
/>
<span className="text-sm text-gray-500">Minuten Inaktivität</span>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Die automatische Abwesenheit greift nur, wenn dein manueller
Status in der Topbar auf Online steht.
</p>
</div>
<div className="border-t border-gray-200 pt-6 dark:border-white/10">
<Switch
checked={settings.showLastSeen}
onChange={(event) =>
setSettings((current) => ({
...current,
showLastSeen: event.target.checked,
}))
}
label="Letzten Online-Zeitpunkt anzeigen"
description="Wenn aktiv, teilst du deinen Status (zuletzt online, Abwesenheitsdauer) in Einzelchats und siehst im Gegenzug auch den der anderen."
/>
</div>
<Button type="submit" isLoading={saving}>
Datenschutz speichern
</Button>
</form>
</div>
</Container>
)
}

View File

@ -0,0 +1,256 @@
// frontend/src/utils/activityLog.ts
//
// Clientseitiges Aktivitäts- und Fehlerprotokoll. Erfasst app-weit, was der
// Nutzer anklickt, welche Seiten er aufruft und welche Fehler im Browser
// auftreten. Wird auf der Feedback-Seite sichtbar gemacht und ans Feedback
// angehängt, damit Administratoren Probleme nachvollziehen können.
export type ActivityLogType = 'action' | 'navigation' | 'error'
export type ActivityLogEntry = {
id: string
type: ActivityLogType
timestamp: string
path: string
message: string
details?: Record<string, unknown>
}
const MAX_ENTRIES = 200
const STORAGE_KEY = 'teg.activityLog'
const MAX_STACK_LENGTH = 4000
let entries: ActivityLogEntry[] = loadEntries()
let initialized = false
const listeners = new Set<(entries: ActivityLogEntry[]) => void>()
function loadEntries(): ActivityLogEntry[] {
if (typeof sessionStorage === 'undefined') {
return []
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.slice(-MAX_ENTRIES) : []
} catch {
return []
}
}
function persist() {
if (typeof sessionStorage === 'undefined') {
return
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
} catch {
// Speicher voll oder im privaten Modus Protokoll bleibt nur im Speicher.
}
}
function createId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function notify() {
for (const listener of listeners) {
listener(entries)
}
}
function push(
type: ActivityLogType,
message: string,
details?: Record<string, unknown>,
path?: string,
) {
const entry: ActivityLogEntry = {
id: createId(),
type,
timestamp: new Date().toISOString(),
path: path ?? window.location.pathname,
message: message.slice(0, 500),
details,
}
entries = [...entries, entry].slice(-MAX_ENTRIES)
persist()
notify()
}
export function recordAction(message: string, details?: Record<string, unknown>) {
push('action', message, details)
}
export function recordNavigation(path: string) {
push('navigation', `Seitenaufruf: ${path}`, undefined, path)
}
export function recordError(message: string, details?: Record<string, unknown>) {
push('error', message, details)
}
export function getActivityLog(): ActivityLogEntry[] {
return entries
}
export function clearActivityLog() {
entries = []
persist()
notify()
}
export function subscribeActivityLog(
listener: (entries: ActivityLogEntry[]) => void,
) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function truncateStack(stack?: string) {
if (!stack) {
return undefined
}
return stack.length > MAX_STACK_LENGTH ? stack.slice(0, MAX_STACK_LENGTH) : stack
}
function describeElement(target: Element): string | null {
const actionable = target.closest(
'button, a, [role="button"], input, select, textarea, label, summary, [role="menuitem"], [role="tab"], [data-log-label]',
)
if (!actionable) {
return null
}
const element = actionable as HTMLElement
const tag = element.tagName.toLowerCase()
const role =
element.getAttribute('data-log-role') ||
(tag === 'a'
? 'Link'
: tag === 'button' || element.getAttribute('role') === 'button'
? 'Schaltfläche'
: tag === 'input'
? `Eingabe (${(element as HTMLInputElement).type || 'text'})`
: tag === 'select'
? 'Auswahl'
: tag === 'textarea'
? 'Textfeld'
: tag === 'summary'
? 'Aufklappen'
: 'Element')
const rawLabel =
element.getAttribute('data-log-label') ||
element.getAttribute('aria-label') ||
element.getAttribute('title') ||
(element as HTMLInputElement).placeholder ||
(element.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!rawLabel) {
return role
}
const label = rawLabel.length > 80 ? `${rawLabel.slice(0, 80)}` : rawLabel
return `${role}: „${label}"`
}
function handleClick(event: MouseEvent) {
const target = event.target
if (!(target instanceof Element)) {
return
}
const description = describeElement(target)
if (description) {
recordAction(`Klick ${description}`)
}
}
function handleError(event: ErrorEvent) {
if (!event.message && event.target && event.target !== window) {
const element = event.target as HTMLElement & { src?: string; href?: string }
const source = element.src || element.href || ''
recordError(
`Ressource konnte nicht geladen werden${source ? `: ${source}` : ''}`,
{ element: element.tagName?.toLowerCase() },
)
return
}
recordError(event.message || 'Unbekannter Fehler', {
source: event.filename,
line: event.lineno,
column: event.colno,
stack: truncateStack(event.error?.stack),
})
}
function handleRejection(event: PromiseRejectionEvent) {
const reason = event.reason
const message = reason instanceof Error ? reason.message : String(reason)
recordError(`Unbehandelte Ablehnung: ${message}`, {
stack: reason instanceof Error ? truncateStack(reason.stack) : undefined,
})
}
function safeStringify(value: unknown) {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
export function initActivityLog() {
if (initialized || typeof window === 'undefined') {
return
}
initialized = true
document.addEventListener('click', handleClick, { capture: true })
window.addEventListener('error', handleError, true)
window.addEventListener('unhandledrejection', handleRejection)
const originalConsoleError = console.error.bind(console)
console.error = (...args: unknown[]) => {
try {
const errorArg = args.find((arg): arg is Error => arg instanceof Error)
const message = args
.map((arg) =>
arg instanceof Error
? arg.message
: typeof arg === 'string'
? arg
: safeStringify(arg),
)
.join(' ')
// React-Entwicklungswarnungen sind kein echter Nutzerfehler.
if (!message.startsWith('Warning:')) {
recordError(`console.error: ${message}`, {
stack: truncateStack(errorArg?.stack),
})
}
} catch {
// Logging darf die App niemals stören.
}
originalConsoleError(...args)
}
}

View File

@ -0,0 +1,147 @@
export type OperationCoordinates = {
lat: number
lon: number
}
export function isValidOperationCoordinates(
value: OperationCoordinates | null | undefined,
): value is OperationCoordinates {
return (
Boolean(value) &&
Number.isFinite(value?.lat) &&
Number.isFinite(value?.lon) &&
value!.lat >= -90 &&
value!.lat <= 90 &&
value!.lon >= -180 &&
value!.lon <= 180
)
}
function toRadians(value: number) {
return (value * Math.PI) / 180
}
function toDegrees(value: number) {
return (value * 180) / Math.PI
}
export function getOperationDistanceMeters(
start: OperationCoordinates,
end: OperationCoordinates,
) {
const earthRadius = 6371000
const startLat = toRadians(start.lat)
const endLat = toRadians(end.lat)
const deltaLat = toRadians(end.lat - start.lat)
const deltaLon = toRadians(end.lon - start.lon)
const a =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(startLat) *
Math.cos(endLat) *
Math.sin(deltaLon / 2) ** 2
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
export function getOperationBearingDegrees(
start: OperationCoordinates,
end: OperationCoordinates,
) {
const startLat = toRadians(start.lat)
const endLat = toRadians(end.lat)
const deltaLon = toRadians(end.lon - start.lon)
const y = Math.sin(deltaLon) * Math.cos(endLat)
const x =
Math.cos(startLat) * Math.sin(endLat) -
Math.sin(startLat) * Math.cos(endLat) * Math.cos(deltaLon)
return (toDegrees(Math.atan2(y, x)) + 360) % 360
}
export function getOperationSignedAngleDifferenceDegrees(
first: number,
second: number,
) {
return ((first - second + 540) % 360) - 180
}
export function getOperationDestinationPoint(
start: OperationCoordinates,
distanceMeters: number,
bearingDegrees: number,
): [number, number] {
const earthRadius = 6371000
const bearing = toRadians(bearingDegrees)
const startLat = toRadians(start.lat)
const startLon = toRadians(start.lon)
const angularDistance = distanceMeters / earthRadius
const lat = Math.asin(
Math.sin(startLat) * Math.cos(angularDistance) +
Math.cos(startLat) * Math.sin(angularDistance) * Math.cos(bearing),
)
const lon =
startLon +
Math.atan2(
Math.sin(bearing) *
Math.sin(angularDistance) *
Math.cos(startLat),
Math.cos(angularDistance) - Math.sin(startLat) * Math.sin(lat),
)
return [toDegrees(lat), ((toDegrees(lon) + 540) % 360) - 180]
}
export function createOperationViewCone({
from,
to,
fov = 36,
length = 0,
steps = 24,
}: {
from: OperationCoordinates
to: OperationCoordinates
fov?: number
length?: number
steps?: number
}) {
const targetDistance = getOperationDistanceMeters(from, to)
if (targetDistance < 1) {
return null
}
const coneLength =
length > 0 ? Math.min(length, targetDistance) : targetDistance
const bearing = getOperationBearingDegrees(from, to)
const safeFov = Math.min(180, Math.max(1, fov))
const halfAngle = safeFov / 2
const positions: Array<[number, number]> = [[from.lat, from.lon]]
for (let step = 0; step <= steps; step += 1) {
const angle = bearing - halfAngle + (safeFov * step) / steps
positions.push(
getOperationDestinationPoint(from, coneLength, angle),
)
}
positions.push([from.lat, from.lon])
return {
positions,
centerLine: [
[from.lat, from.lon],
getOperationDestinationPoint(from, coneLength, bearing),
] as [[number, number], [number, number]],
targetLine: [
[from.lat, from.lon],
[to.lat, to.lon],
] as [[number, number], [number, number]],
bearing,
targetDistance,
coneLength,
}
}