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

View File

@ -3,8 +3,10 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -13,12 +15,13 @@ import (
) )
type reverseAddressSuggestion struct { type reverseAddressSuggestion struct {
ID string `json:"id"` ID string `json:"id"`
Label string `json:"label"` Label string `json:"label"`
Lat float64 `json:"lat"` Lat float64 `json:"lat"`
Lon float64 `json:"lon"` Lon float64 `json:"lon"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
GeoJSON json.RawMessage `json:"geojson,omitempty"` HasHouseNumber bool `json:"hasHouseNumber"`
GeoJSON json.RawMessage `json:"geojson,omitempty"`
} }
type nominatimReverseAddress struct { type nominatimReverseAddress struct {
@ -112,6 +115,215 @@ func formatReverseAddressLabel(result nominatimReverseResponse) string {
return strings.TrimSpace(result.DisplayName) 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) { func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
latRaw := strings.TrimSpace(r.URL.Query().Get("lat")) latRaw := strings.TrimSpace(r.URL.Query().Get("lat"))
lonRaw := strings.TrimSpace(r.URL.Query().Get("lon")) lonRaw := strings.TrimSpace(r.URL.Query().Get("lon"))
@ -179,14 +391,42 @@ func (s *Server) handleAddressReverse(w http.ResponseWriter, r *http.Request) {
return 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{ writeJSON(w, http.StatusOK, map[string]any{
"suggestion": reverseAddressSuggestion{ "suggestion": reverseAddressSuggestion{
ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()), ID: fmt.Sprintf("reverse-%d-%d", result.PlaceID, time.Now().UnixNano()),
Label: label, Label: label,
Lat: lat, Lat: lat,
Lon: lon, Lon: lon,
Type: result.Type, Type: result.Type,
GeoJSON: result.GeoJSON, HasHouseNumber: hasHouseNumber,
GeoJSON: geoJSON,
}, },
}) })
} }

View File

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

View File

@ -24,17 +24,20 @@ type AdminTeam struct {
} }
type AdminUser struct { type AdminUser struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Email string `json:"email"` Email string `json:"email"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Unit string `json:"unit"` Unit string `json:"unit"`
Group string `json:"group"` Group string `json:"group"`
Rights []string `json:"rights"` Rights []string `json:"rights"`
Teams []AdminTeam `json:"teams"` Teams []AdminTeam `json:"teams"`
CreatedAt time.Time `json:"createdAt"` Online bool `json:"onlineStatus"`
UpdatedAt time.Time `json:"updatedAt"` PresenceStatus string `json:"presenceStatus"`
LastSeenAt *time.Time `json:"lastSeenAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type AdminUserRequest struct { type AdminUserRequest struct {
@ -120,6 +123,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
COALESCE(unit, ''), COALESCE(unit, ''),
COALESCE(user_group, ''), COALESCE(user_group, ''),
rights, rights,
`+presenceStatusSQL+`,
last_seen_at,
created_at, created_at,
updated_at updated_at
FROM users FROM users
@ -148,6 +153,8 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
&user.Unit, &user.Unit,
&user.Group, &user.Group,
&user.Rights, &user.Rights,
&user.PresenceStatus,
&user.LastSeenAt,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
); err != nil { ); err != nil {
@ -156,6 +163,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
} }
user.Teams = []AdminTeam{} user.Teams = []AdminTeam{}
user.Online = user.PresenceStatus == "online"
users = append(users, user) users = append(users, user)
userIndexByID[user.ID] = len(users) - 1 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 { type CreateFeedbackRequest struct {
Message string `json:"message"` 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 { type FeedbackResponse struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
@ -63,6 +72,16 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
return 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{ feedbackLog := map[string]any{
"user": buildUserLogSnapshot(user), "user": buildUserLogSnapshot(user),
"request": mergeLogFields( "request": mergeLogFields(
@ -74,6 +93,8 @@ func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
"server": map[string]any{ "server": map[string]any{
"createdAt": time.Now().UTC().Format(time.RFC3339Nano), "createdAt": time.Now().UTC().Format(time.RFC3339Nano),
}, },
"client": clientMeta,
"activity": clientActivity,
} }
logJSON, err := json.Marshal(feedbackLog) logJSON, err := json.Marshal(feedbackLog)

View File

@ -5,13 +5,12 @@ module main
go 1.26.2 go 1.26.2
require ( require (
github.com/coder/websocket v1.8.14
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.51.0
) )
require github.com/coder/websocket v1.8.14 // indirect
require ( require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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 := NewServer(db, jwtSecret, frontendOrigin)
server.startPresenceMonitor(ctx)
log.Printf("Backend läuft auf http://localhost:%s", port) log.Printf("Backend läuft auf http://localhost:%s", port)

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -16,16 +17,22 @@ import (
) )
type Operation struct { type Operation struct {
ID string `json:"id"` ID string `json:"id"`
OperationNumber string `json:"operationNumber"` OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"` OperationName string `json:"operationName"`
Offense string `json:"offense"` Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"` CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"` TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"` KwAddress string `json:"kwAddress"`
OperationLeader string `json:"operationLeader"` KwLatitude *float64 `json:"kwLatitude"`
OperationTeam string `json:"operationTeam"` KwLongitude *float64 `json:"kwLongitude"`
Legend string `json:"legend"` 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"` Camera string `json:"camera"`
Router string `json:"router"` Router string `json:"router"`
Technology string `json:"technology"` Technology string `json:"technology"`
@ -47,23 +54,29 @@ type UpdateOperationJournalEntryRequest struct {
} }
type CreateOperationRequest struct { type CreateOperationRequest struct {
OperationNumber string `json:"operationNumber"` OperationNumber string `json:"operationNumber"`
OperationName string `json:"operationName"` OperationName string `json:"operationName"`
Offense string `json:"offense"` Offense string `json:"offense"`
CaseWorker string `json:"caseWorker"` CaseWorker string `json:"caseWorker"`
TargetObject string `json:"targetObject"` TargetObject string `json:"targetObject"`
KwAddress string `json:"kwAddress"` KwAddress string `json:"kwAddress"`
OperationLeader string `json:"operationLeader"` KwLatitude *float64 `json:"kwLatitude"`
OperationTeam string `json:"operationTeam"` KwLongitude *float64 `json:"kwLongitude"`
Legend string `json:"legend"` TargetLatitude *float64 `json:"targetLatitude"`
Camera string `json:"camera"` TargetLongitude *float64 `json:"targetLongitude"`
Router string `json:"router"` ViewConeFOV float64 `json:"viewConeFov"`
Technology string `json:"technology"` ViewConeLength float64 `json:"viewConeLength"`
Switchbox string `json:"switchbox"` OperationLeader string `json:"operationLeader"`
HardDrive string `json:"hardDrive"` OperationTeam string `json:"operationTeam"`
Laptop string `json:"laptop"` Legend string `json:"legend"`
Remark string `json:"remark"` Camera string `json:"camera"`
MilestoneStorage string `json:"milestoneStorage"` 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 type UpdateOperationRequest = CreateOperationRequest
@ -119,6 +132,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
case_worker, case_worker,
target_object, target_object,
kw_address, kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader, operation_leader,
operation_team, operation_team,
legend, legend,
@ -156,6 +175,12 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker, &operation.CaseWorker,
&operation.TargetObject, &operation.TargetObject,
&operation.KwAddress, &operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader, &operation.OperationLeader,
&operation.OperationTeam, &operation.OperationTeam,
&operation.Legend, &operation.Legend,
@ -201,6 +226,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return return
} }
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context()) user, ok := userFromContext(r.Context())
if !ok { if !ok {
@ -227,6 +256,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
case_worker, case_worker,
target_object, target_object,
kw_address, kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader, operation_leader,
operation_team, operation_team,
legend, legend,
@ -239,7 +274,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
remark, remark,
milestone_storage 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 RETURNING
id, id,
operation_number, operation_number,
@ -248,6 +283,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
case_worker, case_worker,
target_object, target_object,
kw_address, kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader, operation_leader,
operation_team, operation_team,
legend, legend,
@ -268,6 +309,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
input.CaseWorker, input.CaseWorker,
input.TargetObject, input.TargetObject,
input.KwAddress, input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader, input.OperationLeader,
input.OperationTeam, input.OperationTeam,
input.Legend, input.Legend,
@ -287,6 +334,12 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker, &operation.CaseWorker,
&operation.TargetObject, &operation.TargetObject,
&operation.KwAddress, &operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader, &operation.OperationLeader,
&operation.OperationTeam, &operation.OperationTeam,
&operation.Legend, &operation.Legend,
@ -380,6 +433,10 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich")
return return
} }
if err := validateOperationGeometry(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, ok := userFromContext(r.Context()) user, ok := userFromContext(r.Context())
if !ok { if !ok {
@ -418,17 +475,23 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
case_worker = $5, case_worker = $5,
target_object = $6, target_object = $6,
kw_address = $7, kw_address = $7,
operation_leader = $8, kw_latitude = $8,
operation_team = $9, kw_longitude = $9,
legend = $10, target_latitude = $10,
camera = $11, target_longitude = $11,
router = $12, view_cone_fov = $12,
technology = $13, view_cone_length = $13,
switchbox = $14, operation_leader = $14,
hard_drive = $15, operation_team = $15,
laptop = $16, legend = $16,
remark = $17, camera = $17,
milestone_storage = $18, router = $18,
technology = $19,
switchbox = $20,
hard_drive = $21,
laptop = $22,
remark = $23,
milestone_storage = $24,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING RETURNING
@ -439,6 +502,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
case_worker, case_worker,
target_object, target_object,
kw_address, kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader, operation_leader,
operation_team, operation_team,
legend, legend,
@ -460,6 +529,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
input.CaseWorker, input.CaseWorker,
input.TargetObject, input.TargetObject,
input.KwAddress, input.KwAddress,
input.KwLatitude,
input.KwLongitude,
input.TargetLatitude,
input.TargetLongitude,
input.ViewConeFOV,
input.ViewConeLength,
input.OperationLeader, input.OperationLeader,
input.OperationTeam, input.OperationTeam,
input.Legend, input.Legend,
@ -479,6 +554,12 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
&operation.CaseWorker, &operation.CaseWorker,
&operation.TargetObject, &operation.TargetObject,
&operation.KwAddress, &operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader, &operation.OperationLeader,
&operation.OperationTeam, &operation.OperationTeam,
&operation.Legend, &operation.Legend,
@ -644,6 +725,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
case_worker, case_worker,
target_object, target_object,
kw_address, kw_address,
kw_latitude,
kw_longitude,
target_latitude,
target_longitude,
view_cone_fov,
view_cone_length,
operation_leader, operation_leader,
operation_team, operation_team,
legend, legend,
@ -670,6 +757,12 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
&operation.CaseWorker, &operation.CaseWorker,
&operation.TargetObject, &operation.TargetObject,
&operation.KwAddress, &operation.KwAddress,
&operation.KwLatitude,
&operation.KwLongitude,
&operation.TargetLatitude,
&operation.TargetLongitude,
&operation.ViewConeFOV,
&operation.ViewConeLength,
&operation.OperationLeader, &operation.OperationLeader,
&operation.OperationTeam, &operation.OperationTeam,
&operation.Legend, &operation.Legend,
@ -730,6 +823,12 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker) changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject) changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress) 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, "operationLeader", "Einsatzleiter", "", input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam) changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend) 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, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker)
changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject) changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject)
changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress) 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, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader)
changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam) changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam)
changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend) changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend)
@ -809,6 +912,56 @@ func normalizeOperationInput(input *CreateOperationRequest) {
input.Laptop = strings.TrimSpace(input.Laptop) input.Laptop = strings.TrimSpace(input.Laptop)
input.Remark = strings.TrimSpace(input.Remark) input.Remark = strings.TrimSpace(input.Remark)
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage) 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) { 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 := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth) 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/register", s.handleRegister)
mux.HandleFunc("POST /auth/login", s.handleLogin) 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("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead))
mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents)) 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))) 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("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("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 */ /* milestone settings */

View File

@ -8,6 +8,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -27,19 +28,27 @@ type Server struct {
db *pgxpool.Pool db *pgxpool.Pool
jwtSecret []byte jwtSecret []byte
frontendOrigin string frontendOrigin string
presenceMu sync.Mutex
presenceStatus map[string]string
} }
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Email string `json:"email"` Email string `json:"email"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Unit string `json:"unit"` Unit string `json:"unit"`
Group string `json:"group"` Group string `json:"group"`
Rights []string `json:"rights"` Rights []string `json:"rights"`
Teams []Team `json:"teams"` Teams []Team `json:"teams"`
TokenVersion int `json:"-"` 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 { type Team struct {
@ -87,6 +96,7 @@ func NewServer(db *pgxpool.Pool, jwtSecret string, frontendOrigin string) *Serve
db: db, db: db,
jwtSecret: []byte(jwtSecret), jwtSecret: []byte(jwtSecret),
frontendOrigin: frontendOrigin, frontendOrigin: frontendOrigin,
presenceStatus: map[string]string{},
} }
} }
@ -196,6 +206,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return 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 { if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session") writeError(w, http.StatusInternalServerError, "Could not create session")
return return
@ -249,6 +267,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return 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 { if err := s.setSessionCookie(w, user, sessionID); err != nil {
writeError(w, http.StatusInternalServerError, "Could not create session") writeError(w, http.StatusInternalServerError, "Could not create session")
return return
@ -264,10 +287,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_, _ = s.db.Exec( _, _ = s.db.Exec(
r.Context(), r.Context(),
` `
UPDATE user_sessions WITH revoked_session AS (
SET revoked_at = now() UPDATE user_sessions
WHERE id = $1 SET revoked_at = now()
AND revoked_at IS NULL 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, sessionID,
) )
@ -381,6 +416,11 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
} }
_ = s.touchUserSession(r.Context(), user.ID, claims.SessionID) _ = 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(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID) ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID)
@ -408,6 +448,11 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us
COALESCE(unit, ''), COALESCE(unit, ''),
COALESCE(user_group, ''), COALESCE(user_group, ''),
COALESCE(rights, '{}'::TEXT[]), COALESCE(rights, '{}'::TEXT[]),
COALESCE(presence_mode, 'online'),
away_after_minutes,
show_last_seen,
`+presenceStatusSQL+`,
last_seen_at,
token_version token_version
FROM users FROM users
WHERE lower(email) = $1 OR lower(username) = $1 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.Unit,
&user.Group, &user.Group,
&user.Rights, &user.Rights,
&user.PresenceMode,
&user.AwayAfterMinutes,
&user.ShowLastSeen,
&user.PresenceStatus,
&user.LastSeenAt,
&user.TokenVersion, &user.TokenVersion,
) )
user.Online = user.PresenceStatus == "online"
if err != nil { if err != nil {
return user, passwordHash, err return user, passwordHash, err
@ -456,6 +507,11 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
COALESCE(unit, ''), COALESCE(unit, ''),
COALESCE(user_group, ''), COALESCE(user_group, ''),
COALESCE(rights, '{}'::TEXT[]), COALESCE(rights, '{}'::TEXT[]),
COALESCE(presence_mode, 'online'),
away_after_minutes,
show_last_seen,
`+presenceStatusSQL+`,
last_seen_at,
token_version token_version
FROM users FROM users
WHERE id = $1 WHERE id = $1
@ -471,8 +527,14 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) {
&user.Unit, &user.Unit,
&user.Group, &user.Group,
&user.Rights, &user.Rights,
&user.PresenceMode,
&user.AwayAfterMinutes,
&user.ShowLastSeen,
&user.PresenceStatus,
&user.LastSeenAt,
&user.TokenVersion, &user.TokenVersion,
) )
user.Online = user.PresenceStatus == "online"
if err != nil { if err != nil {
return user, err 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, token_version INTEGER NOT NULL DEFAULT 1,
last_seen_at TIMESTAMPTZ, 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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 // aktualisiert. "Online" wird daraus abgeleitet (z. B. Aktivität innerhalb
// der letzten Minuten). // 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_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 ( 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 '', case_worker TEXT NOT NULL DEFAULT '',
target_object TEXT NOT NULL DEFAULT '', target_object TEXT NOT NULL DEFAULT '',
kw_address 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_leader TEXT NOT NULL DEFAULT '',
operation_team TEXT NOT NULL DEFAULT '', operation_team TEXT NOT NULL DEFAULT '',
legend 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 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 ( CREATE TABLE IF NOT EXISTS operation_history (
@ -485,12 +556,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
entity_id UUID, entity_id UUID,
data JSONB NOT NULL DEFAULT '{}'::JSONB, data JSONB NOT NULL DEFAULT '{}'::JSONB,
in_app_visible BOOLEAN NOT NULL DEFAULT true,
read_at TIMESTAMPTZ, read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() 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 ( CREATE TABLE IF NOT EXISTS milestone_settings (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), 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_updates BOOLEAN NOT NULL DEFAULT true,
device_journals BOOLEAN NOT NULL DEFAULT true, device_journals BOOLEAN NOT NULL DEFAULT true,
system_messages 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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 ( CREATE TABLE IF NOT EXISTS feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -619,15 +698,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`, `,
// ── Chat ──────────────────────────────────────────────────────────── // ── Chat ────────────────────────────────────────────────────────────
// Eine Konversation ist entweder ein Team-Gruppenchat (type='team', // Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat,
// genau einer pro Team), ein Einzelchat zwischen zwei Personen // freier Gruppenchat oder ein schreibgeschützter Integrations-Channel.
// (type='direct') oder ein freier Gruppenchat (type='group').
` `
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL DEFAULT 'direct' type TEXT NOT NULL DEFAULT 'direct'
CHECK (type IN ('team', 'direct', 'group')), CHECK (type IN ('team', 'direct', 'group', 'channel')),
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE, 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), -- Stabiler Schlüssel für Einzelchats (sortierte User-IDs),
-- damit pro Personenpaar nur eine Konversation existiert. -- damit pro Personenpaar nur eine Konversation existiert.
direct_key TEXT NOT NULL DEFAULT '', 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, 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() 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 ( 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, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMPTZ, last_read_at TIMESTAMPTZ,
muted BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 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 ( CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL DEFAULT '', 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ, 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_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, `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)`, `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. // 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_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_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_type ON conversations(type)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_last_message_at ON conversations(last_message_at DESC)`, `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_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_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_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_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 { for _, query := range queries {

View File

@ -9,6 +9,10 @@ import (
) )
func (s *Server) publishUserProfileEvent(eventType string, user User) { 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{ data, err := json.Marshal(map[string]any{
"user": user, "user": user,
}) })

View File

@ -54,6 +54,16 @@ func (s *Server) createUserSession(
err := s.db.QueryRow( err := s.db.QueryRow(
ctx, 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 ( INSERT INTO user_sessions (
user_id, user_id,
browser, browser,
@ -144,11 +154,18 @@ func (s *Server) touchUserSession(
_, err := s.db.Exec( _, err := s.db.Exec(
ctx, 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() SET last_seen_at = now()
WHERE id = $1 FROM touched_session
AND user_id = $2 WHERE users.id = touched_session.user_id
AND revoked_at IS NULL
`, `,
sessionID, sessionID,
userID, userID,

View File

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

View File

@ -1,8 +1,8 @@
// frontend/src/App.tsx // frontend/src/App.tsx
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Navigate, Route, Routes, useNavigate } from 'react-router' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { NotificationProvider } from './components/Notifications' import { useNotifications } from './components/Notifications'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import AppLayout from './components/AppLayout' import AppLayout from './components/AppLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
@ -21,6 +21,8 @@ import Feedback from './components/Feedback'
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage' import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage' import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
import { hasRight } from './components/permissions' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -65,6 +67,23 @@ function getUserFromNotification(notification: Notification) {
return user as User 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) { function playNotificationSound(soundName?: string) {
if (!soundName) { if (!soundName) {
return return
@ -96,31 +115,57 @@ function RequireRight({
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { showToast } = useNotifications()
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadChatCount, setUnreadChatCount] = useState(0)
const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>( const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
) )
const currentUserId = user?.id
const awayAfterMinutes = user?.awayAfterMinutes
const unreadNotificationCount = notifications.filter( const unreadNotificationCount = notifications.filter(
(notification) => !notification.readAt, (notification) => !notification.readAt,
).length ).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(() => { useEffect(() => {
void loadUser() void loadUser()
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!user) { recordNavigation(location.pathname)
}, [location.pathname])
useEffect(() => {
if (!currentUserId) {
setNotifications([]) setNotifications([])
setUnreadJournalOperationIds(new Set()) setUnreadJournalOperationIds(new Set())
return return
} }
void loadNotifications() void loadNotifications()
void loadUnreadChatCount()
void loadUnreadJournalOperations() void loadUnreadJournalOperations()
const eventSource = new EventSource(`${API_URL}/events`, { const eventSource = new EventSource(`${API_URL}/events`, {
@ -132,6 +177,89 @@ function App() {
return 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) playNotificationSound(notification.soundName)
setNotifications((currentNotifications) => [ setNotifications((currentNotifications) => [
@ -167,6 +295,10 @@ function App() {
addVisibleNotification(notification) addVisibleNotification(notification)
dispatchEntityEvent(notification) dispatchEntityEvent(notification)
if (notification.entityType === 'chat') {
void loadUnreadChatCount()
}
} }
function handleOperationEvent(event: MessageEvent) { function handleOperationEvent(event: MessageEvent) {
@ -248,7 +380,103 @@ function App() {
eventSource.removeEventListener('user-event', handleUserEvent) eventSource.removeEventListener('user-event', handleUserEvent)
eventSource.close() 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() { async function loadUser() {
try { try {
@ -352,6 +580,16 @@ function App() {
} }
function handleNotificationClick(notification: Notification) { 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 ( if (
notification.type === 'operation.created' || notification.type === 'operation.created' ||
notification.entityType === 'operation' notification.entityType === 'operation'
@ -369,6 +607,7 @@ function App() {
} finally { } finally {
setUser(null) setUser(null)
setNotifications([]) setNotifications([])
setUnreadChatCount(0)
setUnreadJournalOperationIds(new Set()) setUnreadJournalOperationIds(new Set())
} }
} }
@ -396,10 +635,11 @@ function App() {
} }
return ( return (
<NotificationProvider> <AppLayout
<AppLayout
user={user} user={user}
onUserUpdated={setUser}
onLogout={handleLogout} onLogout={handleLogout}
unreadChatCount={unreadChatCount}
notificationCenter={ notificationCenter={
<NotificationCenter <NotificationCenter
notifications={notifications} notifications={notifications}
@ -511,11 +751,23 @@ function App() {
/> />
<Route path="/feedback" element={<Feedback />} /> <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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AppLayout> </AppLayout>
</NotificationProvider>
) )
} }

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

View File

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

View File

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

View File

@ -1,54 +1,96 @@
// frontend/src/pages/Feedback.tsx // frontend/src/components/Feedback.tsx
import { useState, type FormEvent } from 'react' import { useMemo, useState, type FormEvent } from 'react'
import Textarea from '../components/Textarea' 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 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() { export default function Feedback() {
const [logSnapshot, setLogSnapshot] = useState<ActivityLogEntry[]>(() =>
getActivityLog(),
)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) 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>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
const trimmedMessage = message.trim() const trimmedMessage = message.trim()
if (!trimmedMessage) { if (!trimmedMessage) {
setError('Bitte gib dein Feedback ein.') setError('Bitte gib dein Feedback ein.')
setSuccessMessage(null)
return return
} }
setIsSubmitting(true) setIsSubmitting(true)
setError(null) setError(null)
setSuccessMessage(null)
try { try {
const client = await collectClientMeta()
const response = await fetch(`${API_URL}/feedback`, { const response = await fetch(`${API_URL}/feedback`, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
message: trimmedMessage, message: trimmedMessage,
clientLog: logSnapshot,
client,
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null) 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('') setSubmitted(true)
setSuccessMessage('Danke, dein Feedback wurde gespeichert.') } catch (submitError) {
} catch (error) {
setError( setError(
error instanceof Error submitError instanceof Error
? error.message ? submitError.message
: 'Feedback konnte nicht gesendet werden', : 'Feedback konnte nicht gesendet werden',
) )
} finally { } finally {
@ -56,55 +98,140 @@ export default function Feedback() {
} }
} }
return ( function startNewFeedback() {
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]"> setSubmitted(false)
<div className="mx-auto max-w-3xl"> setMessage('')
<div> setError(null)
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white"> setLogSnapshot(getActivityLog())
Feedback }
</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400"> return (
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast. <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]">
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt. <div className="mx-auto max-w-3xl">
</p> <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> </div>
{successMessage && ( {submitted ? (
<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"> <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">
{successMessage} <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> </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>
</div> </div>
) )

View File

@ -2,11 +2,15 @@
import { type ComponentType, type SVGProps, useState } from 'react' import { type ComponentType, type SVGProps, useState } from 'react'
import { import {
ArrowRightIcon,
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
ClockIcon,
PaperAirplaneIcon,
PencilSquareIcon, PencilSquareIcon,
PlusCircleIcon, PlusCircleIcon,
UserCircleIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Avatar from './Avatar'
import { Badge, type BadgeTone } from './Badge'
import Button from './Button' import Button from './Button'
import NotificationDot from './NotificationDot' import NotificationDot from './NotificationDot'
@ -126,6 +130,25 @@ function getJournalIconBackground(item: JournalItem) {
return 'bg-indigo-500' 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({ export default function Journal({
items = [], items = [],
currentUserAvatar, currentUserAvatar,
@ -177,151 +200,189 @@ export default function Journal({
return ( return (
<div className={className}> <div className={className}>
{showEntryForm && ( {showEntryForm && (
<div className="mb-8 flex gap-x-3"> <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">
{currentUserAvatar ? ( <div className="mb-3 flex items-center gap-3">
<img <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">
alt="" <ChatBubbleLeftEllipsisIcon
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
aria-hidden="true" aria-hidden="true"
className="size-6 text-gray-500 dark:text-gray-400" className="size-5"
/>
</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"
/> />
</div> </div>
<div className="absolute inset-x-0 bottom-0 flex justify-end py-2 pr-2 pl-3"> <div>
<Button <h4 className="text-sm font-semibold text-gray-950 dark:text-white">
type="button" Neuer Journal-Eintrag
variant="secondary" </h4>
color="indigo" <p className="text-xs text-gray-500 dark:text-gray-400">
size="sm" Notizen und wichtige Informationen nachvollziehbar festhalten.
isLoading={isSubmitting} </p>
disabled={!entry.trim() || isSubmitting} </div>
onClick={() => void handleSubmit()} </div>
>
{submitLabel} <div className="flex items-start gap-3">
</Button> <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> </div>
</div> </div>
)} )}
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400"> <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]">
{emptyText} <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">
</p> <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"> <div className="flow-root">
<ul role="list" className="-mb-8"> <ul role="list" className="space-y-4">
{items.map((item, itemIndex) => { {items.map((item, itemIndex) => {
const personName = getItemPersonName(item) const personName = getItemPersonName(item)
const imageUrl = getItemImageUrl(item) const imageUrl = getItemImageUrl(item)
const dateTime = getItemDateTime(item) const dateTime = getItemDateTime(item)
const Icon = getJournalIcon(item) const Icon = getJournalIcon(item)
const itemType = getJournalType(item)
const isManualEntry = item.type === 'journal' || item.type === 'comment' const isManualEntry = item.type === 'journal' || item.type === 'comment'
const changes = item.changes ?? [] const changes = item.changes ?? []
const hasChanges = !isManualEntry && changes.length > 0 const hasChanges = !isManualEntry && changes.length > 0
return ( return (
<li key={item.id}> <li key={item.id} className="relative">
<div className="relative pb-8"> {itemIndex !== items.length - 1 && (
{itemIndex !== items.length - 1 && ( <span
<span aria-hidden="true"
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"
className="absolute top-4 left-4 z-0 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10" />
)}
<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"> <span
<div className="relative z-10 size-8 flex-none self-start"> className={classNames(
{imageUrl ? ( getJournalIconBackground(item),
<img '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',
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>
)} )}
>
<Icon aria-hidden="true" className="size-3 text-white" />
</span>
</div>
<span <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">
className={classNames( <header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
getJournalIconBackground(item), <div className="min-w-0">
'absolute -right-1 -bottom-1 z-20 flex size-5 items-center justify-center rounded-full ring-2 ring-white dark:ring-gray-900', <div className="flex flex-wrap items-center gap-2">
)} {item.person?.href ? (
> <a
<Icon aria-hidden="true" className="size-3.5 text-white" /> href={item.person.href}
</span> className="truncate text-sm font-semibold text-gray-950 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-300"
</div> >
{personName}
<div className="min-w-0 flex-1 pt-1.5"> </a>
<div className="flex justify-between gap-x-4"> ) : (
<p className="text-sm text-gray-500 dark:text-gray-400"> <span className="truncate text-sm font-semibold text-gray-950 dark:text-white">
<span className="inline-flex items-center gap-x-2 font-medium text-gray-900 dark:text-white"> {personName}
<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)}
</span> </span>
)} )}
</p>
<div className="text-right whitespace-nowrap"> <Badge
<time tone={itemType.tone}
dateTime={dateTime} variant="border"
className="block text-sm text-gray-500 dark:text-gray-400" shape="pill"
size="small"
> >
{item.date} {itemType.label}
</time> </Badge>
{item.hasNotification && (
<NotificationDot
title="Neuer Journal-Eintrag"
label="Neuer Journal-Eintrag"
className="size-2"
/>
)}
</div> </div>
{!isManualEntry && (
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
{getItemDescription(item)}
</p>
)}
</div> </div>
{isManualEntry && editingItemId === item.id ? ( <time
<div className="mt-2"> 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 <textarea
rows={3} rows={3}
value={editEntry} value={editEntry}
onChange={(event) => setEditEntry(event.target.value)} 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 <Button
type="button" type="button"
variant="secondary" variant="secondary"
@ -345,77 +406,87 @@ export default function Journal({
Speichern Speichern
</Button> </Button>
</div> </div>
</div> </div>
) : isManualEntry ? ( ) : 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="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 items-start gap-x-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 py-1 whitespace-pre-wrap break-words"> <div className="min-w-0 flex-1 whitespace-pre-wrap break-words leading-6">
{getItemDescription(item)} {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> </div>
{item.editedDate && ( {item.canEdit && onEntryUpdate && (
<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"> <Button
<PencilSquareIcon aria-hidden="true" className="size-3.5" /> type="button"
<span> variant="secondary"
Zuletzt bearbeitet:{' '} color="indigo"
<time dateTime={getItemEditedDateTime(item)}> size="sm"
{item.editedDate} disabled={isSubmitting}
</time> onClick={() => startEditing(item)}
</span> leadingIcon={
</div> <PencilSquareIcon
aria-hidden="true"
className="size-4"
/>
}
>
Bearbeiten
</Button>
)} )}
</div> </div>
) : hasChanges ? (
<div className="mt-2 space-y-2"> {item.editedDate && (
{changes.map((change, changeIndex) => ( <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">
<div <PencilSquareIcon aria-hidden="true" className="size-3.5" />
key={getChangeKey(change, changeIndex)} <span>
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" Zuletzt bearbeitet:{' '}
> <time dateTime={getItemEditedDateTime(item)}>
<div className="text-xs font-semibold text-gray-900 dark:text-white"> {item.editedDate}
{change.label} </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>
<div className="mt-2 grid grid-cols-1 gap-2 lg:grid-cols-2"> <div className="hidden items-center lg:flex">
<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"> <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">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500"> <ArrowRightIcon aria-hidden="true" className="size-3.5" />
Vorher </span>
</div> </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="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="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-[11px] font-medium uppercase tracking-wide text-indigo-500 dark:text-indigo-300"> <div className="text-[10px] font-semibold tracking-wider text-indigo-600 uppercase dark:text-indigo-300">
Nachher Nachher
</div> </div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm font-medium text-gray-900 dark:text-white"> <div className="mt-1.5 whitespace-pre-wrap break-words text-sm font-medium text-gray-950 dark:text-white">
{getChangeValue(change.newValue)} {getChangeValue(change.newValue)}
</div>
</div> </div>
</div> </div>
</div> </div>
))} </section>
</div> ))}
) : null} </div>
</div> ) : null}
</div> </article>
</div> </div>
</li> </li>
) )

View File

@ -7,6 +7,7 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
type FormEvent,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -18,6 +19,7 @@ import {
XCircleIcon, XCircleIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { XMarkIcon } from '@heroicons/react/20/solid' import { XMarkIcon } from '@heroicons/react/20/solid'
import Avatar from './Avatar'
export type NotificationVariant = export type NotificationVariant =
| 'success' | 'success'
@ -34,6 +36,11 @@ export type ToastNotification = {
onAction?: () => void onAction?: () => void
autoClose?: boolean autoClose?: boolean
duration?: number duration?: number
imageUrl?: string
imageName?: string
replyPlaceholder?: string
replyActionLabel?: string
onReply?: (message: string) => void | Promise<void>
} }
type NotificationsProps = { type NotificationsProps = {
@ -119,9 +126,42 @@ function NotificationToast({
onAction, onAction,
autoClose = true, autoClose = true,
duration = 3000, duration = 3000,
imageUrl,
imageName,
replyPlaceholder,
replyActionLabel = 'Antworten',
onReply,
} = notification } = notification
const { icon: Icon, iconClassName } = getVariantStyles(variant) 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(() => { useEffect(() => {
if (!autoClose) { if (!autoClose) {
@ -143,10 +183,18 @@ function NotificationToast({
<div className="p-4"> <div className="p-4">
<div className="flex items-start"> <div className="flex items-start">
<div className="shrink-0"> <div className="shrink-0">
<Icon {imageUrl || imageName ? (
aria-hidden="true" <Avatar
className={classNames('size-6', iconClassName)} src={imageUrl}
/> name={imageName || title}
size={10}
/>
) : (
<Icon
aria-hidden="true"
className={classNames('size-6', iconClassName)}
/>
)}
</div> </div>
<div className="ml-3 w-0 flex-1 pt-0.5"> <div className="ml-3 w-0 flex-1 pt-0.5">
@ -160,11 +208,40 @@ function NotificationToast({
</p> </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 && ( {actionLabel && onAction && (
<div className="mt-3"> <div className="mt-3">
<button <button
type="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" 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} {actionLabel}

View File

@ -10,6 +10,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
UserGroupIcon, UserGroupIcon,
XMarkIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import type { User } from './types' import type { User } from './types'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
@ -139,7 +140,7 @@ function HighlightedText({
return ( return (
<mark <mark
key={`${part}-${index}`} 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} {part}
</mark> </mark>
@ -284,13 +285,18 @@ export default function SearchOverlay({
search.length >= 2 && !isLoading && !error && !hasResults search.length >= 2 && !isLoading && !error && !hasResults
return ( return (
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]"> <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="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> <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 */} {/* 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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-x-4"> <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 <MagnifyingGlassIcon
aria-hidden="true" aria-hidden="true"
className="size-6" className="size-6"
@ -298,11 +304,11 @@ export default function SearchOverlay({
</div> </div>
<div> <div>
<p className="text-base font-semibold text-gray-900"> <p className="text-base font-semibold text-gray-950 dark:text-white">
Suche Suche
</p> </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 {search.length >= 2
? `${resultCount} Treffer für „${query}` ? `${resultCount} Treffer für „${query}`
: 'Gib mindestens 2 Zeichen ein.'} : 'Gib mindestens 2 Zeichen ein.'}
@ -312,16 +318,25 @@ export default function SearchOverlay({
<div className="flex flex-col gap-3 sm:items-end"> <div className="flex flex-col gap-3 sm:items-end">
{search.length >= 2 && !error && ( {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 && ( {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`} {isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
</div> </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 <Checkbox
checked={effectiveExactPhrase} checked={effectiveExactPhrase}
disabled={!canUseExactPhrase} disabled={!canUseExactPhrase}
@ -330,8 +345,8 @@ export default function SearchOverlay({
wrapperClassName={[ wrapperClassName={[
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset', 'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
canUseExactPhrase canUseExactPhrase
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50' ? '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 text-gray-400 ring-gray-200', : '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(' ')} ].join(' ')}
labelClassName="text-xs/5 font-medium text-inherit" labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4" inputClassName="size-4"
@ -341,7 +356,7 @@ export default function SearchOverlay({
checked={caseSensitive} checked={caseSensitive}
onChange={(event) => setCaseSensitive(event.target.checked)} onChange={(event) => setCaseSensitive(event.target.checked)}
label="Groß-/Kleinschreibung" 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" labelClassName="text-xs/5 font-medium text-inherit"
inputClassName="size-4" inputClassName="size-4"
/> />
@ -351,72 +366,72 @@ export default function SearchOverlay({
</div> </div>
{search.length < 2 && ( {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 <MagnifyingGlassIcon
aria-hidden="true" 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 Suche starten
</p> </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. Gib mindestens 2 Zeichen ein.
</p> </p>
</div> </div>
)} )}
{showFullLoading && ( {showFullLoading && (
<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">
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" /> <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... Suche läuft...
</p> </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. Ergebnisse werden geladen.
</p> </p>
</div> </div>
)} )}
{showError && ( {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 <ExclamationTriangleIcon
aria-hidden="true" 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 Suche fehlgeschlagen
</p> </p>
<p className="mt-1 text-sm text-red-700"> <p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error} {error}
</p> </p>
</div> </div>
)} )}
{showNoResults && ( {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 <ExclamationTriangleIcon
aria-hidden="true" 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 Keine Ergebnisse gefunden
</p> </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. Prüfe den Suchbegriff oder versuche es mit weniger Begriffen.
</p> </p>
</div> </div>
)} )}
{!error && groups.length > 0 && ( {!error && groups.length > 0 && (
<div className="space-y-8"> <div className="space-y-10">
{groups.map((group) => { {groups.map((group) => {
const Icon = group.icon const Icon = group.icon
@ -424,7 +439,7 @@ export default function SearchOverlay({
<section key={group.id}> <section key={group.id}>
<div className="mb-4 flex items-center justify-between gap-x-4"> <div className="mb-4 flex items-center justify-between gap-x-4">
<div className="flex items-center gap-x-3"> <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 <Icon
aria-hidden="true" aria-hidden="true"
className="size-5" className="size-5"
@ -432,11 +447,11 @@ export default function SearchOverlay({
</div> </div>
<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} {group.title}
</h3> </h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500 dark:text-gray-400">
{group.items.length} Treffer {group.items.length} Treffer
</p> </p>
</div> </div>
@ -449,15 +464,15 @@ export default function SearchOverlay({
key={`${group.id}-${item.id}`} key={`${group.id}-${item.id}`}
to={item.href} to={item.href}
onClick={onClose} 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 <ArrowRightIcon
aria-hidden="true" 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> <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 <HighlightedText
value={item.title} value={item.title}
search={search} search={search}
@ -467,7 +482,7 @@ export default function SearchOverlay({
</p> </p>
{item.subtitle && ( {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 <HighlightedText
value={item.subtitle} value={item.subtitle}
search={search} search={search}

View File

@ -23,7 +23,7 @@ import {
import type { ComponentType, SVGProps } from 'react' import type { ComponentType, SVGProps } from 'react'
import { NavLink, useLocation } from 'react-router' import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions' import { hasRight } from './permissions'
import type { User, Team } from './types' import type { User } from './types'
type NavigationItem = { type NavigationItem = {
name: string name: string
@ -82,6 +82,11 @@ const navigationSections: NavigationSection[] = [
{ {
name: 'Organisation', name: 'Organisation',
items: [ items: [
{
name: 'Chats',
href: '/chat',
icon: ChatBubbleLeftRightIcon,
},
{ {
name: 'Kalender', name: 'Kalender',
href: '/kalender', href: '/kalender',
@ -109,20 +114,13 @@ type SidebarProps = {
sidebarOpen: boolean sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void setSidebarOpen: (open: boolean) => void
onNavigate?: () => void onNavigate?: () => void
unreadChatCount?: number
} }
function classNames(...classes: Array<string | false | null | undefined>) { function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') 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) { function isPathActive(pathname: string, href: string) {
if (href === '/') { if (href === '/') {
return pathname === '/' return pathname === '/'
@ -170,12 +168,13 @@ function SidebarContent({
user, user,
setSidebarOpen, setSidebarOpen,
onNavigate, onNavigate,
unreadChatCount = 0,
}: { }: {
user: User user: User
setSidebarOpen: (open: boolean) => void setSidebarOpen: (open: boolean) => void
onNavigate?: () => void onNavigate?: () => void
unreadChatCount?: number
}) { }) {
const teams = user.teams ?? []
const location = useLocation() const location = useLocation()
const visibleNavigationSections = getVisibleNavigationSections(user) const visibleNavigationSections = getVisibleNavigationSections(user)
@ -222,6 +221,11 @@ function SidebarContent({
)} )}
/> />
<span className="truncate">{item.name}</span> <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> </NavLink>
{showChildren && ( {showChildren && (
@ -298,42 +302,6 @@ function SidebarContent({
</div> </div>
</li> </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"> <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"> <h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Hilfe & System Hilfe & System
@ -413,6 +381,7 @@ export default function Sidebar({
sidebarOpen, sidebarOpen,
setSidebarOpen, setSidebarOpen,
onNavigate, onNavigate,
unreadChatCount = 0,
}: SidebarProps) { }: SidebarProps) {
return ( return (
<> <>
@ -449,6 +418,7 @@ export default function Sidebar({
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate} onNavigate={onNavigate}
unreadChatCount={unreadChatCount}
/> />
</div> </div>
</DialogPanel> </DialogPanel>
@ -461,6 +431,7 @@ export default function Sidebar({
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
onNavigate={onNavigate} onNavigate={onNavigate}
unreadChatCount={unreadChatCount}
/> />
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
// frontend/src/components/Topbar.tsx // frontend/src/components/Topbar.tsx
import type { ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { import {
Menu, Menu,
MenuButton, MenuButton,
@ -11,15 +11,19 @@ import {
Bars3Icon, Bars3Icon,
BellIcon, BellIcon,
} from '@heroicons/react/24/outline' } 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 { Link } from 'react-router'
import Search from './Search' import Search from './Search'
import Camera from './Camera' import Camera from './Camera'
import Avatar from './Avatar' import Avatar from './Avatar'
import type { User } from './types' import type { User } from './types'
import { useNotifications } from './Notifications'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type TopbarProps = { type TopbarProps = {
user: User user: User
onUserUpdated: (user: User) => void
onLogout: () => void | Promise<void> onLogout: () => void | Promise<void>
onOpenSidebar: () => void onOpenSidebar: () => void
notificationCenter?: ReactNode notificationCenter?: ReactNode
@ -34,6 +38,7 @@ const userNavigation = [
export default function Topbar({ export default function Topbar({
user, user,
onUserUpdated,
onLogout, onLogout,
onOpenSidebar, onOpenSidebar,
notificationCenter, notificationCenter,
@ -41,10 +46,44 @@ export default function Topbar({
onSearchQueryChange, onSearchQueryChange,
onNavigate, onNavigate,
}: TopbarProps) { }: TopbarProps) {
const { showErrorToast } = useNotifications()
const [updatingPresenceMode, setUpdatingPresenceMode] = useState<
User['presenceMode'] | null
>(null)
function handleQrScan(value: string) { function handleQrScan(value: string) {
console.log('QR-Code erkannt:', value) 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 ( 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"> <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 <button
@ -98,6 +137,7 @@ export default function Topbar({
src={user.avatar} src={user.avatar}
name={user.displayName || user.username || user.email} name={user.displayName || user.username || user.email}
size={8} size={8}
presenceStatus={user.presenceStatus}
/> />
<span className="hidden lg:flex lg:items-center"> <span className="hidden lg:flex lg:items-center">
@ -116,8 +156,36 @@ export default function Topbar({
<MenuItems <MenuItems
transition 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) => ( {userNavigation.map((item) => (
<MenuItem key={item.name}> <MenuItem key={item.name}>
<Link <Link

View File

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

View File

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

View File

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

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 // frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { import {
ArrowPathIcon, ArrowPathIcon,
MagnifyingGlassIcon,
TrashIcon, TrashIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import {
ComputerDesktopIcon,
CursorArrowRaysIcon,
ExclamationTriangleIcon,
GlobeAltIcon,
} from '@heroicons/react/24/outline'
import Button from '../../../components/Button' import Button from '../../../components/Button'
import LoadingSpinner from '../../../components/LoadingSpinner' import LoadingSpinner from '../../../components/LoadingSpinner'
import Avatar from '../../../components/Avatar' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -21,6 +30,14 @@ type FeedbackItem = {
createdAt: string createdAt: string
} }
type ClientMeta = {
browser: string
operatingSystem: string
deviceType: string
url: string
viewport: string
}
type NotificationLike = { type NotificationLike = {
type?: string type?: string
entityType?: string entityType?: string
@ -39,32 +56,82 @@ function formatDate(value: string) {
function getInitials(name: string, email: string) { function getInitials(name: string, email: string) {
const source = name || email const source = name || email
if (!source) { if (!source) {
return '?' return '?'
} }
const parts = source return source
.replace(/@.*/, '') .replace(/@.*/, '')
.split(/[.\s_-]+/) .split(/[.\s_-]+/)
.filter(Boolean) .filter(Boolean)
return parts
.slice(0, 2) .slice(0, 2)
.map((part) => part.charAt(0)) .map((part) => part.charAt(0))
.join('') .join('')
.toUpperCase() .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 { function extractNotification(eventData: string): NotificationLike | null {
try { try {
const parsed = JSON.parse(eventData) const parsed = JSON.parse(eventData)
return parsed?.notification ?? parsed
if (parsed?.notification) {
return parsed.notification
}
return parsed
} catch { } catch {
return null 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() { export default function FeedbackAdministration() {
const [feedback, setFeedback] = useState<FeedbackItem[]>([]) const [feedback, setFeedback] = useState<FeedbackItem[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -90,17 +172,28 @@ export default function FeedbackAdministration() {
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>( const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
() => new Set(), () => new Set(),
) )
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const sortedFeedback = useMemo( const visibleFeedback = useMemo(() => {
() => const sorted = [...feedback].sort(
[...feedback].sort( (firstItem, secondItem) =>
(firstItem, secondItem) => new Date(secondItem.createdAt).getTime() -
new Date(secondItem.createdAt).getTime() - new Date(firstItem.createdAt).getTime(),
new Date(firstItem.createdAt).getTime(), )
),
[feedback], 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 }) => { const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
@ -122,12 +215,11 @@ export default function FeedbackAdministration() {
} }
const data = await response.json() const data = await response.json()
setFeedback(Array.isArray(data.feedback) ? data.feedback : []) setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
} catch (error) { } catch (loadError) {
setError( setError(
error instanceof Error loadError instanceof Error
? error.message ? loadError.message
: 'Feedback konnte nicht geladen werden', : 'Feedback konnte nicht geladen werden',
) )
} finally { } finally {
@ -146,9 +238,7 @@ export default function FeedbackAdministration() {
}) })
events.onmessage = (event) => { events.onmessage = (event) => {
const notification = extractNotification(event.data) if (isFeedbackEvent(extractNotification(event.data))) {
if (isFeedbackEvent(notification)) {
void loadFeedback({ silent: true }) void loadFeedback({ silent: true })
} }
} }
@ -166,17 +256,11 @@ export default function FeedbackAdministration() {
const confirmed = window.confirm( const confirmed = window.confirm(
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`, `Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
) )
if (!confirmed) { if (!confirmed) {
return return
} }
setDeletingFeedbackIds((currentIds) => { setDeletingFeedbackIds((currentIds) => new Set(currentIds).add(feedbackItem.id))
const nextIds = new Set(currentIds)
nextIds.add(feedbackItem.id)
return nextIds
})
setError(null) setError(null)
try { try {
@ -193,10 +277,10 @@ export default function FeedbackAdministration() {
setFeedback((currentFeedback) => setFeedback((currentFeedback) =>
currentFeedback.filter((item) => item.id !== feedbackItem.id), currentFeedback.filter((item) => item.id !== feedbackItem.id),
) )
} catch (error) { } catch (deleteError) {
setError( setError(
error instanceof Error deleteError instanceof Error
? error.message ? deleteError.message
: 'Feedback konnte nicht gelöscht werden', : 'Feedback konnte nicht gelöscht werden',
) )
} finally { } finally {
@ -223,75 +307,95 @@ export default function FeedbackAdministration() {
return ( return (
<section> <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> <div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-base font-semibold text-gray-900 dark:text-white">
Feedback 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> </h2>
<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">
Eingereichtes Feedback inklusive technischem Log einsehen und verwalten. Eingereichtes Feedback inklusive Aktivitäts- und Fehlerprotokoll.
</p> </p>
</div> </div>
<Button <div className="flex items-center gap-2">
type="button" <div className="relative">
variant="secondary" <MagnifyingGlassIcon
color="gray" className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
size="sm" aria-hidden="true"
disabled={isRefreshing} />
isLoading={isRefreshing} <input
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />} value={search}
onClick={() => void loadFeedback({ silent: true })} onChange={(event) => setSearch(event.target.value)}
> placeholder="Suchen..."
Aktualisieren 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"
</Button> />
</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> </div>
{error && ( {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} {error}
</div> </div>
)} )}
{sortedFeedback.length === 0 ? ( {visibleFeedback.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"> <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"> <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> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{sortedFeedback.map((feedbackItem) => { {visibleFeedback.map((feedbackItem) => {
const isDeleting = deletingFeedbackIds.has(feedbackItem.id) 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 ( return (
<article <article
key={feedbackItem.id} 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 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-x-3"> <div className="flex min-w-0 items-start gap-3">
<Avatar <Avatar
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)} initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
name={feedbackItem.userName || feedbackItem.userEmail} name={feedbackItem.userName || feedbackItem.userEmail}
size={10} size={10}
/> />
<div className="min-w-0"> <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"> <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{feedbackItem.userName || 'Unbekannter Benutzer'} {feedbackItem.userName || 'Unbekannter Benutzer'}
</h3> </h3>
{feedbackItem.userEmail && ( {feedbackItem.userEmail && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{feedbackItem.userEmail} {feedbackItem.userEmail}
</span> </span>
)} )}
</div> </div>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatDate(feedbackItem.createdAt)} {formatDate(feedbackItem.createdAt)}
</p> </p>
</div> </div>
@ -311,19 +415,61 @@ export default function FeedbackAdministration() {
</Button> </Button>
</div> </div>
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"> <div className="p-5">
{feedbackItem.message} <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">
</p> {feedbackItem.message}
</p>
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5"> {client && (
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300"> <div className="mt-4 flex flex-wrap gap-2">
Technisches Log anzeigen {client.browser && (
</summary> <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"> <details className="group mt-4 overflow-hidden rounded-xl border border-gray-200 dark:border-white/10">
{JSON.stringify(feedbackItem.log, null, 2)} <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">
</pre> <span className="flex items-center gap-2">
</details> <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> </article>
) )
})} })}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -44,13 +44,13 @@ type DeviceFormFieldsProps = {
} }
export const inputClassName = 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 = 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 = 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>) { function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
@ -64,8 +64,9 @@ export function SectionHeader({
description?: string description?: string
}) { }) {
return ( return (
<div className="border-b border-gray-200 pb-3 dark:border-white/10"> <div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <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} {title}
</h3> </h3>
@ -402,7 +403,7 @@ export default function DeviceFormFields({
} }
return ( 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 && ( {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"> <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} {lookupError}
@ -589,7 +590,7 @@ export default function DeviceFormFields({
)} )}
{isMilestoneDevice && ( {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" /> <input type="hidden" name="isMilestoneDevice" value="true" />
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20"> <div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
@ -672,7 +673,7 @@ export default function DeviceFormFields({
Milestone-Status Milestone-Status
</label> </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 <VideoCameraIcon
aria-hidden="true" aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400" className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
@ -838,7 +839,7 @@ export default function DeviceFormFields({
</div> </div>
<div className="sm:col-span-6"> <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 <Switch
id="isBaoDevice" id="isBaoDevice"
name="isBaoDevice" name="isBaoDevice"
@ -901,7 +902,7 @@ export default function DeviceFormFields({
</span> </span>
</div> </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 ? ( {assignableDevices.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine anderen Geräte vorhanden. Noch keine anderen Geräte vorhanden.
@ -952,7 +953,7 @@ export default function DeviceFormFields({
/> />
{!isMilestoneDevice ? ( {!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. Dieses Gerät ist kein Milestone-Gerät.
</div> </div>
) : ( ) : (

View File

@ -34,10 +34,10 @@ export default function DeviceModalTabs({
onChange, onChange,
}: DeviceModalTabsProps) { }: DeviceModalTabsProps) {
return ( 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 <nav
aria-label="Geräteformular" 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) => { {tabs.map((tab) => {
const isActive = activeTab === tab.id const isActive = activeTab === tab.id
@ -50,10 +50,10 @@ export default function DeviceModalTabs({
disabled={tab.disabled} disabled={tab.disabled}
onClick={() => onChange(tab.id)} onClick={() => onChange(tab.id)}
className={classNames( 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 isActive
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400' ? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
: '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', : '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', tab.disabled && 'cursor-not-allowed opacity-50',
)} )}
> >

View File

@ -6,7 +6,6 @@ import {
ArrowPathIcon, ArrowPathIcon,
CheckCircleIcon, CheckCircleIcon,
ClockIcon, ClockIcon,
ExclamationTriangleIcon,
PlusIcon, PlusIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
XCircleIcon, XCircleIcon,
@ -110,20 +109,6 @@ function formatFirmwareCheckRemaining(nextAllowedAt?: string | null, now = Date.
return `in ${hours} Std. ${remainingMinutes} Min.` 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']> type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
function getLoanStatusBadgeTone(status: string): BadgeTone { function getLoanStatusBadgeTone(status: string): BadgeTone {
@ -769,7 +754,7 @@ export default function DevicesPage() {
recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on', recordKeyframesOnly: formData.get('recordKeyframesOnly') === 'on',
recordingStorageId: recordingStorageId:
String(formData.get('recordingStorageId') ?? '').trim() || null, String(formData.get('recordingStorageId') ?? '').trim() || null,
streams: cameraDetails.streams.map((stream, index) => ({ streams: cameraDetails.streams.map((_stream, index) => ({
index, index,
codec: String(formData.get(`streamCodec-${index}`) ?? ''), codec: String(formData.get(`streamCodec-${index}`) ?? ''),
fps: Number(formData.get(`streamFps-${index}`) ?? 0) || null, 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 firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
const isFirmwareCheckLocked = const isFirmwareCheckLocked =
Boolean(firmwareCheckNextAllowedAt) && Boolean(firmwareCheckNextAllowedAt) &&
@ -1250,12 +1232,16 @@ export default function DevicesPage() {
] ]
return ( 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 <Tables
title="Geräte" 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." description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
headerAction={ 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"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
type="button" type="button"
@ -1283,7 +1269,7 @@ export default function DevicesPage() {
</div> </div>
{firmwareCheckNextAllowedLabel && ( {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" /> <ClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
<span>{firmwareCheckNextAllowedLabel}</span> <span>{firmwareCheckNextAllowedLabel}</span>
</div> </div>
@ -1291,6 +1277,7 @@ export default function DevicesPage() {
</div> </div>
} }
onAction={openCreateModal} 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} rows={devices}
columns={columns} columns={columns}
getRowId={(device) => device.id} getRowId={(device) => device.id}

View File

@ -7,7 +7,6 @@ import {
type ComponentType, type ComponentType,
type FormEvent, type FormEvent,
type MouseEvent, type MouseEvent,
type ReactNode,
type SVGProps, type SVGProps,
} from 'react' } from 'react'
import { DialogTitle } from '@headlessui/react' import { DialogTitle } from '@headlessui/react'
@ -25,14 +24,11 @@ import {
UserGroupIcon, UserGroupIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import {
CircleMarker,
MapContainer,
TileLayer,
} from 'react-leaflet'
import Modal from '../../components/Modal' import Modal from '../../components/Modal'
import Button from '../../components/Button' import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox' import AddressCombobox, {
type AddressArea,
} from '../../components/AddressCombobox'
import Textarea from '../../components/Textarea' import Textarea from '../../components/Textarea'
import Combobox, { type ComboboxItem } from '../../components/Combobox' import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox' import MultiCombobox from '../../components/MultiCombobox'
@ -43,6 +39,8 @@ import ServerFolderPicker, {
joinServerFolderPath, joinServerFolderPath,
} from '../../components/ServerFolderPicker' } from '../../components/ServerFolderPicker'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter' 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 API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG' const OPERATION_UNIT = 'TEG'
@ -227,7 +225,7 @@ function hasInvalidWindowsFolderNameCharacters(value: string) {
type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>> type SetupStepIcon = ComponentType<SVGProps<SVGSVGElement>>
const inputClassName = 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 = 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' '!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({ function StepHeader({
title, title,
description, description,
@ -718,8 +453,9 @@ function StepHeader({
description: string description: string
}) { }) {
return ( return (
<div> <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">
<h3 className="text-base font-semibold text-gray-900 dark:text-white"> <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} {title}
</h3> </h3>
<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">
@ -739,6 +475,12 @@ export default function CreateOperationModal({
const [activeStep, setActiveStep] = useState<SetupStepId>('masterData') const [activeStep, setActiveStep] = useState<SetupStepId>('masterData')
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues) const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
const [localError, setLocalError] = useState<string | null>(null) 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 [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([]) const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
@ -781,6 +523,12 @@ export default function CreateOperationModal({
setFormValues(initialFormValues) setFormValues(initialFormValues)
setSelectedOperationLeader(null) setSelectedOperationLeader(null)
setSelectedOperationTeam([]) setSelectedOperationTeam([])
setKwPosition(null)
setTargetPosition(null)
setKwArea(null)
setTargetArea(null)
setViewConeFov(36)
setViewConeLength(0)
setLocalError(null) setLocalError(null)
setMilestoneStorages([]) setMilestoneStorages([])
setSelectedMilestoneStorage(null) setSelectedMilestoneStorage(null)
@ -1074,7 +822,7 @@ export default function CreateOperationModal({
open={open} open={open}
setOpen={handleOpenChange} setOpen={handleOpenChange}
zIndexClassName="z-[9999]" 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 <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -1086,6 +834,12 @@ export default function CreateOperationModal({
<input type="hidden" name="caseWorker" value={formValues.caseWorker} /> <input type="hidden" name="caseWorker" value={formValues.caseWorker} />
<input type="hidden" name="targetObject" value={formValues.targetObject} /> <input type="hidden" name="targetObject" value={formValues.targetObject} />
<input type="hidden" name="kwAddress" value={formValues.kwAddress} /> <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="operationLeader" value={formValues.operationLeader} />
<input type="hidden" name="operationTeam" value={formValues.operationTeam} /> <input type="hidden" name="operationTeam" value={formValues.operationTeam} />
<input type="hidden" name="legend" value={formValues.legend} /> <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="remark" value={formValues.remark} />
<input type="hidden" name="milestoneStorage" value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} /> <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 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> <div className="flex min-w-0 items-start gap-3.5">
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <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">
Einsatz anlegen <ClipboardDocumentListIcon aria-hidden="true" className="size-5" />
</DialogTitle> </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. Folge den Schritten oder überspringe optionale Bereiche.
</p> </p>
</div>
</div> </div>
<Button <Button
@ -1122,9 +882,10 @@ export default function CreateOperationModal({
</Button> </Button>
</div> </div>
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10"> <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="grid grid-cols-2 gap-2 md:grid-cols-7"> <nav aria-label="Einrichtungsschritte" className="overflow-x-auto pb-1">
{setupSteps.map((step, index) => { <div className="grid min-w-[52rem] grid-cols-7 gap-2">
{setupSteps.map((step, index) => {
const isActive = step.id === activeStep const isActive = step.id === activeStep
const isDone = index < activeStepIndex const isDone = index < activeStepIndex
const Icon = step.icon const Icon = step.icon
@ -1135,12 +896,12 @@ export default function CreateOperationModal({
type="button" type="button"
onClick={() => goToStep(step.id)} onClick={() => goToStep(step.id)}
className={classNames( className={classNames(
'rounded-lg border px-3 py-2 text-left transition', 'rounded-xl border px-3 py-2.5 text-left transition',
isActive 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 : 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-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-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-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"> <span className="flex items-center gap-x-1.5 text-[11px] font-medium">
@ -1153,17 +914,18 @@ export default function CreateOperationModal({
</span> </span>
</button> </button>
) )
})} })}
</div>
</nav> </nav>
</div> </div>
{combinedError && ( {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} {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' && ( {activeStep === 'masterData' && (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@ -1231,14 +993,18 @@ export default function CreateOperationModal({
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst." 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 <AddressCombobox
id="createKwAddress" id="createKwAddress"
name="createKwAddressDisplay" name="createKwAddressDisplay"
label="KW" label="KW"
value={formValues.kwAddress} value={formValues.kwAddress}
onChange={(value) => updateField('kwAddress', value)} onChange={(value) => updateField('kwAddress', value)}
position={kwPosition}
onPositionChange={setKwPosition}
onAreaChange={setKwArea}
placeholder="KW-Adresse suchen oder eingeben" placeholder="KW-Adresse suchen oder eingeben"
showMap={false}
/> />
<AddressCombobox <AddressCombobox
@ -1247,9 +1013,27 @@ export default function CreateOperationModal({
label="Zielobjekt" label="Zielobjekt"
value={formValues.targetObject} value={formValues.targetObject}
onChange={(value) => updateField('targetObject', value)} onChange={(value) => updateField('targetObject', value)}
position={targetPosition}
onPositionChange={setTargetPosition}
onAreaChange={setTargetArea}
placeholder="Adresse suchen oder Zielobjekt eingeben" placeholder="Adresse suchen oder Zielobjekt eingeben"
showMap={false}
/> />
</div> </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> </div>
)} )}
@ -1775,16 +1559,28 @@ export default function CreateOperationModal({
</div> </div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ReviewAddressItem <ReviewItem label="KW" value={formValues.kwAddress} />
label="KW" <ReviewItem
value={formValues.kwAddress}
/>
<ReviewAddressItem
label="Zielobjekt" label="Zielobjekt"
value={formValues.targetObject} value={formValues.targetObject}
/> />
</div> </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>
<div> <div>
@ -1855,9 +1651,18 @@ export default function CreateOperationModal({
)} )}
</div> </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="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="text-sm text-gray-500 dark:text-gray-400"> <div className="min-w-36">
Schritt {activeStepIndex + 1} von {setupSteps.length} <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>
<div className="flex justify-end gap-x-3"> <div className="flex justify-end gap-x-3">

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 Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner' import LoadingSpinner from '../../components/LoadingSpinner'
import type { Operation } from '../../components/types' 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' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -21,8 +26,6 @@ type AddressSuggestion = {
type OperationMarkerType = 'kwAddress' | 'targetObject' type OperationMarkerType = 'kwAddress' | 'targetObject'
type OperationMapMode = 'markers' | 'heatmap'
type OperationMarker = { type OperationMarker = {
id: string id: string
operation: Operation operation: Operation
@ -40,6 +43,7 @@ type OperationViewCone = {
color: string color: string
positions: Array<[number, number]> positions: Array<[number, number]>
centerLine: [[number, number], [number, number]] centerLine: [[number, number], [number, number]]
targetLine: [[number, number], [number, number]]
} }
type OperationMapProps = { type OperationMapProps = {
@ -218,22 +222,40 @@ function getOperationAddresses(operation: Operation) {
const addresses: Array<{ const addresses: Array<{
type: OperationMarkerType type: OperationMarkerType
address: string address: string
position: OperationCoordinates | null
}> = [] }> = []
const kwAddress = operation.kwAddress?.trim() const kwAddress = operation.kwAddress?.trim()
const targetObject = operation.targetObject?.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({ addresses.push({
type: 'kwAddress', type: 'kwAddress',
address: kwAddress, address: kwAddress || 'KW',
position: kwPosition,
}) })
} }
if (targetObject) { if (targetObject || isValidOperationCoordinates(targetPosition)) {
addresses.push({ addresses.push({
type: 'targetObject', 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[]) { function getOperationViewCones(markers: OperationMarker[]) {
const markersByOperation = new globalThis.Map<string, OperationMarker[]>() const markersByOperation = new globalThis.Map<string, OperationMarker[]>()
@ -438,12 +356,14 @@ function getOperationViewCones(markers: OperationMarker[]) {
return return
} }
const positions = createViewConePositions({ const geometry = createOperationViewCone({
from: kwMarker, from: kwMarker,
to: targetMarker, to: targetMarker,
fov: kwMarker.operation.viewConeFov || 36,
length: kwMarker.operation.viewConeLength || 0,
}) })
if (positions.length === 0) { if (!geometry) {
return return
} }
@ -451,11 +371,9 @@ function getOperationViewCones(markers: OperationMarker[]) {
id: `${operationId}-view-cone`, id: `${operationId}-view-cone`,
operation: kwMarker.operation, operation: kwMarker.operation,
color: kwMarker.color, color: kwMarker.color,
positions, positions: geometry.positions,
centerLine: [ centerLine: geometry.centerLine,
[kwMarker.lat, kwMarker.lon], targetLine: geometry.targetLine,
[targetMarker.lat, targetMarker.lon],
],
}) })
}) })
@ -503,7 +421,12 @@ export default function OperationMap({
new Set( new Set(
operationsWithAddresses operationsWithAddresses
.flatMap((operation) => .flatMap((operation) =>
getOperationAddresses(operation).map((item) => item.address), getOperationAddresses(operation)
.filter(
(item) =>
!isValidOperationCoordinates(item.position),
)
.map((item) => item.address),
) )
.filter(Boolean), .filter(Boolean),
), ),
@ -537,7 +460,15 @@ export default function OperationMap({
const nextMarkers = operationsWithAddresses const nextMarkers = operationsWithAddresses
.flatMap((operation) => .flatMap((operation) =>
getOperationAddresses(operation).map((addressItem) => { 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) { if (!suggestion) {
return null return null
@ -713,6 +644,19 @@ 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> </AppMap>
</div> </div>
</div> </div>

View File

@ -28,13 +28,17 @@ import Modal from '../../components/Modal'
import Journal, { type JournalItem } from '../../components/Journal' import Journal, { type JournalItem } from '../../components/Journal'
import Button from '../../components/Button' import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner' import LoadingSpinner from '../../components/LoadingSpinner'
import AddressCombobox from '../../components/AddressCombobox' import AddressCombobox, {
import type { Operation, OperationHistoryChange, OperationHistoryEntry } from '../../components/types' type AddressArea,
} from '../../components/AddressCombobox'
import type { Operation, OperationHistoryEntry } from '../../components/types'
import { formatOperationNumberInput } from '../../components/formatter' import { formatOperationNumberInput } from '../../components/formatter'
import NotificationDot from '../../components/NotificationDot' import NotificationDot from '../../components/NotificationDot'
import Combobox, { type ComboboxItem } from '../../components/Combobox' import Combobox, { type ComboboxItem } from '../../components/Combobox'
import MultiCombobox from '../../components/MultiCombobox' import MultiCombobox from '../../components/MultiCombobox'
import Tabs from '../../components/Tabs' import Tabs from '../../components/Tabs'
import type { OperationCoordinates } from '../../utils/operationGeometry'
import OperationGeometryEditor from './OperationGeometryEditor'
type OperationModalProps = { type OperationModalProps = {
open: boolean open: boolean
@ -65,10 +69,10 @@ type OperationTabId =
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>> type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
const inputClassName = 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 = 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 API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const OPERATION_UNIT = 'TEG' const OPERATION_UNIT = 'TEG'
@ -354,8 +358,9 @@ function SectionHeader({
description?: string description?: string
}) { }) {
return ( return (
<div className="border-b border-gray-200 pb-3 dark:border-white/10"> <div className="relative border-b border-gray-200 pb-4 pl-3 dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white"> <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} {title}
</h3> </h3>
@ -415,6 +420,12 @@ export default function OperationModal({
const [activeTab, setActiveTab] = useState<OperationTabId>('masterData') const [activeTab, setActiveTab] = useState<OperationTabId>('masterData')
const [targetObject, setTargetObject] = useState('') const [targetObject, setTargetObject] = useState('')
const [kwAddress, setKwAddress] = 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 [operationLeaderOptions, setOperationLeaderOptions] = useState<ComboboxItem[]>([])
const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([]) const [operationTeamOptions, setOperationTeamOptions] = useState<ComboboxItem[]>([])
@ -458,8 +469,30 @@ export default function OperationModal({
} }
setActiveTab('masterData') setActiveTab('masterData')
setKwArea(null)
setTargetArea(null)
setTargetObject(editingOperation?.targetObject ?? '') setTargetObject(editingOperation?.targetObject ?? '')
setKwAddress(editingOperation?.kwAddress ?? '') 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([]) setMilestoneStorages([])
setSelectedMilestoneStorage(null) setSelectedMilestoneStorage(null)
setShowCreateStorageForm(false) setShowCreateStorageForm(false)
@ -471,6 +504,12 @@ export default function OperationModal({
editingOperation?.id, editingOperation?.id,
editingOperation?.targetObject, editingOperation?.targetObject,
editingOperation?.kwAddress, editingOperation?.kwAddress,
editingOperation?.kwLatitude,
editingOperation?.kwLongitude,
editingOperation?.targetLatitude,
editingOperation?.targetLongitude,
editingOperation?.viewConeFov,
editingOperation?.viewConeLength,
]) ])
useEffect(() => { useEffect(() => {
@ -856,7 +895,7 @@ export default function OperationModal({
open={open} open={open}
setOpen={handleOpenChange} setOpen={handleOpenChange}
zIndexClassName="z-[9999]" 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 <form
key={editingOperation?.id ?? 'create'} key={editingOperation?.id ?? 'create'}
@ -898,18 +937,30 @@ export default function OperationModal({
name="milestoneStorage" name="milestoneStorage"
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''} 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 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> <div className="flex min-w-0 items-start gap-3.5">
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <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'} {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 {isEditing
? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.' ? 'Bearbeite Einsatzdaten, Zuständigkeiten, Zielobjekt und eingesetzte Technik.'
: 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'} : 'Erfasse einen neuen Einsatz inklusive Einsatznummer, Einsatzname und Technik.'}
</p> </p>
</div>
</div> </div>
<Button <Button
@ -924,8 +975,8 @@ export default function OperationModal({
</Button> </Button>
</div> </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="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="-mb-px flex gap-x-6 overflow-x-auto"> <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) => { {visibleTabs.map((tab) => {
const Icon = tab.icon const Icon = tab.icon
@ -941,10 +992,10 @@ export default function OperationModal({
} }
}} }}
className={classNames( 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 activeTab === tab.id
? 'border-indigo-600 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400' ? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/5 dark:bg-white/10 dark:text-indigo-300 dark:ring-white/10'
: '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', : '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"> <span className="inline-flex items-center gap-x-2">
@ -966,13 +1017,13 @@ export default function OperationModal({
</div> </div>
{error && ( {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} {error}
</div> </div>
)} )}
<div className="min-h-0 flex-1 overflow-y-auto"> <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 */} {/* Stammdaten */}
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}> <div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
@ -1045,25 +1096,51 @@ export default function OperationModal({
description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau." description="Zielobjekt und KW-Adresse mit Adresssuche und Karten-Vorschau."
/> />
<div className="mt-4 grid grid-cols-2 gap-4 xl:grid-cols-2"> <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<AddressCombobox
id="targetObject"
name="targetObject"
label="Zielobjekt"
value={targetObject}
onChange={setTargetObject}
placeholder="Adresse suchen oder Zielobjekt eingeben"
mapVisible={activeTab === 'addresses'}
/>
<AddressCombobox <AddressCombobox
key={`kw-${editingOperation?.id ?? 'new'}-${open}`}
id="kwAddress" id="kwAddress"
name="kwAddress" name="kwAddress"
label="KW" label="KW"
value={kwAddress} value={kwAddress}
onChange={setKwAddress} onChange={setKwAddress}
position={kwPosition}
onPositionChange={setKwPosition}
onAreaChange={setKwArea}
placeholder="KW-Adresse suchen oder eingeben" placeholder="KW-Adresse suchen oder eingeben"
mapVisible={activeTab === 'addresses'} 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> </div>
</section> </section>
@ -1796,7 +1873,7 @@ export default function OperationModal({
</div> </div>
</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 <Button
type="button" type="button"
variant="secondary" variant="secondary"

View File

@ -908,6 +908,15 @@ export default function OperationsPage({
const form = event.currentTarget const form = event.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const operationToEdit = editingOperation 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) setIsSaving(true)
setSaveError(null) setSaveError(null)
@ -919,6 +928,12 @@ export default function OperationsPage({
caseWorker: String(formData.get('caseWorker') ?? ''), caseWorker: String(formData.get('caseWorker') ?? ''),
targetObject: String(formData.get('targetObject') ?? ''), targetObject: String(formData.get('targetObject') ?? ''),
kwAddress: String(formData.get('kwAddress') ?? ''), 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') ?? ''), operationLeader: String(formData.get('operationLeader') ?? ''),
operationTeam: String(formData.get('operationTeam') ?? ''), operationTeam: String(formData.get('operationTeam') ?? ''),
legend: String(formData.get('legend') ?? ''), legend: String(formData.get('legend') ?? ''),
@ -929,6 +944,7 @@ export default function OperationsPage({
hardDrive: String(formData.get('hardDrive') ?? ''), hardDrive: String(formData.get('hardDrive') ?? ''),
laptop: String(formData.get('laptop') ?? ''), laptop: String(formData.get('laptop') ?? ''),
remark: String(formData.get('remark') ?? ''), remark: String(formData.get('remark') ?? ''),
milestoneStorage: String(formData.get('milestoneStorage') ?? ''),
} }
try { try {

View File

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

View File

@ -32,6 +32,8 @@ type NotificationSettings = {
deviceUpdates: boolean deviceUpdates: boolean
deviceJournals: boolean deviceJournals: boolean
systemMessages: boolean systemMessages: boolean
chatMessages: boolean
desktopEnabled: boolean
} }
const defaultSettings: NotificationSettings = { const defaultSettings: NotificationSettings = {
@ -44,6 +46,8 @@ const defaultSettings: NotificationSettings = {
deviceUpdates: true, deviceUpdates: true,
deviceJournals: true, deviceJournals: true,
systemMessages: true, systemMessages: true,
chatMessages: true,
desktopEnabled: false,
} }
const notificationSounds: Array<{ 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) { if (isLoading) {
return ( return (
<Section <Section
@ -293,11 +323,29 @@ export default function Benachrichtigungen() {
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest." description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
> >
<div className="space-y-6 sm:max-w-2xl"> <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 <NotificationCard
icon={<BellIcon aria-hidden="true" className="size-5" />} icon={<BellIcon aria-hidden="true" className="size-5" />}
title="In-App-Benachrichtigungen" title="In-App-Benachrichtigungen"
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden." 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 <Switch
checked={settings.soundEnabled} checked={settings.soundEnabled}
onChange={(event) => onChange={(event) =>
@ -355,11 +403,52 @@ export default function Benachrichtigungen() {
</div> </div>
</NotificationCard> </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 <NotificationCard
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />} icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
title="Ereignisse" title="Ereignisse"
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest." 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 <Switch
checked={settings.operationUpdates} checked={settings.operationUpdates}
onChange={(event) => onChange={(event) =>
@ -380,26 +469,6 @@ export default function Benachrichtigungen() {
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen." 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 <Switch
checked={settings.systemMessages} checked={settings.systemMessages}
onChange={(event) => onChange={(event) =>

View File

@ -17,6 +17,7 @@ import {
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import { useNotifications } from '../../../components/Notifications' import { useNotifications } from '../../../components/Notifications'
import Avatar from '../../../components/Avatar' import Avatar from '../../../components/Avatar'
import Combobox, { type ComboboxItem } from '../../../components/Combobox'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' 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') ?? ''), username: String(formData.get('username') ?? ''),
email: String(formData.get('email') ?? ''), email: String(formData.get('email') ?? ''),
avatar: String(formData.get('avatar') ?? ''), 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> </div>
<div className="sm:col-span-3"> <div className="col-span-full">
<label <Combobox
htmlFor="unit" label="Einheit"
className="block text-sm/6 font-medium text-gray-900 dark:text-white" items={
> currentUser?.unit
Einheit ? [{ id: currentUser.unit, name: currentUser.unit }]
</label> : []
}
<div className="mt-2"> value={
<input currentUser?.unit
id="unit" ? ({
name="unit" id: currentUser.unit,
type="text" name: currentUser.unit,
defaultValue={currentUser?.unit ?? ''} } satisfies ComboboxItem)
className={inputClassName} : null
/> }
</div> onChange={() => undefined}
</div> placeholder="Keine Einheit zugeordnet"
disabled
<div className="sm:col-span-3"> />
<label <p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
htmlFor="group" Die Einheit kann nur in der Benutzeradministration geändert werden.
className="block text-sm/6 font-medium text-gray-900 dark:text-white" </p>
>
Gruppe
</label>
<div className="mt-2">
<input
id="group"
name="group"
type="text"
defaultValue={currentUser?.group ?? ''}
className={inputClassName}
/>
</div>
</div> </div>
</div> </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,
}
}