updated
This commit is contained in:
parent
2b10d894a7
commit
9ecb3acb14
@ -52,6 +52,28 @@ type milestoneCredentials struct {
|
||||
SkipTLSVerify bool
|
||||
}
|
||||
|
||||
type milestoneSyncResult struct {
|
||||
TokenReceived bool `json:"tokenReceived"`
|
||||
TokenType string `json:"tokenType"`
|
||||
TokenExpiresAt time.Time `json:"tokenExpiresAt"`
|
||||
RecordingServerID string `json:"recordingServerId"`
|
||||
RecordingServerName string `json:"recordingServerName"`
|
||||
RecordingServerVersion string `json:"recordingServerVersion"`
|
||||
HardwareCount int `json:"hardwareCount"`
|
||||
}
|
||||
|
||||
type milestoneSyncEvent struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"`
|
||||
RecordingServerID string `json:"recordingServerId,omitempty"`
|
||||
RecordingServerName string `json:"recordingServerName,omitempty"`
|
||||
RecordingServerVersion string `json:"recordingServerVersion,omitempty"`
|
||||
HardwareCount int `json:"hardwareCount,omitempty"`
|
||||
Settings *milestoneSettingsResponse `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type milestoneTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
@ -71,6 +93,68 @@ type milestoneRecordingServer struct {
|
||||
RecordingServerVersion string `json:"recordingServerVersion"`
|
||||
}
|
||||
|
||||
type milestoneHardwareResponse struct {
|
||||
Array []milestoneHardware `json:"array"`
|
||||
}
|
||||
|
||||
type milestoneHardware struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Address string `json:"address"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
Relations milestoneRelations `json:"relations"`
|
||||
}
|
||||
|
||||
type milestoneRelations struct {
|
||||
Parent milestoneRelationRef `json:"parent"`
|
||||
Self milestoneRelationRef `json:"self"`
|
||||
}
|
||||
|
||||
type milestoneRelationRef struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type milestoneHardwareDriverSettingsResponse struct {
|
||||
Array []milestoneHardwareDriverSettingsItem `json:"array"`
|
||||
}
|
||||
|
||||
type milestoneHardwareDriverSettingsItem struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"`
|
||||
Relations milestoneRelations `json:"relations"`
|
||||
}
|
||||
|
||||
type milestoneHardwareDriverSettings struct {
|
||||
DetectedModelName string `json:"detectedModelName"`
|
||||
ProductID string `json:"productID"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
Address string `json:"address"`
|
||||
HostName string `json:"hostName"`
|
||||
DeviceModelName string `json:"deviceModelName"`
|
||||
DeviceManufacturer string `json:"deviceManufacturer"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
}
|
||||
|
||||
type milestoneHardwareDevice struct {
|
||||
HardwareID string
|
||||
RecordingServerID string
|
||||
DisplayName string
|
||||
DeviceName string
|
||||
Manufacturer string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
DeviceModelName string
|
||||
SerialNumber string
|
||||
FirmwareVersion string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.getMilestoneSettings(r.Context())
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@ -207,14 +291,30 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("sync") == "false" {
|
||||
settings, err := s.getMilestoneSettings(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": settings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
syncWarning := ""
|
||||
|
||||
var syncResult milestoneSyncResult
|
||||
|
||||
credentials, err := s.getMilestoneCredentials(r.Context())
|
||||
if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" {
|
||||
if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
|
||||
syncResult, err = s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
|
||||
if err != nil {
|
||||
log.Printf("Milestone sync after settings save failed: %v", err)
|
||||
|
||||
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht abgerufen werden."
|
||||
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Hardware konnten nicht vollständig abgerufen werden."
|
||||
}
|
||||
} else {
|
||||
syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden."
|
||||
@ -232,6 +332,8 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re
|
||||
|
||||
if syncWarning != "" {
|
||||
response["warning"] = syncWarning
|
||||
} else if syncResult.TokenReceived {
|
||||
response["sync"] = syncResult
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
@ -254,10 +356,11 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil {
|
||||
syncResult, err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials)
|
||||
if err != nil {
|
||||
log.Printf("Milestone token/server sync failed: %v", err)
|
||||
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID konnten nicht abgerufen werden")
|
||||
writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden")
|
||||
return
|
||||
}
|
||||
|
||||
@ -269,9 +372,83 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": settings,
|
||||
"sync": syncResult,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http.Request) {
|
||||
credentials, err := s.getMilestoneCredentials(r.Context())
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
_, err = s.refreshMilestoneTokenAndServerIDWithProgress(
|
||||
r.Context(),
|
||||
credentials,
|
||||
func(event milestoneSyncEvent) {
|
||||
writeMilestoneSyncEvent(w, event)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Milestone token/server sync stream failed: %v", err)
|
||||
|
||||
writeMilestoneSyncEvent(w, milestoneSyncEvent{
|
||||
Type: "error",
|
||||
Message: "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.getMilestoneSettings(r.Context())
|
||||
if err != nil {
|
||||
writeMilestoneSyncEvent(w, milestoneSyncEvent{
|
||||
Type: "error",
|
||||
Message: "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeMilestoneSyncEvent(w, milestoneSyncEvent{
|
||||
Type: "done",
|
||||
Settings: &settings,
|
||||
})
|
||||
}
|
||||
|
||||
func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) {
|
||||
if err := json.NewEncoder(w).Encode(event); err != nil {
|
||||
log.Printf("Milestone sync stream event could not be written: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func emitMilestoneSyncEvent(
|
||||
progress func(milestoneSyncEvent),
|
||||
event milestoneSyncEvent,
|
||||
) {
|
||||
if progress != nil {
|
||||
progress(event)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) {
|
||||
var settings milestoneSettingsResponse
|
||||
var updatedAt time.Time
|
||||
@ -331,15 +508,25 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes
|
||||
func (s *Server) refreshMilestoneTokenAndServerID(
|
||||
ctx context.Context,
|
||||
credentials milestoneCredentials,
|
||||
) error {
|
||||
) (milestoneSyncResult, error) {
|
||||
return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil)
|
||||
}
|
||||
|
||||
func (s *Server) refreshMilestoneTokenAndServerIDWithProgress(
|
||||
ctx context.Context,
|
||||
credentials milestoneCredentials,
|
||||
progress func(milestoneSyncEvent),
|
||||
) (milestoneSyncResult, error) {
|
||||
var syncResult milestoneSyncResult
|
||||
|
||||
tokenResponse, err := s.requestMilestoneToken(ctx, credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
accessToken := strings.TrimSpace(tokenResponse.AccessToken)
|
||||
if accessToken == "" {
|
||||
return errors.New("milestone returned empty access token")
|
||||
return syncResult, errors.New("milestone returned empty access token")
|
||||
}
|
||||
|
||||
tokenType := strings.TrimSpace(tokenResponse.TokenType)
|
||||
@ -354,6 +541,16 @@ func (s *Server) refreshMilestoneTokenAndServerID(
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
syncResult.TokenReceived = true
|
||||
syncResult.TokenType = tokenType
|
||||
syncResult.TokenExpiresAt = tokenExpiresAt
|
||||
|
||||
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
|
||||
Type: "token",
|
||||
TokenType: tokenType,
|
||||
TokenExpiresAt: &tokenExpiresAt,
|
||||
})
|
||||
|
||||
serverID, serverName, serverVersion, err := s.requestMilestoneServerID(
|
||||
ctx,
|
||||
credentials.Host,
|
||||
@ -361,14 +558,41 @@ func (s *Server) refreshMilestoneTokenAndServerID(
|
||||
accessToken,
|
||||
credentials.SkipTLSVerify,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
syncResult.RecordingServerID = serverID
|
||||
syncResult.RecordingServerName = serverName
|
||||
syncResult.RecordingServerVersion = serverVersion
|
||||
|
||||
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
|
||||
Type: "recordingServer",
|
||||
RecordingServerID: serverID,
|
||||
RecordingServerName: serverName,
|
||||
RecordingServerVersion: serverVersion,
|
||||
})
|
||||
|
||||
hardwareDevices, err := s.requestMilestoneHardwareDevices(
|
||||
ctx,
|
||||
credentials.Host,
|
||||
serverID,
|
||||
accessToken,
|
||||
credentials.SkipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return syncResult, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
encryptionKey := s.milestoneEncryptionKey()
|
||||
|
||||
_, err = s.db.Exec(
|
||||
_, err = tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
UPDATE milestone_settings
|
||||
@ -392,8 +616,33 @@ func (s *Server) refreshMilestoneTokenAndServerID(
|
||||
serverName,
|
||||
serverVersion,
|
||||
)
|
||||
if err != nil {
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
return err
|
||||
syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices)
|
||||
if err != nil {
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
syncResult.HardwareCount = syncedHardwareCount
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return syncResult, err
|
||||
}
|
||||
|
||||
emitMilestoneSyncEvent(progress, milestoneSyncEvent{
|
||||
Type: "hardware",
|
||||
HardwareCount: syncResult.HardwareCount,
|
||||
})
|
||||
|
||||
log.Printf(
|
||||
"Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d",
|
||||
serverID,
|
||||
syncedHardwareCount,
|
||||
)
|
||||
|
||||
return syncResult, nil
|
||||
}
|
||||
|
||||
func (s *Server) requestMilestoneServerID(
|
||||
@ -498,6 +747,269 @@ func (s *Server) requestMilestoneServerID(
|
||||
return recordingServerID, recordingServerName, recordingServerVersion, nil
|
||||
}
|
||||
|
||||
func (s *Server) requestMilestoneHardwareDevices(
|
||||
ctx context.Context,
|
||||
host string,
|
||||
recordingServerID string,
|
||||
accessToken string,
|
||||
skipTLSVerify bool,
|
||||
) ([]milestoneHardwareDevice, error) {
|
||||
hardwareItems, err := s.requestMilestoneHardware(
|
||||
ctx,
|
||||
host,
|
||||
recordingServerID,
|
||||
accessToken,
|
||||
skipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]milestoneHardwareDevice, 0, len(hardwareItems))
|
||||
|
||||
for _, hardware := range hardwareItems {
|
||||
hardwareID := strings.TrimSpace(hardware.ID)
|
||||
if hardwareID == "" {
|
||||
hardwareID = strings.TrimSpace(hardware.Relations.Self.ID)
|
||||
}
|
||||
|
||||
if hardwareID == "" {
|
||||
log.Printf("Milestone hardware skipped: missing hardware id")
|
||||
continue
|
||||
}
|
||||
|
||||
settings, err := s.requestMilestoneHardwareDriverSettings(
|
||||
ctx,
|
||||
host,
|
||||
hardwareID,
|
||||
accessToken,
|
||||
skipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(hardware.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(hardware.Name)
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(settings.DeviceModelName)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(settings.DetectedModelName)
|
||||
}
|
||||
|
||||
manufacturer := normalizeMilestoneManufacturer(
|
||||
settings.DeviceManufacturer,
|
||||
model,
|
||||
)
|
||||
|
||||
if manufacturer == "" {
|
||||
manufacturer = normalizeMilestoneManufacturer(
|
||||
settings.ProductID,
|
||||
model,
|
||||
)
|
||||
}
|
||||
|
||||
if manufacturer == "" {
|
||||
manufacturer = extractMilestoneManufacturerFromName(model)
|
||||
}
|
||||
|
||||
deviceName := strings.TrimSpace(hardware.DeviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = displayName
|
||||
}
|
||||
|
||||
enabled := true
|
||||
|
||||
if hardware.Enabled != nil {
|
||||
enabled = *hardware.Enabled
|
||||
}
|
||||
|
||||
if hardware.Disabled != nil {
|
||||
enabled = !*hardware.Disabled
|
||||
}
|
||||
|
||||
ipAddress := extractMilestoneHardwareIPAddress(hardware.Address)
|
||||
|
||||
result = append(result, milestoneHardwareDevice{
|
||||
HardwareID: hardwareID,
|
||||
RecordingServerID: recordingServerID,
|
||||
DisplayName: displayName,
|
||||
DeviceName: deviceName,
|
||||
Manufacturer: manufacturer,
|
||||
MacAddress: strings.TrimSpace(settings.MacAddress),
|
||||
IPAddress: ipAddress,
|
||||
DeviceModelName: model,
|
||||
SerialNumber: strings.TrimSpace(settings.SerialNumber),
|
||||
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"Milestone hardware loaded successfully: recordingServerId=%s count=%d",
|
||||
recordingServerID,
|
||||
len(result),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func extractMilestoneHardwareIPAddress(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err == nil && parsedURL.Hostname() != "" {
|
||||
return parsedURL.Hostname()
|
||||
}
|
||||
|
||||
value = strings.TrimPrefix(value, "http://")
|
||||
value = strings.TrimPrefix(value, "https://")
|
||||
value = strings.TrimRight(value, "/")
|
||||
|
||||
if host, _, found := strings.Cut(value, ":"); found {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func (s *Server) requestMilestoneHardware(
|
||||
ctx context.Context,
|
||||
host string,
|
||||
recordingServerID string,
|
||||
accessToken string,
|
||||
skipTLSVerify bool,
|
||||
) ([]milestoneHardware, error) {
|
||||
requestURL := strings.TrimRight(host, "/") +
|
||||
"/API/rest/v1/recordingServers/" +
|
||||
url.PathEscape(recordingServerID) +
|
||||
"/hardware?disabled"
|
||||
|
||||
log.Printf(
|
||||
"Requesting Milestone hardware: url=%s skipTLSVerify=%t",
|
||||
requestURL,
|
||||
skipTLSVerify,
|
||||
)
|
||||
|
||||
body, err := requestMilestoneJSON(
|
||||
ctx,
|
||||
requestURL,
|
||||
accessToken,
|
||||
skipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var hardwareResponse milestoneHardwareResponse
|
||||
if err := json.Unmarshal(body, &hardwareResponse); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"milestone hardware response could not be parsed: body=%s error=%w",
|
||||
truncateMilestoneLogBody(body),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return hardwareResponse.Array, nil
|
||||
}
|
||||
|
||||
func (s *Server) requestMilestoneHardwareDriverSettings(
|
||||
ctx context.Context,
|
||||
host string,
|
||||
hardwareID string,
|
||||
accessToken string,
|
||||
skipTLSVerify bool,
|
||||
) (milestoneHardwareDriverSettings, error) {
|
||||
requestURL := strings.TrimRight(host, "/") +
|
||||
"/API/rest/v1/hardware/" +
|
||||
url.PathEscape(hardwareID) +
|
||||
"/hardwareDriverSettings"
|
||||
|
||||
log.Printf(
|
||||
"Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t",
|
||||
requestURL,
|
||||
skipTLSVerify,
|
||||
)
|
||||
|
||||
body, err := requestMilestoneJSON(
|
||||
ctx,
|
||||
requestURL,
|
||||
accessToken,
|
||||
skipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return milestoneHardwareDriverSettings{}, err
|
||||
}
|
||||
|
||||
var settingsResponse milestoneHardwareDriverSettingsResponse
|
||||
if err := json.Unmarshal(body, &settingsResponse); err != nil {
|
||||
return milestoneHardwareDriverSettings{}, fmt.Errorf(
|
||||
"milestone hardware driver settings response could not be parsed: body=%s error=%w",
|
||||
truncateMilestoneLogBody(body),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if len(settingsResponse.Array) == 0 {
|
||||
return milestoneHardwareDriverSettings{}, fmt.Errorf(
|
||||
"milestone returned no hardware driver settings: hardwareId=%s body=%s",
|
||||
hardwareID,
|
||||
truncateMilestoneLogBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
return settingsResponse.Array[0].HardwareDriverSettings, nil
|
||||
}
|
||||
|
||||
func requestMilestoneJSON(
|
||||
ctx context.Context,
|
||||
requestURL string,
|
||||
accessToken string,
|
||||
skipTLSVerify bool,
|
||||
) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
requestURL,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := milestoneHTTPClient(skipTLSVerify)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf(
|
||||
"milestone request failed: url=%s status=%d body=%s",
|
||||
requestURL,
|
||||
response.StatusCode,
|
||||
truncateMilestoneLogBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) {
|
||||
var credentials milestoneCredentials
|
||||
|
||||
|
||||
1015
backend/camera_firmware.go
Normal file
1015
backend/camera_firmware.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -45,6 +45,134 @@ type DashboardOperationChange struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DashboardSettings struct {
|
||||
UserID string `json:"userId"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DashboardSettingsRequest struct {
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
|
||||
func scanDashboardSettings(scan func(dest ...any) error) (DashboardSettings, error) {
|
||||
var settings DashboardSettings
|
||||
var settingsRaw []byte
|
||||
|
||||
err := scan(
|
||||
&settings.UserID,
|
||||
&settingsRaw,
|
||||
&settings.CreatedAt,
|
||||
&settings.UpdatedAt,
|
||||
)
|
||||
|
||||
if len(settingsRaw) == 0 {
|
||||
settingsRaw = []byte(`{}`)
|
||||
}
|
||||
|
||||
settings.Settings = json.RawMessage(settingsRaw)
|
||||
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDashboardSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := scanDashboardSettings(func(dest ...any) error {
|
||||
return s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO dashboard_settings (
|
||||
user_id,
|
||||
settings
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
'{}'::JSONB
|
||||
)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id
|
||||
RETURNING
|
||||
user_id::TEXT,
|
||||
settings,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
user.ID,
|
||||
).Scan(dest...)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dashboardSettings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDashboardSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var input DashboardSettingsRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if len(input.Settings) == 0 || !json.Valid(input.Settings) {
|
||||
input.Settings = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
settings, err := scanDashboardSettings(func(dest ...any) error {
|
||||
return s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO dashboard_settings (
|
||||
user_id,
|
||||
settings
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2::JSONB
|
||||
)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
settings = EXCLUDED.settings,
|
||||
updated_at = now()
|
||||
RETURNING
|
||||
user_id::TEXT,
|
||||
settings,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
user.ID,
|
||||
string(input.Settings),
|
||||
).Scan(dest...)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"dashboardSettings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) {
|
||||
input.Type = strings.TrimSpace(input.Type)
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
|
||||
118
backend/device_events.go
Normal file
118
backend/device_events.go
Normal file
@ -0,0 +1,118 @@
|
||||
// backend/device_events.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func (s *Server) publishDeviceChangeEvent(
|
||||
ctx context.Context,
|
||||
actorUserID string,
|
||||
deviceID string,
|
||||
notificationType string,
|
||||
title string,
|
||||
message string,
|
||||
data map[string]any,
|
||||
) error {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
data["deviceId"] = deviceID
|
||||
data["actorUserId"] = actorUserID
|
||||
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE 'admin' = ANY(rights)
|
||||
OR 'devices:read' = ANY(rights)
|
||||
OR 'devices:write' = ANY(rights)
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
|
||||
err = s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
false,
|
||||
'device',
|
||||
$5,
|
||||
$6
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
read_at,
|
||||
created_at
|
||||
`,
|
||||
userID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
deviceID,
|
||||
dataJSON,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
¬ification.Data,
|
||||
¬ification.Visible,
|
||||
¬ification.ReadAt,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
@ -14,19 +14,45 @@ import (
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
|
||||
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
|
||||
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
|
||||
MilestoneHardwareID string `json:"milestoneHardwareId"`
|
||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||
MilestoneEnabled bool `json:"milestoneEnabled"`
|
||||
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
|
||||
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeviceFirmwareUpdate struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
SupportURL string `json:"supportUrl"`
|
||||
Source string `json:"source"`
|
||||
CheckedAt *time.Time `json:"checkedAt,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceSupportBadge struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
SupportURL string `json:"supportUrl"`
|
||||
}
|
||||
|
||||
type DeviceShort struct {
|
||||
@ -37,16 +63,19 @@ type DeviceShort struct {
|
||||
}
|
||||
|
||||
type CreateDeviceRequest struct {
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
RelatedDeviceIDs []string `json:"relatedDeviceIds"`
|
||||
}
|
||||
|
||||
type UpdateDeviceRequest = CreateDeviceRequest
|
||||
@ -75,10 +104,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment,
|
||||
COALESCE(firmware_version, ''),
|
||||
COALESCE(milestone_recording_server_id, ''),
|
||||
COALESCE(milestone_hardware_id, ''),
|
||||
COALESCE(milestone_display_name, ''),
|
||||
milestone_enabled,
|
||||
milestone_synced_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM devices
|
||||
@ -105,10 +141,17 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
&device.Comment,
|
||||
&device.FirmwareVersion,
|
||||
&device.MilestoneRecordingServerID,
|
||||
&device.MilestoneHardwareID,
|
||||
&device.MilestoneDisplayName,
|
||||
&device.MilestoneEnabled,
|
||||
&device.MilestoneSyncedAt,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
); err != nil {
|
||||
@ -175,6 +218,8 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.enrichDevicesWithFirmwareUpdates(r.Context(), devices)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": devices,
|
||||
})
|
||||
@ -224,12 +269,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
ip_address,
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment
|
||||
comment,
|
||||
firmware_version
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING
|
||||
id,
|
||||
inventory_number,
|
||||
@ -237,10 +284,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment,
|
||||
COALESCE(firmware_version, ''),
|
||||
COALESCE(milestone_recording_server_id, ''),
|
||||
COALESCE(milestone_hardware_id, ''),
|
||||
COALESCE(milestone_display_name, ''),
|
||||
COALESCE(milestone_enabled, true),
|
||||
milestone_synced_at,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
@ -249,10 +303,12 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
input.Model,
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.IPAddress,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.IsBaoDevice,
|
||||
input.Comment,
|
||||
input.FirmwareVersion,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
@ -260,10 +316,17 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
&device.Comment,
|
||||
&device.FirmwareVersion,
|
||||
&device.MilestoneRecordingServerID,
|
||||
&device.MilestoneHardwareID,
|
||||
&device.MilestoneDisplayName,
|
||||
&device.MilestoneEnabled,
|
||||
&device.MilestoneSyncedAt,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
)
|
||||
@ -308,6 +371,28 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
|
||||
if err := s.publishDeviceChangeEvent(
|
||||
r.Context(),
|
||||
user.ID,
|
||||
device.ID,
|
||||
"device.created",
|
||||
"Gerät erstellt",
|
||||
formatText(
|
||||
"Gerät %s wurde erstellt.",
|
||||
device.InventoryNumber,
|
||||
),
|
||||
LogFields{
|
||||
"inventoryNumber": device.InventoryNumber,
|
||||
"manufacturer": device.Manufacturer,
|
||||
"model": device.Model,
|
||||
},
|
||||
); err != nil {
|
||||
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||
"deviceId": device.ID,
|
||||
"type": "device.created",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"device": device,
|
||||
})
|
||||
@ -376,10 +461,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
model = $4,
|
||||
serial_number = $5,
|
||||
mac_address = $6,
|
||||
location = $7,
|
||||
loan_status = $8,
|
||||
is_bao_device = $9,
|
||||
comment = $10,
|
||||
ip_address = $7,
|
||||
location = $8,
|
||||
loan_status = $9,
|
||||
is_bao_device = $10,
|
||||
comment = $11,
|
||||
firmware_version = $12,
|
||||
milestone_display_name = $13,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
@ -389,10 +477,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment,
|
||||
COALESCE(firmware_version, ''),
|
||||
COALESCE(milestone_recording_server_id, ''),
|
||||
COALESCE(milestone_hardware_id, ''),
|
||||
COALESCE(milestone_display_name, ''),
|
||||
COALESCE(milestone_enabled, true),
|
||||
milestone_synced_at,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
@ -402,10 +497,13 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
input.Model,
|
||||
input.SerialNumber,
|
||||
input.MacAddress,
|
||||
input.IPAddress,
|
||||
input.Location,
|
||||
input.LoanStatus,
|
||||
input.IsBaoDevice,
|
||||
input.Comment,
|
||||
input.FirmwareVersion,
|
||||
input.MilestoneDisplayName,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
@ -413,10 +511,17 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
&device.Model,
|
||||
&device.SerialNumber,
|
||||
&device.MacAddress,
|
||||
&device.IPAddress,
|
||||
&device.Location,
|
||||
&device.LoanStatus,
|
||||
&device.IsBaoDevice,
|
||||
&device.Comment,
|
||||
&device.FirmwareVersion,
|
||||
&device.MilestoneRecordingServerID,
|
||||
&device.MilestoneHardwareID,
|
||||
&device.MilestoneDisplayName,
|
||||
&device.MilestoneEnabled,
|
||||
&device.MilestoneSyncedAt,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
)
|
||||
@ -468,6 +573,29 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
|
||||
if err := s.publishDeviceChangeEvent(
|
||||
r.Context(),
|
||||
user.ID,
|
||||
device.ID,
|
||||
"device.updated",
|
||||
"Gerät geändert",
|
||||
formatText(
|
||||
"Gerät %s wurde geändert.",
|
||||
device.InventoryNumber,
|
||||
),
|
||||
LogFields{
|
||||
"inventoryNumber": device.InventoryNumber,
|
||||
"manufacturer": device.Manufacturer,
|
||||
"model": device.Model,
|
||||
"changes": changes,
|
||||
},
|
||||
); err != nil {
|
||||
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||
"deviceId": device.ID,
|
||||
"type": "device.updated",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"device": device,
|
||||
})
|
||||
@ -505,6 +633,9 @@ func normalizeDeviceInput(input *CreateDeviceRequest) {
|
||||
input.Model = strings.TrimSpace(input.Model)
|
||||
input.SerialNumber = strings.TrimSpace(input.SerialNumber)
|
||||
input.MacAddress = strings.TrimSpace(input.MacAddress)
|
||||
input.IPAddress = strings.TrimSpace(input.IPAddress)
|
||||
input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion)
|
||||
input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName)
|
||||
input.Location = strings.TrimSpace(input.Location)
|
||||
input.LoanStatus = strings.TrimSpace(input.LoanStatus)
|
||||
input.Comment = strings.TrimSpace(input.Comment)
|
||||
|
||||
445
backend/feedback.go
Normal file
445
backend/feedback.go
Normal file
@ -0,0 +1,445 @@
|
||||
// backend/feedback.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type CreateFeedbackRequest struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type FeedbackResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserEmail string `json:"userEmail"`
|
||||
Message string `json:"message"`
|
||||
Log json.RawMessage `json:"log"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var input CreateFeedbackRequest
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
s.logUserEvent(r.Context(), UserLogEntry{
|
||||
User: &user,
|
||||
Request: r,
|
||||
Level: UserLogLevelWarning,
|
||||
Category: "feedback",
|
||||
Action: "read_request",
|
||||
Message: "Feedback-Request konnte nicht gelesen werden",
|
||||
Error: err,
|
||||
})
|
||||
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
input.Message = strings.TrimSpace(input.Message)
|
||||
|
||||
if input.Message == "" {
|
||||
writeError(w, http.StatusBadRequest, "Feedback darf nicht leer sein")
|
||||
return
|
||||
}
|
||||
|
||||
if len([]rune(input.Message)) > 5000 {
|
||||
writeError(w, http.StatusBadRequest, "Feedback darf maximal 5000 Zeichen lang sein")
|
||||
return
|
||||
}
|
||||
|
||||
feedbackLog := map[string]any{
|
||||
"user": buildUserLogSnapshot(user),
|
||||
"request": mergeLogFields(
|
||||
LogFields(buildRequestLogSnapshot(r)),
|
||||
LogFields{
|
||||
"contentType": r.Header.Get("Content-Type"),
|
||||
},
|
||||
),
|
||||
"server": map[string]any{
|
||||
"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
}
|
||||
|
||||
logJSON, err := json.Marshal(feedbackLog)
|
||||
if err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
r,
|
||||
user,
|
||||
http.StatusInternalServerError,
|
||||
"feedback",
|
||||
"build_feedback_log",
|
||||
"Feedback-Log konnte nicht erstellt werden",
|
||||
err,
|
||||
LogFields{
|
||||
"messageLength": len([]rune(input.Message)),
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var feedback FeedbackResponse
|
||||
|
||||
err = s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO feedback (
|
||||
user_id,
|
||||
message,
|
||||
log
|
||||
)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
$4::TEXT,
|
||||
$5::TEXT,
|
||||
message,
|
||||
log,
|
||||
created_at
|
||||
`,
|
||||
user.ID,
|
||||
input.Message,
|
||||
logJSON,
|
||||
user.DisplayName,
|
||||
user.Email,
|
||||
).Scan(
|
||||
&feedback.ID,
|
||||
&feedback.UserID,
|
||||
&feedback.UserName,
|
||||
&feedback.UserEmail,
|
||||
&feedback.Message,
|
||||
&feedback.Log,
|
||||
&feedback.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
r,
|
||||
user,
|
||||
http.StatusInternalServerError,
|
||||
"feedback",
|
||||
"insert_feedback",
|
||||
"Feedback konnte nicht gespeichert werden",
|
||||
err,
|
||||
LogFields{
|
||||
"messageLength": len([]rune(input.Message)),
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
s.logUserEvent(r.Context(), UserLogEntry{
|
||||
User: &user,
|
||||
Request: r,
|
||||
Level: UserLogLevelInfo,
|
||||
Category: "feedback",
|
||||
Action: "create_feedback",
|
||||
Message: "Feedback wurde gespeichert",
|
||||
Details: LogFields{
|
||||
"feedbackId": feedback.ID,
|
||||
"messageLength": len([]rune(input.Message)),
|
||||
},
|
||||
})
|
||||
|
||||
_ = s.notifyAdministratorsAboutFeedback(
|
||||
r.Context(),
|
||||
"feedback.created",
|
||||
"Neues Feedback",
|
||||
formatText("Neues Feedback von %s.", displayFeedbackUserName(feedback)),
|
||||
feedback.ID,
|
||||
true,
|
||||
map[string]any{
|
||||
"feedbackId": feedback.ID,
|
||||
"userId": feedback.UserID,
|
||||
"userName": feedback.UserName,
|
||||
"userEmail": feedback.UserEmail,
|
||||
},
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"feedback": feedback,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.db.Query(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
f.id::TEXT,
|
||||
f.user_id::TEXT,
|
||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
|
||||
COALESCE(u.email, '') AS user_email,
|
||||
f.message,
|
||||
f.log,
|
||||
f.created_at
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 200
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
feedbackItems := make([]FeedbackResponse, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var feedback FeedbackResponse
|
||||
|
||||
if err := rows.Scan(
|
||||
&feedback.ID,
|
||||
&feedback.UserID,
|
||||
&feedback.UserName,
|
||||
&feedback.UserEmail,
|
||||
&feedback.Message,
|
||||
&feedback.Log,
|
||||
&feedback.CreatedAt,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
feedbackItems = append(feedbackItems, feedback)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Feedback konnte nicht gelesen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"feedback": feedbackItems,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
feedbackID := strings.TrimSpace(r.PathValue("id"))
|
||||
if feedbackID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Feedback-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
var deleted FeedbackResponse
|
||||
|
||||
err := s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
DELETE FROM feedback f
|
||||
USING users u
|
||||
WHERE f.id = $1
|
||||
AND u.id = f.user_id
|
||||
RETURNING
|
||||
f.id::TEXT,
|
||||
f.user_id::TEXT,
|
||||
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt') AS user_name,
|
||||
COALESCE(u.email, '') AS user_email,
|
||||
f.message,
|
||||
f.log,
|
||||
f.created_at
|
||||
`,
|
||||
feedbackID,
|
||||
).Scan(
|
||||
&deleted.ID,
|
||||
&deleted.UserID,
|
||||
&deleted.UserName,
|
||||
&deleted.UserEmail,
|
||||
&deleted.Message,
|
||||
&deleted.Log,
|
||||
&deleted.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "Feedback wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
r,
|
||||
user,
|
||||
http.StatusInternalServerError,
|
||||
"feedback",
|
||||
"delete_feedback",
|
||||
"Feedback konnte nicht gelöscht werden",
|
||||
err,
|
||||
LogFields{
|
||||
"feedbackId": feedbackID,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
s.logUserEvent(r.Context(), UserLogEntry{
|
||||
User: &user,
|
||||
Request: r,
|
||||
Level: UserLogLevelInfo,
|
||||
Category: "feedback",
|
||||
Action: "delete_feedback",
|
||||
Message: "Feedback wurde gelöscht",
|
||||
Details: LogFields{
|
||||
"feedbackId": deleted.ID,
|
||||
"userId": deleted.UserID,
|
||||
},
|
||||
})
|
||||
|
||||
_ = s.notifyAdministratorsAboutFeedback(
|
||||
r.Context(),
|
||||
"feedback.deleted",
|
||||
"Feedback gelöscht",
|
||||
formatText("Feedback von %s wurde gelöscht.", displayFeedbackUserName(deleted)),
|
||||
deleted.ID,
|
||||
false,
|
||||
map[string]any{
|
||||
"feedbackId": deleted.ID,
|
||||
"userId": deleted.UserID,
|
||||
"userName": deleted.UserName,
|
||||
"userEmail": deleted.UserEmail,
|
||||
},
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
})
|
||||
}
|
||||
|
||||
func displayFeedbackUserName(feedback FeedbackResponse) string {
|
||||
if strings.TrimSpace(feedback.UserName) != "" {
|
||||
return feedback.UserName
|
||||
}
|
||||
|
||||
if strings.TrimSpace(feedback.UserEmail) != "" {
|
||||
return feedback.UserEmail
|
||||
}
|
||||
|
||||
return "Unbekannt"
|
||||
}
|
||||
|
||||
func (s *Server) notifyAdministratorsAboutFeedback(
|
||||
ctx context.Context,
|
||||
notificationType string,
|
||||
title string,
|
||||
message string,
|
||||
feedbackID string,
|
||||
visible bool,
|
||||
data map[string]any,
|
||||
) error {
|
||||
adminRows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE 'admin' = ANY(rights)
|
||||
OR 'administration:read' = ANY(rights)
|
||||
`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer adminRows.Close()
|
||||
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for adminRows.Next() {
|
||||
var adminUserID string
|
||||
|
||||
if err := adminRows.Scan(&adminUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
|
||||
err = s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
true,
|
||||
'feedback',
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
read_at,
|
||||
created_at
|
||||
`,
|
||||
adminUserID,
|
||||
notificationType,
|
||||
title,
|
||||
message,
|
||||
visible,
|
||||
feedbackID,
|
||||
dataJSON,
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
¬ification.Data,
|
||||
¬ification.Visible,
|
||||
¬ification.ReadAt,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return adminRows.Err()
|
||||
}
|
||||
158
backend/log.go
Normal file
158
backend/log.go
Normal file
@ -0,0 +1,158 @@
|
||||
// backend/log.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelInfo LogLevel = "info"
|
||||
LogLevelWarning LogLevel = "warning"
|
||||
LogLevelError LogLevel = "error"
|
||||
)
|
||||
|
||||
type LogFields map[string]any
|
||||
|
||||
type AppError struct {
|
||||
Message string
|
||||
Err error
|
||||
Fields LogFields
|
||||
}
|
||||
|
||||
var appLogger = log.New(os.Stdout, "", 0)
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if e.Err == nil {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
if e.Message == "" {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
return e.Message + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *AppError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func newErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func newAppError(message string, err error, fields LogFields) error {
|
||||
return &AppError{
|
||||
Message: message,
|
||||
Err: err,
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
func newAppErrorf(message string, fields LogFields, format string, args ...any) error {
|
||||
return newAppError(
|
||||
message,
|
||||
fmt.Errorf(format, args...),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
func formatText(format string, args ...any) string {
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
func logInfo(message string, fields LogFields) {
|
||||
writeAppLog(LogLevelInfo, message, nil, fields)
|
||||
}
|
||||
|
||||
func logInfof(format string, args ...any) {
|
||||
writeAppLog(LogLevelInfo, fmt.Sprintf(format, args...), nil, nil)
|
||||
}
|
||||
|
||||
func logWarning(message string, fields LogFields) {
|
||||
writeAppLog(LogLevelWarning, message, nil, fields)
|
||||
}
|
||||
|
||||
func logWarningf(format string, args ...any) {
|
||||
writeAppLog(LogLevelWarning, fmt.Sprintf(format, args...), nil, nil)
|
||||
}
|
||||
|
||||
func logError(message string, err error, fields LogFields) {
|
||||
writeAppLog(LogLevelError, message, err, fields)
|
||||
}
|
||||
|
||||
func logErrorf(err error, format string, args ...any) {
|
||||
writeAppLog(LogLevelError, fmt.Sprintf(format, args...), err, nil)
|
||||
}
|
||||
|
||||
func appErrorFields(err error) LogFields {
|
||||
var appErr *AppError
|
||||
|
||||
if errors.As(err, &appErr) && appErr.Fields != nil {
|
||||
return appErr.Fields
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeLogFields(base LogFields, extra LogFields) LogFields {
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
merged := LogFields{}
|
||||
|
||||
for key, value := range base {
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
for key, value := range extra {
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func writeAppLog(level LogLevel, message string, err error, fields LogFields) {
|
||||
payload := map[string]any{
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"level": level,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
payload["error"] = err.Error()
|
||||
}
|
||||
|
||||
if len(fields) > 0 {
|
||||
payload["fields"] = fields
|
||||
}
|
||||
|
||||
data, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
appLogger.Printf(
|
||||
`{"timestamp":"%s","level":"error","message":"could not marshal log payload","error":%q}`,
|
||||
time.Now().UTC().Format(time.RFC3339Nano),
|
||||
marshalErr.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
appLogger.Println(string(data))
|
||||
}
|
||||
354
backend/milestone_devices.go
Normal file
354
backend/milestone_devices.go
Normal file
@ -0,0 +1,354 @@
|
||||
// backend\milestone_devices.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func syncMilestoneHardwareDevices(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
hardwareDevices []milestoneHardwareDevice,
|
||||
) (int, error) {
|
||||
syncedCount := 0
|
||||
|
||||
for _, hardwareDevice := range hardwareDevices {
|
||||
hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID)
|
||||
hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID)
|
||||
hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName)
|
||||
hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName)
|
||||
hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer)
|
||||
hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName)
|
||||
hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber)
|
||||
hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress)
|
||||
hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress)
|
||||
hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion)
|
||||
|
||||
if hardwareDevice.HardwareID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if hardwareDevice.Manufacturer != "" {
|
||||
if err := ensureOptionalDeviceLookupValue(
|
||||
ctx,
|
||||
tx,
|
||||
"device_manufacturers",
|
||||
hardwareDevice.Manufacturer,
|
||||
); err != nil {
|
||||
return syncedCount, err
|
||||
}
|
||||
}
|
||||
|
||||
synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice)
|
||||
if err != nil {
|
||||
return syncedCount, err
|
||||
}
|
||||
|
||||
if synced {
|
||||
syncedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return syncedCount, nil
|
||||
}
|
||||
|
||||
func createOrUpdateMilestoneHardwareDevice(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
hardwareDevice milestoneHardwareDevice,
|
||||
) (bool, error) {
|
||||
inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
commandTag, err := tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO devices (
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
ip_address,
|
||||
location,
|
||||
loan_status,
|
||||
is_bao_device,
|
||||
comment,
|
||||
firmware_version,
|
||||
milestone_recording_server_id,
|
||||
milestone_hardware_id,
|
||||
milestone_display_name,
|
||||
milestone_enabled,
|
||||
milestone_synced_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
'',
|
||||
'Verfügbar',
|
||||
false,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (milestone_hardware_id)
|
||||
WHERE milestone_hardware_id <> ''
|
||||
DO UPDATE SET
|
||||
inventory_number = EXCLUDED.inventory_number,
|
||||
manufacturer = EXCLUDED.manufacturer,
|
||||
model = EXCLUDED.model,
|
||||
serial_number = EXCLUDED.serial_number,
|
||||
mac_address = EXCLUDED.mac_address,
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
location = EXCLUDED.location,
|
||||
comment = EXCLUDED.comment,
|
||||
firmware_version = EXCLUDED.firmware_version,
|
||||
milestone_recording_server_id = EXCLUDED.milestone_recording_server_id,
|
||||
milestone_display_name = EXCLUDED.milestone_display_name,
|
||||
milestone_enabled = EXCLUDED.milestone_enabled,
|
||||
milestone_synced_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
inventoryNumber,
|
||||
hardwareDevice.Manufacturer,
|
||||
hardwareDevice.DeviceModelName,
|
||||
hardwareDevice.SerialNumber,
|
||||
hardwareDevice.MacAddress,
|
||||
hardwareDevice.IPAddress,
|
||||
buildMilestoneDeviceComment(hardwareDevice),
|
||||
hardwareDevice.FirmwareVersion,
|
||||
hardwareDevice.RecordingServerID,
|
||||
hardwareDevice.HardwareID,
|
||||
hardwareDevice.DisplayName,
|
||||
hardwareDevice.Enabled,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return commandTag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func normalizeMilestoneManufacturer(value string, model string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
model = strings.TrimSpace(model)
|
||||
normalizedValue := strings.ToLower(value)
|
||||
|
||||
if strings.EqualFold(value, model) ||
|
||||
strings.HasSuffix(normalizedValue, " device") ||
|
||||
strings.Contains(normalizedValue, " camera") ||
|
||||
strings.Contains(normalizedValue, " encoder") ||
|
||||
strings.Contains(normalizedValue, " video") {
|
||||
if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" {
|
||||
return manufacturer
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func extractMilestoneManufacturerFromName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return unicode.IsSpace(r) ||
|
||||
r == '_' ||
|
||||
r == '-' ||
|
||||
r == '/' ||
|
||||
r == '\\' ||
|
||||
r == '(' ||
|
||||
r == ')' ||
|
||||
r == ','
|
||||
})
|
||||
|
||||
ignoredWords := map[string]bool{
|
||||
"device": true,
|
||||
"camera": true,
|
||||
"video": true,
|
||||
"encoder": true,
|
||||
"ip": true,
|
||||
"network": true,
|
||||
"hardware": true,
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isOnlyDigits(part) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ignoredWords[strings.ToLower(part)] {
|
||||
continue
|
||||
}
|
||||
|
||||
return part
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildMilestoneInventoryNumber(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
hardwareDevice milestoneHardwareDevice,
|
||||
) (string, error) {
|
||||
inventoryNumber := extractInventoryNumberFromMilestoneDeviceName(
|
||||
hardwareDevice.DeviceName,
|
||||
)
|
||||
|
||||
if inventoryNumber == "" {
|
||||
inventoryNumber = extractInventoryNumberFromMilestoneDeviceName(
|
||||
hardwareDevice.DisplayName,
|
||||
)
|
||||
}
|
||||
|
||||
if inventoryNumber == "" {
|
||||
inventoryNumber = buildGeneratedMilestoneInventoryNumber(
|
||||
hardwareDevice.HardwareID,
|
||||
)
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
err := tx.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM devices
|
||||
WHERE lower(inventory_number) = lower($1)
|
||||
AND milestone_hardware_id <> $2
|
||||
)
|
||||
`,
|
||||
inventoryNumber,
|
||||
hardwareDevice.HardwareID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return inventoryNumber, nil
|
||||
}
|
||||
|
||||
suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", ""))
|
||||
if len(suffix) > 8 {
|
||||
suffix = suffix[:8]
|
||||
}
|
||||
|
||||
if suffix == "" {
|
||||
suffix = "MS"
|
||||
}
|
||||
|
||||
return inventoryNumber + "-" + suffix, nil
|
||||
}
|
||||
|
||||
func extractInventoryNumberFromMilestoneDeviceName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == '_' || r == '-' || unicode.IsSpace(r)
|
||||
})
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isOnlyDigits(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isOnlyDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func buildGeneratedMilestoneInventoryNumber(hardwareID string) string {
|
||||
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", ""))
|
||||
|
||||
if len(value) > 12 {
|
||||
value = value[:12]
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
value = "UNKNOWN"
|
||||
}
|
||||
|
||||
return "MS-" + value
|
||||
}
|
||||
|
||||
func buildMilestoneDeviceComment(hardwareDevice milestoneHardwareDevice) string {
|
||||
parts := []string{}
|
||||
|
||||
if strings.TrimSpace(hardwareDevice.DisplayName) != "" {
|
||||
parts = append(parts, strings.TrimSpace(hardwareDevice.DisplayName))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(hardwareDevice.DeviceName) != "" &&
|
||||
strings.TrimSpace(hardwareDevice.DeviceName) != strings.TrimSpace(hardwareDevice.DisplayName) {
|
||||
parts = append(parts, "DeviceName: "+strings.TrimSpace(hardwareDevice.DeviceName))
|
||||
}
|
||||
|
||||
if hardwareDevice.Enabled {
|
||||
parts = append(parts, "Status: aktiviert")
|
||||
} else {
|
||||
parts = append(parts, "Status: deaktiviert")
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Automatisch aus Milestone synchronisiert."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Automatisch aus Milestone synchronisiert: %s",
|
||||
strings.Join(parts, " · "),
|
||||
)
|
||||
}
|
||||
338
backend/milestone_hardware.go
Normal file
338
backend/milestone_hardware.go
Normal file
@ -0,0 +1,338 @@
|
||||
// backend/milestone_hardware.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type milestoneRuntimeConfig struct {
|
||||
Host string
|
||||
AccessToken string
|
||||
TokenType string
|
||||
SkipTLSVerify bool
|
||||
TokenExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type milestoneToggleDevice struct {
|
||||
ID string
|
||||
InventoryNumber string
|
||||
MilestoneHardwareID string
|
||||
MilestoneEnabled bool
|
||||
}
|
||||
|
||||
func (s *Server) handleToggleMilestoneDeviceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := strings.TrimSpace(r.PathValue("id"))
|
||||
if deviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
device, err := s.getMilestoneToggleDevice(r.Context(), deviceID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if device.MilestoneHardwareID == "" {
|
||||
writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät")
|
||||
return
|
||||
}
|
||||
|
||||
config, err := s.getMilestoneRuntimeConfig(r.Context())
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt")
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
if config.Host == "" || config.AccessToken == "" {
|
||||
writeError(w, http.StatusBadRequest, "Milestone Host oder Token fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
if config.TokenExpiresAt != nil && time.Now().After(config.TokenExpiresAt.Add(-30*time.Second)) {
|
||||
writeError(w, http.StatusBadRequest, "Milestone-Token ist abgelaufen. Bitte Token neu abrufen.")
|
||||
return
|
||||
}
|
||||
|
||||
nextEnabled := !device.MilestoneEnabled
|
||||
|
||||
if err := patchMilestoneHardware(
|
||||
r.Context(),
|
||||
config,
|
||||
device.MilestoneHardwareID,
|
||||
map[string]bool{
|
||||
"enabled": nextEnabled,
|
||||
},
|
||||
); err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
r,
|
||||
user,
|
||||
http.StatusBadGateway,
|
||||
"milestone_hardware",
|
||||
"patch_hardware",
|
||||
"Milestone-Status konnte nicht geändert werden",
|
||||
err,
|
||||
LogFields{
|
||||
"deviceId": device.ID,
|
||||
"inventoryNumber": device.InventoryNumber,
|
||||
"milestoneHardwareId": device.MilestoneHardwareID,
|
||||
"currentEnabled": device.MilestoneEnabled,
|
||||
"nextEnabled": nextEnabled,
|
||||
"payload": LogFields{
|
||||
"enabled": nextEnabled,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
UPDATE devices
|
||||
SET
|
||||
milestone_enabled = $2,
|
||||
milestone_synced_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
`,
|
||||
device.ID,
|
||||
nextEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
r,
|
||||
user,
|
||||
http.StatusInternalServerError,
|
||||
"milestone_hardware",
|
||||
"update_local_device",
|
||||
"Milestone-Status konnte lokal nicht aktualisiert werden",
|
||||
err,
|
||||
LogFields{
|
||||
"deviceId": device.ID,
|
||||
"inventoryNumber": device.InventoryNumber,
|
||||
"milestoneHardwareId": device.MilestoneHardwareID,
|
||||
"nextEnabled": nextEnabled,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
oldValue := "Aktiviert"
|
||||
newValue := "Deaktiviert"
|
||||
|
||||
if nextEnabled {
|
||||
oldValue = "Deaktiviert"
|
||||
newValue = "Aktiviert"
|
||||
}
|
||||
|
||||
changes, _ := json.Marshal([]map[string]string{
|
||||
{
|
||||
"field": "milestoneEnabled",
|
||||
"label": "Milestone-Status",
|
||||
"oldValue": oldValue,
|
||||
"newValue": newValue,
|
||||
},
|
||||
})
|
||||
|
||||
_, _ = s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO device_history (
|
||||
device_id,
|
||||
user_id,
|
||||
action,
|
||||
changes
|
||||
)
|
||||
VALUES ($1, $2, 'updated', $3)
|
||||
`,
|
||||
device.ID,
|
||||
user.ID,
|
||||
changes,
|
||||
)
|
||||
|
||||
if err := s.publishDeviceChangeEvent(
|
||||
r.Context(),
|
||||
user.ID,
|
||||
device.ID,
|
||||
"device.milestone.status",
|
||||
"Milestone-Status geändert",
|
||||
formatText(
|
||||
"Gerät %s wurde in Milestone %s.",
|
||||
device.InventoryNumber,
|
||||
strings.ToLower(newValue),
|
||||
),
|
||||
LogFields{
|
||||
"inventoryNumber": device.InventoryNumber,
|
||||
"milestoneHardwareId": device.MilestoneHardwareID,
|
||||
"milestoneEnabled": nextEnabled,
|
||||
},
|
||||
); err != nil {
|
||||
logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{
|
||||
"deviceId": device.ID,
|
||||
"type": "device.milestone.status",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"id": device.ID,
|
||||
"milestoneEnabled": nextEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getMilestoneToggleDevice(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
) (milestoneToggleDevice, error) {
|
||||
var device milestoneToggleDevice
|
||||
|
||||
err := s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
id::TEXT,
|
||||
inventory_number,
|
||||
COALESCE(milestone_hardware_id, ''),
|
||||
milestone_enabled
|
||||
FROM devices
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
deviceID,
|
||||
).Scan(
|
||||
&device.ID,
|
||||
&device.InventoryNumber,
|
||||
&device.MilestoneHardwareID,
|
||||
&device.MilestoneEnabled,
|
||||
)
|
||||
|
||||
return device, err
|
||||
}
|
||||
|
||||
func (s *Server) getMilestoneRuntimeConfig(
|
||||
ctx context.Context,
|
||||
) (milestoneRuntimeConfig, error) {
|
||||
var config milestoneRuntimeConfig
|
||||
var tokenExpiresAt sql.NullTime
|
||||
|
||||
err := s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
host,
|
||||
COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''),
|
||||
COALESCE(token_type, ''),
|
||||
skip_tls_verify,
|
||||
token_expires_at
|
||||
FROM milestone_settings
|
||||
WHERE id = 1
|
||||
LIMIT 1
|
||||
`,
|
||||
s.milestoneEncryptionKey(),
|
||||
).Scan(
|
||||
&config.Host,
|
||||
&config.AccessToken,
|
||||
&config.TokenType,
|
||||
&config.SkipTLSVerify,
|
||||
&tokenExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
if tokenExpiresAt.Valid {
|
||||
config.TokenExpiresAt = &tokenExpiresAt.Time
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func patchMilestoneHardware(
|
||||
ctx context.Context,
|
||||
config milestoneRuntimeConfig,
|
||||
hardwareID string,
|
||||
payload any,
|
||||
) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(config.Host, "/") +
|
||||
"/API/rest/v1/hardware/" +
|
||||
url.PathEscape(hardwareID)
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPatch,
|
||||
requestURL,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tokenType := strings.TrimSpace(config.TokenType)
|
||||
if tokenType == "" {
|
||||
tokenType = "Bearer"
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, _ := io.ReadAll(response.Body)
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return newAppErrorf(
|
||||
"milestone hardware patch failed",
|
||||
LogFields{
|
||||
"statusCode": response.StatusCode,
|
||||
"responseBody": string(responseBody),
|
||||
"hardwareId": hardwareID,
|
||||
},
|
||||
"milestone hardware patch failed: status=%d body=%s",
|
||||
response.StatusCode,
|
||||
string(responseBody),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
160
backend/notification_preferences.go
Normal file
160
backend/notification_preferences.go
Normal file
@ -0,0 +1,160 @@
|
||||
// backend\notification_preferences.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type notificationPreferencesResponse struct {
|
||||
InAppEnabled bool `json:"inAppEnabled"`
|
||||
SoundEnabled bool `json:"soundEnabled"`
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
OperationUpdates bool `json:"operationUpdates"`
|
||||
OperationJournals bool `json:"operationJournals"`
|
||||
DeviceUpdates bool `json:"deviceUpdates"`
|
||||
DeviceJournals bool `json:"deviceJournals"`
|
||||
SystemMessages bool `json:"systemMessages"`
|
||||
}
|
||||
|
||||
func defaultNotificationPreferences() notificationPreferencesResponse {
|
||||
return notificationPreferencesResponse{
|
||||
InAppEnabled: true,
|
||||
SoundEnabled: false,
|
||||
EmailEnabled: false,
|
||||
OperationUpdates: true,
|
||||
OperationJournals: true,
|
||||
DeviceUpdates: true,
|
||||
DeviceJournals: true,
|
||||
SystemMessages: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": defaultNotificationPreferences(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var input notificationPreferencesResponse
|
||||
|
||||
if err := readJSON(r, &input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
r.Context(),
|
||||
`
|
||||
INSERT INTO notification_preferences (
|
||||
user_id,
|
||||
in_app_enabled,
|
||||
sound_enabled,
|
||||
email_enabled,
|
||||
operation_updates,
|
||||
operation_journals,
|
||||
device_updates,
|
||||
device_journals,
|
||||
system_messages
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
in_app_enabled = EXCLUDED.in_app_enabled,
|
||||
sound_enabled = EXCLUDED.sound_enabled,
|
||||
email_enabled = EXCLUDED.email_enabled,
|
||||
operation_updates = EXCLUDED.operation_updates,
|
||||
operation_journals = EXCLUDED.operation_journals,
|
||||
device_updates = EXCLUDED.device_updates,
|
||||
device_journals = EXCLUDED.device_journals,
|
||||
system_messages = EXCLUDED.system_messages,
|
||||
updated_at = now()
|
||||
`,
|
||||
user.ID,
|
||||
input.InAppEnabled,
|
||||
input.SoundEnabled,
|
||||
input.EmailEnabled,
|
||||
input.OperationUpdates,
|
||||
input.OperationJournals,
|
||||
input.DeviceUpdates,
|
||||
input.DeviceJournals,
|
||||
input.SystemMessages,
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen konnten nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.getNotificationPreferences(r, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungseinstellungen wurden gespeichert, konnten aber nicht neu geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getNotificationPreferences(r *http.Request, userID string) (notificationPreferencesResponse, error) {
|
||||
settings := defaultNotificationPreferences()
|
||||
|
||||
err := s.db.QueryRow(
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
in_app_enabled,
|
||||
sound_enabled,
|
||||
email_enabled,
|
||||
operation_updates,
|
||||
operation_journals,
|
||||
device_updates,
|
||||
device_journals,
|
||||
system_messages
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
userID,
|
||||
).Scan(
|
||||
&settings.InAppEnabled,
|
||||
&settings.SoundEnabled,
|
||||
&settings.EmailEnabled,
|
||||
&settings.OperationUpdates,
|
||||
&settings.OperationJournals,
|
||||
&settings.DeviceUpdates,
|
||||
&settings.DeviceJournals,
|
||||
&settings.SystemMessages,
|
||||
)
|
||||
|
||||
return settings, err
|
||||
}
|
||||
@ -144,7 +144,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
r.Context(),
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
@ -152,6 +152,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
read_at,
|
||||
created_at
|
||||
FROM notifications
|
||||
@ -185,6 +186,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&data,
|
||||
¬ification.Visible,
|
||||
&readAt,
|
||||
¬ification.CreatedAt,
|
||||
); err != nil {
|
||||
@ -283,9 +285,13 @@ func (s *Server) createOperationNotification(
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
SELECT id::TEXT
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
AND (
|
||||
'admin' = ANY(rights)
|
||||
OR 'operations:read' = ANY(rights)
|
||||
)
|
||||
`,
|
||||
actor.ID,
|
||||
)
|
||||
@ -294,18 +300,13 @@ func (s *Server) createOperationNotification(
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
recipientIDs := []string{}
|
||||
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
var recipientID string
|
||||
|
||||
if err := rows.Scan(&recipientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recipientIDs = append(recipientIDs, userID)
|
||||
}
|
||||
|
||||
for _, recipientID := range recipientIDs {
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
@ -319,11 +320,21 @@ func (s *Server) createOperationNotification(
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
'operation',
|
||||
$5,
|
||||
$6::JSONB,
|
||||
true
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB)
|
||||
RETURNING
|
||||
id,
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
@ -331,6 +342,7 @@ func (s *Server) createOperationNotification(
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
@ -348,6 +360,109 @@ func (s *Server) createOperationNotification(
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notification.Data = json.RawMessage(rawData)
|
||||
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) createOperationUpdateEvent(
|
||||
ctx context.Context,
|
||||
actor User,
|
||||
operation Operation,
|
||||
data map[string]any,
|
||||
) error {
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id <> $1
|
||||
AND (
|
||||
'admin' = ANY(rights)
|
||||
OR 'operations:read' = ANY(rights)
|
||||
)
|
||||
`,
|
||||
actor.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var recipientID string
|
||||
|
||||
if err := rows.Scan(&recipientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var notification Notification
|
||||
var rawData []byte
|
||||
|
||||
err := s.db.QueryRow(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO notifications (
|
||||
user_id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
entity_id,
|
||||
data,
|
||||
visible
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
'operation.updated',
|
||||
'Einsatz geändert',
|
||||
'',
|
||||
'operation',
|
||||
$2,
|
||||
$3::JSONB,
|
||||
false
|
||||
)
|
||||
RETURNING
|
||||
id::TEXT,
|
||||
user_id::TEXT,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
entity_type,
|
||||
COALESCE(entity_id::TEXT, ''),
|
||||
data,
|
||||
visible,
|
||||
created_at
|
||||
`,
|
||||
recipientID,
|
||||
operation.ID,
|
||||
string(dataJSON),
|
||||
).Scan(
|
||||
¬ification.ID,
|
||||
¬ification.UserID,
|
||||
¬ification.Type,
|
||||
¬ification.Title,
|
||||
¬ification.Message,
|
||||
¬ification.EntityType,
|
||||
¬ification.EntityID,
|
||||
&rawData,
|
||||
¬ification.Visible,
|
||||
¬ification.CreatedAt,
|
||||
)
|
||||
|
||||
@ -359,7 +474,7 @@ func (s *Server) createOperationNotification(
|
||||
notificationsBroker.publish(notification)
|
||||
}
|
||||
|
||||
return nil
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) createOperationJournalEvent(
|
||||
|
||||
@ -500,20 +500,19 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.createOperationNotification(
|
||||
r.Context(),
|
||||
user,
|
||||
operation,
|
||||
"operation.updated",
|
||||
"Einsatz geändert",
|
||||
fmt.Sprintf("Einsatz %s wurde geändert.", operation.OperationNumber),
|
||||
map[string]any{
|
||||
"operationNumber": operation.OperationNumber,
|
||||
"operationName": operation.OperationName,
|
||||
"changes": changes,
|
||||
},
|
||||
); err != nil {
|
||||
// nicht abbrechen, Änderung wurde bereits gespeichert
|
||||
if len(changes) > 0 {
|
||||
if err := s.createOperationUpdateEvent(
|
||||
r.Context(),
|
||||
user,
|
||||
operation,
|
||||
map[string]any{
|
||||
"operationNumber": operation.OperationNumber,
|
||||
"operationName": operation.OperationName,
|
||||
"changes": changes,
|
||||
},
|
||||
); err != nil {
|
||||
// nicht abbrechen, Änderung wurde bereits gespeichert
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// backend\routes.go
|
||||
// backend/routes.go
|
||||
|
||||
package main
|
||||
|
||||
@ -16,17 +16,26 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /settings/sessions", s.requireAuth(s.handleListUserSessions))
|
||||
mux.HandleFunc("DELETE /settings/sessions/{id}", s.requireAuth(s.handleRevokeUserSession))
|
||||
mux.HandleFunc("PUT /settings/profile", s.requireAuth(s.handleUpdateSettingsProfile))
|
||||
mux.HandleFunc("PUT /settings/account", s.requireAuth(s.handleUpdateSettingsAccount))
|
||||
mux.HandleFunc("PUT /settings/password", s.requireAuth(s.handleUpdateSettingsPassword))
|
||||
mux.HandleFunc("POST /settings/logout-other-sessions", s.requireAuth(s.handleLogoutOtherSessions))
|
||||
mux.HandleFunc("DELETE /settings/account", s.requireAuth(s.handleDeleteOwnAccount))
|
||||
|
||||
mux.HandleFunc("GET /settings/notifications/preferences", s.requireAuth(s.handleGetNotificationPreferences))
|
||||
mux.HandleFunc("PUT /settings/notifications/preferences", s.requireAuth(s.handleUpdateNotificationPreferences))
|
||||
|
||||
mux.HandleFunc("POST /feedback", s.requireAuth(s.handleCreateFeedback))
|
||||
|
||||
mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices))
|
||||
mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice))
|
||||
mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice))
|
||||
mux.HandleFunc("POST /devices/{id}/milestone-toggle", s.requireAuth(s.handleToggleMilestoneDeviceStatus))
|
||||
mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory))
|
||||
mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry))
|
||||
|
||||
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
||||
|
||||
mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups)
|
||||
mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer)
|
||||
mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation)
|
||||
@ -52,6 +61,9 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges)))
|
||||
|
||||
mux.HandleFunc("GET /dashboard/settings", s.requireAuth(s.handleGetDashboardSettings))
|
||||
mux.HandleFunc("PUT /dashboard/settings", s.requireAuth(s.handleUpdateDashboardSettings))
|
||||
|
||||
mux.HandleFunc("GET /dashboard/widgets", s.requireAuth(s.handleListDashboardWidgets))
|
||||
mux.HandleFunc("POST /dashboard/widgets", s.requireAuth(s.handleCreateDashboardWidget))
|
||||
mux.HandleFunc("PUT /dashboard/widgets/{id}", s.requireAuth(s.handleUpdateDashboardWidget))
|
||||
@ -67,11 +79,15 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam)))
|
||||
mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam)))
|
||||
|
||||
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)))
|
||||
|
||||
/* milestone settings */
|
||||
|
||||
mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings)))
|
||||
mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings)))
|
||||
mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken)))
|
||||
mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream)))
|
||||
|
||||
return s.cors(mux)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type globalSearchResponse struct {
|
||||
@ -30,6 +31,13 @@ type globalSearchItem struct {
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type globalSearchOptions struct {
|
||||
Query string
|
||||
Limit int
|
||||
ExactPhrase bool
|
||||
CaseSensitive bool
|
||||
}
|
||||
|
||||
func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
@ -38,7 +46,17 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
limit := parseSearchLimit(r.URL.Query().Get("limit"), 8)
|
||||
|
||||
options := globalSearchOptions{
|
||||
Query: query,
|
||||
Limit: parseSearchLimit(r.URL.Query().Get("limit"), 8),
|
||||
ExactPhrase: parseSearchBool(r.URL.Query().Get("exact")),
|
||||
CaseSensitive: parseSearchBool(r.URL.Query().Get("caseSensitive")),
|
||||
}
|
||||
|
||||
if !strings.Contains(query, " ") {
|
||||
options.ExactPhrase = false
|
||||
}
|
||||
|
||||
if len([]rune(query)) < 2 {
|
||||
writeJSON(w, http.StatusOK, globalSearchResponse{
|
||||
@ -48,10 +66,10 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
groups := make([]globalSearchGroup, 0, 3)
|
||||
groups := make([]globalSearchGroup, 0, 5)
|
||||
|
||||
if userCanSearch(user, "operations:read") {
|
||||
items, err := s.searchOperations(r.Context(), query, limit)
|
||||
items, err := s.searchOperations(r.Context(), options)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden")
|
||||
return
|
||||
@ -66,8 +84,40 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if userCanSearch(user, "operations:read") {
|
||||
items, err := s.searchOperationJournalEntries(r.Context(), options)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einsatz-Journale konnten nicht durchsucht werden")
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) > 0 {
|
||||
groups = append(groups, globalSearchGroup{
|
||||
ID: "operation-journals",
|
||||
Title: "Einsatz-Journale",
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if userCanSearch(user, "devices:read") {
|
||||
items, err := s.searchDevices(r.Context(), query, limit)
|
||||
items, err := s.searchDeviceJournalEntries(r.Context(), options)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Geräte-Journale konnten nicht durchsucht werden")
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) > 0 {
|
||||
groups = append(groups, globalSearchGroup{
|
||||
ID: "device-journals",
|
||||
Title: "Geräte-Journale",
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if userCanSearch(user, "devices:read") {
|
||||
items, err := s.searchDevices(r.Context(), options)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden")
|
||||
return
|
||||
@ -83,7 +133,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if userCanSearch(user, "users:read") {
|
||||
items, err := s.searchUsers(r.Context(), query, limit)
|
||||
items, err := s.searchUsers(r.Context(), options)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden")
|
||||
return
|
||||
@ -104,8 +154,8 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) searchOperations(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
|
||||
pattern := "%" + query + "%"
|
||||
func (s *Server) searchOperations(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||
patterns := buildSearchPatterns(options)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
@ -119,24 +169,47 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
|
||||
operation_leader,
|
||||
operation_team
|
||||
FROM operations
|
||||
WHERE CONCAT_WS(
|
||||
' ',
|
||||
operation_number,
|
||||
operation_name,
|
||||
offense,
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
remark
|
||||
) ILIKE $1
|
||||
WHERE (
|
||||
(
|
||||
$3 = false
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
operation_number,
|
||||
operation_name,
|
||||
offense,
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
remark
|
||||
) ILIKE ALL($1::TEXT[])
|
||||
)
|
||||
OR
|
||||
(
|
||||
$3 = true
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
operation_number,
|
||||
operation_name,
|
||||
offense,
|
||||
case_worker,
|
||||
target_object,
|
||||
kw_address,
|
||||
operation_leader,
|
||||
operation_team,
|
||||
legend,
|
||||
remark
|
||||
) LIKE ALL($1::TEXT[])
|
||||
)
|
||||
)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
pattern,
|
||||
limit,
|
||||
patterns,
|
||||
options.Limit,
|
||||
options.CaseSensitive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -195,8 +268,8 @@ func (s *Server) searchOperations(ctx context.Context, query string, limit int)
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
|
||||
pattern := "%" + query + "%"
|
||||
func (s *Server) searchDevices(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||
patterns := buildSearchPatterns(options)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
@ -208,25 +281,52 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
location,
|
||||
loan_status
|
||||
FROM devices
|
||||
WHERE CONCAT_WS(
|
||||
' ',
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
comment
|
||||
) ILIKE $1
|
||||
COALESCE(firmware_version, '')
|
||||
FROM devices
|
||||
WHERE (
|
||||
(
|
||||
$3 = false
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
COALESCE(firmware_version, '')
|
||||
) ILIKE ALL($1::TEXT[])
|
||||
)
|
||||
OR
|
||||
(
|
||||
$3 = true
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
inventory_number,
|
||||
manufacturer,
|
||||
model,
|
||||
serial_number,
|
||||
mac_address,
|
||||
COALESCE(ip_address, ''),
|
||||
location,
|
||||
loan_status,
|
||||
comment,
|
||||
COALESCE(firmware_version, '')
|
||||
) LIKE ALL($1::TEXT[])
|
||||
)
|
||||
)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
pattern,
|
||||
limit,
|
||||
patterns,
|
||||
options.Limit,
|
||||
options.CaseSensitive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -242,8 +342,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
|
||||
var model string
|
||||
var serialNumber string
|
||||
var macAddress string
|
||||
var ipAddress string
|
||||
var location string
|
||||
var loanStatus string
|
||||
var firmwareVersion string
|
||||
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
@ -252,8 +354,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
|
||||
&model,
|
||||
&serialNumber,
|
||||
&macAddress,
|
||||
&ipAddress,
|
||||
&location,
|
||||
&loanStatus,
|
||||
&firmwareVersion,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -268,8 +372,10 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
|
||||
model,
|
||||
serialNumber,
|
||||
macAddress,
|
||||
ipAddress,
|
||||
location,
|
||||
loanStatus,
|
||||
firmwareVersion,
|
||||
),
|
||||
Href: "/geraete",
|
||||
Data: map[string]any{
|
||||
@ -281,8 +387,201 @@ func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]globalSearchItem, error) {
|
||||
pattern := "%" + query + "%"
|
||||
func (s *Server) searchOperationJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||
patterns := buildSearchPatterns(options)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
operation_history.id::TEXT,
|
||||
operation_history.operation_id::TEXT,
|
||||
operations.operation_number,
|
||||
operations.operation_name,
|
||||
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
|
||||
COALESCE(journal_change.change->>'newValue', ''),
|
||||
operation_history.created_at
|
||||
FROM operation_history
|
||||
INNER JOIN operations
|
||||
ON operations.id = operation_history.operation_id
|
||||
LEFT JOIN users
|
||||
ON users.id = operation_history.user_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements(operation_history.changes) AS journal_change(change)
|
||||
WHERE
|
||||
journal_change.change->>'field' = 'journal'
|
||||
AND (
|
||||
(
|
||||
$3 = false
|
||||
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
|
||||
)
|
||||
OR
|
||||
(
|
||||
$3 = true
|
||||
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
|
||||
)
|
||||
)
|
||||
ORDER BY operation_history.created_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
patterns,
|
||||
options.Limit,
|
||||
options.CaseSensitive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []globalSearchItem{}
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var operationID string
|
||||
var operationNumber string
|
||||
var operationName string
|
||||
var userDisplayName string
|
||||
var content string
|
||||
var createdAt time.Time
|
||||
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&operationID,
|
||||
&operationNumber,
|
||||
&operationName,
|
||||
&userDisplayName,
|
||||
&content,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title := operationName
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = operationNumber
|
||||
}
|
||||
|
||||
items = append(items, globalSearchItem{
|
||||
ID: "operation-journal-" + id,
|
||||
EntityID: operationID,
|
||||
EntityType: "operationJournal",
|
||||
Title: title,
|
||||
Subtitle: joinSearchSubtitle(
|
||||
formatSearchDate(createdAt),
|
||||
userDisplayName,
|
||||
truncateSearchText(content, 140),
|
||||
),
|
||||
Href: "/einsaetze",
|
||||
Data: map[string]any{
|
||||
"historyId": id,
|
||||
"operationId": operationID,
|
||||
"operationNumber": operationNumber,
|
||||
"journalCreatedAt": createdAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) searchDeviceJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||
patterns := buildSearchPatterns(options)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
device_history.id::TEXT,
|
||||
device_history.device_id::TEXT,
|
||||
devices.inventory_number,
|
||||
devices.manufacturer,
|
||||
devices.model,
|
||||
COALESCE(users.display_name, users.username, users.email, 'Unbekannt'),
|
||||
COALESCE(journal_change.change->>'newValue', ''),
|
||||
device_history.created_at
|
||||
FROM device_history
|
||||
INNER JOIN devices
|
||||
ON devices.id = device_history.device_id
|
||||
LEFT JOIN users
|
||||
ON users.id = device_history.user_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements(device_history.changes) AS journal_change(change)
|
||||
WHERE
|
||||
journal_change.change->>'field' = 'journal'
|
||||
AND (
|
||||
(
|
||||
$3 = false
|
||||
AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[])
|
||||
)
|
||||
OR
|
||||
(
|
||||
$3 = true
|
||||
AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[])
|
||||
)
|
||||
)
|
||||
ORDER BY device_history.created_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
patterns,
|
||||
options.Limit,
|
||||
options.CaseSensitive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []globalSearchItem{}
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var deviceID string
|
||||
var inventoryNumber string
|
||||
var manufacturer string
|
||||
var model string
|
||||
var userDisplayName string
|
||||
var content string
|
||||
var createdAt time.Time
|
||||
|
||||
if err := rows.Scan(
|
||||
&id,
|
||||
&deviceID,
|
||||
&inventoryNumber,
|
||||
&manufacturer,
|
||||
&model,
|
||||
&userDisplayName,
|
||||
&content,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceName := joinSearchSubtitle(manufacturer, model)
|
||||
|
||||
items = append(items, globalSearchItem{
|
||||
ID: "device-journal-" + id,
|
||||
EntityID: deviceID,
|
||||
EntityType: "deviceJournal",
|
||||
Title: inventoryNumber,
|
||||
Subtitle: joinSearchSubtitle(
|
||||
deviceName,
|
||||
formatSearchDate(createdAt),
|
||||
userDisplayName,
|
||||
truncateSearchText(content, 140),
|
||||
),
|
||||
Href: "/geraete",
|
||||
Data: map[string]any{
|
||||
"historyId": id,
|
||||
"deviceId": deviceID,
|
||||
"inventoryNumber": inventoryNumber,
|
||||
"journalCreatedAt": createdAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) {
|
||||
patterns := buildSearchPatterns(options)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
ctx,
|
||||
@ -291,23 +590,41 @@ func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]gl
|
||||
id::TEXT,
|
||||
COALESCE(username, ''),
|
||||
COALESCE(display_name, ''),
|
||||
email,
|
||||
COALESCE(email, ''),
|
||||
COALESCE(unit, ''),
|
||||
COALESCE(user_group, '')
|
||||
FROM users
|
||||
WHERE CONCAT_WS(
|
||||
' ',
|
||||
username,
|
||||
display_name,
|
||||
email,
|
||||
unit,
|
||||
user_group
|
||||
) ILIKE $1
|
||||
WHERE (
|
||||
(
|
||||
$3 = false
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
username,
|
||||
display_name,
|
||||
email,
|
||||
unit,
|
||||
user_group
|
||||
) ILIKE ALL($1::TEXT[])
|
||||
)
|
||||
OR
|
||||
(
|
||||
$3 = true
|
||||
AND CONCAT_WS(
|
||||
' ',
|
||||
username,
|
||||
display_name,
|
||||
email,
|
||||
unit,
|
||||
user_group
|
||||
) LIKE ALL($1::TEXT[])
|
||||
)
|
||||
)
|
||||
ORDER BY display_name ASC, username ASC, email ASC
|
||||
LIMIT $2
|
||||
`,
|
||||
pattern,
|
||||
limit,
|
||||
patterns,
|
||||
options.Limit,
|
||||
options.CaseSensitive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -377,6 +694,43 @@ func parseSearchLimit(value string, fallback int) int {
|
||||
return limit
|
||||
}
|
||||
|
||||
func parseSearchBool(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
|
||||
return value == "1" ||
|
||||
value == "true" ||
|
||||
value == "yes" ||
|
||||
value == "on"
|
||||
}
|
||||
|
||||
func buildSearchPatterns(options globalSearchOptions) []string {
|
||||
query := strings.TrimSpace(options.Query)
|
||||
|
||||
if query == "" {
|
||||
return []string{"%%"}
|
||||
}
|
||||
|
||||
if options.ExactPhrase && strings.Contains(query, " ") {
|
||||
return []string{"%" + query + "%"}
|
||||
}
|
||||
|
||||
parts := strings.Fields(query)
|
||||
patterns := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
patterns = append(patterns, "%"+part+"%")
|
||||
}
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
return []string{"%" + query + "%"}
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
func userCanSearch(user User, right string) bool {
|
||||
for _, userRight := range user.Rights {
|
||||
if userRight == "admin" || userRight == right {
|
||||
@ -399,3 +753,26 @@ func joinSearchSubtitle(values ...string) string {
|
||||
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func formatSearchDate(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
func truncateSearchText(value string, maxLength int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if maxLength <= 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLength {
|
||||
return value
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(runes[:maxLength])) + "…"
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
@ -46,6 +47,11 @@ func main() {
|
||||
|
||||
if *reset {
|
||||
if err := dropDatabaseIfExists(ctx, parsedURL, databaseName); err != nil {
|
||||
if errors.Is(err, errResetAborted) {
|
||||
fmt.Println("Reset abgebrochen. Es wurden keine Daten gelöscht.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -74,6 +80,28 @@ func main() {
|
||||
}
|
||||
|
||||
printAdminResult(password, created)
|
||||
waitForExit()
|
||||
}
|
||||
|
||||
var errResetAborted = errors.New("reset abgebrochen")
|
||||
|
||||
func confirmDatabaseReset(databaseName string) (bool, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Println("")
|
||||
fmt.Println("WARNUNG: Der Datenbank-Reset löscht alle Daten unwiderruflich.")
|
||||
fmt.Printf("Datenbank: %s\n", databaseName)
|
||||
fmt.Println("")
|
||||
fmt.Printf("Bitte gib den Datenbanknamen %q ein, um fortzufahren: ", databaseName)
|
||||
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
return input == databaseName, nil
|
||||
}
|
||||
|
||||
func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName string) error {
|
||||
@ -107,6 +135,15 @@ func dropDatabaseIfExists(ctx context.Context, parsedURL *url.URL, databaseName
|
||||
return nil
|
||||
}
|
||||
|
||||
confirmed, err := confirmDatabaseReset(databaseName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !confirmed {
|
||||
return errResetAborted
|
||||
}
|
||||
|
||||
fmt.Printf("Datenbank %q wird zurückgesetzt...\n", databaseName)
|
||||
|
||||
_, err = db.Exec(
|
||||
@ -259,6 +296,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_settings (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
settings JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@ -268,11 +316,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
serial_number TEXT NOT NULL DEFAULT '',
|
||||
mac_address TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
loan_status TEXT NOT NULL DEFAULT 'Verfügbar',
|
||||
is_bao_device BOOLEAN NOT NULL DEFAULT false,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
|
||||
milestone_recording_server_id TEXT NOT NULL DEFAULT '',
|
||||
milestone_hardware_id TEXT NOT NULL DEFAULT '',
|
||||
milestone_display_name TEXT NOT NULL DEFAULT '',
|
||||
firmware_version TEXT NOT NULL DEFAULT '',
|
||||
milestone_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
milestone_synced_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
@ -414,6 +470,89 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
sound_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
email_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
operation_updates BOOLEAN NOT NULL DEFAULT true,
|
||||
operation_journals BOOLEAN NOT NULL DEFAULT true,
|
||||
device_updates BOOLEAN NOT NULL DEFAULT true,
|
||||
device_journals BOOLEAN NOT NULL DEFAULT true,
|
||||
system_messages BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
message TEXT NOT NULL,
|
||||
log JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
|
||||
user_snapshot JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
request JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
details JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS camera_firmware_cache (
|
||||
manufacturer TEXT NOT NULL,
|
||||
normalized_model TEXT NOT NULL,
|
||||
|
||||
latest_version TEXT NOT NULL DEFAULT '',
|
||||
support_url TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
check_error TEXT NOT NULL DEFAULT '',
|
||||
support_badges JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (manufacturer, normalized_model)
|
||||
)
|
||||
`,
|
||||
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS camera_firmware_refresh_state (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
last_checked_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`,
|
||||
|
||||
@ -428,9 +567,14 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
|
||||
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_ip_address ON devices(ip_address)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_milestone_hardware_id_unique ON devices(milestone_hardware_id) WHERE milestone_hardware_id <> ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_milestone_recording_server_id ON devices(milestone_recording_server_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_manufacturer ON devices(manufacturer)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_devices_model ON devices(model)`,
|
||||
@ -462,6 +606,19 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_notification_preferences_user_id ON notification_preferences(user_id)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback(created_at)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_id ON user_logs(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_logs_level ON user_logs(level)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_logs_category ON user_logs(category)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_logs_created_at ON user_logs(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_logs_user_created_at ON user_logs(user_id, created_at DESC, id DESC)`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_camera_firmware_cache_checked_at ON camera_firmware_cache(checked_at)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
@ -728,3 +885,11 @@ func env(key string, fallback string) string {
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func waitForExit() {
|
||||
fmt.Println("")
|
||||
fmt.Print("Drücke Enter, um das Setup zu beenden...")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, _ = reader.ReadString('\n')
|
||||
}
|
||||
|
||||
400
backend/user_log.go
Normal file
400
backend/user_log.go
Normal file
@ -0,0 +1,400 @@
|
||||
// backend/user_log.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserLogLevel string
|
||||
|
||||
const (
|
||||
UserLogLevelInfo UserLogLevel = "info"
|
||||
UserLogLevelWarning UserLogLevel = "warning"
|
||||
UserLogLevelError UserLogLevel = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
maxUserLogEntriesPerUser = 500
|
||||
maxAnonymousUserLogEntries = 1000
|
||||
)
|
||||
|
||||
type UserLogEntry struct {
|
||||
User *User
|
||||
UserID string
|
||||
|
||||
Request *http.Request
|
||||
|
||||
Level UserLogLevel
|
||||
Category string
|
||||
Action string
|
||||
|
||||
Message string
|
||||
Error error
|
||||
Details LogFields
|
||||
}
|
||||
|
||||
func (s *Server) logUserEvent(ctx context.Context, entry UserLogEntry) {
|
||||
if entry.Level == "" {
|
||||
entry.Level = UserLogLevelInfo
|
||||
}
|
||||
|
||||
userID := any(nil)
|
||||
userIDForPrune := ""
|
||||
userSnapshot := map[string]any{}
|
||||
|
||||
if entry.User != nil {
|
||||
userID = entry.User.ID
|
||||
userIDForPrune = entry.User.ID
|
||||
userSnapshot = buildUserLogSnapshot(*entry.User)
|
||||
} else if strings.TrimSpace(entry.UserID) != "" {
|
||||
userID = strings.TrimSpace(entry.UserID)
|
||||
userIDForPrune = strings.TrimSpace(entry.UserID)
|
||||
}
|
||||
|
||||
requestSnapshot := map[string]any{}
|
||||
if entry.Request != nil {
|
||||
requestSnapshot = buildRequestLogSnapshot(entry.Request)
|
||||
}
|
||||
|
||||
details := mergeLogFields(appErrorFields(entry.Error), entry.Details)
|
||||
|
||||
errorMessage := ""
|
||||
if entry.Error != nil {
|
||||
errorMessage = entry.Error.Error()
|
||||
}
|
||||
|
||||
userSnapshotJSON, err := json.Marshal(userSnapshot)
|
||||
if err != nil {
|
||||
logError("User-Log konnte nicht serialisiert werden", err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
requestSnapshotJSON, err := json.Marshal(requestSnapshot)
|
||||
if err != nil {
|
||||
logError("Request-Log konnte nicht serialisiert werden", err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
logError("User-Log-Details konnten nicht serialisiert werden", err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
INSERT INTO user_logs (
|
||||
user_id,
|
||||
level,
|
||||
category,
|
||||
action,
|
||||
message,
|
||||
error,
|
||||
user_snapshot,
|
||||
request,
|
||||
details
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
userID,
|
||||
string(entry.Level),
|
||||
entry.Category,
|
||||
entry.Action,
|
||||
entry.Message,
|
||||
errorMessage,
|
||||
userSnapshotJSON,
|
||||
requestSnapshotJSON,
|
||||
detailsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
logError("User-Log konnte nicht gespeichert werden", err, LogFields{
|
||||
"category": entry.Category,
|
||||
"action": entry.Action,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if userIDForPrune != "" {
|
||||
s.pruneUserLogsByUserID(ctx, userIDForPrune)
|
||||
return
|
||||
}
|
||||
|
||||
s.pruneAnonymousUserLogs(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) pruneUserLogs(ctx context.Context, user *User) {
|
||||
if user == nil || user.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.pruneUserLogsByUserID(ctx, user.ID)
|
||||
}
|
||||
|
||||
func (s *Server) pruneUserLogsByUserID(ctx context.Context, userID string) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
DELETE FROM user_logs
|
||||
WHERE user_id = $1
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM user_logs
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $2
|
||||
)
|
||||
`,
|
||||
userID,
|
||||
maxUserLogEntriesPerUser,
|
||||
)
|
||||
if err != nil {
|
||||
logError("Alte User-Logs konnten nicht gelöscht werden", err, LogFields{
|
||||
"userId": userID,
|
||||
"limit": maxUserLogEntriesPerUser,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pruneAnonymousUserLogs(ctx context.Context) {
|
||||
_, err := s.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
DELETE FROM user_logs
|
||||
WHERE user_id IS NULL
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM user_logs
|
||||
WHERE user_id IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $1
|
||||
)
|
||||
`,
|
||||
maxAnonymousUserLogEntries,
|
||||
)
|
||||
if err != nil {
|
||||
logError("Alte anonyme User-Logs konnten nicht gelöscht werden", err, LogFields{
|
||||
"limit": maxAnonymousUserLogEntries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logUserError(
|
||||
r *http.Request,
|
||||
user User,
|
||||
category string,
|
||||
action string,
|
||||
message string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logUserEvent(r.Context(), UserLogEntry{
|
||||
User: &user,
|
||||
Request: r,
|
||||
Level: UserLogLevelError,
|
||||
Category: category,
|
||||
Action: action,
|
||||
Message: message,
|
||||
Error: err,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logAndWriteUserError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
user User,
|
||||
statusCode int,
|
||||
category string,
|
||||
action string,
|
||||
publicMessage string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logUserError(
|
||||
r,
|
||||
user,
|
||||
category,
|
||||
action,
|
||||
publicMessage,
|
||||
err,
|
||||
details,
|
||||
)
|
||||
|
||||
writeError(w, statusCode, publicMessage)
|
||||
}
|
||||
|
||||
func buildUserLogSnapshot(user User) map[string]any {
|
||||
return map[string]any{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"displayName": user.DisplayName,
|
||||
"email": user.Email,
|
||||
"avatar": user.Avatar,
|
||||
"unit": user.Unit,
|
||||
"group": user.Group,
|
||||
"rights": user.Rights,
|
||||
"teams": user.Teams,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRequestLogSnapshot(r *http.Request) map[string]any {
|
||||
if r == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"query": r.URL.RawQuery,
|
||||
"ipAddress": requestIPAddress(r),
|
||||
"remoteAddr": r.RemoteAddr,
|
||||
"userAgent": r.UserAgent(),
|
||||
"referer": r.Referer(),
|
||||
}
|
||||
}
|
||||
|
||||
func requestIPAddress(r *http.Request) string {
|
||||
for _, headerName := range []string{
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"CF-Connecting-IP",
|
||||
} {
|
||||
headerValue := strings.TrimSpace(r.Header.Get(headerName))
|
||||
if headerValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ipAddress := strings.TrimSpace(strings.Split(headerValue, ",")[0])
|
||||
if ipAddress != "" {
|
||||
return ipAddress
|
||||
}
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func (s *Server) logRequestEvent(
|
||||
r *http.Request,
|
||||
level UserLogLevel,
|
||||
category string,
|
||||
action string,
|
||||
message string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
var user *User
|
||||
userID := ""
|
||||
|
||||
if currentUser, ok := userFromContext(r.Context()); ok {
|
||||
user = ¤tUser
|
||||
} else if currentUserID, ok := s.currentUserIDFromRequest(r); ok {
|
||||
userID = currentUserID
|
||||
}
|
||||
|
||||
s.logUserEvent(r.Context(), UserLogEntry{
|
||||
User: user,
|
||||
UserID: userID,
|
||||
Request: r,
|
||||
Level: level,
|
||||
Category: category,
|
||||
Action: action,
|
||||
Message: message,
|
||||
Error: err,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logRequestInfo(
|
||||
r *http.Request,
|
||||
category string,
|
||||
action string,
|
||||
message string,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logRequestEvent(
|
||||
r,
|
||||
UserLogLevelInfo,
|
||||
category,
|
||||
action,
|
||||
message,
|
||||
nil,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) logRequestWarning(
|
||||
r *http.Request,
|
||||
category string,
|
||||
action string,
|
||||
message string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logRequestEvent(
|
||||
r,
|
||||
UserLogLevelWarning,
|
||||
category,
|
||||
action,
|
||||
message,
|
||||
err,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) logRequestError(
|
||||
r *http.Request,
|
||||
category string,
|
||||
action string,
|
||||
message string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logRequestEvent(
|
||||
r,
|
||||
UserLogLevelError,
|
||||
category,
|
||||
action,
|
||||
message,
|
||||
err,
|
||||
details,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) logAndWriteSettingsError(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
statusCode int,
|
||||
category string,
|
||||
action string,
|
||||
publicMessage string,
|
||||
err error,
|
||||
details LogFields,
|
||||
) {
|
||||
s.logRequestError(
|
||||
r,
|
||||
category,
|
||||
action,
|
||||
publicMessage,
|
||||
err,
|
||||
details,
|
||||
)
|
||||
|
||||
writeSettingsError(w, statusCode, publicMessage)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// backend\user_sessions.go
|
||||
// backend/user_sessions.go
|
||||
|
||||
package main
|
||||
|
||||
@ -35,6 +35,7 @@ func (s *Server) createUserSession(
|
||||
operatingSystem := strings.TrimSpace(input.OperatingSystem)
|
||||
deviceType := strings.TrimSpace(input.DeviceType)
|
||||
userAgent := r.UserAgent()
|
||||
ipAddress := getRequestIP(r)
|
||||
|
||||
if browser == "" {
|
||||
browser = detectBrowserFromUserAgent(userAgent)
|
||||
@ -69,10 +70,45 @@ func (s *Server) createUserSession(
|
||||
operatingSystem,
|
||||
deviceType,
|
||||
userAgent,
|
||||
getRequestIP(r),
|
||||
ipAddress,
|
||||
).Scan(&sessionID)
|
||||
if err != nil {
|
||||
s.logUserEvent(ctx, UserLogEntry{
|
||||
UserID: userID,
|
||||
Request: r,
|
||||
Level: UserLogLevelError,
|
||||
Category: "session",
|
||||
Action: "create_session",
|
||||
Message: "Sitzung konnte nicht erstellt werden",
|
||||
Error: err,
|
||||
Details: LogFields{
|
||||
"browser": browser,
|
||||
"operatingSystem": operatingSystem,
|
||||
"deviceType": deviceType,
|
||||
"ipAddress": ipAddress,
|
||||
},
|
||||
})
|
||||
|
||||
return sessionID, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.logUserEvent(ctx, UserLogEntry{
|
||||
UserID: userID,
|
||||
Request: r,
|
||||
Level: UserLogLevelInfo,
|
||||
Category: "session",
|
||||
Action: "create_session",
|
||||
Message: "Neue Sitzung wurde erstellt",
|
||||
Details: LogFields{
|
||||
"sessionId": sessionID,
|
||||
"browser": browser,
|
||||
"operatingSystem": operatingSystem,
|
||||
"deviceType": deviceType,
|
||||
"ipAddress": ipAddress,
|
||||
},
|
||||
})
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
func (s *Server) isUserSessionActive(
|
||||
@ -124,6 +160,15 @@ func (s *Server) touchUserSession(
|
||||
func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.currentUserIDFromRequest(r)
|
||||
if !ok {
|
||||
s.logRequestWarning(
|
||||
r,
|
||||
"session",
|
||||
"list_sessions",
|
||||
"Sitzungen konnten nicht geladen werden: nicht angemeldet",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
@ -153,7 +198,19 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
|
||||
currentSessionID,
|
||||
)
|
||||
if err != nil {
|
||||
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht geladen werden")
|
||||
s.logAndWriteSettingsError(
|
||||
w,
|
||||
r,
|
||||
http.StatusInternalServerError,
|
||||
"session",
|
||||
"list_sessions",
|
||||
"Sitzungen konnten nicht geladen werden",
|
||||
err,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"currentSessionId": currentSessionID,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@ -173,7 +230,18 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
|
||||
&session.CreatedAt,
|
||||
&session.LastSeenAt,
|
||||
); err != nil {
|
||||
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
|
||||
s.logAndWriteSettingsError(
|
||||
w,
|
||||
r,
|
||||
http.StatusInternalServerError,
|
||||
"session",
|
||||
"scan_sessions",
|
||||
"Sitzungen konnten nicht gelesen werden",
|
||||
err,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@ -182,10 +250,32 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden")
|
||||
s.logAndWriteSettingsError(
|
||||
w,
|
||||
r,
|
||||
http.StatusInternalServerError,
|
||||
"session",
|
||||
"read_sessions",
|
||||
"Sitzungen konnten nicht gelesen werden",
|
||||
err,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
s.logRequestInfo(
|
||||
r,
|
||||
"session",
|
||||
"list_sessions",
|
||||
"Sitzungen wurden geladen",
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"sessionCount": len(sessions),
|
||||
},
|
||||
)
|
||||
|
||||
writeSettingsJSON(w, http.StatusOK, map[string]any{
|
||||
"sessions": sessions,
|
||||
})
|
||||
@ -194,19 +284,52 @@ func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.currentUserIDFromRequest(r)
|
||||
if !ok {
|
||||
s.logRequestWarning(
|
||||
r,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Sitzung konnte nicht abgemeldet werden: nicht angemeldet",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := r.PathValue("id")
|
||||
sessionID := strings.TrimSpace(r.PathValue("id"))
|
||||
currentSessionID, _ := s.currentSessionIDFromRequest(r)
|
||||
|
||||
if sessionID == "" {
|
||||
s.logRequestWarning(
|
||||
r,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Sitzung konnte nicht abgemeldet werden: Sitzung fehlt",
|
||||
nil,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
},
|
||||
)
|
||||
|
||||
writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
if sessionID == currentSessionID {
|
||||
s.logRequestWarning(
|
||||
r,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Aktuelle Sitzung kann hier nicht abgemeldet werden",
|
||||
nil,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"sessionId": sessionID,
|
||||
"currentSessionId": currentSessionID,
|
||||
},
|
||||
)
|
||||
|
||||
writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden")
|
||||
return
|
||||
}
|
||||
@ -224,15 +347,50 @@ func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request)
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
writeSettingsError(w, http.StatusInternalServerError, "Sitzung konnte nicht abgemeldet werden")
|
||||
s.logAndWriteSettingsError(
|
||||
w,
|
||||
r,
|
||||
http.StatusInternalServerError,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Sitzung konnte nicht abgemeldet werden",
|
||||
err,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"sessionId": sessionID,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
s.logRequestWarning(
|
||||
r,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Sitzung wurde nicht gefunden",
|
||||
nil,
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"sessionId": sessionID,
|
||||
},
|
||||
)
|
||||
|
||||
writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
s.logRequestInfo(
|
||||
r,
|
||||
"session",
|
||||
"revoke_session",
|
||||
"Sitzung wurde abgemeldet",
|
||||
LogFields{
|
||||
"userId": userID,
|
||||
"sessionId": sessionID,
|
||||
},
|
||||
)
|
||||
|
||||
writeSettingsJSON(w, http.StatusOK, map[string]any{
|
||||
"message": "Sitzung wurde abgemeldet",
|
||||
})
|
||||
@ -316,7 +474,8 @@ func detectOperatingSystemFromUserAgent(userAgent string) string {
|
||||
|
||||
func detectDeviceTypeFromUserAgent(userAgent string) string {
|
||||
switch {
|
||||
case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile"):
|
||||
case strings.Contains(userAgent, "iPhone") ||
|
||||
(strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile")):
|
||||
return "Smartphone"
|
||||
case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"):
|
||||
return "Tablet"
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type updateSettingsProfileRequest struct {
|
||||
type updateSettingsAccountRequest struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
@ -32,14 +32,14 @@ type confirmPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSettingsProfile(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUpdateSettingsAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.currentUserIDFromRequest(r)
|
||||
if !ok {
|
||||
writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
||||
return
|
||||
}
|
||||
|
||||
var payload updateSettingsProfileRequest
|
||||
var payload updateSettingsAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
|
||||
@ -16,6 +16,7 @@ import LoadingSpinner from './components/LoadingSpinner'
|
||||
import NotificationCenter from './components/NotificationCenter'
|
||||
import type { Notification, User } from './components/types'
|
||||
import AdministrationPage from './pages/administration/AdministrationPage'
|
||||
import Feedback from './components/Feedback'
|
||||
import { hasRight } from './components/permissions'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
@ -75,20 +76,18 @@ function App() {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
eventSource.addEventListener('notification', (event) => {
|
||||
const notification = parseNotificationEvent(event)
|
||||
|
||||
if (!notification) {
|
||||
function addVisibleNotification(notification: Notification) {
|
||||
if (notification.visible === false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (notification.visible !== false) {
|
||||
setNotifications((currentNotifications) => [
|
||||
notification,
|
||||
...currentNotifications.filter((item) => item.id !== notification.id),
|
||||
])
|
||||
}
|
||||
setNotifications((currentNotifications) => [
|
||||
notification,
|
||||
...currentNotifications.filter((item) => item.id !== notification.id),
|
||||
])
|
||||
}
|
||||
|
||||
function dispatchEntityEvent(notification: Notification) {
|
||||
if (notification.entityType === 'operation') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('operation-notification', {
|
||||
@ -104,15 +103,28 @@ function App() {
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
eventSource.addEventListener('operation-event', (event) => {
|
||||
function handleNotificationEvent(event: MessageEvent) {
|
||||
const notification = parseNotificationEvent(event)
|
||||
|
||||
if (!notification) {
|
||||
return
|
||||
}
|
||||
|
||||
addVisibleNotification(notification)
|
||||
dispatchEntityEvent(notification)
|
||||
}
|
||||
|
||||
function handleOperationEvent(event: MessageEvent) {
|
||||
const notification = parseNotificationEvent(event)
|
||||
|
||||
if (!notification) {
|
||||
return
|
||||
}
|
||||
|
||||
addVisibleNotification(notification)
|
||||
|
||||
if (
|
||||
notification.type === 'operation.journal' &&
|
||||
notification.entityType === 'operation' &&
|
||||
@ -125,30 +137,28 @@ function App() {
|
||||
})
|
||||
}
|
||||
|
||||
if (notification.entityType === 'operation') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('operation-notification', {
|
||||
detail: notification,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
dispatchEntityEvent(notification)
|
||||
}
|
||||
|
||||
eventSource.addEventListener('device-event', (event) => {
|
||||
function handleDeviceEvent(event: MessageEvent) {
|
||||
const notification = parseNotificationEvent(event)
|
||||
|
||||
if (!notification) {
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('device-notification', {
|
||||
detail: notification,
|
||||
}),
|
||||
)
|
||||
})
|
||||
addVisibleNotification(notification)
|
||||
dispatchEntityEvent(notification)
|
||||
}
|
||||
|
||||
eventSource.addEventListener('notification', handleNotificationEvent)
|
||||
eventSource.addEventListener('operation-event', handleOperationEvent)
|
||||
eventSource.addEventListener('device-event', handleDeviceEvent)
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener('notification', handleNotificationEvent)
|
||||
eventSource.removeEventListener('operation-event', handleOperationEvent)
|
||||
eventSource.removeEventListener('device-event', handleDeviceEvent)
|
||||
eventSource.close()
|
||||
}
|
||||
}, [user])
|
||||
@ -354,6 +364,11 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/administration"
|
||||
element={<Navigate to="/administration/benutzer" replace />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/administration/*"
|
||||
element={
|
||||
@ -375,6 +390,8 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/feedback" element={<Feedback />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// frontend/src/components/AppLayout.tsx
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Transition } from '@headlessui/react'
|
||||
import SearchOverlay from './SearchOverlay'
|
||||
import Sidebar from './Sidebar'
|
||||
@ -20,6 +21,8 @@ export default function AppLayout({
|
||||
children,
|
||||
notificationCenter,
|
||||
}: AppLayoutProps) {
|
||||
const location = useLocation()
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
@ -29,15 +32,25 @@ export default function AppLayout({
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
function handleNavigation() {
|
||||
setSearchQuery('')
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery('')
|
||||
}, [location.pathname, location.search, location.hash])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-dvh overflow-hidden">
|
||||
<Sidebar
|
||||
user={user}
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
|
||||
<div className="lg:pl-72">
|
||||
<div className="flex h-dvh min-w-0 flex-col lg:pl-72">
|
||||
<Topbar
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
@ -45,9 +58,10 @@ export default function AppLayout({
|
||||
notificationCenter={notificationCenter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
|
||||
<main className="relative min-h-[calc(100dvh-4rem)] overflow-hidden">
|
||||
<main className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{children}
|
||||
|
||||
<Transition
|
||||
|
||||
181
frontend/src/components/Avatar.tsx
Normal file
181
frontend/src/components/Avatar.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
// frontend/src/components/Avatar.tsx
|
||||
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
|
||||
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
|
||||
export type AvatarShape = 'circle' | 'rounded'
|
||||
export type AvatarStatus = 'gray' | 'red' | 'green'
|
||||
export type AvatarStatusPosition = 'top' | 'bottom'
|
||||
|
||||
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
|
||||
src?: string
|
||||
name?: string
|
||||
initials?: string
|
||||
size?: AvatarSize
|
||||
shape?: AvatarShape
|
||||
status?: AvatarStatus
|
||||
statusPosition?: AvatarStatusPosition
|
||||
showPlaceholderIcon?: boolean
|
||||
wrapperClassName?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const sizeClassNames: Record<AvatarSize, string> = {
|
||||
6: 'size-6',
|
||||
8: 'size-8',
|
||||
9: 'size-9',
|
||||
10: 'size-10',
|
||||
12: 'size-12',
|
||||
14: 'size-14',
|
||||
16: 'size-16',
|
||||
}
|
||||
|
||||
const initialsSizeClassNames: Record<AvatarSize, string> = {
|
||||
6: 'text-xs',
|
||||
8: 'text-sm',
|
||||
9: 'text-sm',
|
||||
10: 'text-base',
|
||||
12: 'text-lg',
|
||||
14: 'text-xl',
|
||||
16: 'text-2xl',
|
||||
}
|
||||
|
||||
const statusSizeClassNames: Record<AvatarSize, string> = {
|
||||
6: 'size-1.5',
|
||||
8: 'size-2',
|
||||
9: 'size-2',
|
||||
10: 'size-2.5',
|
||||
12: 'size-3',
|
||||
14: 'size-3.5',
|
||||
16: 'size-4',
|
||||
}
|
||||
|
||||
const statusColorClassNames: Record<AvatarStatus, string> = {
|
||||
gray: 'bg-gray-300 dark:bg-gray-500',
|
||||
red: 'bg-red-400 dark:bg-red-500',
|
||||
green: 'bg-green-400 dark:bg-green-500',
|
||||
}
|
||||
|
||||
function getInitials(name?: string, initials?: string) {
|
||||
if (initials) {
|
||||
return initials.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function PlaceholderIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0 1 12.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Avatar({
|
||||
src,
|
||||
alt,
|
||||
name,
|
||||
initials,
|
||||
size = 10,
|
||||
shape = 'circle',
|
||||
status,
|
||||
statusPosition = 'bottom',
|
||||
showPlaceholderIcon = true,
|
||||
wrapperClassName,
|
||||
className,
|
||||
...props
|
||||
}: AvatarProps) {
|
||||
const roundedClassName = shape === 'circle' ? 'rounded-full' : 'rounded-md'
|
||||
const avatarInitials = getInitials(name, initials)
|
||||
|
||||
const avatar = src ? (
|
||||
<img
|
||||
{...props}
|
||||
src={src}
|
||||
alt={alt ?? name ?? ''}
|
||||
className={classNames(
|
||||
'inline-block bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
||||
sizeClassNames[size],
|
||||
roundedClassName,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
) : avatarInitials ? (
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-flex items-center justify-center bg-gray-500 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
||||
sizeClassNames[size],
|
||||
roundedClassName,
|
||||
className,
|
||||
)}
|
||||
aria-label={name}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'font-medium text-white',
|
||||
initialsSizeClassNames[size],
|
||||
)}
|
||||
>
|
||||
{avatarInitials}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-block overflow-hidden bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
||||
sizeClassNames[size],
|
||||
roundedClassName,
|
||||
className,
|
||||
)}
|
||||
aria-label={name}
|
||||
>
|
||||
{showPlaceholderIcon && (
|
||||
<PlaceholderIcon className="size-full text-gray-300 dark:text-gray-600" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!status) {
|
||||
return avatar
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames('relative inline-block', wrapperClassName)}>
|
||||
{avatar}
|
||||
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute right-0 block rounded-full ring-2 ring-white dark:ring-gray-900',
|
||||
statusPosition === 'top' ? 'top-0' : 'bottom-0',
|
||||
statusSizeClassNames[size],
|
||||
statusColorClassNames[status],
|
||||
shape === 'rounded' &&
|
||||
statusPosition === 'top' &&
|
||||
'translate-x-1/2 -translate-y-1/2 transform',
|
||||
shape === 'rounded' &&
|
||||
statusPosition === 'bottom' &&
|
||||
'translate-x-1/2 translate-y-1/2 transform',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
198
frontend/src/components/Badge.tsx
Normal file
198
frontend/src/components/Badge.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
// frontend/src/components/Badge.tsx
|
||||
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BadgeTone =
|
||||
| "gray"
|
||||
| "red"
|
||||
| "yellow"
|
||||
| "green"
|
||||
| "blue"
|
||||
| "indigo"
|
||||
| "purple"
|
||||
| "pink";
|
||||
|
||||
export type BadgeVariant = "border" | "flat";
|
||||
export type BadgeShape = "rounded" | "pill";
|
||||
export type BadgeSize = "default" | "small";
|
||||
|
||||
type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "color"> & {
|
||||
children: ReactNode;
|
||||
tone?: BadgeTone;
|
||||
variant?: BadgeVariant;
|
||||
shape?: BadgeShape;
|
||||
size?: BadgeSize;
|
||||
dot?: boolean;
|
||||
leadingIcon?: ReactNode;
|
||||
trailingIcon?: ReactNode;
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
};
|
||||
|
||||
function cn(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const badgeClasses: Record<BadgeVariant, Record<BadgeTone, string>> = {
|
||||
border: {
|
||||
gray: "bg-gray-50 text-gray-600 inset-ring inset-ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:inset-ring-gray-400/20",
|
||||
red: "bg-red-50 text-red-700 inset-ring inset-ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:inset-ring-red-400/20",
|
||||
yellow:
|
||||
"bg-yellow-50 text-yellow-800 inset-ring inset-ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:inset-ring-yellow-400/20",
|
||||
green:
|
||||
"bg-green-50 text-green-700 inset-ring inset-ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:inset-ring-green-500/20",
|
||||
blue: "bg-blue-50 text-blue-700 inset-ring inset-ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:inset-ring-blue-400/30",
|
||||
indigo:
|
||||
"bg-indigo-50 text-indigo-700 inset-ring inset-ring-indigo-700/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:inset-ring-indigo-400/30",
|
||||
purple:
|
||||
"bg-purple-50 text-purple-700 inset-ring inset-ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:inset-ring-purple-400/30",
|
||||
pink: "bg-pink-50 text-pink-700 inset-ring inset-ring-pink-700/10 dark:bg-pink-400/10 dark:text-pink-400 dark:inset-ring-pink-400/20",
|
||||
},
|
||||
flat: {
|
||||
gray: "bg-gray-100 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400",
|
||||
red: "bg-red-100 text-red-700 dark:bg-red-400/10 dark:text-red-400",
|
||||
yellow:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-400/10 dark:text-yellow-500",
|
||||
green:
|
||||
"bg-green-100 text-green-700 dark:bg-green-400/10 dark:text-green-400",
|
||||
blue: "bg-blue-100 text-blue-700 dark:bg-blue-400/10 dark:text-blue-400",
|
||||
indigo:
|
||||
"bg-indigo-100 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-400",
|
||||
purple:
|
||||
"bg-purple-100 text-purple-700 dark:bg-purple-400/10 dark:text-purple-400",
|
||||
pink: "bg-pink-100 text-pink-700 dark:bg-pink-400/10 dark:text-pink-400",
|
||||
},
|
||||
};
|
||||
|
||||
const dotClasses: Record<BadgeTone, string> = {
|
||||
gray: "fill-gray-400",
|
||||
red: "fill-red-500 dark:fill-red-400",
|
||||
yellow: "fill-yellow-500 dark:fill-yellow-400",
|
||||
green: "fill-green-500 dark:fill-green-400",
|
||||
blue: "fill-blue-500 dark:fill-blue-400",
|
||||
indigo: "fill-indigo-500 dark:fill-indigo-400",
|
||||
purple: "fill-purple-500 dark:fill-purple-400",
|
||||
pink: "fill-pink-500 dark:fill-pink-400",
|
||||
};
|
||||
|
||||
const removeButtonClasses: Record<BadgeTone, string> = {
|
||||
gray: "hover:bg-gray-500/20 dark:hover:bg-gray-500/30",
|
||||
red: "hover:bg-red-600/20 dark:hover:bg-red-500/30",
|
||||
yellow: "hover:bg-yellow-600/20 dark:hover:bg-yellow-500/30",
|
||||
green: "hover:bg-green-600/20 dark:hover:bg-green-500/30",
|
||||
blue: "hover:bg-blue-600/20 dark:hover:bg-blue-500/30",
|
||||
indigo: "hover:bg-indigo-600/20 dark:hover:bg-indigo-500/30",
|
||||
purple: "hover:bg-purple-600/20 dark:hover:bg-purple-500/30",
|
||||
pink: "hover:bg-pink-600/20 dark:hover:bg-pink-500/30",
|
||||
};
|
||||
|
||||
const removeIconClasses: Record<BadgeTone, string> = {
|
||||
gray: "stroke-gray-600/50 group-hover:stroke-gray-600/75 dark:stroke-gray-400 dark:group-hover:stroke-gray-300",
|
||||
red: "stroke-red-600/50 group-hover:stroke-red-600/75 dark:stroke-red-400 dark:group-hover:stroke-red-300",
|
||||
yellow:
|
||||
"stroke-yellow-700/50 group-hover:stroke-yellow-700/75 dark:stroke-yellow-400 dark:group-hover:stroke-yellow-300",
|
||||
green:
|
||||
"stroke-green-700/50 group-hover:stroke-green-700/75 dark:stroke-green-400 dark:group-hover:stroke-green-300",
|
||||
blue: "stroke-blue-700/50 group-hover:stroke-blue-700/75 dark:stroke-blue-400 dark:group-hover:stroke-blue-300",
|
||||
indigo:
|
||||
"stroke-indigo-600/50 group-hover:stroke-indigo-600/75 dark:stroke-indigo-400 dark:group-hover:stroke-indigo-300",
|
||||
purple:
|
||||
"stroke-purple-700/50 group-hover:stroke-purple-700/75 dark:stroke-purple-400 dark:group-hover:stroke-purple-300",
|
||||
pink: "stroke-pink-700/50 group-hover:stroke-pink-700/75 dark:stroke-pink-400 dark:group-hover:stroke-pink-300",
|
||||
};
|
||||
|
||||
function BadgeIcon({
|
||||
children,
|
||||
size,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
size: BadgeSize;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center",
|
||||
size === "small"
|
||||
? "size-3 [&>svg]:size-3"
|
||||
: "size-3.5 [&>svg]:size-3.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "gray",
|
||||
variant = "border",
|
||||
shape = "rounded",
|
||||
size = "default",
|
||||
dot = false,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
onRemove,
|
||||
removeLabel = "Remove",
|
||||
className,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const hasAdornment = Boolean(dot || leadingIcon || trailingIcon);
|
||||
const hasOnlyRemoveAction = Boolean(onRemove && !hasAdornment);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center text-xs font-medium",
|
||||
hasAdornment && "gap-x-1.5",
|
||||
hasOnlyRemoveAction && "gap-x-0.5",
|
||||
onRemove && hasAdornment && "gap-x-1.5",
|
||||
shape === "pill" ? "rounded-full" : "rounded-md",
|
||||
size === "small" ? "px-1.5 py-0.5" : "px-2 py-1",
|
||||
badgeClasses[variant][tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{leadingIcon ? (
|
||||
<BadgeIcon size={size}>{leadingIcon}</BadgeIcon>
|
||||
) : null}
|
||||
|
||||
{dot ? (
|
||||
<svg
|
||||
viewBox="0 0 6 6"
|
||||
aria-hidden="true"
|
||||
className={cn("size-1.5", dotClasses[tone])}
|
||||
>
|
||||
<circle r={3} cx={3} cy={3} />
|
||||
</svg>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
|
||||
{trailingIcon ? (
|
||||
<BadgeIcon size={size}>{trailingIcon}</BadgeIcon>
|
||||
) : null}
|
||||
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
"group relative -mr-1 size-3.5 rounded-xs",
|
||||
removeButtonClasses[tone],
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{removeLabel}</span>
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
className={cn("size-3.5", removeIconClasses[tone])}
|
||||
>
|
||||
<path d="M4 4l6 6m0-6l-6 6" />
|
||||
</svg>
|
||||
<span className="absolute -inset-1" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -120,10 +120,9 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
|
||||
)
|
||||
|
||||
const text = label || description ? (
|
||||
<div className="min-w-0 text-sm/6">
|
||||
<span className="min-w-0 text-sm/6">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
<span
|
||||
className={classNames(
|
||||
'font-small text-gray-900 select-none dark:text-white',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
@ -131,28 +130,31 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p
|
||||
<span
|
||||
id={descriptionId}
|
||||
className={classNames(
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
'block text-gray-500 dark:text-gray-400',
|
||||
disabled && 'opacity-60',
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={classNames(
|
||||
'flex gap-3',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
checkboxPosition === 'right' && 'justify-between',
|
||||
wrapperClassName,
|
||||
)}
|
||||
@ -168,7 +170,7 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
|
||||
{checkbox}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
100
frontend/src/components/EmptyState.tsx
Normal file
100
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
// frontend/src/components/EmptyState.tsx
|
||||
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import Button from './Button'
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
actionIcon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
function DefaultIcon({
|
||||
className,
|
||||
'aria-hidden': ariaHidden,
|
||||
}: {
|
||||
className?: string
|
||||
'aria-hidden'?: boolean
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden={ariaHidden}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = DefaultIcon,
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionIcon,
|
||||
action,
|
||||
className,
|
||||
iconClassName,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={classNames('text-center', className)}>
|
||||
<Icon
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
'mx-auto size-12 text-gray-400 dark:text-gray-500',
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
<h3 className="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(action || (actionLabel && onAction)) && (
|
||||
<div className="mt-6">
|
||||
{action ?? (
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
onClick={onAction}
|
||||
leadingIcon={
|
||||
actionIcon ?? (
|
||||
<PlusIcon aria-hidden="true" className="size-5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
frontend/src/components/Feedback.tsx
Normal file
111
frontend/src/components/Feedback.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
// frontend/src/pages/Feedback.tsx
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Textarea from '../components/Textarea'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
export default function Feedback() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const trimmedMessage = message.trim()
|
||||
|
||||
if (!trimmedMessage) {
|
||||
setError('Bitte gib dein Feedback ein.')
|
||||
setSuccessMessage(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
message: trimmedMessage,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Feedback konnte nicht gesendet werden')
|
||||
}
|
||||
|
||||
setMessage('')
|
||||
setSuccessMessage('Danke, dein Feedback wurde gespeichert.')
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Feedback konnte nicht gesendet werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Feedback
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Teile uns mit, wenn dir etwas auffällt, etwas nicht funktioniert oder du eine Idee zur Verbesserung hast.
|
||||
Beim Absenden wird automatisch ein technisches Log mit deinem Benutzerkonto angehängt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="mt-6 rounded-md bg-green-50 p-4 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{successMessage}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
LayerGroup,
|
||||
LayersControl,
|
||||
MapContainer,
|
||||
Marker,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
Tooltip,
|
||||
WMSTileLayer,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from 'react-leaflet'
|
||||
import * as L from 'leaflet'
|
||||
import 'leaflet.heat'
|
||||
@ -72,6 +74,8 @@ type MapProps = {
|
||||
heatPoints?: MapHeatPoint[]
|
||||
showHeatmap?: boolean
|
||||
heatmapOptions?: MapHeatOptions
|
||||
heatmapLayerName?: string
|
||||
heatmapChecked?: boolean
|
||||
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
@ -239,14 +243,16 @@ type LeafletWithHeat = typeof L & {
|
||||
function HeatmapLayer({
|
||||
points,
|
||||
options,
|
||||
enabled = true,
|
||||
}: {
|
||||
points: MapHeatPoint[]
|
||||
options?: MapHeatOptions
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
if (points.length === 0) {
|
||||
if (!enabled || points.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -264,7 +270,34 @@ function HeatmapLayer({
|
||||
return () => {
|
||||
heatLayer.remove()
|
||||
}
|
||||
}, [map, points, options])
|
||||
}, [enabled, map, points, options])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function HeatmapLayerControlEvents({
|
||||
heatmapLayerName,
|
||||
onVisibilityChange,
|
||||
}: {
|
||||
heatmapLayerName: string
|
||||
onVisibilityChange: (visible: boolean) => void
|
||||
}) {
|
||||
useMapEvents({
|
||||
overlayadd: (event) => {
|
||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||
|
||||
if (layerEvent.name === heatmapLayerName) {
|
||||
onVisibilityChange(true)
|
||||
}
|
||||
},
|
||||
overlayremove: (event) => {
|
||||
const layerEvent = event as L.LeafletEvent & { name?: string }
|
||||
|
||||
if (layerEvent.name === heatmapLayerName) {
|
||||
onVisibilityChange(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@ -482,6 +515,8 @@ export default function LeafletMap({
|
||||
heatPoints = [],
|
||||
showHeatmap = false,
|
||||
heatmapOptions,
|
||||
heatmapLayerName = 'Heatmap',
|
||||
heatmapChecked = false,
|
||||
|
||||
center = [51.1657, 10.4515],
|
||||
zoom = 6,
|
||||
@ -506,6 +541,7 @@ export default function LeafletMap({
|
||||
}: MapProps) {
|
||||
|
||||
const [currentLocation, setCurrentLocation] = useState<[number, number] | null>(null)
|
||||
const [isHeatmapLayerVisible, setIsHeatmapLayerVisible] = useState(heatmapChecked)
|
||||
|
||||
const fallbackTileLayer =
|
||||
tileLayers.find((layer) => layer.checked) ?? tileLayers[0]
|
||||
@ -519,6 +555,10 @@ export default function LeafletMap({
|
||||
? heatPoints
|
||||
: markers
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeatmapLayerVisible(heatmapChecked)
|
||||
}, [heatmapChecked])
|
||||
|
||||
return (
|
||||
<div className={classNames('relative z-0 h-full', minHeightClassName, className)}>
|
||||
<MapContainer
|
||||
@ -530,7 +570,13 @@ export default function LeafletMap({
|
||||
className={classNames('relative z-0 h-full w-full', mapClassName)}
|
||||
>
|
||||
<InvalidateSizeOnResize />
|
||||
{showLayerControl ? (
|
||||
|
||||
<HeatmapLayerControlEvents
|
||||
heatmapLayerName={heatmapLayerName}
|
||||
onVisibilityChange={setIsHeatmapLayerVisible}
|
||||
/>
|
||||
|
||||
{showLayerControl ? (
|
||||
<LayersControl position="topright">
|
||||
{tileLayers.map((layer) => (
|
||||
<LayersControl.BaseLayer
|
||||
@ -565,6 +611,15 @@ export default function LeafletMap({
|
||||
{renderWmsLayer(layer, maxZoom)}
|
||||
</LayersControl.Overlay>
|
||||
))}
|
||||
|
||||
{showHeatmap && heatPoints.length > 0 && (
|
||||
<LayersControl.Overlay
|
||||
name={heatmapLayerName}
|
||||
checked={heatmapChecked}
|
||||
>
|
||||
<LayerGroup />
|
||||
</LayersControl.Overlay>
|
||||
)}
|
||||
</LayersControl>
|
||||
) : fallbackTileLayer ? (
|
||||
renderTileLayer(fallbackTileLayer, maxZoom)
|
||||
@ -572,12 +627,11 @@ export default function LeafletMap({
|
||||
renderWmsLayer(fallbackWmsLayer, maxZoom)
|
||||
) : null}
|
||||
|
||||
{showHeatmap && (
|
||||
<HeatmapLayer
|
||||
points={heatPoints}
|
||||
options={heatmapOptions}
|
||||
/>
|
||||
)}
|
||||
<HeatmapLayer
|
||||
points={heatPoints}
|
||||
options={heatmapOptions}
|
||||
enabled={showHeatmap && isHeatmapLayerVisible}
|
||||
/>
|
||||
|
||||
{showLocateControl && (
|
||||
<LocateButton
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
// frontend/src/components/NotificationCenter.tsx
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BellIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BellIcon,
|
||||
EnvelopeOpenIcon,
|
||||
FaceSmileIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from './Button'
|
||||
import Tabs, { type TabItem } from './Tabs'
|
||||
import type { Notification } from './types'
|
||||
|
||||
type NotificationCenterProps = {
|
||||
@ -11,6 +17,8 @@ type NotificationCenterProps = {
|
||||
onMarkAllRead: () => void
|
||||
}
|
||||
|
||||
type NotificationTab = 'unread' | 'all'
|
||||
|
||||
function formatNotificationDate(value: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
@ -29,9 +37,78 @@ export default function NotificationCenter({
|
||||
onMarkAllRead,
|
||||
}: NotificationCenterProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<NotificationTab>('unread')
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const filteredNotifications = useMemo(() => {
|
||||
if (activeTab === 'unread') {
|
||||
return notifications.filter((notification) => !notification.readAt)
|
||||
}
|
||||
|
||||
return notifications
|
||||
}, [activeTab, notifications])
|
||||
|
||||
const notificationTabs: TabItem[] = [
|
||||
{
|
||||
name: 'Ungelesen',
|
||||
href: '#',
|
||||
current: activeTab === 'unread',
|
||||
count: unreadCount > 0 ? unreadCount : undefined,
|
||||
onClick: () => setActiveTab('unread'),
|
||||
},
|
||||
{
|
||||
name: 'Alle',
|
||||
href: '#',
|
||||
current: activeTab === 'all',
|
||||
count: notifications.length > 0 ? notifications.length : undefined,
|
||||
onClick: () => setActiveTab('all'),
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof Node)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (containerRef.current?.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function getEmptyText() {
|
||||
if (activeTab === 'unread') {
|
||||
return 'Keine ungelesenen Benachrichtigungen vorhanden.'
|
||||
}
|
||||
|
||||
return 'Keine Benachrichtigungen vorhanden.'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
@ -49,29 +126,57 @@ export default function NotificationCenter({
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-96 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black/5 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-gray-200 dark:border-white/10">
|
||||
<h2 className="min-w-0 truncate px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Benachrichtigungen
|
||||
</h2>
|
||||
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkAllRead}
|
||||
className="text-xs font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||||
>
|
||||
Alle gelesen
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end px-4">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
onClick={onMarkAllRead}
|
||||
aria-label="Alle Benachrichtigungen als gelesen markieren"
|
||||
title="Alle gelesen"
|
||||
leadingIcon={
|
||||
<EnvelopeOpenIcon aria-hidden="true" className="size-4" />
|
||||
}
|
||||
>
|
||||
Alle gelesen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Benachrichtigungen vorhanden.
|
||||
</p>
|
||||
<Tabs
|
||||
tabs={notificationTabs}
|
||||
variant="full-width-underline"
|
||||
align="center"
|
||||
ariaLabel="Benachrichtigungsfilter"
|
||||
className="px-4 [&_a]:py-3"
|
||||
/>
|
||||
|
||||
<div className="h-96 overflow-y-auto">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<FaceSmileIcon
|
||||
aria-hidden="true"
|
||||
className="size-12 text-gray-300 dark:text-gray-600"
|
||||
/>
|
||||
|
||||
<p className="mt-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Alles erledigt
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{getEmptyText()}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
filteredNotifications.map((notification) => {
|
||||
const unread = !notification.readAt
|
||||
|
||||
return (
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
CircleStackIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
ExclamationTriangleIcon,
|
||||
@ -10,6 +12,7 @@ import {
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import type { User } from './types'
|
||||
import Checkbox from './Checkbox'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
@ -48,8 +51,12 @@ function getGroupIcon(groupId: string) {
|
||||
switch (groupId) {
|
||||
case 'operations':
|
||||
return ClipboardDocumentListIcon
|
||||
case 'operation-journals':
|
||||
return ChatBubbleLeftEllipsisIcon
|
||||
case 'devices':
|
||||
return CircleStackIcon
|
||||
case 'device-journals':
|
||||
return ChatBubbleLeftEllipsisIcon
|
||||
case 'users':
|
||||
return UserGroupIcon
|
||||
default:
|
||||
@ -57,25 +64,91 @@ function getGroupIcon(groupId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getEntityLabel(entityType: string) {
|
||||
switch (entityType) {
|
||||
case 'operation':
|
||||
return 'Einsatz'
|
||||
case 'device':
|
||||
return 'Gerät'
|
||||
case 'user':
|
||||
return 'Benutzer'
|
||||
default:
|
||||
return entityType
|
||||
}
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function getHighlightTokens(
|
||||
search: string,
|
||||
exactPhrase: boolean,
|
||||
caseSensitive: boolean,
|
||||
) {
|
||||
const trimmedSearch = search.trim()
|
||||
|
||||
if (exactPhrase && /\s/.test(trimmedSearch)) {
|
||||
return [
|
||||
caseSensitive
|
||||
? trimmedSearch
|
||||
: trimmedSearch.toLowerCase(),
|
||||
]
|
||||
}
|
||||
|
||||
const normalizedTokens = new Set<string>()
|
||||
|
||||
trimmedSearch
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2)
|
||||
.forEach((token) =>
|
||||
normalizedTokens.add(caseSensitive ? token : token.toLowerCase()),
|
||||
)
|
||||
|
||||
return Array.from(normalizedTokens)
|
||||
}
|
||||
|
||||
function HighlightedText({
|
||||
value,
|
||||
search,
|
||||
exactPhrase,
|
||||
caseSensitive,
|
||||
}: {
|
||||
value: string
|
||||
search: string
|
||||
exactPhrase: boolean
|
||||
caseSensitive: boolean
|
||||
}) {
|
||||
const tokens = getHighlightTokens(search, exactPhrase, caseSensitive)
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
const pattern = new RegExp(
|
||||
`(${tokens.map(escapeRegExp).join('|')})`,
|
||||
caseSensitive ? 'g' : 'ig',
|
||||
)
|
||||
|
||||
const parts = value.split(pattern)
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
const normalizedPart = caseSensitive ? part : part.toLowerCase()
|
||||
const isMatch = tokens.includes(normalizedPart)
|
||||
|
||||
if (!isMatch) {
|
||||
return <span key={`${part}-${index}`}>{part}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<mark
|
||||
key={`${part}-${index}`}
|
||||
className="rounded bg-yellow-100 px-0.5 py-px font-semibold text-yellow-900 ring-1 ring-yellow-200 ring-inset"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchOverlay({
|
||||
user,
|
||||
query,
|
||||
@ -89,6 +162,18 @@ export default function SearchOverlay({
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [exactPhrase, setExactPhrase] = useState(false)
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
|
||||
const canUseExactPhrase = /\s/.test(search)
|
||||
const effectiveExactPhrase = canUseExactPhrase && exactPhrase
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseExactPhrase && exactPhrase) {
|
||||
setExactPhrase(false)
|
||||
}
|
||||
}, [canUseExactPhrase, exactPhrase])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
@ -118,8 +203,21 @@ export default function SearchOverlay({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
q: search,
|
||||
limit: '8',
|
||||
})
|
||||
|
||||
if (effectiveExactPhrase) {
|
||||
searchParams.set('exact', '1')
|
||||
}
|
||||
|
||||
if (caseSensitive) {
|
||||
searchParams.set('caseSensitive', '1')
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_URL}/search?q=${encodeURIComponent(search)}&limit=8`,
|
||||
`${API_URL}/search?${searchParams.toString()}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
signal: abortController.signal,
|
||||
@ -137,11 +235,13 @@ export default function SearchOverlay({
|
||||
|
||||
const data = (await response.json()) as GlobalSearchResponse
|
||||
|
||||
const nextGroups: SearchGroup[] = (data.groups ?? []).map((group) => ({
|
||||
...group,
|
||||
icon: getGroupIcon(group.id),
|
||||
items: group.items ?? [],
|
||||
}))
|
||||
const nextGroups: SearchGroup[] = (data.groups ?? [])
|
||||
.map((group) => ({
|
||||
...group,
|
||||
icon: getGroupIcon(group.id),
|
||||
items: group.items ?? [],
|
||||
}))
|
||||
.filter((group) => group.items.length > 0)
|
||||
|
||||
setGroups(nextGroups)
|
||||
} catch (error) {
|
||||
@ -170,41 +270,88 @@ export default function SearchOverlay({
|
||||
window.clearTimeout(timeoutId)
|
||||
abortController.abort()
|
||||
}
|
||||
}, [search])
|
||||
}, [search, effectiveExactPhrase, caseSensitive])
|
||||
|
||||
const resultCount = useMemo(
|
||||
() => groups.reduce((count, group) => count + group.items.length, 0),
|
||||
[groups],
|
||||
)
|
||||
|
||||
const hasResults = resultCount > 0
|
||||
const showFullLoading = search.length >= 2 && isLoading && !hasResults
|
||||
const showError = search.length >= 2 && !isLoading && error
|
||||
const showNoResults =
|
||||
search.length >= 2 && !isLoading && !error && !hasResults
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-white backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex items-center justify-between gap-x-4">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="size-5"
|
||||
/>
|
||||
<div className="h-full overflow-y-auto bg-white/95 backdrop-blur-sm [scrollbar-gutter:stable]">
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-base font-semibold text-gray-900">
|
||||
Suche
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{search.length >= 2
|
||||
? `${resultCount} Treffer für „${query}“`
|
||||
: 'Gib mindestens 2 Zeichen ein.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
Suche
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:items-end">
|
||||
{search.length >= 2 && !error && (
|
||||
<div className="inline-flex items-center gap-x-2 rounded-full bg-gray-50 px-3 py-1 text-sm font-medium text-gray-600 ring-1 ring-gray-200 ring-inset">
|
||||
{isLoading && (
|
||||
<span className="size-3 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
{search.length >= 2
|
||||
? `${resultCount} Treffer für „${query}“`
|
||||
: 'Gib mindestens 2 Zeichen ein.'}
|
||||
</p>
|
||||
{isLoading ? 'Suche läuft...' : `${groups.length} Kategorien`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Checkbox
|
||||
checked={effectiveExactPhrase}
|
||||
disabled={!canUseExactPhrase}
|
||||
onChange={(event) => setExactPhrase(event.target.checked)}
|
||||
label="Exakter Wortlaut"
|
||||
wrapperClassName={[
|
||||
'items-center gap-2 rounded-full px-3 py-1.5 ring-1 ring-inset',
|
||||
canUseExactPhrase
|
||||
? 'cursor-pointer bg-white text-gray-700 ring-gray-200 hover:bg-gray-50'
|
||||
: 'cursor-not-allowed bg-gray-50 text-gray-400 ring-gray-200',
|
||||
].join(' ')}
|
||||
labelClassName="text-xs/5 font-medium text-inherit"
|
||||
inputClassName="size-4"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={caseSensitive}
|
||||
onChange={(event) => setCaseSensitive(event.target.checked)}
|
||||
label="Groß-/Kleinschreibung"
|
||||
wrapperClassName="items-center gap-2 rounded-full bg-white px-3 py-1.5 text-gray-700 ring-1 ring-gray-200 ring-inset hover:bg-gray-50"
|
||||
labelClassName="text-xs/5 font-medium text-inherit"
|
||||
inputClassName="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{search.length < 2 && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="mx-auto size-10 text-gray-300"
|
||||
@ -220,8 +367,8 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search.length >= 2 && isLoading && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
|
||||
{showFullLoading && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
||||
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
||||
|
||||
<p className="mt-4 text-sm font-medium text-gray-900">
|
||||
@ -234,8 +381,8 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search.length >= 2 && !isLoading && error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center">
|
||||
{showError && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 p-10 text-center shadow-sm">
|
||||
<ExclamationTriangleIcon
|
||||
aria-hidden="true"
|
||||
className="mx-auto size-10 text-red-400"
|
||||
@ -251,8 +398,8 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{search.length >= 2 && !isLoading && !error && resultCount === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center">
|
||||
{showNoResults && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
||||
<ExclamationTriangleIcon
|
||||
aria-hidden="true"
|
||||
className="mx-auto size-10 text-gray-300"
|
||||
@ -268,19 +415,16 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && groups.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{!error && groups.length > 0 && (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => {
|
||||
const Icon = group.icon
|
||||
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-5 py-4">
|
||||
<section key={group.id}>
|
||||
<div className="mb-4 flex items-center justify-between gap-x-4">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg 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-gray-50 text-gray-500 ring-1 ring-gray-200 ring-inset">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="size-5"
|
||||
@ -299,35 +443,43 @@ export default function SearchOverlay({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-gray-100">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{group.items.map((item) => (
|
||||
<li key={`${group.id}-${item.id}`}>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className="group block px-5 py-4 transition hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
|
||||
{item.title}
|
||||
</p>
|
||||
<Link
|
||||
key={`${group.id}-${item.id}`}
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className="group relative flex min-h-28 flex-col justify-between rounded-xl border border-gray-200 bg-white p-4 pr-10 shadow-sm transition hover:-translate-y-0.5 hover:border-indigo-200 hover:shadow-md"
|
||||
>
|
||||
<ArrowRightIcon
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 right-4 size-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-indigo-500"
|
||||
/>
|
||||
|
||||
{item.subtitle && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-1 text-sm font-semibold text-gray-900 group-hover:text-indigo-600">
|
||||
<HighlightedText
|
||||
value={item.title}
|
||||
search={search}
|
||||
exactPhrase={effectiveExactPhrase}
|
||||
caseSensitive={caseSensitive}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<span className="shrink-0 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 ring-1 ring-gray-200 ring-inset">
|
||||
{getEntityLabel(item.entityType)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
{item.subtitle && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">
|
||||
<HighlightedText
|
||||
value={item.subtitle}
|
||||
search={search}
|
||||
exactPhrase={effectiveExactPhrase}
|
||||
caseSensitive={caseSensitive}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChartPieIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
Cog6ToothIcon,
|
||||
ComputerDesktopIcon,
|
||||
DocumentDuplicateIcon,
|
||||
@ -17,7 +18,7 @@ import {
|
||||
HomeIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { NavLink } from 'react-router'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { hasRight } from './permissions'
|
||||
import type { User, Team } from './types'
|
||||
|
||||
@ -63,6 +64,7 @@ type SidebarProps = {
|
||||
user: User
|
||||
sidebarOpen: boolean
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
@ -80,16 +82,36 @@ function getTeamInitial(team: Team) {
|
||||
function SidebarContent({
|
||||
user,
|
||||
setSidebarOpen,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: User
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const teams = user.teams ?? []
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const isAdministrationActive =
|
||||
location.pathname === '/administration' ||
|
||||
location.pathname.startsWith('/administration/')
|
||||
|
||||
const isSettingsActive =
|
||||
location.pathname === '/einstellungen' ||
|
||||
location.pathname.startsWith('/einstellungen/')
|
||||
|
||||
const isFeedbackActive =
|
||||
location.pathname === '/feedback'
|
||||
|
||||
const visibleNavigation = navigation.filter((item) =>
|
||||
!item.right || hasRight(user, item.right),
|
||||
)
|
||||
|
||||
function handleNavigate() {
|
||||
onNavigate?.()
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
@ -108,7 +130,7 @@ function SidebarContent({
|
||||
<li key={item.name}>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
onClick={handleNavigate}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
isActive
|
||||
@ -137,6 +159,7 @@ function SidebarContent({
|
||||
<li key={team.id}>
|
||||
<a
|
||||
href={team.href || '#'}
|
||||
onClick={handleNavigate}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-white/5 text-white'
|
||||
@ -160,31 +183,29 @@ function SidebarContent({
|
||||
</li>
|
||||
|
||||
<li className="mt-auto space-y-1">
|
||||
{hasRight(user, 'administration:read') && (
|
||||
<NavLink
|
||||
to="/administration"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
isActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Administration
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink
|
||||
to="/feedback"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isFeedbackActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChatBubbleLeftRightIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Feedback
|
||||
</NavLink>
|
||||
|
||||
{hasRight(user, 'settings:read') && (
|
||||
<NavLink
|
||||
to="/einstellungen"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
to="/einstellungen/konto"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isActive
|
||||
isSettingsActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
@ -195,6 +216,24 @@ function SidebarContent({
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{hasRight(user, 'administration:read') && (
|
||||
<NavLink
|
||||
to="/administration/benutzer"
|
||||
onClick={handleNavigate}
|
||||
className={() =>
|
||||
classNames(
|
||||
isAdministrationActive
|
||||
? 'bg-white/5 text-white'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white',
|
||||
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon aria-hidden="true" className="size-6 shrink-0" />
|
||||
Administration
|
||||
</NavLink>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@ -202,7 +241,12 @@ function SidebarContent({
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarProps) {
|
||||
export default function Sidebar({
|
||||
user,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 lg:hidden">
|
||||
@ -226,7 +270,11 @@ export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarPr
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-4 ring-1 ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||
<SidebarContent
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
@ -234,7 +282,11 @@ export default function Sidebar({ user, sidebarOpen, setSidebarOpen }: SidebarPr
|
||||
|
||||
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 pb-4">
|
||||
<SidebarContent user={user} setSidebarOpen={setSidebarOpen} />
|
||||
<SidebarContent
|
||||
user={user}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@ -28,6 +30,14 @@ export type TableVariant =
|
||||
| 'summary'
|
||||
| 'border'
|
||||
|
||||
export type TableSortValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Date
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string
|
||||
header: string
|
||||
@ -35,7 +45,8 @@ export type TableColumn<T> = {
|
||||
mobileRender?: (row: T) => ReactNode
|
||||
isPrimary?: boolean
|
||||
sortable?: boolean
|
||||
hideOnMobile?: 'sm' | 'md' | 'lg'
|
||||
sortValue?: (row: T) => TableSortValue
|
||||
hideOnMobile?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
@ -74,6 +85,9 @@ type TablesProps<T> = {
|
||||
headerAction?: ReactNode
|
||||
|
||||
rowActions?: (row: T) => ReactNode
|
||||
onRowClick?: (row: T) => void
|
||||
getRowAriaLabel?: (row: T) => string
|
||||
|
||||
isLoading?: boolean
|
||||
loadingContent?: ReactNode
|
||||
|
||||
@ -189,6 +203,8 @@ export default function Tables<T>({
|
||||
headerAction,
|
||||
|
||||
rowActions,
|
||||
onRowClick,
|
||||
getRowAriaLabel,
|
||||
isLoading = false,
|
||||
loadingContent,
|
||||
|
||||
@ -434,6 +450,40 @@ export default function Tables<T>({
|
||||
)
|
||||
}
|
||||
|
||||
function isInteractiveTarget(
|
||||
target: EventTarget | null,
|
||||
currentTarget: HTMLElement,
|
||||
) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const interactiveTarget = target.closest(
|
||||
'a, button, input, select, textarea, label, [role="button"], [role="link"]',
|
||||
)
|
||||
|
||||
return Boolean(interactiveTarget && interactiveTarget !== currentTarget)
|
||||
}
|
||||
|
||||
function handleRowClick(row: T, event: MouseEvent<HTMLTableRowElement>) {
|
||||
if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
onRowClick(row)
|
||||
}
|
||||
|
||||
function handleRowKeyDown(row: T, event: KeyboardEvent<HTMLTableRowElement>) {
|
||||
if (!onRowClick || isInteractiveTarget(event.target, event.currentTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(row: T, rowIndex: number) {
|
||||
const rowId = getRowId(row)
|
||||
const selected = selectedSet.has(rowId)
|
||||
@ -441,10 +491,16 @@ export default function Tables<T>({
|
||||
return (
|
||||
<tr
|
||||
key={rowId}
|
||||
role={onRowClick ? 'button' : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
aria-label={onRowClick ? getRowAriaLabel?.(row) : undefined}
|
||||
onClick={(event) => handleRowClick(row, event)}
|
||||
onKeyDown={(event) => handleRowKeyDown(row, event)}
|
||||
className={classNames(
|
||||
isStriped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
|
||||
selected && 'bg-gray-50 dark:bg-gray-800/50',
|
||||
isVerticalLines && 'divide-x divide-gray-200 dark:divide-white/10',
|
||||
onRowClick && 'cursor-pointer hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-inset dark:hover:bg-gray-800/70 dark:focus-visible:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
@ -470,7 +526,7 @@ export default function Tables<T>({
|
||||
<td
|
||||
key={column.key}
|
||||
className={classNames(
|
||||
isCondensed ? 'py-2' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||
isCondensed ? 'py-2' : isCard ? 'py-3' : isAvatarVariant(variant) ? 'py-5' : 'py-4',
|
||||
isCondensed ? 'px-2' : 'px-3',
|
||||
isFirst && !selectable && 'pl-4 sm:pl-0',
|
||||
isCard && isFirst && 'sm:pl-6',
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
// frontend\src\components\Tabs.tsx
|
||||
// frontend/src/components/Tabs.tsx
|
||||
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ComponentType,
|
||||
MouseEvent,
|
||||
SVGProps,
|
||||
} from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { ChevronDownIcon } from '@heroicons/react/16/solid'
|
||||
|
||||
@ -12,6 +17,7 @@ export type TabItem = {
|
||||
current?: boolean
|
||||
icon?: TabIcon
|
||||
count?: string | number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type TabsVariant =
|
||||
@ -47,6 +53,10 @@ const alignClassMap: Record<TabsAlign, string> = {
|
||||
right: 'justify-end',
|
||||
}
|
||||
|
||||
function hasCount(tab: TabItem) {
|
||||
return tab.count !== undefined && tab.count !== null && tab.count !== ''
|
||||
}
|
||||
|
||||
function isTabActive(tab: TabItem, pathname: string) {
|
||||
if (typeof tab.current === 'boolean') {
|
||||
return tab.current
|
||||
@ -56,6 +66,10 @@ function isTabActive(tab: TabItem, pathname: string) {
|
||||
return pathname === '/'
|
||||
}
|
||||
|
||||
if (tab.href === '#') {
|
||||
return false
|
||||
}
|
||||
|
||||
return pathname.startsWith(tab.href)
|
||||
}
|
||||
|
||||
@ -71,16 +85,20 @@ export default function Tabs({
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeTab = tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
||||
const activeTab =
|
||||
tabs.find((tab) => isTabActive(tab, location.pathname)) ?? tabs[0]
|
||||
|
||||
const alignClassName = alignClassMap[align]
|
||||
|
||||
function handleSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
function handleSelectChange(event: ChangeEvent<HTMLSelectElement>) {
|
||||
const selectedTab = tabs.find((tab) => tab.name === event.target.value)
|
||||
|
||||
if (!selectedTab) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedTab.onClick?.()
|
||||
|
||||
if (selectedTab.href === '#') {
|
||||
return
|
||||
}
|
||||
@ -88,6 +106,51 @@ export default function Tabs({
|
||||
navigate(selectedTab.href)
|
||||
}
|
||||
|
||||
function handleTabClick(
|
||||
event: MouseEvent<HTMLAnchorElement>,
|
||||
tab: TabItem,
|
||||
) {
|
||||
if (tab.href === '#') {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
tab.onClick?.()
|
||||
}
|
||||
|
||||
function renderCount(tab: TabItem, active: boolean) {
|
||||
if (!hasCount(tab)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (variant === 'underline-badges') {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-2 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@ -117,7 +180,10 @@ export default function Tabs({
|
||||
<div className="hidden sm:block">
|
||||
{variant === 'underline' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('-mb-px flex space-x-8', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
@ -126,14 +192,16 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-200',
|
||||
'border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||
'flex items-center border-b-2 px-1 py-4 text-sm font-medium whitespace-nowrap',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -143,7 +211,10 @@ export default function Tabs({
|
||||
|
||||
{variant === 'underline-icons' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('-mb-px flex space-x-8', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
const Icon = tab.icon
|
||||
@ -153,6 +224,7 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
@ -171,7 +243,9 @@ export default function Tabs({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{tab.name}</span>
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -181,7 +255,10 @@ export default function Tabs({
|
||||
|
||||
{variant === 'pills' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('flex space-x-4', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
@ -190,14 +267,16 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -207,7 +286,10 @@ export default function Tabs({
|
||||
|
||||
{variant === 'pills-gray' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('flex space-x-4', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
@ -216,14 +298,16 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white'
|
||||
: 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -233,7 +317,10 @@ export default function Tabs({
|
||||
|
||||
{variant === 'pills-brand' && (
|
||||
<div className="border-b border-gray-200 px-4 py-3 sm:px-6 lg:px-8 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('flex space-x-4', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('flex space-x-4', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
@ -242,14 +329,16 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200',
|
||||
'rounded-md px-3 py-2 text-sm font-medium',
|
||||
'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -268,14 +357,16 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
|
||||
'flex-1 border-b-2 px-1 py-4 text-center text-sm font-medium',
|
||||
'flex flex-1 items-center justify-center border-b-2 px-1 py-4 text-center text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -296,6 +387,7 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-gray-900 dark:text-white'
|
||||
@ -305,11 +397,17 @@ export default function Tabs({
|
||||
'group relative min-w-0 flex-1 overflow-hidden px-4 py-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<span>{tab.name}</span>
|
||||
<span className="inline-flex items-center justify-center">
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
active ? 'bg-indigo-500 dark:bg-indigo-400' : 'bg-transparent',
|
||||
active
|
||||
? 'bg-indigo-500 dark:bg-indigo-400'
|
||||
: 'bg-transparent',
|
||||
'absolute inset-x-0 bottom-0 h-0.5',
|
||||
)}
|
||||
/>
|
||||
@ -321,7 +419,10 @@ export default function Tabs({
|
||||
|
||||
{variant === 'underline-badges' && (
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<nav aria-label={ariaLabel} className={classNames('-mb-px flex space-x-8', alignClassName)}>
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={classNames('-mb-px flex space-x-8', alignClassName)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = isTabActive(tab, location.pathname)
|
||||
|
||||
@ -330,6 +431,7 @@ export default function Tabs({
|
||||
key={tab.name}
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400'
|
||||
@ -338,19 +440,7 @@ export default function Tabs({
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
|
||||
{tab.count ? (
|
||||
<span
|
||||
className={classNames(
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300',
|
||||
'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block',
|
||||
)}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
) : null}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@ -378,13 +468,16 @@ export default function Tabs({
|
||||
<Link
|
||||
to={tab.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={
|
||||
onClick={(event) => handleTabClick(event, tab)}
|
||||
className={classNames(
|
||||
active
|
||||
? 'text-indigo-600 dark:text-indigo-400'
|
||||
: 'hover:text-gray-700 dark:hover:text-white'
|
||||
}
|
||||
: 'hover:text-gray-700 dark:hover:text-white',
|
||||
'inline-flex items-center',
|
||||
)}
|
||||
>
|
||||
{tab.name}
|
||||
{renderCount(tab, active)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
|
||||
244
frontend/src/components/TaskList.tsx
Normal file
244
frontend/src/components/TaskList.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
// frontend\src\components\TaskList.tsx
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
export type TaskListStepStatus = 'complete' | 'current' | 'upcoming' | 'error'
|
||||
|
||||
export type TaskListStep = {
|
||||
id?: string | number
|
||||
name: ReactNode
|
||||
description?: ReactNode
|
||||
href?: string
|
||||
status: TaskListStepStatus
|
||||
}
|
||||
|
||||
type TaskListProps = {
|
||||
steps: TaskListStep[]
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function TaskList({
|
||||
steps,
|
||||
ariaLabel = 'Progress',
|
||||
className,
|
||||
}: TaskListProps) {
|
||||
return (
|
||||
<nav aria-label={ariaLabel} className={className}>
|
||||
<ol role="list" className="overflow-hidden">
|
||||
{steps.map((step, stepIdx) => {
|
||||
const isLastStep = stepIdx === steps.length - 1
|
||||
const key = step.id ?? stepIdx
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
className={classNames(!isLastStep && 'pb-10', 'relative')}
|
||||
>
|
||||
{step.status === 'complete' ? (
|
||||
<TaskListItemComplete step={step} isLastStep={isLastStep} />
|
||||
) : step.status === 'current' ? (
|
||||
<TaskListItemCurrent step={step} isLastStep={isLastStep} />
|
||||
) : step.status === 'error' ? (
|
||||
<TaskListItemError step={step} isLastStep={isLastStep} />
|
||||
) : (
|
||||
<TaskListItemUpcoming step={step} isLastStep={isLastStep} />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemComplete({
|
||||
step,
|
||||
isLastStep,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!isLastStep && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-indigo-600 dark:bg-indigo-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskListItemWrapper step={step}>
|
||||
<span className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-indigo-600 group-hover:bg-indigo-800 dark:bg-indigo-500 dark:group-hover:bg-indigo-600">
|
||||
<CheckIcon aria-hidden="true" className="size-5 text-white" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<TaskListItemContent
|
||||
step={step}
|
||||
nameClassName="text-gray-900 dark:text-white"
|
||||
/>
|
||||
</TaskListItemWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemCurrent({
|
||||
step,
|
||||
isLastStep,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!isLastStep && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskListItemWrapper step={step} ariaCurrent="step">
|
||||
<span className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-indigo-600 bg-white dark:border-indigo-500 dark:bg-gray-900">
|
||||
<LoadingSpinner
|
||||
size="sm"
|
||||
className="text-indigo-600 dark:text-indigo-500"
|
||||
srLabel="Aktueller Schritt läuft"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<TaskListItemContent
|
||||
step={step}
|
||||
nameClassName="text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
</TaskListItemWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemError({
|
||||
step,
|
||||
isLastStep,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!isLastStep && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-white/15"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskListItemWrapper step={step}>
|
||||
<span className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full bg-red-600 dark:bg-red-500">
|
||||
<XMarkIcon aria-hidden="true" className="size-5 text-white" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<TaskListItemContent
|
||||
step={step}
|
||||
nameClassName="text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</TaskListItemWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemUpcoming({
|
||||
step,
|
||||
isLastStep,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!isLastStep && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-4 left-4 mt-0.5 -ml-px h-full w-0.5 bg-gray-300 dark:bg-white/15"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TaskListItemWrapper step={step}>
|
||||
<span aria-hidden="true" className="flex h-9 items-center">
|
||||
<span className="relative z-10 flex size-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400 dark:border-white/15 dark:bg-gray-900 dark:group-hover:border-white/25">
|
||||
<span className="size-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-white/15" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<TaskListItemContent
|
||||
step={step}
|
||||
nameClassName="text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
</TaskListItemWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemWrapper({
|
||||
step,
|
||||
ariaCurrent,
|
||||
children,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
ariaCurrent?: 'step'
|
||||
children: ReactNode
|
||||
}) {
|
||||
if (step.href) {
|
||||
return (
|
||||
<a
|
||||
href={step.href}
|
||||
aria-current={ariaCurrent}
|
||||
className="group relative flex items-start"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-current={ariaCurrent}
|
||||
className="group relative flex items-start"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskListItemContent({
|
||||
step,
|
||||
nameClassName,
|
||||
}: {
|
||||
step: TaskListStep
|
||||
nameClassName: string
|
||||
}) {
|
||||
return (
|
||||
<span className="ml-4 flex min-w-0 flex-col">
|
||||
<span className={classNames('text-sm font-medium', nameClassName)}>
|
||||
{step.name}
|
||||
</span>
|
||||
|
||||
{step.description && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{step.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
663
frontend/src/components/Textarea.tsx
Normal file
663
frontend/src/components/Textarea.tsx
Normal file
@ -0,0 +1,663 @@
|
||||
// frontend/src/components/Textarea.tsx
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useId,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
type TextareaHTMLAttributes,
|
||||
} from 'react'
|
||||
import {
|
||||
Tab,
|
||||
TabGroup,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
AtSymbolIcon,
|
||||
CodeBracketIcon,
|
||||
LinkIcon,
|
||||
PaperClipIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Avatar, { type AvatarProps } from './Avatar'
|
||||
|
||||
export type TextareaVariant =
|
||||
| 'simple'
|
||||
| 'avatar-actions'
|
||||
| 'underline-actions'
|
||||
| 'title-actions'
|
||||
| 'preview-button'
|
||||
|
||||
type TextareaResize = 'none' | 'vertical' | 'horizontal' | 'both'
|
||||
|
||||
type TextareaValue =
|
||||
| string
|
||||
| number
|
||||
| readonly string[]
|
||||
| undefined
|
||||
|
||||
type TextareaProps = Omit<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'value' | 'defaultValue' | 'onChange'
|
||||
> & {
|
||||
value?: TextareaValue
|
||||
defaultValue?: TextareaValue
|
||||
onChange?: (event: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
error?: ReactNode
|
||||
|
||||
variant?: TextareaVariant
|
||||
resize?: TextareaResize
|
||||
srOnlyLabel?: boolean
|
||||
|
||||
avatar?: ReactNode
|
||||
avatarProps?: AvatarProps
|
||||
|
||||
submitLabel?: ReactNode
|
||||
submitButtonType?: 'button' | 'submit'
|
||||
showSubmitButton?: boolean
|
||||
onSubmitButtonClick?: () => void
|
||||
|
||||
showAttachButton?: boolean
|
||||
attachLabel?: string
|
||||
onAttachClick?: () => void
|
||||
|
||||
titleLabel?: ReactNode
|
||||
titleInputProps?: InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
footerAttachLabel?: string
|
||||
|
||||
writeTabLabel?: ReactNode
|
||||
previewTabLabel?: ReactNode
|
||||
previewContent?: ReactNode | ((value: string) => ReactNode)
|
||||
previewPlaceholder?: ReactNode
|
||||
|
||||
showPreviewToolbar?: boolean
|
||||
onInsertLinkClick?: () => void
|
||||
onInsertCodeClick?: () => void
|
||||
onMentionClick?: () => void
|
||||
|
||||
wrapperClassName?: string
|
||||
fieldWrapperClassName?: string
|
||||
toolbarClassName?: string
|
||||
labelClassName?: string
|
||||
descriptionClassName?: string
|
||||
errorClassName?: string
|
||||
textareaClassName?: string
|
||||
titleInputClassName?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function normalizeTextareaValue(value: TextareaValue) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join('')
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getResizeClassName(resize: TextareaResize) {
|
||||
switch (resize) {
|
||||
case 'none':
|
||||
return 'resize-none'
|
||||
case 'horizontal':
|
||||
return 'resize-x'
|
||||
case 'both':
|
||||
return 'resize'
|
||||
case 'vertical':
|
||||
default:
|
||||
return 'resize-y'
|
||||
}
|
||||
}
|
||||
|
||||
const simpleTextareaClassName =
|
||||
'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 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:outline-gray-200 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 dark:disabled:bg-white/5 dark:disabled:text-gray-500'
|
||||
|
||||
const boxedTextareaClassName =
|
||||
'block w-full bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
|
||||
|
||||
const underlineTextareaClassName =
|
||||
'block w-full bg-transparent text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
|
||||
|
||||
const titleTextareaClassName =
|
||||
'block w-full bg-transparent px-3 py-1.5 text-base text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500'
|
||||
|
||||
const titleInputBaseClassName =
|
||||
'block w-full bg-transparent px-3 pt-2.5 text-lg font-medium text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500 dark:text-white dark:placeholder:text-gray-500'
|
||||
|
||||
const errorTextareaClassName =
|
||||
'text-red-900 outline-red-300 placeholder:text-red-300 focus:outline-red-600 dark:text-red-300 dark:outline-red-500/50 dark:placeholder:text-red-400 dark:focus:outline-red-500'
|
||||
|
||||
const boxedWrapperClassName =
|
||||
'rounded-lg bg-white 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'
|
||||
|
||||
const titleWrapperClassName =
|
||||
'rounded-lg bg-white outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-600 dark:bg-gray-800/50 dark:outline-white/10 dark:focus-within:outline-indigo-500'
|
||||
|
||||
const underlineWrapperClassName =
|
||||
'border-b border-gray-200 pb-px focus-within:border-b-2 focus-within:border-indigo-600 focus-within:pb-0 dark:border-white/10 dark:focus-within:border-indigo-500'
|
||||
|
||||
const previewTabClassName =
|
||||
'rounded-md border border-transparent bg-white px-3 py-1.5 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 data-selected:bg-gray-100 data-selected:text-gray-900 data-selected:hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white dark:data-selected:bg-white/10 dark:data-selected:text-white dark:data-selected:hover:bg-white/10'
|
||||
|
||||
const iconButtonClassName =
|
||||
'-m-2.5 inline-flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:text-white'
|
||||
|
||||
const submitButtonClassName =
|
||||
'inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500'
|
||||
|
||||
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
function Textarea(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
|
||||
variant = 'simple',
|
||||
resize,
|
||||
rows = 4,
|
||||
srOnlyLabel = false,
|
||||
|
||||
avatar,
|
||||
avatarProps,
|
||||
|
||||
submitLabel = variant === 'title-actions' ? 'Erstellen' : 'Senden',
|
||||
submitButtonType = 'submit',
|
||||
showSubmitButton = true,
|
||||
onSubmitButtonClick,
|
||||
|
||||
attachLabel = 'Datei anhängen',
|
||||
onAttachClick,
|
||||
showAttachButton = Boolean(onAttachClick),
|
||||
|
||||
titleLabel = 'Title',
|
||||
titleInputProps,
|
||||
|
||||
footerAttachLabel = 'Datei anhängen',
|
||||
|
||||
writeTabLabel = 'Write',
|
||||
previewTabLabel = 'Preview',
|
||||
previewContent,
|
||||
previewPlaceholder = 'Preview content will render here.',
|
||||
|
||||
showPreviewToolbar = true,
|
||||
onInsertLinkClick,
|
||||
onInsertCodeClick,
|
||||
onMentionClick,
|
||||
|
||||
wrapperClassName,
|
||||
fieldWrapperClassName,
|
||||
toolbarClassName,
|
||||
labelClassName,
|
||||
descriptionClassName,
|
||||
errorClassName,
|
||||
textareaClassName,
|
||||
titleInputClassName,
|
||||
className,
|
||||
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
|
||||
disabled,
|
||||
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
|
||||
...textareaProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const generatedId = useId()
|
||||
const textareaId = id ?? `textarea-${generatedId}`
|
||||
const descriptionId = description ? `${textareaId}-description` : undefined
|
||||
const errorId = error ? `${textareaId}-error` : undefined
|
||||
const titleInputId = titleInputProps?.id ?? `${textareaId}-title`
|
||||
|
||||
const effectiveResize =
|
||||
resize ??
|
||||
(
|
||||
variant === 'simple' ||
|
||||
variant === 'preview-button'
|
||||
? 'vertical'
|
||||
: 'none'
|
||||
)
|
||||
|
||||
const [internalValue, setInternalValue] = useState(() =>
|
||||
normalizeTextareaValue(defaultValue),
|
||||
)
|
||||
|
||||
const textareaValue =
|
||||
value !== undefined ? normalizeTextareaValue(value) : internalValue
|
||||
|
||||
const describedBy = [ariaDescribedBy, descriptionId, errorId]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const hasAvatar = variant === 'avatar-actions' || variant === 'underline-actions'
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
|
||||
if (value === undefined) {
|
||||
setInternalValue(event.target.value)
|
||||
}
|
||||
|
||||
onChange?.(event)
|
||||
}
|
||||
|
||||
function renderFeedback() {
|
||||
return (
|
||||
<>
|
||||
{description && !error && (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={classNames(
|
||||
'mt-1 text-sm/6 text-gray-500 dark:text-gray-400',
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p
|
||||
id={errorId}
|
||||
className={classNames(
|
||||
'mt-1 text-sm/6 text-red-600 dark:text-red-400',
|
||||
errorClassName,
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLabel(defaultSrOnly = false) {
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className={classNames(
|
||||
srOnlyLabel || defaultSrOnly
|
||||
? 'sr-only'
|
||||
: 'block text-sm/6 font-medium text-gray-900 dark:text-white',
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTextarea(baseClassName: string) {
|
||||
return (
|
||||
<textarea
|
||||
{...textareaProps}
|
||||
ref={ref}
|
||||
id={textareaId}
|
||||
rows={rows}
|
||||
value={textareaValue}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
aria-invalid={Boolean(error) ? true : ariaInvalid}
|
||||
aria-describedby={describedBy || undefined}
|
||||
className={classNames(
|
||||
baseClassName,
|
||||
getResizeClassName(effectiveResize),
|
||||
Boolean(error) && errorTextareaClassName,
|
||||
textareaClassName,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAvatar() {
|
||||
if (avatar) {
|
||||
return avatar
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
size={10}
|
||||
shape="circle"
|
||||
{...avatarProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAttachButton(className = iconButtonClassName) {
|
||||
if (!showAttachButton) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onAttachClick}
|
||||
className={className}
|
||||
>
|
||||
<PaperClipIcon aria-hidden="true" className="size-5" />
|
||||
<span className="sr-only">{attachLabel}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSubmitButton() {
|
||||
if (!showSubmitButton) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type={submitButtonType}
|
||||
disabled={disabled}
|
||||
onClick={onSubmitButtonClick}
|
||||
className={submitButtonClassName}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewToolbar() {
|
||||
if (!showPreviewToolbar) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ml-auto hidden items-center space-x-5 group-has-[*:first-child[aria-selected='true']]:flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onInsertLinkClick}
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<span className="sr-only">Insert link</span>
|
||||
<LinkIcon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onInsertCodeClick}
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<span className="sr-only">Insert code</span>
|
||||
<CodeBracketIcon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onMentionClick}
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<span className="sr-only">Mention someone</span>
|
||||
<AtSymbolIcon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'avatar-actions') {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
hasAvatar && 'flex items-start space-x-4',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{renderAvatar()}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={classNames(
|
||||
boxedWrapperClassName,
|
||||
fieldWrapperClassName,
|
||||
)}
|
||||
>
|
||||
{renderLabel(true)}
|
||||
{renderTextarea(boxedTextareaClassName)}
|
||||
|
||||
<div aria-hidden="true" className="py-2">
|
||||
<div className="py-px">
|
||||
<div className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute inset-x-0 bottom-0 flex justify-between py-2 pr-2 pl-3',
|
||||
toolbarClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="flex items-center">
|
||||
{renderAttachButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderFeedback()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'underline-actions') {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
hasAvatar && 'flex items-start space-x-4',
|
||||
wrapperClassName,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{renderAvatar()}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={classNames(
|
||||
underlineWrapperClassName,
|
||||
fieldWrapperClassName,
|
||||
)}
|
||||
>
|
||||
{renderLabel(true)}
|
||||
{renderTextarea(underlineTextareaClassName)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
'flex justify-between pt-2',
|
||||
toolbarClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="flow-root">
|
||||
{renderAttachButton('-m-2 inline-flex size-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500 dark:hover:text-white')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderFeedback()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'title-actions') {
|
||||
const {
|
||||
className: titleInputCustomClassName,
|
||||
id: _providedTitleInputId,
|
||||
name: providedTitleInputName,
|
||||
...restTitleInputProps
|
||||
} = titleInputProps ?? {}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={classNames(
|
||||
titleWrapperClassName,
|
||||
fieldWrapperClassName,
|
||||
)}
|
||||
>
|
||||
<label htmlFor={titleInputId} className="sr-only">
|
||||
{titleLabel}
|
||||
</label>
|
||||
|
||||
<input
|
||||
{...restTitleInputProps}
|
||||
id={titleInputId}
|
||||
name={providedTitleInputName ?? 'title'}
|
||||
type={titleInputProps?.type ?? 'text'}
|
||||
disabled={disabled || titleInputProps?.disabled}
|
||||
className={classNames(
|
||||
titleInputBaseClassName,
|
||||
titleInputClassName,
|
||||
titleInputCustomClassName,
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderLabel(true)}
|
||||
{renderTextarea(titleTextareaClassName)}
|
||||
|
||||
<div aria-hidden="true" className="py-2">
|
||||
<div className="py-px">
|
||||
<div className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-px bottom-0">
|
||||
<div className="flex items-center justify-between space-x-3 border-t border-gray-200 px-2 py-2 sm:px-3 dark:border-white/10">
|
||||
<div className="flex">
|
||||
{showAttachButton && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onAttachClick}
|
||||
className="group -my-2 -ml-2 inline-flex items-center rounded-full px-3 py-2 text-left text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-500"
|
||||
>
|
||||
<PaperClipIcon
|
||||
aria-hidden="true"
|
||||
className="mr-2 -ml-1 size-5 group-hover:text-gray-500 dark:group-hover:text-gray-400"
|
||||
/>
|
||||
<span className="text-sm text-gray-500 italic group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300">
|
||||
{footerAttachLabel}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderFeedback()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'preview-button') {
|
||||
const resolvedPreviewContent =
|
||||
typeof previewContent === 'function'
|
||||
? previewContent(textareaValue)
|
||||
: previewContent
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<TabGroup>
|
||||
<div className="group flex items-center">
|
||||
<TabList className="flex gap-2">
|
||||
<Tab className={previewTabClassName}>
|
||||
{writeTabLabel}
|
||||
</Tab>
|
||||
|
||||
<Tab className={previewTabClassName}>
|
||||
{previewTabLabel}
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
{renderPreviewToolbar()}
|
||||
</div>
|
||||
|
||||
<TabPanels className="mt-2">
|
||||
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
||||
{renderLabel(true)}
|
||||
<div>
|
||||
{renderTextarea(simpleTextareaClassName)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel className="-m-0.5 rounded-lg p-0.5">
|
||||
<div className="border-b border-gray-200 dark:border-white/10">
|
||||
<div className="mx-px mt-px px-3 pt-2 pb-12 text-sm text-gray-800 whitespace-pre-wrap dark:text-gray-300">
|
||||
{resolvedPreviewContent || previewPlaceholder}
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
{renderSubmitButton()}
|
||||
</div>
|
||||
|
||||
{renderFeedback()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
{renderLabel()}
|
||||
|
||||
<div className={label && !srOnlyLabel ? 'mt-2' : undefined}>
|
||||
{renderTextarea(simpleTextareaClassName)}
|
||||
</div>
|
||||
|
||||
{renderFeedback()}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default Textarea
|
||||
@ -24,10 +24,11 @@ type TopbarProps = {
|
||||
notificationCenter?: ReactNode
|
||||
searchQuery: string
|
||||
onSearchQueryChange: (query: string) => void
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
const userNavigation = [
|
||||
{ name: 'Mein Account', href: '/einstellungen' },
|
||||
{ name: 'Mein Account', href: '/einstellungen/konto' },
|
||||
]
|
||||
|
||||
export default function Topbar({
|
||||
@ -37,6 +38,7 @@ export default function Topbar({
|
||||
notificationCenter,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onNavigate,
|
||||
}: TopbarProps) {
|
||||
function handleQrScan(value: string) {
|
||||
console.log('QR-Code erkannt:', value)
|
||||
@ -123,6 +125,7 @@ export default function Topbar({
|
||||
<MenuItem key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={onNavigate}
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||
>
|
||||
{item.name}
|
||||
@ -133,7 +136,10 @@ export default function Topbar({
|
||||
<MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
onClick={() => {
|
||||
onNavigate?.()
|
||||
void onLogout()
|
||||
}}
|
||||
className="block w-full px-3 py-1 text-left text-sm/6 text-gray-900 data-focus:bg-gray-50 data-focus:outline-hidden dark:text-white dark:data-focus:bg-white/5"
|
||||
>
|
||||
Abmelden
|
||||
|
||||
@ -64,6 +64,8 @@ export default function WidgetSettingsModal({
|
||||
isSaving,
|
||||
onSave,
|
||||
}: WidgetSettingsModalProps) {
|
||||
const [renderedWidget, setRenderedWidget] = useState<DashboardWidget | null>(null)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [limit, setLimit] = useState(8)
|
||||
|
||||
@ -74,13 +76,16 @@ export default function WidgetSettingsModal({
|
||||
const [initialAddress, setInitialAddress] = useState('')
|
||||
const [initialZoom, setInitialZoom] = useState(14)
|
||||
|
||||
const activeWidget = widget ?? renderedWidget
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !widget) {
|
||||
if (!widget) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitle(widget.title)
|
||||
setRenderedWidget(widget)
|
||||
|
||||
setTitle(widget.title)
|
||||
setLimit(getConfigNumber(widget.config, 'limit', 8))
|
||||
|
||||
setDefaultLayer(getConfigString(widget.config, 'defaultLayer', 'nrw-dop'))
|
||||
@ -95,23 +100,31 @@ export default function WidgetSettingsModal({
|
||||
)
|
||||
setInitialAddress(getConfigString(widget.config, 'initialAddress', ''))
|
||||
setInitialZoom(getConfigNumber(widget.config, 'initialZoom', 14))
|
||||
}, [open, widget])
|
||||
}, [widget])
|
||||
|
||||
function handleClose() {
|
||||
if (isSaving) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!widget) {
|
||||
if (!activeWidget) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextConfig =
|
||||
widget.type === 'operationChanges'
|
||||
activeWidget.type === 'operationChanges'
|
||||
? {
|
||||
...widget.config,
|
||||
...activeWidget.config,
|
||||
limit: Math.min(Math.max(limit, 1), 50),
|
||||
}
|
||||
: {
|
||||
...widget.config,
|
||||
...activeWidget.config,
|
||||
defaultLayer,
|
||||
showLabelsOverlay,
|
||||
showLocateControl,
|
||||
@ -120,8 +133,8 @@ export default function WidgetSettingsModal({
|
||||
initialZoom: Math.min(Math.max(initialZoom, 4), 20),
|
||||
}
|
||||
|
||||
void onSave(widget, {
|
||||
title: title.trim() || widget.title,
|
||||
void onSave(activeWidget, {
|
||||
title: title.trim() || activeWidget.title,
|
||||
config: nextConfig,
|
||||
})
|
||||
}
|
||||
@ -129,7 +142,14 @@ export default function WidgetSettingsModal({
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
setOpen={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
zIndexClassName="z-[9999]"
|
||||
panelClassName="max-w-xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
@ -151,7 +171,7 @@ export default function WidgetSettingsModal({
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-5" />
|
||||
@ -173,12 +193,13 @@ export default function WidgetSettingsModal({
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
disabled={isSaving}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{widget?.type === 'operationChanges' && (
|
||||
{activeWidget?.type === 'operationChanges' && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="operationChangesLimit"
|
||||
@ -195,6 +216,7 @@ export default function WidgetSettingsModal({
|
||||
max={50}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number(event.target.value))}
|
||||
disabled={isSaving}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
@ -205,7 +227,7 @@ export default function WidgetSettingsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget?.type === 'operationMap' && (
|
||||
{activeWidget?.type === 'operationMap' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
@ -220,6 +242,7 @@ export default function WidgetSettingsModal({
|
||||
id="mapDefaultLayer"
|
||||
value={defaultLayer}
|
||||
onChange={(event) => setDefaultLayer(event.target.value)}
|
||||
disabled={isSaving}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="osm">OpenStreetMap</option>
|
||||
@ -267,6 +290,7 @@ export default function WidgetSettingsModal({
|
||||
value={initialAddress}
|
||||
onChange={(event) => setInitialAddress(event.target.value)}
|
||||
placeholder="z. B. Düsseldorf, Marktplatz"
|
||||
disabled={isSaving}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
@ -292,6 +316,7 @@ export default function WidgetSettingsModal({
|
||||
max={20}
|
||||
value={initialZoom}
|
||||
onChange={(event) => setInitialZoom(Number(event.target.value))}
|
||||
disabled={isSaving}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
@ -306,7 +331,7 @@ export default function WidgetSettingsModal({
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
@ -315,6 +340,7 @@ export default function WidgetSettingsModal({
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
disabled={!activeWidget}
|
||||
>
|
||||
Einstellungen speichern
|
||||
</Button>
|
||||
|
||||
@ -362,14 +362,6 @@ export default function OperationMapWidget({
|
||||
)
|
||||
}
|
||||
|
||||
if (operations.length === 0 || markers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Einsatzpositionen vorhanden.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppMap
|
||||
markers={markers}
|
||||
|
||||
@ -54,13 +54,39 @@ export type Device = {
|
||||
model: string
|
||||
serialNumber: string
|
||||
macAddress: string
|
||||
ipAddress: string
|
||||
location: string
|
||||
loanStatus: string
|
||||
comment: string
|
||||
firmwareVersion: string
|
||||
firmwareUpdate?: DeviceFirmwareUpdate | null
|
||||
supportBadges?: DeviceSupportBadge[]
|
||||
milestoneRecordingServerId?: string
|
||||
milestoneHardwareId?: string
|
||||
milestoneDisplayName: string
|
||||
milestoneEnabled?: boolean
|
||||
milestoneSyncedAt?: string
|
||||
relatedDevices: RelatedDevice[]
|
||||
isBaoDevice: boolean
|
||||
}
|
||||
|
||||
export type DeviceFirmwareUpdate = {
|
||||
available: boolean
|
||||
currentVersion: string
|
||||
latestVersion: string
|
||||
supportUrl: string
|
||||
source: string
|
||||
checkedAt?: string
|
||||
}
|
||||
|
||||
export type DeviceSupportBadge = {
|
||||
type: string
|
||||
label: string
|
||||
message: string
|
||||
severity: 'info' | 'warning' | 'danger' | string
|
||||
supportUrl: string
|
||||
}
|
||||
|
||||
export type Operation = {
|
||||
id: string
|
||||
operationNumber: string
|
||||
|
||||
@ -5,9 +5,9 @@ import {
|
||||
CheckIcon,
|
||||
Cog6ToothIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import { Squares2X2Icon } from '@heroicons/react/24/outline'
|
||||
import WidgetSettingsModal from '../components/dashboard/WidgetSettingsModal'
|
||||
import Button from '../components/Button'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
@ -18,12 +18,14 @@ import type {
|
||||
DashboardWidgetDefinition,
|
||||
DashboardWidgetType,
|
||||
} from '../components/dashboard/types'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
const GRID_COLUMNS = 12
|
||||
const GRID_ROW_HEIGHT = 88
|
||||
const GRID_GAP = 16
|
||||
const GRID_PADDING = 16
|
||||
|
||||
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
|
||||
|
||||
@ -88,6 +90,10 @@ type DashboardPageProps = {
|
||||
username: string
|
||||
}
|
||||
|
||||
type DashboardSettings = {
|
||||
isEditingDashboard?: boolean
|
||||
}
|
||||
|
||||
const widgetTypes: DashboardWidgetDefinition[] = [
|
||||
{
|
||||
id: 'operationChanges',
|
||||
@ -176,31 +182,6 @@ function getWidgetTypeDefinition(type: DashboardWidgetType) {
|
||||
return widgetTypes.find((widgetType) => widgetType.id === type) ?? widgetTypes[0]
|
||||
}
|
||||
|
||||
function getGridOverlayStyle() {
|
||||
const columnWidth = `calc((100% - ${(GRID_COLUMNS - 1) * GRID_GAP}px) / ${GRID_COLUMNS})`
|
||||
const lineColor = 'rgb(99 102 241 / 0.18)'
|
||||
|
||||
return {
|
||||
backgroundImage: `
|
||||
linear-gradient(
|
||||
to right,
|
||||
transparent calc(${columnWidth} - 1px),
|
||||
${lineColor} calc(${columnWidth} - 1px),
|
||||
${lineColor} ${columnWidth},
|
||||
transparent ${columnWidth}
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent ${GRID_ROW_HEIGHT - 1}px,
|
||||
${lineColor} ${GRID_ROW_HEIGHT - 1}px,
|
||||
${lineColor} ${GRID_ROW_HEIGHT}px,
|
||||
transparent ${GRID_ROW_HEIGHT}px
|
||||
)
|
||||
`,
|
||||
backgroundSize: `calc(${columnWidth} + ${GRID_GAP}px) ${GRID_ROW_HEIGHT + GRID_GAP}px`,
|
||||
}
|
||||
}
|
||||
|
||||
export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
const [widgets, setWidgets] = useState<DashboardWidget[]>([])
|
||||
const [isEditingDashboard, setIsEditingDashboard] = useState(false)
|
||||
@ -214,6 +195,8 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>({})
|
||||
|
||||
const gridRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const interactionRef = useRef<DashboardInteraction | null>(null)
|
||||
@ -229,23 +212,24 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
previewWidgetRef.current = previewWidget
|
||||
}, [previewWidget])
|
||||
|
||||
const dashboardHeight = useMemo(() => {
|
||||
const maxY = Math.max(
|
||||
const dashboardRows = useMemo(() => {
|
||||
return Math.max(
|
||||
4,
|
||||
...widgets.map((widget) => widget.y + widget.h),
|
||||
drawPlacement ? drawPlacement.y + drawPlacement.h : 0,
|
||||
previewWidget ? previewWidget.y + previewWidget.h : 0,
|
||||
)
|
||||
|
||||
return maxY * GRID_ROW_HEIGHT + Math.max(0, maxY - 1) * GRID_GAP
|
||||
}, [drawPlacement, previewWidget, widgets])
|
||||
|
||||
const dashboardHeight = useMemo(() => {
|
||||
return dashboardRows * GRID_ROW_HEIGHT + Math.max(0, dashboardRows - 1) * GRID_GAP
|
||||
}, [dashboardRows])
|
||||
|
||||
useEffect(() => {
|
||||
void loadWidgets()
|
||||
void loadDashboard()
|
||||
}, [])
|
||||
|
||||
async function loadWidgets() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@ -266,11 +250,78 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
? error.message
|
||||
: 'Dashboard konnte nicht geladen werden',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadWidgets(),
|
||||
loadDashboardSettings(),
|
||||
])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboardSettings() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/dashboard/settings`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(
|
||||
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht geladen werden',
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const settings = data.dashboardSettings?.settings ?? {}
|
||||
|
||||
setDashboardSettings(settings)
|
||||
|
||||
if (typeof settings.isEditingDashboard === 'boolean') {
|
||||
setIsEditingDashboard(settings.isEditingDashboard)
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Dashboard-Einstellungen konnten nicht geladen werden',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDashboardSettings(nextSettings: DashboardSettings) {
|
||||
setDashboardSettings(nextSettings)
|
||||
|
||||
const response = await fetch(`${API_URL}/dashboard/settings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
settings: nextSettings,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(
|
||||
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDashboardSettings(data.dashboardSettings?.settings ?? nextSettings)
|
||||
}
|
||||
|
||||
async function saveWidget(widget: DashboardWidget) {
|
||||
const response = await fetch(`${API_URL}/dashboard/widgets/${widget.id}`, {
|
||||
method: 'PUT',
|
||||
@ -795,6 +846,22 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
void updateWidget(nextWidget)
|
||||
}
|
||||
|
||||
function openEmptyDashboardWidgetPicker() {
|
||||
setIsEditingDashboard(true)
|
||||
openWidgetPickerForPlacement(null)
|
||||
|
||||
void saveDashboardSettings({
|
||||
...dashboardSettings,
|
||||
isEditingDashboard: true,
|
||||
}).catch((error) => {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
@ -821,28 +888,43 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isEditingDashboard ? 'primary' : 'secondary'}
|
||||
color={isEditingDashboard ? 'emerald' : 'gray'}
|
||||
leadingIcon={
|
||||
isEditingDashboard ? (
|
||||
<CheckIcon aria-hidden="true" className="size-4" />
|
||||
) : (
|
||||
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setIsEditingDashboard((currentValue) => !currentValue)
|
||||
setPreviewWidget(null)
|
||||
setDrawPlacement(null)
|
||||
setPendingWidgetPlacement(null)
|
||||
}}
|
||||
>
|
||||
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
|
||||
</Button>
|
||||
</div>
|
||||
{widgets.length > 0 && (
|
||||
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isEditingDashboard ? 'primary' : 'secondary'}
|
||||
color={isEditingDashboard ? 'emerald' : 'gray'}
|
||||
leadingIcon={
|
||||
isEditingDashboard ? (
|
||||
<CheckIcon aria-hidden="true" className="size-4" />
|
||||
) : (
|
||||
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
const nextValue = !isEditingDashboard
|
||||
|
||||
setIsEditingDashboard(nextValue)
|
||||
setPreviewWidget(null)
|
||||
setDrawPlacement(null)
|
||||
setPendingWidgetPlacement(null)
|
||||
|
||||
void saveDashboardSettings({
|
||||
...dashboardSettings,
|
||||
isEditingDashboard: nextValue,
|
||||
}).catch((error) => {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@ -852,28 +934,15 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
)}
|
||||
|
||||
{widgets.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 text-center dark:border-white/10 dark:bg-gray-900">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Noch keine Widgets
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsEditingDashboard(true)
|
||||
openWidgetPickerForPlacement(null)
|
||||
}}
|
||||
>
|
||||
Widget hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900">
|
||||
<EmptyState
|
||||
icon={Squares2X2Icon}
|
||||
iconClassName="text-indigo-400 dark:text-indigo-500"
|
||||
title="Noch keine Widgets"
|
||||
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
|
||||
actionLabel="Widget hinzufügen"
|
||||
onAction={openEmptyDashboardWidgetPicker}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@ -892,15 +961,26 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
|
||||
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
minHeight: dashboardHeight,
|
||||
minHeight: dashboardHeight + GRID_PADDING * 2,
|
||||
}}
|
||||
>
|
||||
{isEditingDashboard && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-4 z-0 rounded-lg border border-indigo-500/25 opacity-0 transition group-hover/dashboard:opacity-100"
|
||||
style={getGridOverlayStyle()}
|
||||
/>
|
||||
className="pointer-events-none absolute inset-4 z-0 grid overflow-hidden rounded-lg"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
|
||||
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: dashboardRows * GRID_COLUMNS }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border border-indigo-500/20 bg-indigo-500/[0.015]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drawPlacement && (
|
||||
@ -953,46 +1033,46 @@ export default function DashboardPage({ username }: DashboardPageProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-x-1">
|
||||
<button
|
||||
type="button"
|
||||
title="Widget konfigurieren"
|
||||
aria-label="Widget konfigurieren"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSettingsWidget(widget)
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
||||
>
|
||||
<Cog6ToothIcon aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
|
||||
|
||||
{isEditingDashboard && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="Widget konfigurieren"
|
||||
aria-label="Widget konfigurieren"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSettingsWidget(widget)
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
||||
>
|
||||
<Cog6ToothIcon aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Widget löschen"
|
||||
aria-label="Widget löschen"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void deleteWidget(widget.id)
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Widget löschen"
|
||||
aria-label="Widget löschen"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void deleteWidget(widget.id)
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,928 +1,86 @@
|
||||
// frontend/src/pages/settings/AdministrationPage.tsx
|
||||
// frontend/src/pages/administration/AdministrationPage.tsx
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
import Button from '../../components/Button'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
import Tabs from '../../components/Tabs'
|
||||
import { useLocation, Navigate } from 'react-router'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
UserGroupIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
VideoCameraIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Milestone from './Milestone'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type AdminTeam = {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
href: string
|
||||
}
|
||||
|
||||
type AdminUser = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: AdminTeam[]
|
||||
}
|
||||
|
||||
const adminRightId = 'admin'
|
||||
|
||||
const rightGroups = [
|
||||
{
|
||||
id: 'devices',
|
||||
label: 'Geräte',
|
||||
rights: [
|
||||
{ id: 'devices:read', label: 'anzeigen' },
|
||||
{ id: 'devices:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Einsätze',
|
||||
rights: [
|
||||
{ id: 'operations:read', label: 'anzeigen' },
|
||||
{ id: 'operations:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Kalender',
|
||||
rights: [
|
||||
{ id: 'calendar:read', label: 'anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Dokumente',
|
||||
rights: [
|
||||
{ id: 'documents:read', label: 'anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
label: 'Berichte',
|
||||
rights: [
|
||||
{ id: 'reports:read', label: 'anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
rights: [
|
||||
{ id: 'settings:read', label: 'anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'administration',
|
||||
label: 'Administration',
|
||||
rights: [
|
||||
{ id: 'administration:read', label: 'anzeigen' },
|
||||
{ id: 'administration:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Benutzer',
|
||||
rights: [
|
||||
{ id: 'users:read', label: 'anzeigen' },
|
||||
{ id: 'users:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'teams',
|
||||
label: 'Teams',
|
||||
rights: [
|
||||
{ id: 'teams:read', label: 'anzeigen' },
|
||||
{ id: 'teams:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const availableRights = [
|
||||
{ id: adminRightId, label: 'Administrator' },
|
||||
...rightGroups.flatMap((group) => group.rights),
|
||||
]
|
||||
|
||||
const adminRight = availableRights.find((right) => right.id === adminRightId)
|
||||
|
||||
function getAllRightIds() {
|
||||
return availableRights.map((right) => right.id)
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getSelectedValues(formData: FormData, name: string) {
|
||||
return formData
|
||||
.getAll(name)
|
||||
.map((value) => String(value))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getUserDisplayName(user: AdminUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
|
||||
function getUserInitials(user: AdminUser) {
|
||||
const displayName = getUserDisplayName(user).trim()
|
||||
|
||||
if (!displayName) {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const parts = displayName
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
|
||||
}
|
||||
|
||||
return displayName.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function getUserSearchText(user: AdminUser) {
|
||||
return [
|
||||
user.username,
|
||||
user.displayName,
|
||||
user.email,
|
||||
user.unit,
|
||||
user.group,
|
||||
...user.teams.map((team) => team.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
import Tabs from '../../components/Tabs'
|
||||
import UsersAdministration from './users/UsersAdministration'
|
||||
import TeamsAdministration from './teams/TeamsAdministration'
|
||||
import FeedbackAdministration from './feedback/FeedbackAdministration'
|
||||
import Milestone from './milestone/Milestone'
|
||||
|
||||
export default function AdministrationPage() {
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
const pathname = location.pathname.replace(/\/$/, '')
|
||||
const pathParts = pathname.split('/').filter(Boolean)
|
||||
const lastPathPart = pathParts.at(-1)
|
||||
|
||||
const activeTab =
|
||||
lastPathPart === 'teams'
|
||||
? 'teams'
|
||||
: lastPathPart === 'milestone'
|
||||
? 'milestone'
|
||||
: 'benutzer'
|
||||
const section = pathParts[1] ?? 'benutzer'
|
||||
|
||||
const administrationTabs = [
|
||||
{
|
||||
name: 'Benutzer',
|
||||
href: '/administration',
|
||||
href: '/administration/benutzer',
|
||||
icon: UsersIcon,
|
||||
current: activeTab === 'benutzer',
|
||||
current: section === 'benutzer',
|
||||
},
|
||||
{
|
||||
name: 'Teams',
|
||||
href: '/administration/teams',
|
||||
icon: UserGroupIcon,
|
||||
current: activeTab === 'teams',
|
||||
current: section === 'teams',
|
||||
},
|
||||
{
|
||||
name: 'Milestone',
|
||||
href: '/administration/milestone',
|
||||
icon: VideoCameraIcon,
|
||||
current: activeTab === 'milestone',
|
||||
current: section === 'milestone',
|
||||
},
|
||||
{
|
||||
name: 'Feedback',
|
||||
href: '/administration/feedback',
|
||||
icon: ChatBubbleLeftRightIcon,
|
||||
current: section === 'feedback',
|
||||
},
|
||||
]
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [teams, setTeams] = useState<AdminTeam[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('new')
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [selectedRights, setSelectedRights] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => users.find((user) => user.id === selectedUserId) ?? null,
|
||||
[selectedUserId, users],
|
||||
)
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const search = userSearch.trim().toLowerCase()
|
||||
|
||||
if (!search) {
|
||||
return users
|
||||
}
|
||||
|
||||
return users.filter((user) => getUserSearchText(user).includes(search))
|
||||
}, [userSearch, users])
|
||||
|
||||
const isAdministratorSelected = selectedRights.includes(adminRightId)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRights(selectedUser?.rights ?? [])
|
||||
}, [selectedUser])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAdministration()
|
||||
}, [])
|
||||
|
||||
async function loadAdministration() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [usersResponse, teamsResponse] = await Promise.all([
|
||||
fetch(`${API_URL}/admin/users`, { credentials: 'include' }),
|
||||
fetch(`${API_URL}/admin/teams`, { credentials: 'include' }),
|
||||
])
|
||||
|
||||
if (!usersResponse.ok || !teamsResponse.ok) {
|
||||
throw new Error('Administration konnte nicht geladen werden')
|
||||
}
|
||||
|
||||
const usersData = await usersResponse.json()
|
||||
const teamsData = await teamsResponse.json()
|
||||
|
||||
setUsers(usersData.users ?? [])
|
||||
setTeams(teamsData.teams ?? [])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Administration konnte nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRightChange(rightId: string, checked: boolean) {
|
||||
if (rightId === adminRightId) {
|
||||
setSelectedRights(checked ? getAllRightIds() : [])
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedRights((currentRights) => {
|
||||
if (checked) {
|
||||
return [...new Set([...currentRights, rightId])]
|
||||
}
|
||||
|
||||
return currentRights.filter((currentRight) => currentRight !== rightId)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectNewUser() {
|
||||
setSelectedUserId('new')
|
||||
setSelectedRights([])
|
||||
}
|
||||
|
||||
async function handleSaveUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const isCreate = selectedUserId === 'new'
|
||||
|
||||
const payload = {
|
||||
username: String(formData.get('username') ?? ''),
|
||||
displayName: String(formData.get('displayName') ?? ''),
|
||||
email: String(formData.get('email') ?? ''),
|
||||
password: String(formData.get('password') ?? ''),
|
||||
avatar: String(formData.get('avatar') ?? ''),
|
||||
unit: String(formData.get('unit') ?? ''),
|
||||
group: String(formData.get('group') ?? ''),
|
||||
rights: isAdministratorSelected ? getAllRightIds() : selectedRights,
|
||||
teamIds: getSelectedValues(formData, 'teamIds'),
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
isCreate
|
||||
? `${API_URL}/admin/users`
|
||||
: `${API_URL}/admin/users/${selectedUserId}`,
|
||||
{
|
||||
method: isCreate ? 'POST' : 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Benutzer konnte nicht gespeichert werden')
|
||||
}
|
||||
|
||||
await loadAdministration()
|
||||
form.reset()
|
||||
setSelectedUserId('new')
|
||||
setSelectedRights([])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benutzer konnte nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
const payload = {
|
||||
name: String(formData.get('teamName') ?? ''),
|
||||
initial: String(formData.get('teamInitial') ?? ''),
|
||||
href: String(formData.get('teamHref') ?? '#'),
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Team konnte nicht erstellt werden')
|
||||
}
|
||||
|
||||
await loadAdministration()
|
||||
form.reset()
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Team konnte nicht erstellt werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<LoadingSpinner
|
||||
size="lg"
|
||||
label="Administration wird geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
if (!['benutzer', 'teams', 'milestone', 'feedback'].includes(section)) {
|
||||
return <Navigate to="/administration/benutzer" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<main className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<h1 className="sr-only">Administration</h1>
|
||||
|
||||
<Tabs
|
||||
tabs={administrationTabs}
|
||||
variant="underline-icons"
|
||||
align="center"
|
||||
sticky
|
||||
stickyTopClassName="top-0"
|
||||
ariaLabel="Administration"
|
||||
/>
|
||||
<div className="shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
||||
<Tabs
|
||||
tabs={administrationTabs}
|
||||
variant="underline-icons"
|
||||
align="center"
|
||||
ariaLabel="Administration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Administration
|
||||
</h1>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Administration
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Benutzer verwalten, Teams zuweisen und Seitenrechte vergeben.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Benutzer verwalten, Teams zuweisen und Integrationen konfigurieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{section === 'benutzer' && <UsersAdministration />}
|
||||
{section === 'teams' && <TeamsAdministration />}
|
||||
{section === 'milestone' && <Milestone />}
|
||||
{section === 'feedback' && <FeedbackAdministration />}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'benutzer' && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
|
||||
<div className="flex items-center justify-between gap-x-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UsersIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Benutzer
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{filteredUsers.length} von {users.length} angezeigt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
onClick={handleSelectNewUser}
|
||||
leadingIcon={<UserPlusIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
placeholder="Benutzer suchen..."
|
||||
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 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:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
|
||||
{selectedUserId === 'new' && (
|
||||
<div className="mb-2 rounded-md bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
|
||||
Neuer Benutzer wird angelegt
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Benutzer gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserId === user.id
|
||||
const displayName = getUserDisplayName(user)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
|
||||
'flex w-full gap-x-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
||||
)}
|
||||
>
|
||||
{getUserInitials(user)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-x-2">
|
||||
<span className="truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
{user.rights.includes('admin') && (
|
||||
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{user.unit && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
{user.unit}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.group && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
{user.group}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.teams.slice(0, 2).map((team) => (
|
||||
<span
|
||||
key={team.id}
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300"
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{user.teams.length > 2 && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
+{user.teams.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
key={selectedUser?.id ?? 'new'}
|
||||
onSubmit={handleSaveUser}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{selectedUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer anlegen'}
|
||||
</h2>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
name="username"
|
||||
defaultValue={selectedUser?.username ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
name="displayName"
|
||||
defaultValue={selectedUser?.displayName ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
E-Mail *
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
defaultValue={selectedUser?.email ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Passwort {selectedUser ? '(leer lassen = unverändert)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required={!selectedUser}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Einheit
|
||||
</label>
|
||||
<input
|
||||
name="unit"
|
||||
defaultValue={selectedUser?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Gruppe
|
||||
</label>
|
||||
<input
|
||||
name="group"
|
||||
defaultValue={selectedUser?.group ?? 'user'}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Teams
|
||||
</h3>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{teams.map((team) => {
|
||||
const checked = selectedUser?.teams.some(
|
||||
(userTeam) => userTeam.id === team.id,
|
||||
)
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={team.id}
|
||||
name="teamIds"
|
||||
value={team.id}
|
||||
defaultChecked={checked}
|
||||
label={team.name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Rechte
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Lege fest, welche Bereiche sichtbar sind und wo Änderungen erlaubt sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
|
||||
{adminRight && (
|
||||
<div className="border-b border-gray-200 bg-indigo-50 px-4 py-3 dark:border-white/10 dark:bg-indigo-500/10">
|
||||
<Checkbox
|
||||
name="rights"
|
||||
value={adminRight.id}
|
||||
checked={isAdministratorSelected}
|
||||
onChange={(event) =>
|
||||
handleRightChange(adminRight.id, event.target.checked)
|
||||
}
|
||||
label="Administrator"
|
||||
description="Besitzt automatisch alle Rechte."
|
||||
wrapperClassName="items-start"
|
||||
labelClassName="block text-sm font-semibold text-indigo-700 dark:text-indigo-300"
|
||||
descriptionClassName="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] border-b border-gray-200 bg-gray-50 px-4 py-2 text-xs font-medium text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
<div>Bereich</div>
|
||||
<div className="text-center">Anzeigen</div>
|
||||
<div className="text-center">Bearbeiten</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
{rightGroups.map((group) => {
|
||||
const readRight = group.rights.find((right) => right.id.endsWith(':read'))
|
||||
const writeRight = group.rights.find((right) => right.id.endsWith(':write'))
|
||||
|
||||
function renderRightControl(right: typeof group.rights[number] | undefined) {
|
||||
if (!right) {
|
||||
return (
|
||||
<span className="inline-flex h-5 items-center justify-center text-xs text-gray-300 dark:text-gray-600">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isChecked =
|
||||
isAdministratorSelected || selectedRights.includes(right.id)
|
||||
const isDisabled = isAdministratorSelected
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
name="rights"
|
||||
value={right.id}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) =>
|
||||
handleRightChange(right.id, event.target.checked)
|
||||
}
|
||||
aria-label={`${group.label} ${right.label}`}
|
||||
title={
|
||||
isAdministratorSelected
|
||||
? 'Durch Administrator-Recht automatisch aktiv'
|
||||
: right.label
|
||||
}
|
||||
wrapperClassName={classNames(
|
||||
'justify-center',
|
||||
isDisabled && 'cursor-not-allowed',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const groupHasAnyRight = group.rights.some((right) =>
|
||||
selectedRights.includes(right.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className={classNames(
|
||||
'grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] items-center px-4 py-3',
|
||||
groupHasAnyRight || isAdministratorSelected
|
||||
? 'bg-white dark:bg-transparent'
|
||||
: 'bg-gray-50/60 dark:bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{group.label}
|
||||
</span>
|
||||
|
||||
{(groupHasAnyRight || isAdministratorSelected) && (
|
||||
<span className="inline-flex size-1.5 rounded-full bg-indigo-600 dark:bg-indigo-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{renderRightControl(readRight)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{renderRightControl(writeRight)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdministratorSelected && (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Einzelrechte sind deaktiviert, weil Administrator bereits alle Berechtigungen umfasst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Benutzer speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'teams' && (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Teams
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Übersicht der aktuell vorhandenen Teams.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
{teams.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Teams vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="flex items-center justify-between gap-x-4 p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
|
||||
{team.initial}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{team.name}
|
||||
</p>
|
||||
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{team.href}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSaveTeam}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Team anlegen
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Teamname
|
||||
</label>
|
||||
<input
|
||||
name="teamName"
|
||||
placeholder="Teamname"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Initial
|
||||
</label>
|
||||
<input
|
||||
name="teamInitial"
|
||||
placeholder="Initial"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Link
|
||||
</label>
|
||||
<input
|
||||
name="teamHref"
|
||||
placeholder="Link, z.B. #"
|
||||
defaultValue="#"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Team erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'milestone' && (
|
||||
<Milestone />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -1,458 +0,0 @@
|
||||
// frontend/src/pages/administration/Milestone.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
KeyIcon,
|
||||
ServerStackIcon,
|
||||
VideoCameraIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../components/Button'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
import Switch from '../../components/Switch'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type MilestoneSettings = {
|
||||
host: string
|
||||
username: string
|
||||
passwordConfigured: boolean
|
||||
skipTlsVerify: boolean
|
||||
tokenConfigured: boolean
|
||||
tokenType: string
|
||||
tokenExpiresAt?: string
|
||||
tokenUpdatedAt?: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverVersion: string
|
||||
serverUpdatedAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value?: string) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export default function Milestone() {
|
||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isFetchingToken, setIsFetchingToken] = useState(false)
|
||||
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [])
|
||||
|
||||
async function loadSettings() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
host: String(formData.get('host') ?? ''),
|
||||
username: String(formData.get('username') ?? ''),
|
||||
password: String(formData.get('password') ?? ''),
|
||||
skipTlsVerify,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
form.reset()
|
||||
|
||||
if (data.warning) {
|
||||
setSuccess(data.warning)
|
||||
} else {
|
||||
setSuccess('Milestone-Einstellungen wurden gespeichert, Token und Recording-Server wurden abgerufen.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchToken() {
|
||||
setIsFetchingToken(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone/token`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Token konnte nicht abgerufen werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
setSuccess('Milestone-Token wurde abgerufen und gespeichert.')
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Token konnte nicht abgerufen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsFetchingToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label="Milestone-Einstellungen werden geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const updatedAt = formatUpdatedAt(settings?.updatedAt)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
<VideoCameraIcon aria-hidden="true" className="size-5" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Milestone Videoserver
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Hinterlege die Zugangsdaten für den Milestone Videoserver.
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<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">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-host"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
IP-Adresse / Hostname *
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-host"
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={settings?.host ?? ''}
|
||||
placeholder="z. B. 192.168.178.50"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benutzername *
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={settings?.username ?? ''}
|
||||
autoComplete="off"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Kennwort {settings?.passwordConfigured ? '(leer lassen = unverändert)' : '*'}
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required={!settings?.passwordConfigured}
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
settings?.passwordConfigured
|
||||
? 'Kennwort ist bereits gespeichert'
|
||||
: ''
|
||||
}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Switch
|
||||
checked={skipTlsVerify}
|
||||
onChange={(event) => setSkipTlsVerify(event.target.checked)}
|
||||
label="TLS-Zertifikatsprüfung deaktivieren"
|
||||
description={
|
||||
<>
|
||||
Entspricht <code>curl --insecure</code>. Nur für selbstsignierte
|
||||
Zertifikate oder interne Testsysteme verwenden.
|
||||
</>
|
||||
}
|
||||
variant="simple"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isFetchingToken}
|
||||
disabled={!settings?.passwordConfigured || isSaving}
|
||||
onClick={() => void handleFetchToken()}
|
||||
>
|
||||
Token & Recording-Server erneut abrufen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Status
|
||||
</h3>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Server
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.host || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Recording-Server-ID
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.serverId || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Benutzername
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.username || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Kennwort
|
||||
</dt>
|
||||
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
|
||||
{settings?.passwordConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
TLS-Zertifikatsprüfung
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings?.skipTlsVerify ? 'Deaktiviert' : 'Aktiv'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Auth-Token
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings?.tokenConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{settings?.tokenExpiresAt && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Token gültig bis
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{formatUpdatedAt(settings.tokenExpiresAt)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.serverName && (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Recording-Server
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings.serverName}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.serverVersion && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Version
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings.serverVersion}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatedAt && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Zuletzt geändert
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{updatedAt}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,334 @@
|
||||
// frontend/src/pages/administration/feedback/FeedbackAdministration.tsx
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
import Avatar from '../../../components/Avatar'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type FeedbackItem = {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
message: string
|
||||
log: unknown
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type NotificationLike = {
|
||||
type?: string
|
||||
entityType?: string
|
||||
entityId?: string
|
||||
data?: {
|
||||
feedbackId?: string
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function getInitials(name: string, email: string) {
|
||||
const source = name || email
|
||||
|
||||
if (!source) {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const parts = source
|
||||
.replace(/@.*/, '')
|
||||
.split(/[.\s_-]+/)
|
||||
.filter(Boolean)
|
||||
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function extractNotification(eventData: string): NotificationLike | null {
|
||||
try {
|
||||
const parsed = JSON.parse(eventData)
|
||||
|
||||
if (parsed?.notification) {
|
||||
return parsed.notification
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isFeedbackEvent(notification: NotificationLike | null) {
|
||||
if (!notification) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
notification.type === 'feedback.created' ||
|
||||
notification.type === 'feedback.deleted' ||
|
||||
notification.entityType === 'feedback' ||
|
||||
Boolean(notification.data?.feedbackId)
|
||||
)
|
||||
}
|
||||
|
||||
export default function FeedbackAdministration() {
|
||||
const [feedback, setFeedback] = useState<FeedbackItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [deletingFeedbackIds, setDeletingFeedbackIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sortedFeedback = useMemo(
|
||||
() =>
|
||||
[...feedback].sort(
|
||||
(firstItem, secondItem) =>
|
||||
new Date(secondItem.createdAt).getTime() -
|
||||
new Date(firstItem.createdAt).getTime(),
|
||||
),
|
||||
[feedback],
|
||||
)
|
||||
|
||||
const loadFeedback = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (options?.silent) {
|
||||
setIsRefreshing(true)
|
||||
} else {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/feedback`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Feedback konnte nicht geladen werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setFeedback(Array.isArray(data.feedback) ? data.feedback : [])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Feedback konnte nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadFeedback()
|
||||
}, [loadFeedback])
|
||||
|
||||
useEffect(() => {
|
||||
const events = new EventSource(`${API_URL}/events`, {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
events.onmessage = (event) => {
|
||||
const notification = extractNotification(event.data)
|
||||
|
||||
if (isFeedbackEvent(notification)) {
|
||||
void loadFeedback({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
events.onerror = () => {
|
||||
events.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
events.close()
|
||||
}
|
||||
}, [loadFeedback])
|
||||
|
||||
async function handleDeleteFeedback(feedbackItem: FeedbackItem) {
|
||||
const confirmed = window.confirm(
|
||||
`Feedback von ${feedbackItem.userName || feedbackItem.userEmail || 'Unbekannt'} wirklich löschen?`,
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingFeedbackIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.add(feedbackItem.id)
|
||||
return nextIds
|
||||
})
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/feedback/${feedbackItem.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Feedback konnte nicht gelöscht werden')
|
||||
}
|
||||
|
||||
setFeedback((currentFeedback) =>
|
||||
currentFeedback.filter((item) => item.id !== feedbackItem.id),
|
||||
)
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Feedback konnte nicht gelöscht werden',
|
||||
)
|
||||
} finally {
|
||||
setDeletingFeedbackIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.delete(feedbackItem.id)
|
||||
return nextIds
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-64 items-center justify-center">
|
||||
<LoadingSpinner
|
||||
size="lg"
|
||||
label="Feedback wird geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-6 flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Feedback
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Eingereichtes Feedback inklusive technischem Log einsehen und verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
isLoading={isRefreshing}
|
||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => void loadFeedback({ silent: true })}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFeedback.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-900">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Es wurde noch kein Feedback abgegeben.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedFeedback.map((feedbackItem) => {
|
||||
const isDeleting = deletingFeedbackIds.has(feedbackItem.id)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={feedbackItem.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-gray-900"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div className="flex min-w-0 items-start gap-x-3">
|
||||
<Avatar
|
||||
initials={getInitials(feedbackItem.userName, feedbackItem.userEmail)}
|
||||
name={feedbackItem.userName || feedbackItem.userEmail}
|
||||
size={10}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{feedbackItem.userName || 'Unbekannter Benutzer'}
|
||||
</h3>
|
||||
|
||||
{feedbackItem.userEmail && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{feedbackItem.userEmail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDate(feedbackItem.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
isLoading={isDeleting}
|
||||
leadingIcon={<TrashIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => void handleDeleteFeedback(feedbackItem)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
|
||||
{feedbackItem.message}
|
||||
</p>
|
||||
|
||||
<details className="mt-4 rounded-md bg-gray-50 p-3 dark:bg-white/5">
|
||||
<summary className="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Technisches Log anzeigen
|
||||
</summary>
|
||||
|
||||
<pre className="mt-3 max-h-96 overflow-auto rounded bg-gray-950 p-3 text-xs text-gray-100">
|
||||
{JSON.stringify(feedbackItem.log, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
814
frontend/src/pages/administration/milestone/Milestone.tsx
Normal file
814
frontend/src/pages/administration/milestone/Milestone.tsx
Normal file
@ -0,0 +1,814 @@
|
||||
// frontend\src\pages\administration\milestone\Milestone.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
KeyIcon,
|
||||
ServerStackIcon,
|
||||
VideoCameraIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
import Switch from '../../../components/Switch'
|
||||
import { DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import Modal from '../../../components/Modal'
|
||||
import TaskList, { type TaskListStep } from '../../../components/TaskList'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type MilestoneSettings = {
|
||||
host: string
|
||||
username: string
|
||||
passwordConfigured: boolean
|
||||
skipTlsVerify: boolean
|
||||
tokenConfigured: boolean
|
||||
tokenType: string
|
||||
tokenExpiresAt?: string
|
||||
tokenUpdatedAt?: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverVersion: string
|
||||
serverUpdatedAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
type MilestoneSyncEvent = {
|
||||
type: 'token' | 'recordingServer' | 'hardware' | 'done' | 'error'
|
||||
message?: string
|
||||
tokenType?: string
|
||||
tokenExpiresAt?: string
|
||||
recordingServerId?: string
|
||||
recordingServerName?: string
|
||||
recordingServerVersion?: string
|
||||
hardwareCount?: number
|
||||
settings?: MilestoneSettings
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value?: string) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function createMilestoneSyncSteps(): TaskListStep[] {
|
||||
return [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token abrufen',
|
||||
description: 'Authentifizierung am Milestone-Server läuft.',
|
||||
status: 'current',
|
||||
},
|
||||
{
|
||||
id: 'recordingServer',
|
||||
name: 'Recording-Server abfragen',
|
||||
description: 'Server-ID, Name und Version werden geladen.',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
name: 'Hardware synchronisieren',
|
||||
description: 'Hardware-Geräte und Treiberdaten werden geladen.',
|
||||
status: 'upcoming',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function updateMilestoneSyncStepsForEvent(
|
||||
steps: TaskListStep[],
|
||||
event: MilestoneSyncEvent,
|
||||
): TaskListStep[] {
|
||||
if (event.type === 'token') {
|
||||
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
|
||||
|
||||
return steps.map((step) => {
|
||||
if (step.id === 'token') {
|
||||
return {
|
||||
...step,
|
||||
status: 'complete',
|
||||
description: tokenExpiresAt
|
||||
? `Token wurde abgerufen. Gültig bis ${tokenExpiresAt}.`
|
||||
: 'Token wurde erfolgreich abgerufen.',
|
||||
}
|
||||
}
|
||||
|
||||
if (step.id === 'recordingServer') {
|
||||
return {
|
||||
...step,
|
||||
status: 'current',
|
||||
}
|
||||
}
|
||||
|
||||
return step
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === 'recordingServer') {
|
||||
const recordingServerDescription = [
|
||||
event.recordingServerName || 'Recording-Server wurde abgerufen.',
|
||||
event.recordingServerVersion ? `Version ${event.recordingServerVersion}` : '',
|
||||
event.recordingServerId ? `ID: ${event.recordingServerId}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return steps.map((step) => {
|
||||
if (step.id === 'recordingServer') {
|
||||
return {
|
||||
...step,
|
||||
status: 'complete',
|
||||
description: recordingServerDescription,
|
||||
}
|
||||
}
|
||||
|
||||
if (step.id === 'hardware') {
|
||||
return {
|
||||
...step,
|
||||
status: 'current',
|
||||
}
|
||||
}
|
||||
|
||||
return step
|
||||
})
|
||||
}
|
||||
|
||||
if (event.type === 'hardware') {
|
||||
return steps.map((step) => {
|
||||
if (step.id === 'hardware') {
|
||||
return {
|
||||
...step,
|
||||
status: 'complete',
|
||||
description:
|
||||
event.hardwareCount === 1
|
||||
? '1 Hardware-Gerät wurde synchronisiert.'
|
||||
: `${event.hardwareCount ?? 0} Hardware-Geräte wurden synchronisiert.`,
|
||||
}
|
||||
}
|
||||
|
||||
return step
|
||||
})
|
||||
}
|
||||
|
||||
return steps
|
||||
}
|
||||
|
||||
function markCurrentMilestoneSyncStepAsError(
|
||||
steps: TaskListStep[],
|
||||
message: string,
|
||||
): TaskListStep[] {
|
||||
const currentStepIndex = steps.findIndex((step) => step.status === 'current')
|
||||
const upcomingStepIndex = steps.findIndex((step) => step.status === 'upcoming')
|
||||
|
||||
const failedStepIndex =
|
||||
currentStepIndex >= 0
|
||||
? currentStepIndex
|
||||
: upcomingStepIndex >= 0
|
||||
? upcomingStepIndex
|
||||
: steps.length - 1
|
||||
|
||||
return steps.map((step, index) =>
|
||||
index === failedStepIndex
|
||||
? {
|
||||
...step,
|
||||
status: 'error',
|
||||
description: message,
|
||||
}
|
||||
: step,
|
||||
)
|
||||
}
|
||||
|
||||
export default function Milestone() {
|
||||
const [settings, setSettings] = useState<MilestoneSettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||
const [isFetchingToken, setIsFetchingToken] = useState(false)
|
||||
const [skipTlsVerify, setSkipTlsVerify] = useState(false)
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false)
|
||||
const [syncSteps, setSyncSteps] = useState<TaskListStep[]>(() => createMilestoneSyncSteps())
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
const [syncCompleted, setSyncCompleted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [])
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
setNotifications((currentNotifications) =>
|
||||
currentNotifications.filter((notification) => notification.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showNotification(
|
||||
notification: Omit<ToastNotification, 'id'>,
|
||||
) {
|
||||
setNotifications((currentNotifications) => [
|
||||
{
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
...notification,
|
||||
},
|
||||
...currentNotifications,
|
||||
])
|
||||
}
|
||||
|
||||
function getHardwareCountMessage(count: number) {
|
||||
if (count === 1) {
|
||||
return '1 Hardware-Gerät wurde von Milestone geladen und gespeichert.'
|
||||
}
|
||||
|
||||
return `${count} Hardware-Geräte wurden von Milestone geladen und gespeichert.`
|
||||
}
|
||||
|
||||
function handleMilestoneSyncEvent(event: MilestoneSyncEvent) {
|
||||
setSyncSteps((currentSteps) =>
|
||||
updateMilestoneSyncStepsForEvent(currentSteps, event),
|
||||
)
|
||||
|
||||
if (event.type === 'error') {
|
||||
const message = event.message || 'Milestone-Synchronisierung fehlgeschlagen'
|
||||
|
||||
setSyncError(message)
|
||||
setSyncSteps((currentSteps) =>
|
||||
markCurrentMilestoneSyncStepAsError(currentSteps, message),
|
||||
)
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (event.type === 'token') {
|
||||
const tokenExpiresAt = formatUpdatedAt(event.tokenExpiresAt)
|
||||
|
||||
showNotification({
|
||||
variant: 'success',
|
||||
title: 'Token abgerufen',
|
||||
message: tokenExpiresAt
|
||||
? `Token wurde erfolgreich abgerufen. Gültig bis ${tokenExpiresAt}.`
|
||||
: 'Token wurde erfolgreich abgerufen.',
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'recordingServer') {
|
||||
showNotification({
|
||||
variant: 'success',
|
||||
title: 'Recording-Infos abgerufen',
|
||||
message: [
|
||||
event.recordingServerName || 'Recording-Server',
|
||||
event.recordingServerVersion
|
||||
? `Version ${event.recordingServerVersion}`
|
||||
: '',
|
||||
event.recordingServerId
|
||||
? `ID: ${event.recordingServerId}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'hardware') {
|
||||
showNotification({
|
||||
variant: 'success',
|
||||
title: 'Hardware synchronisiert',
|
||||
message: getHardwareCountMessage(event.hardwareCount ?? 0),
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'done') {
|
||||
setSyncCompleted(true)
|
||||
|
||||
if (event.settings) {
|
||||
setSettings(event.settings)
|
||||
setSkipTlsVerify(Boolean(event.settings.skipTlsVerify))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function runMilestoneSyncStream() {
|
||||
setSyncModalOpen(true)
|
||||
setSyncCompleted(false)
|
||||
setSyncError(null)
|
||||
setSyncSteps(createMilestoneSyncSteps())
|
||||
|
||||
const response = await fetch(`${API_URL}/admin/milestone/token/stream`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const message = await readApiError(
|
||||
response,
|
||||
'Milestone-Token konnte nicht abgerufen werden',
|
||||
)
|
||||
|
||||
setSyncError(message)
|
||||
setSyncSteps((currentSteps) =>
|
||||
markCurrentMilestoneSyncStepAsError(currentSteps, message),
|
||||
)
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (!trimmedLine) {
|
||||
continue
|
||||
}
|
||||
|
||||
const event = JSON.parse(trimmedLine) as MilestoneSyncEvent
|
||||
handleMilestoneSyncEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
const remainingLine = buffer.trim()
|
||||
|
||||
if (remainingLine) {
|
||||
const event = JSON.parse(remainingLine) as MilestoneSyncEvent
|
||||
handleMilestoneSyncEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSettings(data.settings)
|
||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
title: 'Einstellungen konnten nicht geladen werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht geladen werden',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/milestone?sync=false`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
host: String(formData.get('host') ?? ''),
|
||||
username: String(formData.get('username') ?? ''),
|
||||
password: String(formData.get('password') ?? ''),
|
||||
skipTlsVerify,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
settings: MilestoneSettings
|
||||
}
|
||||
|
||||
setSettings(data.settings)
|
||||
setSkipTlsVerify(Boolean(data.settings?.skipTlsVerify))
|
||||
form.reset()
|
||||
|
||||
await runMilestoneSyncStream()
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Einstellungen konnten nicht gespeichert werden',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchToken() {
|
||||
setIsFetchingToken(true)
|
||||
|
||||
try {
|
||||
await runMilestoneSyncStream()
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
title: 'Abruf fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Token konnte nicht abgerufen werden',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsFetchingToken(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label="Milestone-Einstellungen werden geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const updatedAt = formatUpdatedAt(settings?.updatedAt)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={notifications}
|
||||
onDismiss={dismissNotification}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={syncModalOpen}
|
||||
setOpen={(open) => {
|
||||
const syncIsRunning =
|
||||
!syncCompleted && !syncError && (isSaving || isFetchingToken)
|
||||
|
||||
if (!open && syncIsRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
setSyncModalOpen(open)
|
||||
}}
|
||||
zIndexClassName="z-[9999]"
|
||||
panelClassName="max-w-xl"
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
<div>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Milestone-Synchronisierung
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Token, Recording-Server und Hardware werden nacheinander abgefragt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
|
||||
onClick={() => setSyncModalOpen(false)}
|
||||
className="rounded-md text-gray-400 hover:text-gray-500 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="sr-only">Schließen</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<TaskList
|
||||
steps={syncSteps}
|
||||
ariaLabel="Milestone-Synchronisierung"
|
||||
/>
|
||||
|
||||
{syncError && (
|
||||
<div className="mt-5 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-white/10">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
disabled={!syncCompleted && !syncError && (isSaving || isFetchingToken)}
|
||||
onClick={() => setSyncModalOpen(false)}
|
||||
>
|
||||
{syncCompleted || syncError ? 'Schließen' : 'Bitte warten...'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_40rem]">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
<VideoCameraIcon aria-hidden="true" className="size-5" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Milestone Videoserver
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Hinterlege die Zugangsdaten für den Milestone Videoserver.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-host"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
IP-Adresse / Hostname *
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-host"
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={settings?.host ?? ''}
|
||||
placeholder="z. B. 192.168.178.50"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benutzername *
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={settings?.username ?? ''}
|
||||
autoComplete="off"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="milestone-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Kennwort {settings?.passwordConfigured ? '(leer lassen = unverändert)' : '*'}
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestone-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required={!settings?.passwordConfigured}
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
settings?.passwordConfigured
|
||||
? 'Kennwort ist bereits gespeichert'
|
||||
: ''
|
||||
}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Das gespeicherte Kennwort wird nicht im Klartext angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Switch
|
||||
checked={skipTlsVerify}
|
||||
onChange={(event) => setSkipTlsVerify(event.target.checked)}
|
||||
label="TLS-Zertifikatsprüfung deaktivieren"
|
||||
description={
|
||||
<>
|
||||
Nur für selbstsignierte Zertifikate oder interne Testsysteme verwenden.
|
||||
</>
|
||||
}
|
||||
variant="simple"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isFetchingToken}
|
||||
disabled={!settings?.passwordConfigured || isSaving}
|
||||
onClick={() => void handleFetchToken()}
|
||||
>
|
||||
Token & Infos erneut abrufen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h3 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<ServerStackIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Status
|
||||
</h3>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Server
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.host || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Recording-Server-ID
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.serverId || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Benutzername
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings?.username || 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Kennwort
|
||||
</dt>
|
||||
<dd className="mt-1 flex items-center gap-x-2 font-medium text-gray-900 dark:text-white">
|
||||
<KeyIcon aria-hidden="true" className="size-4 text-gray-400" />
|
||||
{settings?.passwordConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
TLS-Zertifikatsprüfung
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings?.skipTlsVerify ? 'Deaktiviert' : 'Aktiv'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Auth-Token
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings?.tokenConfigured ? 'Gespeichert' : 'Nicht gespeichert'}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{settings?.tokenExpiresAt && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Token gültig bis
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{formatUpdatedAt(settings.tokenExpiresAt)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.serverName && (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Recording-Server
|
||||
</dt>
|
||||
<dd className="mt-1 break-all font-medium text-gray-900 dark:text-white">
|
||||
{settings.serverName}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings?.serverVersion && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Version
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{settings.serverVersion}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatedAt && (
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">
|
||||
Zuletzt geändert
|
||||
</dt>
|
||||
<dd className="mt-1 font-medium text-gray-900 dark:text-white">
|
||||
{updatedAt}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
259
frontend/src/pages/administration/teams/TeamsAdministration.tsx
Normal file
259
frontend/src/pages/administration/teams/TeamsAdministration.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
// frontend\src\pages\administration\teams\TeamsAdministration.tsx
|
||||
|
||||
// frontend/src/pages/administration/teams/TeamsAdministration.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { UserGroupIcon } from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type AdminTeam = {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
href: string
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
export default function TeamsAdministration() {
|
||||
const [teams, setTeams] = useState<AdminTeam[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeams()
|
||||
}, [])
|
||||
|
||||
async function loadTeams() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/teams`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Teams konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setTeams(data.teams ?? [])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Teams konnten nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTeam(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
const payload = {
|
||||
name: String(formData.get('teamName') ?? ''),
|
||||
initial: String(formData.get('teamInitial') ?? ''),
|
||||
href: String(formData.get('teamHref') ?? '#'),
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/teams`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Team konnte nicht erstellt werden'),
|
||||
)
|
||||
}
|
||||
|
||||
await loadTeams()
|
||||
form.reset()
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Team konnte nicht erstellt werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label="Teams werden geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UserGroupIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Teams
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Übersicht der aktuell vorhandenen Teams.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
|
||||
{teams.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Teams vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="flex items-center justify-between gap-x-4 p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-semibold text-white">
|
||||
{team.initial}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{team.name}
|
||||
</p>
|
||||
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{team.href}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSaveTeam}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Team anlegen
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erstelle ein neues Team, das anschließend Benutzern zugewiesen werden kann.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="team-name"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Teamname
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="team-name"
|
||||
name="teamName"
|
||||
placeholder="Teamname"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="team-initial"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Initial
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="team-initial"
|
||||
name="teamInitial"
|
||||
placeholder="Initial"
|
||||
maxLength={3}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="team-href"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Link
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="team-href"
|
||||
name="teamHref"
|
||||
placeholder="Link, z. B. #"
|
||||
defaultValue="#"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Team erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
795
frontend/src/pages/administration/users/UsersAdministration.tsx
Normal file
795
frontend/src/pages/administration/users/UsersAdministration.tsx
Normal file
@ -0,0 +1,795 @@
|
||||
// frontend\src\pages\administration\users\UsersAdministration.tsx
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../../components/Button'
|
||||
import Checkbox from '../../../components/Checkbox'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type AdminTeam = {
|
||||
id: string
|
||||
name: string
|
||||
initial: string
|
||||
href: string
|
||||
}
|
||||
|
||||
type AdminUser = {
|
||||
id: string
|
||||
username: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatar: string
|
||||
unit: string
|
||||
group: string
|
||||
rights: string[]
|
||||
teams: AdminTeam[]
|
||||
}
|
||||
|
||||
const adminRightId = 'admin'
|
||||
|
||||
const rightGroups = [
|
||||
{
|
||||
id: 'devices',
|
||||
label: 'Geräte',
|
||||
rights: [
|
||||
{ id: 'devices:read', label: 'anzeigen' },
|
||||
{ id: 'devices:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: 'Einsätze',
|
||||
rights: [
|
||||
{ id: 'operations:read', label: 'anzeigen' },
|
||||
{ id: 'operations:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Kalender',
|
||||
rights: [{ id: 'calendar:read', label: 'anzeigen' }],
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Dokumente',
|
||||
rights: [{ id: 'documents:read', label: 'anzeigen' }],
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
label: 'Berichte',
|
||||
rights: [{ id: 'reports:read', label: 'anzeigen' }],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
rights: [{ id: 'settings:read', label: 'anzeigen' }],
|
||||
},
|
||||
{
|
||||
id: 'administration',
|
||||
label: 'Administration',
|
||||
rights: [
|
||||
{ id: 'administration:read', label: 'anzeigen' },
|
||||
{ id: 'administration:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Benutzer',
|
||||
rights: [
|
||||
{ id: 'users:read', label: 'anzeigen' },
|
||||
{ id: 'users:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'teams',
|
||||
label: 'Teams',
|
||||
rights: [
|
||||
{ id: 'teams:read', label: 'anzeigen' },
|
||||
{ id: 'teams:write', label: 'bearbeiten' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const availableRights = [
|
||||
{ id: adminRightId, label: 'Administrator' },
|
||||
...rightGroups.flatMap((group) => group.rights),
|
||||
]
|
||||
|
||||
const adminRight = availableRights.find((right) => right.id === adminRightId)
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getAllRightIds() {
|
||||
return availableRights.map((right) => right.id)
|
||||
}
|
||||
|
||||
function getSelectedValues(formData: FormData, name: string) {
|
||||
return formData
|
||||
.getAll(name)
|
||||
.map((value) => String(value))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getUserDisplayName(user: AdminUser) {
|
||||
return user.displayName || user.username || user.email
|
||||
}
|
||||
|
||||
function getUserInitials(user: AdminUser) {
|
||||
const displayName = getUserDisplayName(user).trim()
|
||||
|
||||
if (!displayName) {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const parts = displayName
|
||||
.split(/\s+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase()
|
||||
}
|
||||
|
||||
return displayName.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function getUserSearchText(user: AdminUser) {
|
||||
return [
|
||||
user.username,
|
||||
user.displayName,
|
||||
user.email,
|
||||
user.unit,
|
||||
user.group,
|
||||
...user.teams.map((team) => team.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
export default function UsersAdministration() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [teams, setTeams] = useState<AdminTeam[]>([])
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('new')
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [selectedRights, setSelectedRights] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const selectedUser = useMemo(
|
||||
() => users.find((user) => user.id === selectedUserId) ?? null,
|
||||
[selectedUserId, users],
|
||||
)
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const search = userSearch.trim().toLowerCase()
|
||||
|
||||
if (!search) {
|
||||
return users
|
||||
}
|
||||
|
||||
return users.filter((user) => getUserSearchText(user).includes(search))
|
||||
}, [userSearch, users])
|
||||
|
||||
const isAdministratorSelected = selectedRights.includes(adminRightId)
|
||||
|
||||
useEffect(() => {
|
||||
void loadAdministration()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRights(selectedUser?.rights ?? [])
|
||||
}, [selectedUser])
|
||||
|
||||
async function loadAdministration() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [usersResponse, teamsResponse] = await Promise.all([
|
||||
fetch(`${API_URL}/admin/users`, { credentials: 'include' }),
|
||||
fetch(`${API_URL}/admin/teams`, { credentials: 'include' }),
|
||||
])
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
throw new Error(
|
||||
await readApiError(usersResponse, 'Benutzer konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
if (!teamsResponse.ok) {
|
||||
throw new Error(
|
||||
await readApiError(teamsResponse, 'Teams konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const usersData = await usersResponse.json()
|
||||
const teamsData = await teamsResponse.json()
|
||||
|
||||
setUsers(usersData.users ?? [])
|
||||
setTeams(teamsData.teams ?? [])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Administration konnte nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleRightChange(rightId: string, checked: boolean) {
|
||||
if (rightId === adminRightId) {
|
||||
setSelectedRights(checked ? getAllRightIds() : [])
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedRights((currentRights) => {
|
||||
if (checked) {
|
||||
return [...new Set([...currentRights, rightId])]
|
||||
}
|
||||
|
||||
return currentRights.filter((currentRight) => currentRight !== rightId)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectNewUser() {
|
||||
setSelectedUserId('new')
|
||||
setSelectedRights([])
|
||||
}
|
||||
|
||||
async function handleSaveUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const isCreate = selectedUserId === 'new'
|
||||
|
||||
const payload = {
|
||||
username: String(formData.get('username') ?? ''),
|
||||
displayName: String(formData.get('displayName') ?? ''),
|
||||
email: String(formData.get('email') ?? ''),
|
||||
password: String(formData.get('password') ?? ''),
|
||||
avatar: String(formData.get('avatar') ?? ''),
|
||||
unit: String(formData.get('unit') ?? ''),
|
||||
group: String(formData.get('group') ?? ''),
|
||||
rights: isAdministratorSelected ? getAllRightIds() : selectedRights,
|
||||
teamIds: getSelectedValues(formData, 'teamIds'),
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
isCreate
|
||||
? `${API_URL}/admin/users`
|
||||
: `${API_URL}/admin/users/${selectedUserId}`,
|
||||
{
|
||||
method: isCreate ? 'POST' : 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Benutzer konnte nicht gespeichert werden'),
|
||||
)
|
||||
}
|
||||
|
||||
await loadAdministration()
|
||||
|
||||
form.reset()
|
||||
setSelectedUserId('new')
|
||||
setSelectedRights([])
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benutzer konnte nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label="Benutzer werden geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10">
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-white/10">
|
||||
<div className="flex items-center justify-between gap-x-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-x-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<UsersIcon aria-hidden="true" className="size-5 text-gray-400" />
|
||||
Benutzer
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{filteredUsers.length} von {users.length} angezeigt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
onClick={handleSelectNewUser}
|
||||
leadingIcon={<UserPlusIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Neuer Benutzer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
placeholder="Benutzer suchen..."
|
||||
className="block w-full rounded-md bg-white py-1.5 pr-3 pl-9 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:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100dvh-20rem)] overflow-y-auto p-2">
|
||||
{selectedUserId === 'new' && (
|
||||
<div className="mb-2 rounded-md bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
|
||||
Neuer Benutzer wird angelegt
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Benutzer gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserId === user.id
|
||||
const displayName = getUserDisplayName(user)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5',
|
||||
'flex w-full gap-x-3 rounded-md px-3 py-2 text-left text-sm',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
isSelected
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300',
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
|
||||
)}
|
||||
>
|
||||
{getUserInitials(user)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-x-2">
|
||||
<span className="truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
{user.rights.includes('admin') && (
|
||||
<span className="shrink-0 rounded-md bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="block truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{user.unit && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
{user.unit}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.group && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
{user.group}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{user.teams.slice(0, 2).map((team) => (
|
||||
<span
|
||||
key={team.id}
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300"
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{user.teams.length > 2 && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||
+{user.teams.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
key={selectedUser?.id ?? 'new'}
|
||||
onSubmit={handleSaveUser}
|
||||
className="rounded-lg bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{selectedUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer anlegen'}
|
||||
</h2>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benutzername
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-username"
|
||||
name="username"
|
||||
defaultValue={selectedUser?.username ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-display-name"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-display-name"
|
||||
name="displayName"
|
||||
defaultValue={selectedUser?.displayName ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-email"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
E-Mail *
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
defaultValue={selectedUser?.email ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Passwort {selectedUser ? '(leer lassen = unverändert)' : '*'}
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-password"
|
||||
name="password"
|
||||
type="password"
|
||||
required={!selectedUser}
|
||||
autoComplete="new-password"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-avatar"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-avatar"
|
||||
name="avatar"
|
||||
type="url"
|
||||
defaultValue={selectedUser?.avatar ?? ''}
|
||||
placeholder="https://..."
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-unit"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Einheit
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-unit"
|
||||
name="unit"
|
||||
defaultValue={selectedUser?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="admin-user-group"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Gruppe
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="admin-user-group"
|
||||
name="group"
|
||||
defaultValue={selectedUser?.group ?? 'user'}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Teams
|
||||
</h3>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Keine Teams vorhanden.
|
||||
</p>
|
||||
) : (
|
||||
teams.map((team) => {
|
||||
const checked = selectedUser?.teams.some(
|
||||
(userTeam) => userTeam.id === team.id,
|
||||
)
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={team.id}
|
||||
name="teamIds"
|
||||
value={team.id}
|
||||
defaultChecked={checked}
|
||||
label={team.name}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Rechte
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Lege fest, welche Bereiche sichtbar sind und wo Änderungen erlaubt sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-white/5">
|
||||
{adminRight && (
|
||||
<div className="border-b border-gray-200 bg-indigo-50 px-4 py-3 dark:border-white/10 dark:bg-indigo-500/10">
|
||||
<Checkbox
|
||||
name="rights"
|
||||
value={adminRight.id}
|
||||
checked={isAdministratorSelected}
|
||||
onChange={(event) =>
|
||||
handleRightChange(adminRight.id, event.target.checked)
|
||||
}
|
||||
label="Administrator"
|
||||
description="Besitzt automatisch alle Rechte."
|
||||
wrapperClassName="items-start"
|
||||
labelClassName="block text-sm font-semibold text-indigo-700 dark:text-indigo-300"
|
||||
descriptionClassName="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] border-b border-gray-200 bg-gray-50 px-4 py-2 text-xs font-medium text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
<div>Bereich</div>
|
||||
<div className="text-center">Anzeigen</div>
|
||||
<div className="text-center">Bearbeiten</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
{rightGroups.map((group) => {
|
||||
const readRight = group.rights.find((right) =>
|
||||
right.id.endsWith(':read'),
|
||||
)
|
||||
const writeRight = group.rights.find((right) =>
|
||||
right.id.endsWith(':write'),
|
||||
)
|
||||
|
||||
function renderRightControl(
|
||||
right: (typeof group.rights)[number] | undefined,
|
||||
) {
|
||||
if (!right) {
|
||||
return (
|
||||
<span className="inline-flex h-5 items-center justify-center text-xs text-gray-300 dark:text-gray-600">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isChecked =
|
||||
isAdministratorSelected ||
|
||||
selectedRights.includes(right.id)
|
||||
const isDisabled = isAdministratorSelected
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
name="rights"
|
||||
value={right.id}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) =>
|
||||
handleRightChange(right.id, event.target.checked)
|
||||
}
|
||||
aria-label={`${group.label} ${right.label}`}
|
||||
title={
|
||||
isAdministratorSelected
|
||||
? 'Durch Administrator-Recht automatisch aktiv'
|
||||
: right.label
|
||||
}
|
||||
wrapperClassName={classNames(
|
||||
'justify-center',
|
||||
isDisabled && 'cursor-not-allowed',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const groupHasAnyRight = group.rights.some((right) =>
|
||||
selectedRights.includes(right.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className={classNames(
|
||||
'grid grid-cols-[minmax(8rem,1fr)_7rem_7rem] items-center px-4 py-3',
|
||||
groupHasAnyRight || isAdministratorSelected
|
||||
? 'bg-white dark:bg-transparent'
|
||||
: 'bg-gray-50/60 dark:bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{group.label}
|
||||
</span>
|
||||
|
||||
{(groupHasAnyRight || isAdministratorSelected) && (
|
||||
<span className="inline-flex size-1.5 rounded-full bg-indigo-600 dark:bg-indigo-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{renderRightControl(readRight)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{renderRightControl(writeRight)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdministratorSelected && (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Einzelrechte sind deaktiviert, weil Administrator bereits alle Berechtigungen umfasst.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Benutzer speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -55,7 +55,16 @@ export default function CreateDeviceModal({
|
||||
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
|
||||
if (
|
||||
['inventoryNumber', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'loanStatus'].includes(
|
||||
[
|
||||
'inventoryNumber',
|
||||
'manufacturer',
|
||||
'model',
|
||||
'serialNumber',
|
||||
'macAddress',
|
||||
'ipAddress',
|
||||
'firmwareVersion',
|
||||
'loanStatus',
|
||||
].includes(
|
||||
target.name,
|
||||
)
|
||||
) {
|
||||
|
||||
@ -5,6 +5,7 @@ import Combobox, { type ComboboxItem } from '../../components/Combobox'
|
||||
import Checkbox from '../../components/Checkbox'
|
||||
import Switch from '../../components/Switch'
|
||||
import type { DeviceModalTabId } from './DeviceModalTabs'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import type { Device } from '../../components/types'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
@ -104,6 +105,8 @@ function getDeviceAccessorySearchText(device: Device) {
|
||||
device.model,
|
||||
device.serialNumber,
|
||||
device.macAddress,
|
||||
device.ipAddress,
|
||||
device.firmwareVersion,
|
||||
device.location,
|
||||
device.loanStatus,
|
||||
device.comment,
|
||||
@ -113,6 +116,48 @@ function getDeviceAccessorySearchText(device: Device) {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
type DeviceLookupData = {
|
||||
manufacturers: ComboboxItem[]
|
||||
locations: ComboboxItem[]
|
||||
}
|
||||
|
||||
let deviceLookupCache: DeviceLookupData | null = null
|
||||
let deviceLookupPromise: Promise<DeviceLookupData> | null = null
|
||||
|
||||
async function fetchDeviceLookups() {
|
||||
if (deviceLookupCache) {
|
||||
return deviceLookupCache
|
||||
}
|
||||
|
||||
if (deviceLookupPromise) {
|
||||
return deviceLookupPromise
|
||||
}
|
||||
|
||||
deviceLookupPromise = fetch(`${API_URL}/device-lookups`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Auswahllisten konnten nicht geladen werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
deviceLookupCache = {
|
||||
manufacturers: data.manufacturers ?? [],
|
||||
locations: data.locations ?? [],
|
||||
}
|
||||
|
||||
return deviceLookupCache
|
||||
})
|
||||
.finally(() => {
|
||||
deviceLookupPromise = null
|
||||
})
|
||||
|
||||
return deviceLookupPromise
|
||||
}
|
||||
|
||||
export default function DeviceFormFields({
|
||||
open,
|
||||
activeTab,
|
||||
@ -184,23 +229,21 @@ export default function DeviceFormFields({
|
||||
relatedDeviceIds.includes(assignableDevice.id),
|
||||
).length
|
||||
|
||||
const isMilestoneDevice = Boolean(
|
||||
device?.milestoneHardwareId || device?.milestoneRecordingServerId,
|
||||
)
|
||||
|
||||
const milestoneReadonlyClassName =
|
||||
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
|
||||
|
||||
async function loadDeviceLookups() {
|
||||
setLookupError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/device-lookups`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await fetchDeviceLookups()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
throw new Error(errorData?.error ?? 'Auswahllisten konnten nicht geladen werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setManufacturerOptions(data.manufacturers ?? [])
|
||||
setLocationOptions(data.locations ?? [])
|
||||
setManufacturerOptions(data.manufacturers)
|
||||
setLocationOptions(data.locations)
|
||||
} catch (error) {
|
||||
setLookupError(
|
||||
error instanceof Error
|
||||
@ -335,6 +378,27 @@ export default function DeviceFormFields({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMilestoneDevice && device?.milestoneDisplayName && (
|
||||
<div className="sm:col-span-6">
|
||||
<label
|
||||
htmlFor="milestoneDisplayName"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="milestoneDisplayName"
|
||||
name="milestoneDisplayName"
|
||||
type="text"
|
||||
defaultValue={device.milestoneDisplayName}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<input
|
||||
type="hidden"
|
||||
@ -408,13 +472,67 @@ export default function DeviceFormFields({
|
||||
defaultValue={formatMacAddress(device?.macAddress ?? '')}
|
||||
maxLength={17}
|
||||
pattern="(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
|
||||
title="Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein."
|
||||
onInput={handleMacAddressInput}
|
||||
onInvalid={handleMacAddressInvalid}
|
||||
title={
|
||||
isMilestoneDevice
|
||||
? 'MAC-Adresse wird aus Milestone synchronisiert.'
|
||||
: 'Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein.'
|
||||
}
|
||||
readOnly={isMilestoneDevice}
|
||||
aria-readonly={isMilestoneDevice}
|
||||
onInput={isMilestoneDevice ? undefined : handleMacAddressInput}
|
||||
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="ipAddress"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
IP-Adresse
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="ipAddress"
|
||||
name="ipAddress"
|
||||
type="text"
|
||||
placeholder="192.168.1.100"
|
||||
defaultValue={device?.ipAddress ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="firmwareVersion"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Firmware
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="firmwareVersion"
|
||||
name="firmwareVersion"
|
||||
type="text"
|
||||
defaultValue={device?.firmwareVersion ?? ''}
|
||||
readOnly={isMilestoneDevice}
|
||||
aria-readonly={isMilestoneDevice}
|
||||
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -459,22 +577,14 @@ export default function DeviceFormFields({
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label
|
||||
htmlFor="comment"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Kommentar
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
rows={3}
|
||||
defaultValue={device?.comment ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
label="Kommentar"
|
||||
variant="simple"
|
||||
rows={3}
|
||||
defaultValue={device?.comment ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,17 +1,87 @@
|
||||
// frontend/src/pages/DevicesPage.tsx
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { CheckCircleIcon, PencilSquareIcon, XCircleIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useRef, useState, type ComponentProps, type FormEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Button from '../../components/Button'
|
||||
import { Badge } from '../../components/Badge'
|
||||
import CreateDeviceModal from './CreateDeviceModal'
|
||||
import EditDeviceModal from './EditDeviceModal'
|
||||
import Tables, { type TableColumn } from '../../components/Tables'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
import Button from '../../components/Button'
|
||||
import type { Device, DeviceHistoryEntry } from '../../components/types'
|
||||
import Notifications, { type ToastNotification } from '../../components/Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
function getLoanStatusClassName(status: string) {
|
||||
type FirmwareCheckState = {
|
||||
canCheck: boolean
|
||||
lastCheckedAt?: string | null
|
||||
lastManualCheckedAt?: string | null
|
||||
nextAllowedAt?: string | null
|
||||
remainingSeconds?: number
|
||||
}
|
||||
|
||||
function getLoanStatusBadgeIcon(status: string) {
|
||||
const normalizedStatus = status.toLowerCase()
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('defekt') ||
|
||||
normalizedStatus.includes('verloren') ||
|
||||
normalizedStatus.includes('gesperrt') ||
|
||||
normalizedStatus.includes('deaktiviert')
|
||||
) {
|
||||
return <XCircleIcon />
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('ausgeliehen') ||
|
||||
normalizedStatus.includes('verliehen') ||
|
||||
normalizedStatus.includes('reserviert') ||
|
||||
normalizedStatus.includes('wartung') ||
|
||||
normalizedStatus.includes('prüfung')
|
||||
) {
|
||||
return <ClockIcon />
|
||||
}
|
||||
|
||||
return <CheckCircleIcon />
|
||||
}
|
||||
|
||||
function formatFirmwareCheckedAt(value?: string | null) {
|
||||
if (!value) {
|
||||
return 'Noch nicht geprüft'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function getLatestFirmwareCheckedAt(devices: Device[]) {
|
||||
const timestamps = devices
|
||||
.map((device) => device.firmwareUpdate?.checkedAt)
|
||||
.filter(Boolean)
|
||||
.map((value) => new Date(value as string).getTime())
|
||||
.filter((value) => Number.isFinite(value))
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Date(Math.max(...timestamps)).toISOString()
|
||||
}
|
||||
|
||||
type BadgeTone = NonNullable<ComponentProps<typeof Badge>['tone']>
|
||||
|
||||
function getLoanStatusBadgeTone(status: string): BadgeTone {
|
||||
const normalizedStatus = status.toLowerCase()
|
||||
|
||||
if (
|
||||
@ -19,45 +89,65 @@ function getLoanStatusClassName(status: string) {
|
||||
normalizedStatus.includes('verliehen') ||
|
||||
normalizedStatus.includes('reserviert')
|
||||
) {
|
||||
return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-500/30'
|
||||
return 'yellow'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('defekt') ||
|
||||
normalizedStatus.includes('verloren') ||
|
||||
normalizedStatus.includes('gesperrt')
|
||||
normalizedStatus.includes('gesperrt') ||
|
||||
normalizedStatus.includes('deaktiviert')
|
||||
) {
|
||||
return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedStatus.includes('wartung') ||
|
||||
normalizedStatus.includes('prüfung')
|
||||
) {
|
||||
return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300 dark:ring-blue-500/30'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
function getSupportBadgeTone(severity?: string): BadgeTone {
|
||||
if (severity === 'danger') {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (severity === 'warning') {
|
||||
return 'yellow'
|
||||
}
|
||||
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<'create' | 'edit' | null>(null)
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
const [isJournalSubmitting, setIsJournalSubmitting] = useState(false)
|
||||
|
||||
const [notifications, setNotifications] = useState<ToastNotification[]>([])
|
||||
const [togglingMilestoneDeviceIds, setTogglingMilestoneDeviceIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
const [isCheckingFirmware, setIsCheckingFirmware] = useState(false)
|
||||
const [firmwareCheckState, setFirmwareCheckState] = useState<FirmwareCheckState | null>(null)
|
||||
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
|
||||
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
void loadFirmwareCheckState()
|
||||
|
||||
return () => {
|
||||
if (closeCleanupTimeoutRef.current) {
|
||||
@ -66,9 +156,19 @@ export default function DevicesPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setFirmwareCheckNow(Date.now())
|
||||
}, 30_000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleDeviceNotification(event: Event) {
|
||||
void loadDevices()
|
||||
void loadDevices({ silent: true })
|
||||
|
||||
const notification = (event as CustomEvent).detail as {
|
||||
entityId?: string
|
||||
@ -89,6 +189,90 @@ export default function DevicesPage() {
|
||||
}
|
||||
}, [editingDevice])
|
||||
|
||||
async function loadFirmwareCheckState() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setFirmwareCheckState({
|
||||
canCheck: Boolean(data.canCheck),
|
||||
lastCheckedAt: data.lastCheckedAt ?? null,
|
||||
lastManualCheckedAt: data.lastManualCheckedAt ?? null,
|
||||
nextAllowedAt: data.nextAllowedAt ?? null,
|
||||
remainingSeconds: data.remainingSeconds ?? 0,
|
||||
})
|
||||
} catch {
|
||||
// Status ist optional für die UI.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckFirmware() {
|
||||
setIsCheckingFirmware(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/firmware-check`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
setFirmwareCheckState((currentState) => ({
|
||||
canCheck: false,
|
||||
lastCheckedAt: currentState?.lastCheckedAt ?? null,
|
||||
lastManualCheckedAt: currentState?.lastManualCheckedAt ?? null,
|
||||
nextAllowedAt: data?.nextAllowedAt ?? null,
|
||||
remainingSeconds: data?.remainingSeconds ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
throw new Error(data?.error ?? 'Firmware konnte nicht geprüft werden')
|
||||
}
|
||||
|
||||
setFirmwareCheckState({
|
||||
canCheck: false,
|
||||
lastCheckedAt: data.checkedAt ?? null,
|
||||
lastManualCheckedAt: data.checkedAt ?? null,
|
||||
nextAllowedAt: data.nextAllowedAt ?? null,
|
||||
remainingSeconds: data.remainingSeconds ?? 3600,
|
||||
})
|
||||
|
||||
await loadDevices({ silent: true })
|
||||
await loadFirmwareCheckState()
|
||||
|
||||
showNotification({
|
||||
variant: data.failedModels > 0 ? 'warning' : 'success',
|
||||
title: 'Firmware geprüft',
|
||||
message:
|
||||
data.failedModels > 0
|
||||
? `${data.checkedModels} Modelle geprüft, ${data.failedModels} fehlgeschlagen.`
|
||||
: `${data.checkedModels} Modelle wurden geprüft.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
title: 'Firmware-Prüfung fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Firmware konnte nicht geprüft werden',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsCheckingFirmware(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDeviceJournalEntry(content: string) {
|
||||
if (!editingDevice) {
|
||||
return
|
||||
@ -124,8 +308,11 @@ export default function DevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
setIsLoading(true)
|
||||
async function loadDevices(options?: { silent?: boolean }) {
|
||||
if (!options?.silent) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@ -143,7 +330,9 @@ export default function DevicesPage() {
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Geräte konnten nicht geladen werden')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (!options?.silent) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,15 +375,15 @@ export default function DevicesPage() {
|
||||
setDeviceHistory([])
|
||||
setHistoryError(null)
|
||||
setIsHistoryLoading(false)
|
||||
setIsCreateModalOpen(true)
|
||||
setActiveModal('create')
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
function closeModal() {
|
||||
if (isCreating) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreateModalOpen(false)
|
||||
setActiveModal(null)
|
||||
|
||||
if (closeCleanupTimeoutRef.current) {
|
||||
window.clearTimeout(closeCleanupTimeoutRef.current)
|
||||
@ -212,12 +401,17 @@ export default function DevicesPage() {
|
||||
}
|
||||
|
||||
function openEditModal(device: Device) {
|
||||
if (closeCleanupTimeoutRef.current) {
|
||||
window.clearTimeout(closeCleanupTimeoutRef.current)
|
||||
closeCleanupTimeoutRef.current = null
|
||||
}
|
||||
|
||||
setCreateError(null)
|
||||
setHistoryError(null)
|
||||
setDeviceHistory([])
|
||||
setEditingDevice(device)
|
||||
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
|
||||
setIsCreateModalOpen(true)
|
||||
setActiveModal('edit')
|
||||
|
||||
void loadDeviceHistory(device.id)
|
||||
}
|
||||
@ -232,6 +426,108 @@ export default function DevicesPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
setNotifications((currentNotifications) =>
|
||||
currentNotifications.filter((notification) => notification.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showNotification(notification: Omit<ToastNotification, 'id'>) {
|
||||
setNotifications((currentNotifications) => [
|
||||
{
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
...notification,
|
||||
},
|
||||
...currentNotifications,
|
||||
])
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
async function handleToggleMilestoneDeviceStatus(
|
||||
device: Device,
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
) {
|
||||
event.stopPropagation()
|
||||
|
||||
if (!device.milestoneHardwareId) {
|
||||
return
|
||||
}
|
||||
|
||||
setTogglingMilestoneDeviceIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.add(device.id)
|
||||
return nextIds
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/devices/${device.id}/milestone-toggle`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Milestone-Status konnte nicht geändert werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const milestoneEnabled = Boolean(data.milestoneEnabled)
|
||||
|
||||
setDevices((currentDevices) =>
|
||||
currentDevices.map((currentDevice) =>
|
||||
currentDevice.id === device.id
|
||||
? {
|
||||
...currentDevice,
|
||||
milestoneEnabled,
|
||||
}
|
||||
: currentDevice,
|
||||
),
|
||||
)
|
||||
|
||||
setEditingDevice((currentDevice) =>
|
||||
currentDevice?.id === device.id
|
||||
? {
|
||||
...currentDevice,
|
||||
milestoneEnabled,
|
||||
}
|
||||
: currentDevice,
|
||||
)
|
||||
|
||||
showNotification({
|
||||
variant: 'success',
|
||||
title: 'Milestone-Status geändert',
|
||||
message: `Gerät ${device.inventoryNumber} wurde in Milestone ${
|
||||
milestoneEnabled ? 'aktiviert' : 'deaktiviert'
|
||||
}.`,
|
||||
})
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
variant: 'error',
|
||||
title: 'Status konnte nicht geändert werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Milestone-Status konnte nicht geändert werden',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setTogglingMilestoneDeviceIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds)
|
||||
nextIds.delete(device.id)
|
||||
return nextIds
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDevice(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
@ -248,6 +544,9 @@ export default function DevicesPage() {
|
||||
model: String(formData.get('model') ?? ''),
|
||||
serialNumber: String(formData.get('serialNumber') ?? ''),
|
||||
macAddress: String(formData.get('macAddress') ?? ''),
|
||||
ipAddress: String(formData.get('ipAddress') ?? ''),
|
||||
firmwareVersion: String(formData.get('firmwareVersion') ?? ''),
|
||||
milestoneDisplayName: String(formData.get('milestoneDisplayName') ?? ''),
|
||||
location: String(formData.get('location') ?? ''),
|
||||
loanStatus: String(formData.get('loanStatus') ?? 'Verfügbar'),
|
||||
comment: String(formData.get('comment') ?? ''),
|
||||
@ -282,7 +581,7 @@ export default function DevicesPage() {
|
||||
}
|
||||
|
||||
form.reset()
|
||||
closeCreateModal()
|
||||
closeModal()
|
||||
|
||||
await loadDevices()
|
||||
} catch (error) {
|
||||
@ -298,57 +597,186 @@ export default function DevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const latestFirmwareCheckedAt =
|
||||
firmwareCheckState?.lastCheckedAt ?? getLatestFirmwareCheckedAt(devices)
|
||||
|
||||
const firmwareCheckNextAllowedAt = firmwareCheckState?.nextAllowedAt
|
||||
const isFirmwareCheckLocked =
|
||||
Boolean(firmwareCheckNextAllowedAt) &&
|
||||
new Date(firmwareCheckNextAllowedAt as string).getTime() > firmwareCheckNow
|
||||
|
||||
const firmwareCheckButtonDisabled = isCheckingFirmware || isFirmwareCheckLocked
|
||||
|
||||
const columns: TableColumn<Device>[] = [
|
||||
{
|
||||
key: 'inventoryNumber',
|
||||
header: 'Inventur-Nr.',
|
||||
isPrimary: true,
|
||||
render: (device) => device.inventoryNumber,
|
||||
},
|
||||
{
|
||||
key: 'manufacturer',
|
||||
header: 'Hersteller',
|
||||
render: (device) => device.manufacturer || '-',
|
||||
render: (device) => (
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{device.inventoryNumber}
|
||||
</div>
|
||||
|
||||
{(device.serialNumber || device.macAddress) && (
|
||||
<div className="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{device.serialNumber && (
|
||||
<div className="truncate">
|
||||
SN: {device.serialNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.macAddress && (
|
||||
<div className="truncate">
|
||||
MAC: {device.macAddress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
header: 'Modell',
|
||||
render: (device) => device.model || '-',
|
||||
header: 'Gerät',
|
||||
render: (device) => {
|
||||
const title = [device.manufacturer, device.model]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{device.milestoneDisplayName && (
|
||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{device.milestoneDisplayName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={[
|
||||
'flex flex-wrap items-center gap-1 text-sm',
|
||||
device.milestoneDisplayName
|
||||
? 'mt-0.5 text-gray-500 dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="truncate">
|
||||
{title || '-'}
|
||||
</span>
|
||||
|
||||
{device.supportBadges?.map((badge) => (
|
||||
<a
|
||||
key={badge.type}
|
||||
href={badge.supportUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
title={badge.message}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Badge
|
||||
tone={getSupportBadgeTone(badge.severity)}
|
||||
variant="border"
|
||||
size="small"
|
||||
className="text-[11px]"
|
||||
leadingIcon={<WrenchScrewdriverIcon />}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{device.firmwareVersion && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="truncate">
|
||||
Firmware: {device.firmwareVersion}
|
||||
</span>
|
||||
|
||||
{device.firmwareUpdate?.available && (
|
||||
<a
|
||||
href={device.firmwareUpdate.supportUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="inline-flex"
|
||||
title={`Neue Firmware verfügbar: ${device.firmwareUpdate.latestVersion}`}
|
||||
>
|
||||
<Badge
|
||||
tone="yellow"
|
||||
variant="border"
|
||||
size="small"
|
||||
className="text-[11px]"
|
||||
leadingIcon={<ArrowDownTrayIcon />}
|
||||
>
|
||||
{device.firmwareUpdate.latestVersion}
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'serialNumber',
|
||||
header: 'Seriennummer',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => device.serialNumber || '-',
|
||||
},
|
||||
{
|
||||
key: 'macAddress',
|
||||
header: 'MAC-Adresse',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => device.macAddress || '-',
|
||||
key: 'ipAddress',
|
||||
header: 'IP-Adresse',
|
||||
hideOnMobile: 'md',
|
||||
render: (device) => device.ipAddress || '-',
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Standort',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => device.location || '-',
|
||||
},
|
||||
{
|
||||
key: 'loanStatus',
|
||||
header: 'Verleih-Status',
|
||||
header: 'Verleih',
|
||||
render: (device) => (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
getLoanStatusClassName(device.loanStatus),
|
||||
].join(' ')}
|
||||
<Badge
|
||||
tone={getLoanStatusBadgeTone(device.loanStatus)}
|
||||
variant="border"
|
||||
leadingIcon={getLoanStatusBadgeIcon(device.loanStatus)}
|
||||
>
|
||||
{device.loanStatus || 'Verfügbar'}
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'milestoneEnabled',
|
||||
header: 'Milestone',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => {
|
||||
if (!device.milestoneHardwareId) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const isToggling = togglingMilestoneDeviceIds.has(device.id)
|
||||
const label = device.milestoneEnabled ? 'Aktiviert' : 'Deaktiviert'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isToggling}
|
||||
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
|
||||
className="inline-flex cursor-pointer rounded-md transition disabled:cursor-wait disabled:opacity-60"
|
||||
title={`Milestone-Gerät ${
|
||||
device.milestoneEnabled ? 'deaktivieren' : 'aktivieren'
|
||||
}`}
|
||||
>
|
||||
<Badge tone={getLoanStatusBadgeTone(label)} variant="border">
|
||||
{isToggling ? 'Wird geändert...' : label}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isBaoDevice',
|
||||
header: 'BAO',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) =>
|
||||
device.isBaoDevice ? (
|
||||
<CheckCircleIcon
|
||||
@ -362,43 +790,16 @@ export default function DevicesPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'relatedDevices',
|
||||
header: 'Zubehör',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => {
|
||||
if (!device.relatedDevices?.length) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-xs flex-wrap gap-1">
|
||||
{device.relatedDevices.map((relatedDevice) => (
|
||||
<span
|
||||
key={relatedDevice.id}
|
||||
className="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-gray-500/10 ring-inset dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"
|
||||
>
|
||||
{relatedDevice.inventoryNumber}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'comment',
|
||||
header: 'Kommentar',
|
||||
hideOnMobile: 'lg',
|
||||
render: (device) => (
|
||||
<span className="block max-w-xs truncate">
|
||||
{device.comment || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8 [scrollbar-gutter:stable]">
|
||||
<Notifications
|
||||
notifications={notifications}
|
||||
onDismiss={dismissNotification}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
@ -407,8 +808,32 @@ export default function DevicesPage() {
|
||||
|
||||
<Tables
|
||||
title="Geräte"
|
||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, Standort, Verleih-Status und Zubehör."
|
||||
actionLabel="Gerät hinzufügen"
|
||||
description="Eine Liste aller Geräte inklusive Inventur-Nr., Hersteller, Modell, Seriennummer, MAC-Adresse, IP-Adresse, Firmware, Standort, Verleih-Status und Zubehör."
|
||||
headerAction={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={firmwareCheckButtonDisabled}
|
||||
isLoading={isCheckingFirmware}
|
||||
leadingIcon={<ArrowPathIcon aria-hidden="true" className="size-4" />}
|
||||
onClick={() => void handleCheckFirmware()}
|
||||
>
|
||||
Firmware prüfen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Gerät hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onAction={openCreateModal}
|
||||
rows={devices}
|
||||
columns={columns}
|
||||
@ -424,31 +849,21 @@ export default function DevicesPage() {
|
||||
center
|
||||
/>
|
||||
}
|
||||
rowActions={(device) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(device)}
|
||||
leadingIcon={
|
||||
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
||||
}
|
||||
>
|
||||
Bearbeiten<span className="sr-only">, {device.inventoryNumber}</span>
|
||||
</Button>
|
||||
)}
|
||||
onRowClick={openEditModal}
|
||||
getRowAriaLabel={(device) =>
|
||||
`Gerät ${device.inventoryNumber || device.model || device.id} bearbeiten`
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateDeviceModal
|
||||
open={isCreateModalOpen && editingDevice === null}
|
||||
open={activeModal === 'create'}
|
||||
setOpen={(open) => {
|
||||
if (open) {
|
||||
setIsCreateModalOpen(true)
|
||||
setActiveModal('create')
|
||||
return
|
||||
}
|
||||
|
||||
closeCreateModal()
|
||||
closeModal()
|
||||
}}
|
||||
isSaving={isCreating}
|
||||
error={createError}
|
||||
@ -459,14 +874,14 @@ export default function DevicesPage() {
|
||||
/>
|
||||
|
||||
<EditDeviceModal
|
||||
open={isCreateModalOpen && editingDevice !== null}
|
||||
open={activeModal === 'edit'}
|
||||
setOpen={(open) => {
|
||||
if (open) {
|
||||
setIsCreateModalOpen(true)
|
||||
setActiveModal('edit')
|
||||
return
|
||||
}
|
||||
|
||||
closeCreateModal()
|
||||
closeModal()
|
||||
}}
|
||||
isSaving={isCreating}
|
||||
error={createError}
|
||||
|
||||
@ -49,7 +49,7 @@ const editDeviceTabs: Array<{ id: DeviceModalTabId; name: string }> = [
|
||||
{ id: 'masterData', name: 'Stammdaten' },
|
||||
{ id: 'location', name: 'Standort' },
|
||||
{ id: 'accessories', name: 'Zubehör' },
|
||||
{ id: 'history', name: 'Verlauf' },
|
||||
{ id: 'history', name: 'Journal' },
|
||||
]
|
||||
|
||||
function formatHistoryDate(value: string) {
|
||||
@ -78,6 +78,8 @@ function getTabForInvalidField(fieldName: string): DeviceModalTabId {
|
||||
'model',
|
||||
'serialNumber',
|
||||
'macAddress',
|
||||
'ipAddress',
|
||||
'firmwareVersion',
|
||||
'loanStatus',
|
||||
].includes(fieldName)
|
||||
) {
|
||||
@ -131,55 +133,53 @@ export default function EditDeviceModal({
|
||||
setActiveTab(getTabForInvalidField(target.name))
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return null
|
||||
}
|
||||
const historyFeedItems: JournalItems = device
|
||||
? deviceHistory.map((entry) => {
|
||||
const journalChange = entry.changes.find((change) => change.field === 'journal')
|
||||
const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
|
||||
|
||||
const historyFeedItems: JournalItems = deviceHistory.map((entry) => {
|
||||
const journalChange = entry.changes.find((change) => change.field === 'journal')
|
||||
const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
type: isJournalEntry
|
||||
? ('journal' as const)
|
||||
: entry.action === 'created'
|
||||
? ('created' as const)
|
||||
: ('edited' as const),
|
||||
person: {
|
||||
name: entry.userDisplayName || 'Unbekannt',
|
||||
},
|
||||
content: isJournalEntry
|
||||
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
||||
: entry.action === 'created'
|
||||
? 'hat das Gerät erstellt.'
|
||||
: entry.changes.length > 0
|
||||
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
|
||||
: 'hat das Gerät bearbeitet.',
|
||||
date: formatHistoryDate(entry.createdAt),
|
||||
datetime: entry.createdAt,
|
||||
icon: isJournalEntry
|
||||
? ChatBubbleLeftEllipsisIcon
|
||||
: entry.action === 'created'
|
||||
? PlusCircleIcon
|
||||
: PencilSquareIcon,
|
||||
iconBackground: isJournalEntry
|
||||
? 'bg-indigo-500'
|
||||
: entry.action === 'created'
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500',
|
||||
}
|
||||
})
|
||||
return {
|
||||
id: entry.id,
|
||||
type: isJournalEntry
|
||||
? ('journal' as const)
|
||||
: entry.action === 'created'
|
||||
? ('created' as const)
|
||||
: ('edited' as const),
|
||||
person: {
|
||||
name: entry.userDisplayName || 'Unbekannt',
|
||||
},
|
||||
content: isJournalEntry
|
||||
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
|
||||
: entry.action === 'created'
|
||||
? 'hat das Gerät erstellt.'
|
||||
: entry.changes.length > 0
|
||||
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
|
||||
: 'hat das Gerät bearbeitet.',
|
||||
date: formatHistoryDate(entry.createdAt),
|
||||
datetime: entry.createdAt,
|
||||
icon: isJournalEntry
|
||||
? ChatBubbleLeftEllipsisIcon
|
||||
: entry.action === 'created'
|
||||
? PlusCircleIcon
|
||||
: PencilSquareIcon,
|
||||
iconBackground: isJournalEntry
|
||||
? 'bg-indigo-500'
|
||||
: entry.action === 'created'
|
||||
? 'bg-green-500'
|
||||
: 'bg-blue-500',
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
open={open && Boolean(device)}
|
||||
setOpen={handleOpenChange}
|
||||
zIndexClassName="z-[9999]"
|
||||
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
|
||||
>
|
||||
<form
|
||||
key={device.id}
|
||||
key={device?.id ?? 'edit-device'}
|
||||
onSubmit={onSubmit}
|
||||
onInvalidCapture={handleInvalid}
|
||||
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
|
||||
@ -188,6 +188,16 @@ export default function EditDeviceModal({
|
||||
<div>
|
||||
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Gerät bearbeiten
|
||||
{device && (
|
||||
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
|
||||
{[
|
||||
device.inventoryNumber,
|
||||
device.milestoneDisplayName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@ -262,7 +272,7 @@ export default function EditDeviceModal({
|
||||
<DeviceFormFields
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
device={device}
|
||||
device={device ?? undefined}
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import Modal from '../../components/Modal'
|
||||
import Button from '../../components/Button'
|
||||
import AddressCombobox from '../../components/AddressCombobox'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
|
||||
|
||||
type CreateOperationModalProps = {
|
||||
@ -51,9 +52,6 @@ type SetupStepId =
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
const textareaClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
const initialFormValues: OperationFormValues = {
|
||||
operationNumber: '',
|
||||
operationName: '',
|
||||
@ -520,42 +518,28 @@ export default function CreateOperationModal({
|
||||
|
||||
{activeStep === 'notes' && (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Zusatzinformationen"
|
||||
description="Legende und Bemerkung können beim Anlegen oder später ergänzt werden."
|
||||
/>
|
||||
<StepHeader
|
||||
title="Zusatzinformationen"
|
||||
description="Legende und Bemerkung können beim Anlegen oder später ergänzt werden."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="createLegend" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Legende
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Textarea
|
||||
id="createLegend"
|
||||
label="Legende"
|
||||
rows={6}
|
||||
value={formValues.legend}
|
||||
onChange={(event) => updateField('legend', event.target.value)}
|
||||
className={textareaClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="createRemark" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Bemerkung
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
<Textarea
|
||||
id="createRemark"
|
||||
label="Bemerkung"
|
||||
rows={6}
|
||||
value={formValues.remark}
|
||||
onChange={(event) => updateField('remark', event.target.value)}
|
||||
className={textareaClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -657,7 +657,9 @@ export default function OperationMap({
|
||||
<AppMap
|
||||
markers={visibleMapMarkers}
|
||||
heatPoints={heatPoints}
|
||||
showHeatmap={mode === 'heatmap'}
|
||||
showHeatmap={heatPoints.length > 0}
|
||||
heatmapLayerName="Einsatz-Heatmap"
|
||||
heatmapChecked={mode === 'heatmap'}
|
||||
heatmapOptions={{
|
||||
radius: 34,
|
||||
blur: 26,
|
||||
|
||||
@ -1,16 +1,28 @@
|
||||
// frontend/src/pages/OperationsPage.tsx
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
MapPinIcon,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
FireIcon,
|
||||
MapPinIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UserGroupIcon,
|
||||
UserIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import OperationMap from './OperationMap'
|
||||
import ButtonGroup from '../../components/ButtonGroup'
|
||||
import Card from '../../components/Card'
|
||||
import OperationModal from './OperationModal'
|
||||
import Tables, { type TableColumn } from '../../components/Tables'
|
||||
import LoadingSpinner from '../../components/LoadingSpinner'
|
||||
@ -27,24 +39,38 @@ type OperationsPageProps = {
|
||||
|
||||
type OperationsView = 'table' | 'cards' | 'map' | 'heatmap'
|
||||
|
||||
type OperationSortDirection = 'asc' | 'desc'
|
||||
|
||||
type OperationSortValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Date
|
||||
| null
|
||||
| undefined
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
function renderEquipment(operation: Operation) {
|
||||
const equipment = [
|
||||
function getEquipmentItems(operation: Operation) {
|
||||
return [
|
||||
{ label: 'Kamera', value: operation.camera },
|
||||
{ label: 'Router', value: operation.router },
|
||||
{ label: 'Technik', value: operation.technology },
|
||||
{ label: 'Switchbox', value: operation.switchbox },
|
||||
{ label: 'Festplatte', value: operation.hardDrive },
|
||||
{ label: 'Laptop', value: operation.laptop },
|
||||
].filter((item) => item.value)
|
||||
].filter((item) => item.value.trim() !== '')
|
||||
}
|
||||
|
||||
function renderEquipment(operation: Operation) {
|
||||
const equipment = getEquipmentItems(operation)
|
||||
|
||||
if (equipment.length === 0) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-xs flex-wrap gap-1">
|
||||
<div className="flex max-w-md flex-wrap gap-1.5">
|
||||
{equipment.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
@ -130,6 +156,8 @@ export default function OperationsPage({
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
|
||||
const [view, setView] = useState<OperationsView>('table')
|
||||
const [sortKey, setSortKey] = useState('operation')
|
||||
const [sortDirection, setSortDirection] = useState<OperationSortDirection>('asc')
|
||||
const [heatmapOperations, setHeatmapOperations] = useState<Operation[]>([])
|
||||
const [isHeatmapLoading, setIsHeatmapLoading] = useState(false)
|
||||
const [heatmapError, setHeatmapError] = useState<string | null>(null)
|
||||
@ -503,85 +531,316 @@ export default function OperationsPage({
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSortValue(value: OperationSortValue) {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime()
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
return String(value ?? '').trim().toLocaleLowerCase('de-DE')
|
||||
}
|
||||
|
||||
function compareSortValues(
|
||||
leftValue: OperationSortValue,
|
||||
rightValue: OperationSortValue,
|
||||
) {
|
||||
const left = normalizeSortValue(leftValue)
|
||||
const right = normalizeSortValue(rightValue)
|
||||
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return left - right
|
||||
}
|
||||
|
||||
return String(left).localeCompare(String(right), 'de-DE', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
|
||||
function handleSort(column: TableColumn<Operation>) {
|
||||
if (!column.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sortKey === column.key) {
|
||||
setSortDirection((currentDirection) =>
|
||||
currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSortKey(column.key)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
|
||||
const columns: TableColumn<Operation>[] = [
|
||||
{
|
||||
key: 'operationName',
|
||||
header: 'Einsatzname',
|
||||
key: 'operation',
|
||||
header: 'Einsatz',
|
||||
isPrimary: true,
|
||||
render: (operation) => renderOperationName(operation),
|
||||
sortable: true,
|
||||
sortValue: (operation) =>
|
||||
`${operation.operationNumber} ${operation.operationName}`,
|
||||
render: (operation) => {
|
||||
const operationName = operation.operationName || 'Ohne Einsatzname'
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-sm">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="whitespace-nowrap text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{operation.operationNumber || '-'}
|
||||
</span>
|
||||
|
||||
{hasUnreadJournal(operation) && (
|
||||
<NotificationDot
|
||||
title="Neue Journal-Einträge"
|
||||
label="Neue Journal-Einträge"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
title={operationName}
|
||||
className="mt-1 line-clamp-1 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{operationName}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'operationNumber',
|
||||
header: 'Einsatznummer',
|
||||
render: (operation) => operation.operationNumber,
|
||||
},
|
||||
{
|
||||
key: 'offense',
|
||||
header: 'Delikt',
|
||||
render: (operation) => operation.offense || '-',
|
||||
},
|
||||
{
|
||||
key: 'caseWorker',
|
||||
header: 'Sachbearbeitung',
|
||||
hideOnMobile: 'lg',
|
||||
render: (operation) => operation.caseWorker || '-',
|
||||
},
|
||||
{
|
||||
key: 'targetObject',
|
||||
header: 'Zielobjekt',
|
||||
render: (operation) => renderShortAddress(operation.targetObject),
|
||||
},
|
||||
{
|
||||
key: 'kwAddress',
|
||||
header: 'KW',
|
||||
render: (operation) => renderShortAddress(operation.kwAddress),
|
||||
},
|
||||
{
|
||||
key: 'operationLeader',
|
||||
header: 'Einsatzleiter',
|
||||
hideOnMobile: 'lg',
|
||||
render: (operation) => operation.operationLeader || '-',
|
||||
},
|
||||
{
|
||||
key: 'operationTeam',
|
||||
header: 'Einsatzteam',
|
||||
hideOnMobile: 'lg',
|
||||
render: (operation) => operation.operationTeam || '-',
|
||||
},
|
||||
{
|
||||
key: 'equipment',
|
||||
header: 'Technik',
|
||||
hideOnMobile: 'lg',
|
||||
render: renderEquipment,
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: 'Bemerkung',
|
||||
hideOnMobile: 'lg',
|
||||
key: 'leadership',
|
||||
header: 'Einsatzleitung / Team',
|
||||
sortable: true,
|
||||
sortValue: (operation) =>
|
||||
`${operation.operationLeader} ${operation.operationTeam}`,
|
||||
render: (operation) => (
|
||||
<span className="block max-w-xs truncate">
|
||||
{operation.remark || '-'}
|
||||
</span>
|
||||
<div className="min-w-0 max-w-64 space-y-1 text-sm">
|
||||
<div
|
||||
title={operation.operationLeader || undefined}
|
||||
className="truncate text-gray-900 dark:text-white"
|
||||
>
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">
|
||||
EL:
|
||||
</span>{' '}
|
||||
{operation.operationLeader || '-'}
|
||||
</div>
|
||||
|
||||
<div
|
||||
title={operation.operationTeam || undefined}
|
||||
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span className="font-medium">Team:</span>{' '}
|
||||
{operation.operationTeam || '-'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'addresses',
|
||||
header: 'Adressen',
|
||||
hideOnMobile: 'lg',
|
||||
sortable: true,
|
||||
sortValue: (operation) =>
|
||||
`${operation.kwAddress} ${operation.targetObject}`,
|
||||
render: (operation) => (
|
||||
<div className="min-w-0 max-w-md space-y-1 text-sm">
|
||||
<div
|
||||
title={operation.kwAddress || undefined}
|
||||
className="truncate text-gray-900 dark:text-white"
|
||||
>
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400">
|
||||
KW:
|
||||
</span>{' '}
|
||||
{formatShortAddress(operation.kwAddress)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
title={operation.targetObject || undefined}
|
||||
className="truncate text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span className="font-medium">ZO:</span>{' '}
|
||||
{formatShortAddress(operation.targetObject)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'equipmentAndRemark',
|
||||
header: 'Technik / Bemerkung',
|
||||
hideOnMobile: 'xl',
|
||||
sortable: true,
|
||||
sortValue: (operation) =>
|
||||
[
|
||||
operation.camera,
|
||||
operation.router,
|
||||
operation.technology,
|
||||
operation.switchbox,
|
||||
operation.hardDrive,
|
||||
operation.laptop,
|
||||
operation.remark,
|
||||
].join(' '),
|
||||
render: (operation) => (
|
||||
<div className="min-w-0 max-w-md">
|
||||
{renderEquipment(operation)}
|
||||
|
||||
{operation.remark ? (
|
||||
<p
|
||||
title={operation.remark}
|
||||
className="mt-2 line-clamp-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{operation.remark}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
Keine Bemerkung
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function renderCardValue(label: string, value: string, title?: string) {
|
||||
const sortedOperations = useMemo(() => {
|
||||
const sortColumn = columns.find((column) => column.key === sortKey)
|
||||
|
||||
if (!sortColumn?.sortable || !sortColumn.sortValue) {
|
||||
return operations
|
||||
}
|
||||
|
||||
return [...operations].sort((leftOperation, rightOperation) => {
|
||||
const result = compareSortValues(
|
||||
sortColumn.sortValue?.(leftOperation),
|
||||
sortColumn.sortValue?.(rightOperation),
|
||||
)
|
||||
|
||||
return sortDirection === 'asc' ? result : -result
|
||||
})
|
||||
}, [columns, operations, sortDirection, sortKey])
|
||||
|
||||
function renderCardField(
|
||||
label: string,
|
||||
value: string,
|
||||
title?: string,
|
||||
) {
|
||||
const displayValue = value.trim() || '-'
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
|
||||
<p className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<p
|
||||
title={title || value || undefined}
|
||||
className="mt-1 line-clamp-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{displayValue}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type IconComponent = ComponentType<{
|
||||
className?: string
|
||||
'aria-hidden'?: boolean
|
||||
}>
|
||||
|
||||
function CardSection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: IconComponent
|
||||
title: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
|
||||
<Icon aria-hidden className="size-4" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xs font-semibold tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CardInfo({
|
||||
label,
|
||||
value,
|
||||
title,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
title?: string
|
||||
}) {
|
||||
const displayValue = value.trim() || '-'
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</dt>
|
||||
|
||||
<dd
|
||||
title={title || value || undefined}
|
||||
className="mt-0.5 truncate text-xs text-gray-900 dark:text-white"
|
||||
className="mt-0.5 truncate text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
{value || '-'}
|
||||
{displayValue}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderCardEquipment(operation: Operation) {
|
||||
const equipment = getEquipmentItems(operation)
|
||||
|
||||
if (equipment.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
Keine Technik hinterlegt.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{equipment.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
title={`${item.label}: ${item.value}`}
|
||||
className="min-w-0 max-w-full rounded-md bg-gray-50 px-2.5 py-1.5 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10"
|
||||
>
|
||||
<span className="block text-[10px] font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
<span className="mt-0.5 block max-w-44 truncate text-xs font-medium text-gray-900 dark:text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderOperationCards() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -605,71 +864,116 @@ export default function OperationsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{operations.map((operation) => (
|
||||
<Card
|
||||
key={operation.id}
|
||||
variant="with-gray-header"
|
||||
headerVariant="with-description-action"
|
||||
headerTitle={renderOperationName(operation)}
|
||||
headerDescription={operation.operationNumber}
|
||||
headerAction={
|
||||
canWriteOperations ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={() => openEditModal(operation)}
|
||||
leadingIcon={<PencilSquareIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Bearbeiten
|
||||
<span className="sr-only">, {operation.operationNumber}</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
{renderCardValue('Delikt', operation.offense)}
|
||||
{renderCardValue('Sachbearbeitung', operation.caseWorker)}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{operations.map((operation) => {
|
||||
const operationName = operation.operationName || 'Ohne Einsatzname'
|
||||
const kwAddress = formatShortAddress(operation.kwAddress)
|
||||
const targetObject = formatShortAddress(operation.targetObject)
|
||||
|
||||
{renderCardValue(
|
||||
'Zielobjekt',
|
||||
formatShortAddress(operation.targetObject),
|
||||
operation.targetObject,
|
||||
)}
|
||||
{renderCardValue(
|
||||
'KW',
|
||||
formatShortAddress(operation.kwAddress),
|
||||
operation.kwAddress,
|
||||
)}
|
||||
return (
|
||||
<article
|
||||
key={operation.id}
|
||||
className="group overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 transition hover:-translate-y-0.5 hover:shadow-md dark:bg-gray-900 dark:ring-white/10"
|
||||
>
|
||||
<div className="border-b border-gray-200 bg-gradient-to-br from-indigo-50 via-white to-white p-4 dark:border-white/10 dark:from-indigo-500/10 dark:via-gray-900 dark:to-gray-900">
|
||||
<div className="flex items-start justify-between gap-x-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-xs font-semibold text-white shadow-xs dark:bg-indigo-500">
|
||||
{operation.operationNumber || '-'}
|
||||
</span>
|
||||
|
||||
{renderCardValue('Einsatzleiter', operation.operationLeader)}
|
||||
{renderCardValue('Team', operation.operationTeam)}
|
||||
{renderCardValue('Legende', operation.legend)}
|
||||
</dl>
|
||||
{hasUnreadJournal(operation) && (
|
||||
<NotificationDot
|
||||
title="Neue Journal-Einträge"
|
||||
label="Neue Journal-Einträge"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
Technik
|
||||
</dt>
|
||||
<dd className="mt-1.5">
|
||||
{renderEquipment(operation)}
|
||||
</dd>
|
||||
</div>
|
||||
<h2
|
||||
title={operationName}
|
||||
className="mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{operationName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{operation.remark && (
|
||||
<div className="mt-4">
|
||||
<dt className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
Bemerkung
|
||||
</dt>
|
||||
<dd className="mt-0.5 line-clamp-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
{operation.remark}
|
||||
</dd>
|
||||
{canWriteOperations && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={() => openEditModal(operation)}
|
||||
leadingIcon={
|
||||
<PencilSquareIcon aria-hidden="true" className="size-4" />
|
||||
}
|
||||
className="shrink-0 bg-white/80 dark:bg-white/10"
|
||||
>
|
||||
Bearbeiten
|
||||
<span className="sr-only">
|
||||
, {operation.operationNumber}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="space-y-5 p-4">
|
||||
<CardSection icon={UserIcon} title="Führung">
|
||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<CardInfo
|
||||
label="Einsatzleiter"
|
||||
value={operation.operationLeader}
|
||||
/>
|
||||
|
||||
<CardInfo
|
||||
label="Einsatzteam"
|
||||
value={operation.operationTeam}
|
||||
/>
|
||||
</dl>
|
||||
</CardSection>
|
||||
|
||||
<CardSection icon={MapPinIcon} title="Adressen">
|
||||
<dl className="space-y-3">
|
||||
<CardInfo
|
||||
label="KW-Adresse"
|
||||
value={kwAddress}
|
||||
title={operation.kwAddress}
|
||||
/>
|
||||
|
||||
<CardInfo
|
||||
label="Zielobjekt-Adresse"
|
||||
value={targetObject}
|
||||
title={operation.targetObject}
|
||||
/>
|
||||
</dl>
|
||||
</CardSection>
|
||||
|
||||
<CardSection icon={WrenchScrewdriverIcon} title="Technik">
|
||||
{renderCardEquipment(operation)}
|
||||
</CardSection>
|
||||
|
||||
<CardSection icon={ChatBubbleLeftEllipsisIcon} title="Bemerkung">
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2 ring-1 ring-gray-200 ring-inset dark:bg-white/5 dark:ring-white/10">
|
||||
{operation.remark ? (
|
||||
<p
|
||||
title={operation.remark}
|
||||
className="line-clamp-3 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{operation.remark}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
Keine Bemerkung hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -680,8 +984,8 @@ export default function OperationsPage({
|
||||
<div
|
||||
className={
|
||||
isMapView
|
||||
? 'flex h-[calc(100dvh-4rem)] flex-col overflow-hidden pt-10'
|
||||
: 'px-4 py-10 sm:px-6 lg:px-8'
|
||||
? 'flex h-full min-h-0 flex-col overflow-hidden pt-10'
|
||||
: 'h-full min-h-0 overflow-y-auto px-4 py-10 sm:px-6 lg:px-8'
|
||||
}
|
||||
>
|
||||
{(error || (view === 'heatmap' && heatmapError)) && (
|
||||
@ -748,9 +1052,11 @@ export default function OperationsPage({
|
||||
{canWriteOperations && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
color="indigo"
|
||||
size="md"
|
||||
onClick={openCreateModal}
|
||||
leadingIcon={<PlusIcon aria-hidden="true" className="size-4" />}
|
||||
>
|
||||
Einsatz hinzufügen
|
||||
</Button>
|
||||
@ -760,10 +1066,13 @@ export default function OperationsPage({
|
||||
|
||||
{view === 'table' ? (
|
||||
<Tables
|
||||
rows={operations}
|
||||
rows={sortedOperations}
|
||||
columns={columns}
|
||||
getRowId={(operation) => operation.id}
|
||||
variant="card"
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
emptyText="Keine Einsätze vorhanden."
|
||||
isLoading={isLoading}
|
||||
loadingContent={
|
||||
|
||||
@ -1,831 +0,0 @@
|
||||
// frontend\src\pages\settings\Profile.tsx
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import Button from '../../components/Button'
|
||||
import Container from '../../components/Container'
|
||||
import type { User } from '../../components/types'
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type ProfileProps = {
|
||||
user?: User
|
||||
onUserUpdated?: (user: User) => void
|
||||
}
|
||||
|
||||
type UserSession = {
|
||||
id: string
|
||||
browser: string
|
||||
operatingSystem: string
|
||||
deviceType: string
|
||||
userAgent: string
|
||||
ipAddress: string
|
||||
createdAt: string
|
||||
lastSeenAt: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Container variant="constrained" className="py-16">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function Message({
|
||||
type,
|
||||
children,
|
||||
}: {
|
||||
type: 'success' | 'error'
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
type === 'success'
|
||||
? 'mt-6 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'mt-6 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
export default function Profile({
|
||||
user,
|
||||
onUserUpdated,
|
||||
}: ProfileProps) {
|
||||
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
|
||||
const [sessions, setSessions] = useState<UserSession[]>([])
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
|
||||
const [sessionsError, setSessionsError] = useState<string | null>(null)
|
||||
const [revokeSessionError, setRevokeSessionError] = useState<string | null>(null)
|
||||
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||||
|
||||
const [profileError, setProfileError] = useState<string | null>(null)
|
||||
const [profileSuccess, setProfileSuccess] = useState<string | null>(null)
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false)
|
||||
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null)
|
||||
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null)
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false)
|
||||
|
||||
const [logoutSessionsError, setLogoutSessionsError] = useState<string | null>(null)
|
||||
const [logoutSessionsSuccess, setLogoutSessionsSuccess] = useState<string | null>(null)
|
||||
const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false)
|
||||
|
||||
const [deleteAccountError, setDeleteAccountError] = useState<string | null>(null)
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUser(user)
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions()
|
||||
}, [])
|
||||
|
||||
const avatarUrl =
|
||||
currentUser?.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
async function loadSessions() {
|
||||
setIsLoadingSessions(true)
|
||||
setSessionsError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/sessions`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Sitzungen konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSessions(data.sessions ?? [])
|
||||
} catch (error) {
|
||||
setSessionsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Sitzungen konnten nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeSession(sessionId: string) {
|
||||
setRevokingSessionId(sessionId)
|
||||
setRevokeSessionError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'),
|
||||
)
|
||||
}
|
||||
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
setRevokeSessionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Sitzung konnte nicht abgemeldet werden',
|
||||
)
|
||||
} finally {
|
||||
setRevokingSessionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSavingProfile(true)
|
||||
setProfileError(null)
|
||||
setProfileSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
displayName: String(formData.get('displayName') ?? ''),
|
||||
username: String(formData.get('username') ?? ''),
|
||||
email: String(formData.get('email') ?? ''),
|
||||
avatar: String(formData.get('avatar') ?? ''),
|
||||
unit: String(formData.get('unit') ?? ''),
|
||||
group: String(formData.get('group') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Profil konnte nicht gespeichert werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
const nextUser = data?.user as User | undefined
|
||||
|
||||
if (nextUser) {
|
||||
setCurrentUser(nextUser)
|
||||
onUserUpdated?.(nextUser)
|
||||
}
|
||||
|
||||
setProfileSuccess('Profil wurde gespeichert.')
|
||||
} catch (error) {
|
||||
setProfileError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Profil konnte nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSavingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSavingPassword(true)
|
||||
setPasswordError(null)
|
||||
setPasswordSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/password`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
currentPassword: String(formData.get('currentPassword') ?? ''),
|
||||
newPassword: String(formData.get('newPassword') ?? ''),
|
||||
confirmPassword: String(formData.get('confirmPassword') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Passwort konnte nicht gespeichert werden'),
|
||||
)
|
||||
}
|
||||
|
||||
form.reset()
|
||||
setPasswordSuccess('Passwort wurde gespeichert.')
|
||||
} catch (error) {
|
||||
setPasswordError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Passwort konnte nicht gespeichert werden',
|
||||
)
|
||||
} finally {
|
||||
setIsSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoutSessionsSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsLoggingOutSessions(true)
|
||||
setLogoutSessionsError(null)
|
||||
setLogoutSessionsSuccess(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/logout-other-sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
password: String(formData.get('password') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Andere Sitzungen konnten nicht abgemeldet werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
form.reset()
|
||||
setLogoutSessionsSuccess('Andere Sitzungen wurden abgemeldet.')
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
setLogoutSessionsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Andere Sitzungen konnten nicht abgemeldet werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoggingOutSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAccountSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeletingAccount(true)
|
||||
setDeleteAccountError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/account`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
password: String(formData.get('password') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Konto konnte nicht gelöscht werden'),
|
||||
)
|
||||
}
|
||||
|
||||
window.location.href = '/login'
|
||||
} catch (error) {
|
||||
setDeleteAccountError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Konto konnte nicht gelöscht werden',
|
||||
)
|
||||
} finally {
|
||||
setIsDeletingAccount(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Persönliche Informationen"
|
||||
description="Verwalte deine Profildaten und Kontoinformationen."
|
||||
>
|
||||
<form onSubmit={handleProfileSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
type="url"
|
||||
defaultValue={currentUser?.avatar ?? ''}
|
||||
placeholder="https://..."
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Gib eine Bild-URL für dein Profilbild an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="displayName"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
defaultValue={currentUser?.displayName ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benutzername
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={currentUser?.username ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
defaultValue={currentUser?.email ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="unit"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Einheit
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="unit"
|
||||
name="unit"
|
||||
type="text"
|
||||
defaultValue={currentUser?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="group"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Gruppe
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="group"
|
||||
name="group"
|
||||
type="text"
|
||||
defaultValue={currentUser?.group ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileError && (
|
||||
<Message type="error">
|
||||
{profileError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{profileSuccess && (
|
||||
<Message type="success">
|
||||
{profileSuccess}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isSavingProfile}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Passwort ändern"
|
||||
description="Aktualisiere das Passwort deines Benutzerkontos."
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="current-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="current-password"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="new-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Neues Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="new-password"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordError && (
|
||||
<Message type="error">
|
||||
{passwordError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Message type="success">
|
||||
{passwordSuccess}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isSavingPassword}
|
||||
>
|
||||
Passwort speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Sitzungen"
|
||||
description="Sieh dir deine aktiven Sitzungen an und melde andere Browser ab."
|
||||
>
|
||||
<div className="sm:max-w-2xl">
|
||||
{isLoadingSessions && (
|
||||
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Sitzungen werden geladen...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionsError && (
|
||||
<Message type="error">
|
||||
{sessionsError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{revokeSessionError && (
|
||||
<Message type="error">
|
||||
{revokeSessionError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && !sessionsError && sessions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
<ComputerDesktopIcon
|
||||
aria-hidden="true"
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{session.browser} auf {session.operatingSystem}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{session.deviceType}
|
||||
{session.ipAddress ? ` · ${session.ipAddress}` : ''}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Zuletzt aktiv:{' '}
|
||||
{new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(session.lastSeenAt))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session.current ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-x-1.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30">
|
||||
<ShieldCheckIcon
|
||||
aria-hidden="true"
|
||||
className="size-3.5"
|
||||
/>
|
||||
Aktiv
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
size="sm"
|
||||
isLoading={revokingSessionId === session.id}
|
||||
onClick={() => void handleRevokeSession(session.id)}
|
||||
>
|
||||
Abmelden
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
|
||||
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Keine aktiven Sitzungen gefunden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleLogoutSessionsSubmit}
|
||||
className="mt-8 border-t border-gray-200 pt-6 dark:border-white/10"
|
||||
>
|
||||
<label
|
||||
htmlFor="logout-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Dein Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="logout-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.
|
||||
</p>
|
||||
|
||||
{logoutSessionsError && (
|
||||
<Message type="error">
|
||||
{logoutSessionsError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{logoutSessionsSuccess && (
|
||||
<Message type="success">
|
||||
{logoutSessionsSuccess}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isLoggingOutSessions}
|
||||
>
|
||||
Andere Sitzungen abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Konto löschen"
|
||||
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
|
||||
>
|
||||
<form onSubmit={handleDeleteAccountSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="delete-account-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Dein Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="delete-account-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteAccountError && (
|
||||
<Message type="error">
|
||||
{deleteAccountError}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="red"
|
||||
size="md"
|
||||
isLoading={isDeletingAccount}
|
||||
>
|
||||
Konto löschen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -12,7 +12,8 @@ import {
|
||||
import Tabs from '../../components/Tabs'
|
||||
import Container from '../../components/Container'
|
||||
import type { User } from '../../components/types'
|
||||
import Profile from './Profile'
|
||||
import Konto from './konto/Konto'
|
||||
import Benachrichtigungen from './benachrichtigungen/Benachrichtigungen'
|
||||
|
||||
type SettingsPageProps = {
|
||||
user?: User
|
||||
@ -74,9 +75,9 @@ export default function SettingsPage({
|
||||
const settingsTabs = [
|
||||
{
|
||||
name: 'Konto',
|
||||
href: '/einstellungen',
|
||||
href: '/einstellungen/konto',
|
||||
icon: UserIcon,
|
||||
current: pathname === '/einstellungen',
|
||||
current: pathname === '/einstellungen/konto',
|
||||
},
|
||||
{
|
||||
name: 'Benachrichtigungen',
|
||||
@ -105,40 +106,42 @@ export default function SettingsPage({
|
||||
]
|
||||
|
||||
return (
|
||||
<main>
|
||||
<main className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<h1 className="sr-only">Einstellungen</h1>
|
||||
|
||||
<Tabs
|
||||
tabs={settingsTabs}
|
||||
variant="underline-icons"
|
||||
align="center"
|
||||
sticky
|
||||
stickyTopClassName="top-0"
|
||||
ariaLabel="Einstellungen"
|
||||
/>
|
||||
|
||||
{section === 'konto' && (
|
||||
<Profile
|
||||
user={user}
|
||||
onUserUpdated={onUserUpdated}
|
||||
<div className="shrink-0 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
||||
<Tabs
|
||||
tabs={settingsTabs}
|
||||
variant="underline-icons"
|
||||
align="center"
|
||||
ariaLabel="Einstellungen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{section === 'benachrichtigungen' && (
|
||||
<PlaceholderSection title="Benachrichtigungen" />
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
{section === 'konto' && (
|
||||
<Konto
|
||||
user={user}
|
||||
onUserUpdated={onUserUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === 'abrechnung' && (
|
||||
<PlaceholderSection title="Abrechnung" />
|
||||
)}
|
||||
{section === 'benachrichtigungen' && (
|
||||
<Benachrichtigungen />
|
||||
)}
|
||||
|
||||
{section === 'teams' && (
|
||||
<PlaceholderSection title="Teams" />
|
||||
)}
|
||||
{section === 'abrechnung' && (
|
||||
<PlaceholderSection title="Abrechnung" />
|
||||
)}
|
||||
|
||||
{section === 'integrationen' && (
|
||||
<PlaceholderSection title="Integrationen" />
|
||||
)}
|
||||
{section === 'teams' && (
|
||||
<PlaceholderSection title="Teams" />
|
||||
)}
|
||||
|
||||
{section === 'integrationen' && (
|
||||
<PlaceholderSection title="Integrationen" />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,409 @@
|
||||
// frontend/src/pages/settings/benachrichtigungen/Benachrichtigungen.tsx
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
BellIcon,
|
||||
EnvelopeIcon,
|
||||
SpeakerWaveIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Container from '../../../components/Container'
|
||||
import LoadingSpinner from '../../../components/LoadingSpinner'
|
||||
import Switch from '../../../components/Switch'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type NotificationSettings = {
|
||||
inAppEnabled: boolean
|
||||
soundEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
operationUpdates: boolean
|
||||
operationJournals: boolean
|
||||
deviceUpdates: boolean
|
||||
deviceJournals: boolean
|
||||
systemMessages: boolean
|
||||
}
|
||||
|
||||
const defaultSettings: NotificationSettings = {
|
||||
inAppEnabled: true,
|
||||
soundEnabled: false,
|
||||
emailEnabled: false,
|
||||
operationUpdates: true,
|
||||
operationJournals: true,
|
||||
deviceUpdates: true,
|
||||
deviceJournals: true,
|
||||
systemMessages: true,
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Container variant="constrained" className="py-16">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function Message({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
function NotificationCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Benachrichtigungen() {
|
||||
const [settings, setSettings] =
|
||||
useState<NotificationSettings>(defaultSettings)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
||||
|
||||
const settingsRef = useRef<NotificationSettings>(defaultSettings)
|
||||
const saveRequestIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [])
|
||||
|
||||
function dismissToast(id: string) {
|
||||
setToasts((currentToasts) =>
|
||||
currentToasts.filter((toast) => toast.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
||||
setToasts((currentToasts) => [
|
||||
...currentToasts,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
...notification,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const nextSettings = {
|
||||
...defaultSettings,
|
||||
...(data.settings ?? {}),
|
||||
}
|
||||
|
||||
settingsRef.current = nextSettings
|
||||
setSettings(nextSettings)
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht geladen werden',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSetting<Key extends keyof NotificationSettings>(
|
||||
key: Key,
|
||||
value: NotificationSettings[Key],
|
||||
) {
|
||||
const nextSettings = {
|
||||
...settingsRef.current,
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
settingsRef.current = nextSettings
|
||||
setSettings(nextSettings)
|
||||
|
||||
void saveSettings(nextSettings)
|
||||
}
|
||||
|
||||
async function saveSettings(nextSettings: NotificationSettings) {
|
||||
const requestId = saveRequestIdRef.current + 1
|
||||
saveRequestIdRef.current = requestId
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/notifications/preferences`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(nextSettings),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.settings) {
|
||||
const savedSettings = {
|
||||
...defaultSettings,
|
||||
...data.settings,
|
||||
}
|
||||
|
||||
settingsRef.current = savedSettings
|
||||
setSettings(savedSettings)
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: 'Einstellungen gespeichert',
|
||||
message: 'Deine Benachrichtigungseinstellungen wurden aktualisiert.',
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden',
|
||||
)
|
||||
|
||||
showToast({
|
||||
title: 'Speichern fehlgeschlagen',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Section
|
||||
title="Benachrichtigungen"
|
||||
description="Lege fest, welche Hinweise du erhalten möchtest."
|
||||
>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<LoadingSpinner
|
||||
size="md"
|
||||
label="Benachrichtigungseinstellungen werden geladen..."
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={toasts}
|
||||
onDismiss={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Benachrichtigungen"
|
||||
description="Lege fest, welche Hinweise du in der Anwendung erhalten möchtest."
|
||||
>
|
||||
<div className="space-y-6 sm:max-w-2xl">
|
||||
{error && (
|
||||
<Message>
|
||||
{error}
|
||||
</Message>
|
||||
)}
|
||||
|
||||
<NotificationCard
|
||||
icon={<BellIcon aria-hidden="true" className="size-5" />}
|
||||
title="In-App-Benachrichtigungen"
|
||||
description="Steuere Hinweise, die direkt in der Anwendung angezeigt werden."
|
||||
>
|
||||
<Switch
|
||||
checked={settings.inAppEnabled}
|
||||
onChange={(event) =>
|
||||
updateSetting('inAppEnabled', event.target.checked)
|
||||
}
|
||||
label="Benachrichtigungen anzeigen"
|
||||
description="Aktiviert Hinweise in der Topbar und im Benachrichtigungscenter."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.soundEnabled}
|
||||
onChange={(event) =>
|
||||
updateSetting('soundEnabled', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled}
|
||||
label="Signalton abspielen"
|
||||
description="Spielt einen kurzen Ton bei neuen Benachrichtigungen ab."
|
||||
/>
|
||||
</NotificationCard>
|
||||
|
||||
<NotificationCard
|
||||
icon={<SpeakerWaveIcon aria-hidden="true" className="size-5" />}
|
||||
title="Ereignisse"
|
||||
description="Wähle aus, bei welchen Ereignissen du benachrichtigt werden möchtest."
|
||||
>
|
||||
<Switch
|
||||
checked={settings.operationUpdates}
|
||||
onChange={(event) =>
|
||||
updateSetting('operationUpdates', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
||||
label="Einsatzänderungen"
|
||||
description="Benachrichtigt dich, wenn Einsätze erstellt oder geändert werden."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.operationJournals}
|
||||
onChange={(event) =>
|
||||
updateSetting('operationJournals', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
||||
label="Einsatz-Journal"
|
||||
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Einsätzen."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.deviceUpdates}
|
||||
onChange={(event) =>
|
||||
updateSetting('deviceUpdates', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
||||
label="Geräteänderungen"
|
||||
description="Benachrichtigt dich, wenn Geräte erstellt oder geändert werden."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.deviceJournals}
|
||||
onChange={(event) =>
|
||||
updateSetting('deviceJournals', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
||||
label="Geräte-Journal"
|
||||
description="Benachrichtigt dich bei neuen Journal-Einträgen zu Geräten."
|
||||
/>
|
||||
|
||||
<Switch
|
||||
checked={settings.systemMessages}
|
||||
onChange={(event) =>
|
||||
updateSetting('systemMessages', event.target.checked)
|
||||
}
|
||||
disabled={!settings.inAppEnabled && !settings.emailEnabled}
|
||||
label="Systemmeldungen"
|
||||
description="Benachrichtigt dich bei wichtigen Systemhinweisen."
|
||||
/>
|
||||
</NotificationCard>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
804
frontend/src/pages/settings/konto/Konto.tsx
Normal file
804
frontend/src/pages/settings/konto/Konto.tsx
Normal file
@ -0,0 +1,804 @@
|
||||
// frontend\src\pages\settings\konto\Konto.tsx
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import Button from '../../../components/Button'
|
||||
import Container from '../../../components/Container'
|
||||
import type { User } from '../../../components/types'
|
||||
import {
|
||||
ComputerDesktopIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/20/solid'
|
||||
import Notifications, {
|
||||
type ToastNotification,
|
||||
} from '../../../components/Notifications'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
type KontoProps = {
|
||||
user?: User
|
||||
onUserUpdated?: (user: User) => void
|
||||
}
|
||||
|
||||
type UserSession = {
|
||||
id: string
|
||||
browser: string
|
||||
operatingSystem: string
|
||||
deviceType: string
|
||||
userAgent: string
|
||||
ipAddress: string
|
||||
createdAt: string
|
||||
lastSeenAt: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Container variant="constrained" className="py-16">
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
async function readApiError(response: Response, fallback: string) {
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
return data?.error ?? fallback
|
||||
}
|
||||
|
||||
export default function Konto({
|
||||
user,
|
||||
onUserUpdated,
|
||||
}: KontoProps) {
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([])
|
||||
const [currentUser, setCurrentUser] = useState<User | undefined>(user)
|
||||
const [sessions, setSessions] = useState<UserSession[]>([])
|
||||
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
|
||||
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||||
const [isSavingKonto, setIsSavingKonto] = useState(false)
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false)
|
||||
const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false)
|
||||
const [isDeletingKonto, setIsDeletingKonto] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUser(user)
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions()
|
||||
}, [])
|
||||
|
||||
const avatarUrl =
|
||||
currentUser?.avatar ||
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80'
|
||||
|
||||
function dismissToast(id: string) {
|
||||
setToasts((currentToasts) =>
|
||||
currentToasts.filter((toast) => toast.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
function showToast(notification: Omit<ToastNotification, 'id'>) {
|
||||
setToasts((currentToasts) => [
|
||||
...currentToasts,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
...notification,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
setIsLoadingSessions(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/sessions`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Sitzungen konnten nicht geladen werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSessions(data.sessions ?? [])
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Sitzungen konnten nicht geladen werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Sitzungen konnten nicht geladen werden',
|
||||
variant: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeSession(sessionId: string) {
|
||||
setRevokingSessionId(sessionId)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'),
|
||||
)
|
||||
}
|
||||
|
||||
await loadSessions()
|
||||
showToast({
|
||||
title: 'Sitzung abgemeldet',
|
||||
message: 'Die ausgewählte Sitzung wurde abgemeldet.',
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Sitzung konnte nicht abgemeldet werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Sitzung konnte nicht abgemeldet werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setRevokingSessionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKontoSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSavingKonto(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/account`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
displayName: String(formData.get('displayName') ?? ''),
|
||||
username: String(formData.get('username') ?? ''),
|
||||
email: String(formData.get('email') ?? ''),
|
||||
avatar: String(formData.get('avatar') ?? ''),
|
||||
unit: String(formData.get('unit') ?? ''),
|
||||
group: String(formData.get('group') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Profil konnte nicht gespeichert werden'),
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
const nextUser = data?.user as User | undefined
|
||||
|
||||
if (nextUser) {
|
||||
setCurrentUser(nextUser)
|
||||
onUserUpdated?.(nextUser)
|
||||
}
|
||||
|
||||
showToast({
|
||||
title: 'Profil gespeichert',
|
||||
message: 'Deine Profildaten wurden gespeichert.',
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Profil konnte nicht gespeichert werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Profil konnte nicht gespeichert werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsSavingKonto(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsSavingPassword(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/password`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
currentPassword: String(formData.get('currentPassword') ?? ''),
|
||||
newPassword: String(formData.get('newPassword') ?? ''),
|
||||
confirmPassword: String(formData.get('confirmPassword') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Passwort konnte nicht gespeichert werden'),
|
||||
)
|
||||
}
|
||||
|
||||
form.reset()
|
||||
showToast({
|
||||
title: 'Passwort gespeichert',
|
||||
message: 'Dein Passwort wurde erfolgreich geändert.',
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Passwort konnte nicht gespeichert werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Passwort konnte nicht gespeichert werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsSavingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoutSessionsSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setIsLoggingOutSessions(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/logout-other-sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
password: String(formData.get('password') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(
|
||||
response,
|
||||
'Andere Sitzungen konnten nicht abgemeldet werden',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
form.reset()
|
||||
showToast({
|
||||
title: 'Andere Sitzungen abgemeldet',
|
||||
message: 'Alle anderen aktiven Sitzungen wurden abgemeldet.',
|
||||
variant: 'success',
|
||||
})
|
||||
await loadSessions()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Andere Sitzungen konnten nicht abgemeldet werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Andere Sitzungen konnten nicht abgemeldet werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsLoggingOutSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKontoSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeletingKonto(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/settings/account`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
password: String(formData.get('password') ?? ''),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readApiError(response, 'Konto konnte nicht gelöscht werden'),
|
||||
)
|
||||
}
|
||||
|
||||
window.location.href = '/login'
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: 'Konto konnte nicht gelöscht werden',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Konto konnte nicht gelöscht werden',
|
||||
variant: 'error',
|
||||
autoClose: false,
|
||||
})
|
||||
} finally {
|
||||
setIsDeletingKonto(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Notifications
|
||||
notifications={toasts}
|
||||
onDismiss={dismissToast}
|
||||
position="top-right"
|
||||
/>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<Section
|
||||
title="Persönliche Informationen"
|
||||
description="Verwalte deine Profildaten und Kontoinformationen."
|
||||
>
|
||||
<form onSubmit={handleKontoSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full flex items-center gap-x-8">
|
||||
<img
|
||||
alt=""
|
||||
src={avatarUrl}
|
||||
className="size-24 flex-none rounded-lg bg-gray-100 object-cover outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
type="url"
|
||||
defaultValue={currentUser?.avatar ?? ''}
|
||||
placeholder="https://..."
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Gib eine Bild-URL für dein Profilbild an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="displayName"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
defaultValue={currentUser?.displayName ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Benutzername
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
defaultValue={currentUser?.username ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
defaultValue={currentUser?.email ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="unit"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Einheit
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="unit"
|
||||
name="unit"
|
||||
type="text"
|
||||
defaultValue={currentUser?.unit ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label
|
||||
htmlFor="group"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Gruppe
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="group"
|
||||
name="group"
|
||||
type="text"
|
||||
defaultValue={currentUser?.group ?? ''}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isSavingKonto}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Passwort ändern"
|
||||
description="Aktualisiere das Passwort deines Benutzerkontos."
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="current-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="current-password"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="new-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Neues Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="new-password"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="confirm-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Neues Passwort bestätigen
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isSavingPassword}
|
||||
>
|
||||
Passwort speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Sitzungen"
|
||||
description="Sieh dir deine aktiven Sitzungen an und melde andere Browser ab."
|
||||
>
|
||||
<div className="sm:max-w-2xl">
|
||||
{isLoadingSessions && (
|
||||
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Sitzungen werden geladen...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && sessions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div className="flex items-start gap-x-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
|
||||
<ComputerDesktopIcon
|
||||
aria-hidden="true"
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-x-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
|
||||
</h3>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{session.browser} auf {session.operatingSystem}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{session.deviceType}
|
||||
{session.ipAddress ? ` · ${session.ipAddress}` : ''}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Zuletzt aktiv:{' '}
|
||||
{new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(session.lastSeenAt))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session.current ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-x-1.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30">
|
||||
<ShieldCheckIcon
|
||||
aria-hidden="true"
|
||||
className="size-3.5"
|
||||
/>
|
||||
Aktiv
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
size="sm"
|
||||
isLoading={revokingSessionId === session.id}
|
||||
onClick={() => void handleRevokeSession(session.id)}
|
||||
>
|
||||
Abmelden
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && sessions.length === 0 && (
|
||||
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
Keine aktiven Sitzungen gefunden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleLogoutSessionsSubmit}
|
||||
className="mt-8 border-t border-gray-200 pt-6 dark:border-white/10"
|
||||
>
|
||||
<label
|
||||
htmlFor="logout-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Dein Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="logout-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="indigo"
|
||||
size="md"
|
||||
isLoading={isLoggingOutSessions}
|
||||
>
|
||||
Andere Sitzungen abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Konto löschen"
|
||||
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
|
||||
>
|
||||
<form onSubmit={handleDeleteKontoSubmit}>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
|
||||
<div className="col-span-full">
|
||||
<label
|
||||
htmlFor="delete-konto-password"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Dein Passwort
|
||||
</label>
|
||||
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="delete-konto-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex">
|
||||
<Button
|
||||
type="submit"
|
||||
color="red"
|
||||
size="md"
|
||||
isLoading={isDeletingKonto}
|
||||
>
|
||||
Konto löschen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user