This commit is contained in:
Linrador 2026-06-11 15:02:19 +02:00
parent dd387a884c
commit 1abfc72e3a
17 changed files with 6313 additions and 1307 deletions

View File

@ -4,14 +4,14 @@
// //
// Endpunkte: // Endpunkte:
// GET /health // GET /health
// GET /server-folders?rootType=storage&path=D:\Milestone // GET /server-folders?rootType=storage&path=D:\Milestone
// POST /server-folders // POST /server-folders
// PATCH /server-folders
// DELETE /server-folders
// //
// Start per Parameter: // Start per Parameter:
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME" // server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"
// //
// Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden.
//
// Alternativ per ENV: // Alternativ per ENV:
// LISTEN_ADDR=:8099 // LISTEN_ADDR=:8099
// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage // SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage
@ -38,9 +38,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"syscall"
"time" "time"
"unsafe"
) )
const ( const (
@ -82,7 +80,6 @@ type serverFolderRoot struct {
type serverFolderNode struct { type serverFolderNode struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Path string `json:"path"` Path string `json:"path"`
HasChildren bool `json:"hasChildren"` HasChildren bool `json:"hasChildren"`
} }
@ -101,6 +98,11 @@ type createServerFolderRequest struct {
Name string `json:"name"` Name string `json:"name"`
} }
type renameServerFolderRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
func main() { func main() {
config := readConfigFromFlagsAndEnv() config := readConfigFromFlagsAndEnv()
@ -113,6 +115,12 @@ func main() {
mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
handleCreateServerFolder(w, r, config) handleCreateServerFolder(w, r, config)
})) }))
mux.HandleFunc("PATCH /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
handleRenameServerFolder(w, r, config)
}))
mux.HandleFunc("DELETE /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) {
handleDeleteServerFolder(w, r, config)
}))
server := &http.Server{ server := &http.Server{
Addr: config.ListenAddr, Addr: config.ListenAddr,
@ -136,7 +144,7 @@ func readConfigFromFlagsAndEnv() appConfig {
legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone") legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone")
storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage") storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage")
archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive") archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive")
tokenFlag := flag.String("token", "", "Required access token for X-Server-Folders-Token") tokenFlag := flag.String("token", "", "Optional access token for X-Server-Folders-Token")
flag.Parse() flag.Parse()
listenAddr := strings.TrimSpace(*addrFlag) listenAddr := strings.TrimSpace(*addrFlag)
@ -163,56 +171,29 @@ func readConfigFromFlagsAndEnv() appConfig {
if storageRoots == "" { if storageRoots == "" {
storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv)) storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv))
} }
if storageRoots == "" {
storageRoots = legacyRoots
}
archiveRoots := strings.TrimSpace(*archiveRootsFlag) archiveRoots := strings.TrimSpace(*archiveRootsFlag)
if archiveRoots == "" { if archiveRoots == "" {
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv)) archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
} }
if archiveRoots == "" {
archiveRoots = legacyRoots
}
token := strings.TrimSpace(*tokenFlag) token := strings.TrimSpace(*tokenFlag)
if token == "" { if token == "" {
token = strings.TrimSpace(os.Getenv(agentTokenEnv)) token = strings.TrimSpace(os.Getenv(agentTokenEnv))
} }
config := appConfig{ return appConfig{
ListenAddr: listenAddr, ListenAddr: listenAddr,
StorageRoots: storageRoots, StorageRoots: storageRoots,
ArchiveRoots: archiveRoots, ArchiveRoots: archiveRoots,
Token: token, Token: token,
} }
validateRequiredConfig(config, legacyRoots)
return config
}
func validateRequiredConfig(config appConfig, legacyRoots string) {
missing := make([]string, 0, 3)
if strings.TrimSpace(config.StorageRoots) == "" {
missing = append(missing, "-storage-root oder "+serverStorageRootsEnv)
}
if strings.TrimSpace(config.ArchiveRoots) == "" {
missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv)
}
if strings.TrimSpace(config.Token) == "" {
missing = append(missing, "-token oder "+agentTokenEnv)
}
if len(missing) == 0 {
return
}
if strings.TrimSpace(legacyRoots) != "" {
log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv)
}
log.Fatalf(
`Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`,
strings.Join(missing, ", "),
)
} }
func handleHealth(w http.ResponseWriter, r *http.Request) { func handleHealth(w http.ResponseWriter, r *http.Request) {
@ -301,11 +282,8 @@ func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appC
continue continue
} }
fileSystemName := entry.Name()
folders = append(folders, serverFolderNode{ folders = append(folders, serverFolderNode{
Name: fileSystemName, Name: entry.Name(),
DisplayName: windowsDisplayNameForPath(childPath, fileSystemName),
Path: childPath, Path: childPath,
HasChildren: folderHasVisibleSubdirectories(childPath, roots), HasChildren: folderHasVisibleSubdirectories(childPath, roots),
}) })
@ -392,13 +370,211 @@ func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config app
"ok": true, "ok": true,
"folder": serverFolderNode{ "folder": serverFolderNode{
Name: filepath.Base(targetPath), Name: filepath.Base(targetPath),
DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)),
Path: targetPath, Path: targetPath,
HasChildren: false, HasChildren: false,
}, },
}) })
} }
func handleRenameServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var input renameServerFolderRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
sourcePath, err := resolveExistingPath(input.Path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(sourcePath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
if isConfiguredRootPath(sourcePath, roots) {
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht umbenannt werden")
return
}
info, err := os.Stat(sourcePath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
nextName := strings.TrimSpace(input.Name)
if !isSafeSingleFolderName(nextName) {
writeError(w, http.StatusBadRequest, "Neuer Ordnername ist ungültig")
return
}
parentPath := filepath.Dir(sourcePath)
targetPath, err := cleanAbsolutePath(filepath.Join(parentPath, nextName))
if err != nil {
writeError(w, http.StatusBadRequest, "Neuer Zielpfad ist ungültig")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Neuer Zielpfad ist nicht freigegeben")
return
}
if strings.EqualFold(sourcePath, targetPath) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(sourcePath),
Path: sourcePath,
HasChildren: folderHasVisibleSubdirectories(sourcePath, roots),
},
})
return
}
if _, err := os.Stat(targetPath); err == nil {
writeError(w, http.StatusConflict, "Zielordner existiert bereits")
return
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusBadRequest, "Zielordner konnte nicht geprüft werden")
return
}
if err := os.Rename(sourcePath, targetPath); err != nil {
writeError(w, http.StatusForbidden, "Ordner konnte nicht umbenannt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(targetPath),
Path: targetPath,
HasChildren: folderHasVisibleSubdirectories(targetPath, roots),
},
})
}
func handleDeleteServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
if requestedPath == "" && r.Body != nil {
var input struct {
Path string `json:"path"`
}
if err := readJSON(r, &input); err == nil {
requestedPath = strings.TrimSpace(input.Path)
}
}
if requestedPath == "" {
writeError(w, http.StatusBadRequest, "Pfad fehlt")
return
}
targetPath, err := resolveExistingPath(requestedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
if isConfiguredRootPath(targetPath, roots) {
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht gelöscht werden")
return
}
info, err := os.Stat(targetPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
if err := os.Remove(targetPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"deleted": false,
"path": targetPath,
})
return
}
writeError(w, http.StatusConflict, "Ordner konnte nicht gelöscht werden. Er ist vermutlich nicht leer.")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"deleted": true,
"path": targetPath,
})
}
func isConfiguredRootPath(pathValue string, roots []serverFolderRoot) bool {
pathValue, err := cleanAbsolutePath(pathValue)
if err != nil {
return false
}
for _, root := range roots {
rootPath, err := cleanAbsolutePath(root.Path)
if err != nil {
continue
}
if strings.EqualFold(pathValue, rootPath) {
return true
}
}
return false
}
func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType { func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType"))) value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType")))
if value == "" { if value == "" {
@ -595,57 +771,6 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot)
return false return false
} }
const (
shgfiDisplayName = 0x000000200
)
type shFileInfo struct {
HIcon uintptr
IIcon int32
DwAttributes uint32
SzDisplayName [260]uint16
SzTypeName [80]uint16
}
var (
shell32DLL = syscall.NewLazyDLL("shell32.dll")
procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW")
)
func windowsDisplayNameForPath(pathValue string, fallback string) string {
pathValue = strings.TrimSpace(pathValue)
fallback = strings.TrimSpace(fallback)
if pathValue == "" {
return fallback
}
pathPointer, err := syscall.UTF16PtrFromString(pathValue)
if err != nil {
return fallback
}
var info shFileInfo
result, _, _ := procSHGetFileInfo.Call(
uintptr(unsafe.Pointer(pathPointer)),
0,
uintptr(unsafe.Pointer(&info)),
unsafe.Sizeof(info),
shgfiDisplayName,
)
if result == 0 {
return fallback
}
displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:]))
if displayName == "" {
return fallback
}
return displayName
}
func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc { func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc {
requiredToken = strings.TrimSpace(requiredToken) requiredToken = strings.TrimSpace(requiredToken)

View File

@ -1017,15 +1017,6 @@ func getMilestoneNamedDevice(
return envelope.Data, nil return envelope.Data, nil
} }
func milestoneStringValue(value any) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func milestoneBoolValue(value any) bool { func milestoneBoolValue(value any) bool {
switch typedValue := value.(type) { switch typedValue := value.(type) {
case bool: case bool:

View File

@ -4,7 +4,11 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"io"
"log"
"net/http" "net/http"
"strings" "strings"
@ -398,6 +402,208 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS
return streams return streams
} }
type milestoneCameraListItem struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
RecordingStorageID string `json:"recordingStorageId"`
HardwareID string `json:"hardwareId"`
}
func getMilestoneDeviceList(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
) ([]milestoneCameraListItem, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" + collection + "?disabled"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
setMilestoneRequestHeaders(request, config, false)
response, err := milestoneHTTPClient(config.SkipTLSVerify).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 %s list failed: status=%d", collection, response.StatusCode)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
var rawItems []any
if array, ok := decoded["array"].([]any); ok {
rawItems = array
} else if data, ok := decoded["data"].(map[string]any); ok {
if array, ok := data["array"].([]any); ok {
rawItems = array
}
}
deviceType := "camera"
if collection == "microphones" {
deviceType = "microphone"
}
items := make([]milestoneCameraListItem, 0, len(rawItems))
for _, raw := range rawItems {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
name := milestoneStringValue(item["name"])
displayName := milestoneStringValue(item["displayName"])
if name == "" {
name = displayName
}
if displayName == "" {
displayName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
recordingEnabled := false
if v, ok := item["recordingEnabled"].(bool); ok {
recordingEnabled = v
}
recordingStorageID := ""
if rs, ok := item["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
}
if recordingStorageID == "" {
if relations, ok := item["relations"].(map[string]any); ok {
if rs, ok := relations["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
}
}
}
hardwareID := milestoneNamedDeviceParentID(item)
items = append(items, milestoneCameraListItem{
ID: id,
Name: name,
DisplayName: displayName,
Type: deviceType,
Enabled: enabled,
RecordingEnabled: recordingEnabled,
RecordingStorageID: recordingStorageID,
HardwareID: hardwareID,
})
}
return items, nil
}
func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Request) {
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras")
if err != nil {
log.Printf("Milestone cameras list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
return
}
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones")
if err != nil {
log.Printf("Milestone microphones list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden")
return
}
all := make([]milestoneCameraListItem, 0, len(cameras)+len(microphones))
all = append(all, cameras...)
all = append(all, microphones...)
writeJSON(w, http.StatusOK, map[string]any{"devices": all})
}
type patchMilestoneDeviceRequest struct {
Enabled *bool `json:"enabled"`
RecordingEnabled *bool `json:"recordingEnabled"`
}
func (s *Server) handlePatchMilestoneDevice(w http.ResponseWriter, r *http.Request) {
collection := strings.TrimSpace(r.PathValue("collection"))
deviceID := strings.TrimSpace(r.PathValue("id"))
if collection != "cameras" && collection != "microphones" {
writeError(w, http.StatusBadRequest, "Ungültige Gerätekategorie")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
var input patchMilestoneDeviceRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
payload := map[string]any{}
if input.Enabled != nil {
payload["enabled"] = *input.Enabled
}
if input.RecordingEnabled != nil {
payload["recordingEnabled"] = *input.RecordingEnabled
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
if err := patchMilestoneNamedDevice(r.Context(), config, collection, deviceID, payload); err != nil {
log.Printf("Milestone device patch failed: collection=%s id=%s error=%v", collection, deviceID, err)
writeError(w, http.StatusBadGateway, "Gerät konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func applyMilestoneCameraStreamUpdates( func applyMilestoneCameraStreamUpdates(
cameraData map[string]any, cameraData map[string]any,
streamUpdates []updateMilestoneCameraStreamRequest, streamUpdates []updateMilestoneCameraStreamRequest,

File diff suppressed because it is too large Load Diff

View File

@ -26,15 +26,16 @@ type Operation struct {
OperationLeader string `json:"operationLeader"` OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"` OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"` Legend string `json:"legend"`
Camera string `json:"camera"` Camera string `json:"camera"`
Router string `json:"router"` Router string `json:"router"`
Technology string `json:"technology"` Technology string `json:"technology"`
Switchbox string `json:"switchbox"` Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"` HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"` Laptop string `json:"laptop"`
Remark string `json:"remark"` Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"` MilestoneStorage string `json:"milestoneStorage"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type CreateOperationJournalEntryRequest struct { type CreateOperationJournalEntryRequest struct {
@ -55,13 +56,14 @@ type CreateOperationRequest struct {
OperationLeader string `json:"operationLeader"` OperationLeader string `json:"operationLeader"`
OperationTeam string `json:"operationTeam"` OperationTeam string `json:"operationTeam"`
Legend string `json:"legend"` Legend string `json:"legend"`
Camera string `json:"camera"` Camera string `json:"camera"`
Router string `json:"router"` Router string `json:"router"`
Technology string `json:"technology"` Technology string `json:"technology"`
Switchbox string `json:"switchbox"` Switchbox string `json:"switchbox"`
HardDrive string `json:"hardDrive"` HardDrive string `json:"hardDrive"`
Laptop string `json:"laptop"` Laptop string `json:"laptop"`
Remark string `json:"remark"` Remark string `json:"remark"`
MilestoneStorage string `json:"milestoneStorage"`
} }
type UpdateOperationRequest = CreateOperationRequest type UpdateOperationRequest = CreateOperationRequest
@ -127,6 +129,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
hard_drive, hard_drive,
laptop, laptop,
remark, remark,
milestone_storage,
created_at, created_at,
updated_at updated_at
FROM operations FROM operations
@ -163,6 +166,7 @@ func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
&operation.HardDrive, &operation.HardDrive,
&operation.Laptop, &operation.Laptop,
&operation.Remark, &operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt, &operation.CreatedAt,
&operation.UpdatedAt, &operation.UpdatedAt,
); err != nil { ); err != nil {
@ -232,9 +236,10 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
switchbox, switchbox,
hard_drive, hard_drive,
laptop, laptop,
remark remark,
milestone_storage
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING RETURNING
id, id,
operation_number, operation_number,
@ -253,6 +258,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
hard_drive, hard_drive,
laptop, laptop,
remark, remark,
milestone_storage,
created_at, created_at,
updated_at updated_at
`, `,
@ -272,6 +278,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
input.HardDrive, input.HardDrive,
input.Laptop, input.Laptop,
input.Remark, input.Remark,
input.MilestoneStorage,
).Scan( ).Scan(
&operation.ID, &operation.ID,
&operation.OperationNumber, &operation.OperationNumber,
@ -290,6 +297,7 @@ func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) {
&operation.HardDrive, &operation.HardDrive,
&operation.Laptop, &operation.Laptop,
&operation.Remark, &operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt, &operation.CreatedAt,
&operation.UpdatedAt, &operation.UpdatedAt,
) )
@ -420,6 +428,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
hard_drive = $15, hard_drive = $15,
laptop = $16, laptop = $16,
remark = $17, remark = $17,
milestone_storage = $18,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING RETURNING
@ -440,6 +449,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
hard_drive, hard_drive,
laptop, laptop,
remark, remark,
milestone_storage,
created_at, created_at,
updated_at updated_at
`, `,
@ -460,6 +470,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
input.HardDrive, input.HardDrive,
input.Laptop, input.Laptop,
input.Remark, input.Remark,
input.MilestoneStorage,
).Scan( ).Scan(
&operation.ID, &operation.ID,
&operation.OperationNumber, &operation.OperationNumber,
@ -478,6 +489,7 @@ func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) {
&operation.HardDrive, &operation.HardDrive,
&operation.Laptop, &operation.Laptop,
&operation.Remark, &operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt, &operation.CreatedAt,
&operation.UpdatedAt, &operation.UpdatedAt,
) )
@ -642,6 +654,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
hard_drive, hard_drive,
laptop, laptop,
remark, remark,
milestone_storage,
created_at, created_at,
updated_at updated_at
FROM operations FROM operations
@ -667,6 +680,7 @@ func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string)
&operation.HardDrive, &operation.HardDrive,
&operation.Laptop, &operation.Laptop,
&operation.Remark, &operation.Remark,
&operation.MilestoneStorage,
&operation.CreatedAt, &operation.CreatedAt,
&operation.UpdatedAt, &operation.UpdatedAt,
) )
@ -726,6 +740,7 @@ func buildOperationCreateHistoryChanges(input CreateOperationRequest) []Operatio
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive) changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive)
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop) changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop)
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark) changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark)
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", "", input.MilestoneStorage)
return changes return changes
} }
@ -749,6 +764,7 @@ func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOper
changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive) changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive)
changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop) changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop)
changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark) changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark)
changes = appendOperationHistoryChange(changes, "milestoneStorage", "Milestone-Speicher", oldOperation.MilestoneStorage, input.MilestoneStorage)
return changes return changes
} }
@ -792,6 +808,7 @@ func normalizeOperationInput(input *CreateOperationRequest) {
input.HardDrive = strings.TrimSpace(input.HardDrive) input.HardDrive = strings.TrimSpace(input.HardDrive)
input.Laptop = strings.TrimSpace(input.Laptop) input.Laptop = strings.TrimSpace(input.Laptop)
input.Remark = strings.TrimSpace(input.Remark) input.Remark = strings.TrimSpace(input.Remark)
input.MilestoneStorage = strings.TrimSpace(input.MilestoneStorage)
} }
func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) {

View File

@ -123,6 +123,36 @@ func (s *Server) Routes() http.Handler {
s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)), s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)),
) )
mux.HandleFunc(
"PATCH /admin/server-folders",
s.requireAuth(s.requireRight("administration:write", s.handleProxyRenameServerFolder)),
)
mux.HandleFunc(
"DELETE /admin/server-folders",
s.requireAuth(s.requireRight("administration:write", s.handleProxyDeleteServerFolder)),
)
mux.HandleFunc(
"PATCH /admin/milestone/archive-storages/{id}",
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)),
)
mux.HandleFunc(
"DELETE /admin/milestone/archive-storages/{id}",
s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneArchiveStorage)),
)
mux.HandleFunc(
"GET /admin/milestone/storage-information/{id}",
s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)),
)
mux.HandleFunc(
"GET /admin/milestone/cameras",
s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneCameras)),
)
mux.HandleFunc(
"PATCH /admin/milestone/devices/{collection}/{id}",
s.requireAuth(s.requireRight("administration:write", s.handlePatchMilestoneDevice)),
)
mux.HandleFunc( mux.HandleFunc(
"GET /admin/milestone/storages", "GET /admin/milestone/storages",
s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)), s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)),
@ -131,6 +161,14 @@ func (s *Server) Routes() http.Handler {
"POST /admin/milestone/storages", "POST /admin/milestone/storages",
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)), s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)),
) )
mux.HandleFunc(
"PATCH /admin/milestone/storages/{id}",
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneStorage)),
)
mux.HandleFunc(
"DELETE /admin/milestone/storages/{id}",
s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorage)),
)
mux.HandleFunc( mux.HandleFunc(
"POST /admin/milestone/storages/{id}/archive-storages", "POST /admin/milestone/storages/{id}/archive-storages",
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)), s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)),
@ -140,12 +178,8 @@ func (s *Server) Routes() http.Handler {
s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)), s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)),
) )
mux.HandleFunc( mux.HandleFunc(
"PATCH /admin/milestone/archive-storages/{id}", "GET /admin/milestone/storages/{id}/delete-stream",
s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)), s.requireAuth(s.requireRight("administration:write", s.handleDeleteMilestoneStorageStream)),
)
mux.HandleFunc(
"GET /admin/milestone/storage-information/{id}",
s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)),
) )
return s.cors(mux) return s.cors(mux)

View File

@ -5,6 +5,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -28,6 +29,14 @@ func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Re
s.proxyServerFoldersRequest(w, r, http.MethodPost) s.proxyServerFoldersRequest(w, r, http.MethodPost)
} }
func (s *Server) handleProxyRenameServerFolder(w http.ResponseWriter, r *http.Request) {
s.proxyServerFoldersRequest(w, r, http.MethodPatch)
}
func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Request) {
s.proxyServerFoldersRequest(w, r, http.MethodDelete)
}
func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) { func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) {
config, err := s.getServerFoldersAgentConfig(r.Context()) config, err := s.getServerFoldersAgentConfig(r.Context())
if err != nil { if err != nil {
@ -42,7 +51,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
} }
var body []byte var body []byte
if method == http.MethodPost { if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20)) body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden") writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden")
@ -63,7 +72,7 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
request.Header.Set("Accept", "application/json") request.Header.Set("Accept", "application/json")
if method == http.MethodPost { if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete {
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
} }
@ -98,6 +107,171 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques
_, _ = w.Write(responseBody) _, _ = w.Write(responseBody)
} }
func (s *Server) requestServerFoldersAgent(
ctx context.Context,
method string,
query url.Values,
payload any,
) ([]byte, int, error) {
config, err := s.getServerFoldersAgentConfig(ctx)
if err != nil {
return nil, 0, err
}
targetURL, err := serverFoldersAgentURL(config, query.Encode())
if err != nil {
return nil, 0, err
}
var body []byte
if payload != nil {
body, err = json.Marshal(payload)
if err != nil {
return nil, 0, err
}
}
request, err := http.NewRequestWithContext(
ctx,
method,
targetURL,
bytes.NewReader(body),
)
if err != nil {
return nil, 0, err
}
request.Header.Set("Accept", "application/json")
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
if config.Token != "" {
request.Header.Set("X-Server-Folders-Token", config.Token)
}
client := &http.Client{
Timeout: 30 * time.Second,
}
response, err := client.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20))
if err != nil {
return nil, response.StatusCode, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return responseBody, response.StatusCode, fmt.Errorf(
"server-folders-agent request failed: method=%s url=%s status=%d body=%s",
method,
targetURL,
response.StatusCode,
string(responseBody),
)
}
return responseBody, response.StatusCode, nil
}
type serverFolderAgentFolderResponse struct {
OK bool `json:"ok"`
Folder struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Path string `json:"path"`
HasChildren bool `json:"hasChildren"`
} `json:"folder"`
}
func (s *Server) deleteServerFolderPath(
ctx context.Context,
rootType string,
pathValue string,
) error {
pathValue = strings.TrimSpace(pathValue)
if pathValue == "" {
return nil
}
query := url.Values{}
query.Set("rootType", rootType)
query.Set("path", pathValue)
responseBody, status, err := s.requestServerFoldersAgent(
ctx,
http.MethodDelete,
query,
nil,
)
if err != nil {
if status == http.StatusNotFound {
return nil
}
return fmt.Errorf(
"Ordner konnte nicht gelöscht werden: path=%s status=%d body=%s error=%w",
pathValue,
status,
string(responseBody),
err,
)
}
return nil
}
func (s *Server) renameServerFolderPath(
ctx context.Context,
rootType string,
pathValue string,
nextName string,
) (string, error) {
pathValue = strings.TrimSpace(pathValue)
nextName = strings.TrimSpace(nextName)
if pathValue == "" || nextName == "" {
return pathValue, nil
}
query := url.Values{}
query.Set("rootType", rootType)
responseBody, _, err := s.requestServerFoldersAgent(
ctx,
http.MethodPatch,
query,
map[string]any{
"path": pathValue,
"name": nextName,
},
)
if err != nil {
return "", fmt.Errorf(
"Ordner konnte nicht umbenannt werden: path=%s name=%s body=%s error=%w",
pathValue,
nextName,
string(responseBody),
err,
)
}
var response serverFolderAgentFolderResponse
if err := json.Unmarshal(responseBody, &response); err != nil {
return "", err
}
if strings.TrimSpace(response.Folder.Path) == "" {
return "", fmt.Errorf("Server-Folders-Agent hat keinen neuen Pfad zurückgegeben")
}
return response.Folder.Path, nil
}
func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) { func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) {
var config serverFoldersAgentConfig var config serverFoldersAgentConfig
@ -166,8 +340,6 @@ func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (st
} }
func forwardedServerFoldersQuery(values url.Values) string { func forwardedServerFoldersQuery(values url.Values) string {
// rootType=storage/archive wird bewusst weitergeleitet.
// Nur Verbindungs-/Auth-Parameter werden entfernt.
nextValues := url.Values{} nextValues := url.Values{}
for key, value := range values { for key, value := range values {

View File

@ -439,12 +439,15 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
hard_drive TEXT NOT NULL DEFAULT '', hard_drive TEXT NOT NULL DEFAULT '',
laptop TEXT NOT NULL DEFAULT '', laptop TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '', remark TEXT NOT NULL DEFAULT '',
milestone_storage TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
) )
`, `,
`ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS milestone_storage TEXT NOT NULL DEFAULT ''`,
` `
CREATE TABLE IF NOT EXISTS operation_history ( CREATE TABLE IF NOT EXISTS operation_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@ -19,6 +19,7 @@ import type { Notification, User } from './components/types'
import AdministrationPage from './pages/administration/AdministrationPage' import AdministrationPage from './pages/administration/AdministrationPage'
import Feedback from './components/Feedback' import Feedback from './components/Feedback'
import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage' import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage'
import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage'
import { hasRight } from './components/permissions' import { hasRight } from './components/permissions'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -445,6 +446,17 @@ function App() {
} }
/> />
<Route
path="/einsaetze/milestone-devices"
element={
<RequireRight user={user} right="administration:read">
<MilestoneDevicesPage
canWrite={hasRight(user, 'administration:write')}
/>
</RequireRight>
}
/>
<Route <Route
path="/kalender" path="/kalender"
element={ element={

View File

@ -12,6 +12,7 @@ import {
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
Cog6ToothIcon, Cog6ToothIcon,
ComputerDesktopIcon, ComputerDesktopIcon,
CpuChipIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
ServerStackIcon, ServerStackIcon,
ShieldCheckIcon, ShieldCheckIcon,
@ -19,6 +20,7 @@ import {
HomeIcon, HomeIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import type { ComponentType, SVGProps } from 'react'
import { NavLink, useLocation } from 'react-router' import { NavLink, useLocation } from 'react-router'
import { hasRight } from './permissions' import { hasRight } from './permissions'
import type { User, Team } from './types' import type { User, Team } from './types'
@ -26,53 +28,79 @@ import type { User, Team } from './types'
type NavigationItem = { type NavigationItem = {
name: string name: string
href: string href: string
icon: React.ComponentType<React.SVGProps<SVGSVGElement>> icon: ComponentType<SVGProps<SVGSVGElement>>
right?: string right?: string
isChild?: boolean children?: NavigationItem[]
} }
const navigation: NavigationItem[] = [ type NavigationSection = {
name?: string
items: NavigationItem[]
}
const navigationSections: NavigationSection[] = [
{ {
name: 'Dashboard', items: [
href: '/', {
icon: HomeIcon, name: 'Dashboard',
href: '/',
icon: HomeIcon,
},
],
}, },
{ {
name: 'Geräte', name: 'Betrieb',
href: '/geraete', items: [
icon: ComputerDesktopIcon, {
right: 'devices:read', name: 'Geräte',
href: '/geraete',
icon: ComputerDesktopIcon,
right: 'devices:read',
},
{
name: 'Einsätze',
href: '/einsaetze',
icon: VideoCameraIcon,
right: 'operations:read',
children: [
{
name: 'Milestone-Speicher',
href: '/einsaetze/milestone-speicher',
icon: ServerStackIcon,
right: 'administration:read',
},
{
name: 'Milestone-Geräte',
href: '/einsaetze/milestone-devices',
icon: CpuChipIcon,
right: 'administration:read',
},
],
},
],
}, },
{ {
name: 'Einsätze', name: 'Organisation',
href: '/einsaetze', items: [
icon: VideoCameraIcon, {
right: 'operations:read', name: 'Kalender',
}, href: '/kalender',
{ icon: CalendarIcon,
name: 'Milestone-Speicher', right: 'calendar:read',
href: '/einsaetze/milestone-speicher', },
icon: ServerStackIcon, {
right: 'administration:read', name: 'Dokumente',
isChild: true, href: '/dokumente',
}, icon: DocumentDuplicateIcon,
{ right: 'documents:read',
name: 'Kalender', },
href: '/kalender', {
icon: CalendarIcon, name: 'Berichte',
right: 'calendar:read', href: '/berichte',
}, icon: ChartPieIcon,
{ right: 'reports:read',
name: 'Dokumente', },
href: '/dokumente', ],
icon: DocumentDuplicateIcon,
right: 'documents:read',
},
{
name: 'Berichte',
href: '/berichte',
icon: ChartPieIcon,
right: 'reports:read',
}, },
] ]
@ -95,6 +123,49 @@ function getTeamInitial(team: Team) {
return team.name.charAt(0).toUpperCase() return team.name.charAt(0).toUpperCase()
} }
function isPathActive(pathname: string, href: string) {
if (href === '/') {
return pathname === '/'
}
return pathname === href || pathname.startsWith(`${href}/`)
}
function itemIsActive(item: NavigationItem, pathname: string): boolean {
return (
isPathActive(pathname, item.href) ||
(item.children ?? []).some((child) => itemIsActive(child, pathname))
)
}
function filterNavigationItems(
items: NavigationItem[],
user: User,
): NavigationItem[] {
return items
.map((item) => ({
...item,
children: item.children
? filterNavigationItems(item.children, user)
: undefined,
}))
.filter((item) => {
const canSeeItem = !item.right || hasRight(user, item.right)
const hasVisibleChildren = Boolean(item.children?.length)
return canSeeItem || hasVisibleChildren
})
}
function getVisibleNavigationSections(user: User) {
return navigationSections
.map((section) => ({
...section,
items: filterNavigationItems(section.items, user),
}))
.filter((section) => section.items.length > 0)
}
function SidebarContent({ function SidebarContent({
user, user,
setSidebarOpen, setSidebarOpen,
@ -105,9 +176,10 @@ function SidebarContent({
onNavigate?: () => void onNavigate?: () => void
}) { }) {
const teams = user.teams ?? [] const teams = user.teams ?? []
const location = useLocation() const location = useLocation()
const visibleNavigationSections = getVisibleNavigationSections(user)
const isAdministrationActive = const isAdministrationActive =
location.pathname === '/administration' || location.pathname === '/administration' ||
location.pathname.startsWith('/administration/') location.pathname.startsWith('/administration/')
@ -116,150 +188,220 @@ function SidebarContent({
location.pathname === '/einstellungen' || location.pathname === '/einstellungen' ||
location.pathname.startsWith('/einstellungen/') location.pathname.startsWith('/einstellungen/')
const isFeedbackActive = const isFeedbackActive = location.pathname === '/feedback'
location.pathname === '/feedback'
const visibleNavigation = navigation.filter((item) =>
!item.right || hasRight(user, item.right),
)
function handleNavigate() { function handleNavigate() {
onNavigate?.() onNavigate?.()
setSidebarOpen(false) setSidebarOpen(false)
} }
function renderNavigationItem(item: NavigationItem) {
const active = itemIsActive(item, location.pathname)
const children = item.children ?? []
const showChildren = children.length > 0
return (
<li key={item.name}>
<NavLink
to={item.href}
onClick={handleNavigate}
className={classNames(
active
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<item.icon
aria-hidden="true"
className={classNames(
active
? 'text-white'
: 'text-slate-400 group-hover:text-white',
'size-5 shrink-0 transition',
)}
/>
<span className="truncate">{item.name}</span>
</NavLink>
{showChildren && (
<ul
role="list"
className="ml-[1.35rem] mt-1 space-y-1 border-l border-white/10 pl-3"
>
{children.map((child) => {
const childActive = itemIsActive(child, location.pathname)
return (
<li key={child.name}>
<NavLink
to={child.href}
onClick={handleNavigate}
className={classNames(
childActive
? 'bg-indigo-500/10 text-indigo-200 ring-1 ring-indigo-400/20'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-2 rounded-lg px-2.5 py-2 text-sm/6 font-medium transition',
)}
>
<child.icon
aria-hidden="true"
className={classNames(
childActive
? 'text-indigo-200'
: 'text-slate-500 group-hover:text-white',
'size-4 shrink-0 transition',
)}
/>
<span className="truncate">{child.name}</span>
</NavLink>
</li>
)
})}
</ul>
)}
</li>
)
}
return ( return (
<> <>
<div className="flex h-16 shrink-0 items-center"> <div className="flex h-16 shrink-0 items-center gap-3">
<img <img
alt="Your Company" alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500" src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
className="h-8 w-auto" className="h-8 w-auto"
/> />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">TEG</p>
<p className="truncate text-xs text-slate-400">SE Düsseldorf</p>
</div>
</div> </div>
<nav className="flex flex-1 flex-col"> <nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7"> <ul role="list" className="flex flex-1 flex-col gap-y-6">
<li> <li>
<ul role="list" className="-mx-2 space-y-1"> <div className="space-y-6">
{visibleNavigation.map((item) => ( {visibleNavigationSections.map((section, sectionIndex) => (
<li key={item.name}> <section key={section.name ?? `section-${sectionIndex}`}>
<NavLink {section.name && (
to={item.href} <h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
onClick={handleNavigate} {section.name}
className={({ isActive }) => </h2>
classNames( )}
isActive
? 'bg-white/5 text-white' <ul role="list" className="space-y-1">
: 'text-gray-400 hover:bg-white/5 hover:text-white', {section.items.map(renderNavigationItem)}
item.isChild </ul>
? 'ml-7 gap-x-2 px-2 py-1.5 text-sm/6 font-medium' </section>
: 'gap-x-3 p-2 text-sm/6 font-semibold',
'group flex rounded-md',
)
}
>
<item.icon
aria-hidden="true"
className={classNames(
'shrink-0',
item.isChild ? 'size-5' : 'size-6',
)}
/>
{item.name}
</NavLink>
</li>
))} ))}
</ul> </div>
</li> </li>
<li> <li>
<div className="text-xs/6 font-semibold text-gray-400"> <div className="border-t border-white/10 pt-5">
Teams <h2 className="px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
</div> Teams
</h2>
<ul role="list" className="-mx-2 mt-2 space-y-1"> <ul role="list" className="mt-2 space-y-1">
{teams.length > 0 ? ( {teams.length > 0 ? (
teams.map((team) => ( teams.map((team) => (
<li key={team.id}> <li key={team.id}>
<a <a
href={team.href || '#'} href={team.href || '#'}
onClick={handleNavigate} onClick={handleNavigate}
className={classNames( className={classNames(
team.current team.current
? 'bg-white/5 text-white' ? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-gray-400 hover:bg-white/5 hover:text-white', : 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold', 'group flex items-center gap-x-3 rounded-xl px-3 py-2 text-sm/6 font-semibold transition',
)} )}
> >
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-gray-400 group-hover:border-white/20 group-hover:text-white"> <span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-[0.625rem] font-medium text-slate-300 group-hover:border-white/20 group-hover:text-white">
{getTeamInitial(team)} {getTeamInitial(team)}
</span> </span>
<span className="truncate">{team.name}</span> <span className="truncate">{team.name}</span>
</a> </a>
</li>
))
) : (
<li className="px-3 py-1 text-sm/6 text-slate-500">
Keine Teams zugeordnet
</li> </li>
)) )}
) : ( </ul>
<li className="px-2 py-1 text-sm/6 text-gray-500"> </div>
Keine Teams zugeordnet </li>
<li className="mt-auto border-t border-white/10 pt-5">
<h2 className="mb-2 px-3 text-[0.6875rem]/6 font-semibold uppercase tracking-wider text-slate-500">
Hilfe & System
</h2>
<ul role="list" className="space-y-1">
<li>
<NavLink
to="/feedback"
onClick={handleNavigate}
className={classNames(
isFeedbackActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<ChatBubbleLeftRightIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Feedback
</NavLink>
</li>
{hasRight(user, 'settings:read') && (
<li>
<NavLink
to="/einstellungen/konto"
onClick={handleNavigate}
className={classNames(
isSettingsActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<Cog6ToothIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Einstellungen
</NavLink>
</li>
)}
{hasRight(user, 'administration:read') && (
<li>
<NavLink
to="/administration/benutzer"
onClick={handleNavigate}
className={classNames(
isAdministrationActive
? 'bg-white/10 text-white ring-1 ring-white/10'
: 'text-slate-400 hover:bg-white/5 hover:text-white',
'group flex items-center gap-x-3 rounded-xl px-3 py-2.5 text-sm/6 font-semibold transition',
)}
>
<ShieldCheckIcon
aria-hidden="true"
className="size-5 shrink-0"
/>
Administration
</NavLink>
</li> </li>
)} )}
</ul> </ul>
</li> </li>
<li className="mt-auto space-y-1">
<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/konto"
onClick={handleNavigate}
className={() =>
classNames(
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',
)
}
>
<Cog6ToothIcon aria-hidden="true" className="size-6 shrink-0" />
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> </ul>
</nav> </nav>
</> </>
@ -274,10 +416,14 @@ export default function Sidebar({
}: SidebarProps) { }: SidebarProps) {
return ( return (
<> <>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-[2000] lg:hidden"> <Dialog
open={sidebarOpen}
onClose={setSidebarOpen}
className="relative z-[2000] lg:hidden"
>
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0" className="fixed inset-0 bg-gray-950/85 transition-opacity duration-300 ease-linear data-closed:opacity-0"
/> />
<div className="fixed inset-0 flex"> <div className="fixed inset-0 flex">
@ -287,14 +433,18 @@ export default function Sidebar({
> >
<TransitionChild> <TransitionChild>
<div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0"> <div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0">
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5"> <button
type="button"
onClick={() => setSidebarOpen(false)}
className="-m-2.5 p-2.5"
>
<span className="sr-only">Close sidebar</span> <span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" /> <XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button> </button>
</div> </div>
</TransitionChild> </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"> <div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-slate-950 px-5 pb-4 ring-1 ring-white/10 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.16),transparent_32rem)]">
<SidebarContent <SidebarContent
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
@ -305,8 +455,8 @@ export default function Sidebar({
</div> </div>
</Dialog> </Dialog>
<div className="hidden bg-gray-900 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] lg:flex lg:w-72 lg:flex-col"> <div className="hidden bg-slate-950 ring-1 ring-white/10 lg:fixed lg:inset-y-0 lg:z-[2000] 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"> <div className="relative flex grow flex-col gap-y-5 overflow-y-auto px-5 pb-4 before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(99,102,241,0.14),transparent_34rem)]">
<SidebarContent <SidebarContent
user={user} user={user}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}

View File

@ -13,6 +13,9 @@ export type TreeListNode = {
path: string; path: string;
/** Optionaler echter Dateisystem-/Ordnername, wenn name ein Windows-Anzeigename ist. */ /** Optionaler echter Dateisystem-/Ordnername, wenn name ein Windows-Anzeigename ist. */
secondaryName?: string; secondaryName?: string;
/** Markiert einen Ordner, der noch nicht existiert und nur als Vorschau angezeigt wird. */
preview?: boolean;
previewLabel?: string;
children?: TreeListNode[]; children?: TreeListNode[];
disabled?: boolean; disabled?: boolean;
}; };
@ -30,6 +33,11 @@ type TreeListProps = {
onNewFolderValueChange?: (value: string) => void; onNewFolderValueChange?: (value: string) => void;
onCreateFolder?: (folderName: string, folderPath: string) => void; onCreateFolder?: (folderName: string, folderPath: string) => void;
showPathInput?: boolean; showPathInput?: boolean;
pathInputReadOnly?: boolean;
newFolderReadOnly?: boolean;
previewPath?: string;
previewName?: string;
previewLabel?: string;
showCreateButton?: boolean; showCreateButton?: boolean;
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
@ -80,6 +88,147 @@ function joinPath(basePath: string, childPath: string) {
return `${base}${getPathSeparator(base)}${child}`; return `${base}${getPathSeparator(base)}${child}`;
} }
function normalizeTreePath(value: string) {
return value.trim().replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase();
}
function pathBaseName(path: string) {
const cleanPath = path.trim().replace(/[\\/]+$/, "");
const parts = cleanPath.split(/[\\/]+/).filter(Boolean);
return parts[parts.length - 1] || cleanPath || path;
}
function pathParent(path: string) {
const cleanPath = path.trim().replace(/[\\/]+$/, "");
if (!cleanPath) {
return "";
}
const separatorIndex = Math.max(
cleanPath.lastIndexOf("\\"),
cleanPath.lastIndexOf("/"),
);
if (separatorIndex <= 0) {
return "";
}
return cleanPath.slice(0, separatorIndex);
}
function treeContainsPath(nodes: TreeListNode[], path: string): boolean {
const normalizedPath = normalizeTreePath(path);
return nodes.some((node) => {
if (normalizeTreePath(node.path) === normalizedPath) {
return true;
}
return treeContainsPath(Array.isArray(node.children) ? node.children : [], path);
});
}
function markPreviewNode(
nodes: TreeListNode[],
previewPath: string,
previewLabel?: string,
): TreeListNode[] {
const normalizedPreviewPath = normalizeTreePath(previewPath);
return nodes.map((node) => ({
...node,
preview:
node.preview || normalizeTreePath(node.path) === normalizedPreviewPath,
previewLabel:
normalizeTreePath(node.path) === normalizedPreviewPath
? previewLabel
: node.previewLabel,
disabled:
node.disabled || normalizeTreePath(node.path) === normalizedPreviewPath,
children: Array.isArray(node.children)
? markPreviewNode(node.children, previewPath, previewLabel)
: node.children,
}));
}
function addPreviewNodeToTree(
nodes: TreeListNode[],
previewPath?: string,
previewName?: string,
previewLabel = "Vorschau",
): TreeListNode[] {
const cleanPreviewPath = previewPath?.trim().replace(/[\\/]+$/, "") ?? "";
if (!cleanPreviewPath) {
return nodes;
}
if (treeContainsPath(nodes, cleanPreviewPath)) {
return nodes;
}
const parent = pathParent(cleanPreviewPath);
const normalizedParent = normalizeTreePath(parent);
const previewNode: TreeListNode = {
id: `preview:${cleanPreviewPath}`,
name: previewName?.trim() || pathBaseName(cleanPreviewPath),
path: cleanPreviewPath,
preview: true,
previewLabel,
disabled: true,
children: [],
};
if (!parent) {
return [...nodes, previewNode];
}
let inserted = false;
function insertInto(currentNodes: TreeListNode[]): TreeListNode[] {
return currentNodes.map((node) => {
if (normalizeTreePath(node.path) === normalizedParent) {
inserted = true;
return {
...node,
children: [
...(Array.isArray(node.children) ? node.children : []),
previewNode,
],
};
}
if (!Array.isArray(node.children) || node.children.length === 0) {
return node;
}
return {
...node,
children: insertInto(node.children),
};
});
}
const nextNodes = insertInto(nodes);
if (inserted) {
return nextNodes;
}
if (nodes.length === 0) {
return markPreviewNode(
buildFallbackNodes(cleanPreviewPath),
cleanPreviewPath,
previewLabel,
);
}
return nextNodes;
}
function buildFallbackNodes(path: string): TreeListNode[] { function buildFallbackNodes(path: string): TreeListNode[] {
const parts = splitPath(path); const parts = splitPath(path);
@ -139,6 +288,7 @@ function renderTreeNodes({
const active = node.path === selectedPath; const active = node.path === selectedPath;
const selectable = !disabled && !readOnly && !node.disabled; const selectable = !disabled && !readOnly && !node.disabled;
const children = Array.isArray(node.children) ? node.children : []; const children = Array.isArray(node.children) ? node.children : [];
const Icon = node.preview ? FolderPlusIcon : FolderIcon;
return ( return (
<li key={node.id}> <li key={node.id}>
@ -151,12 +301,14 @@ function renderTreeNodes({
active active
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300" ? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
: "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5", : "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5",
node.preview &&
"border border-dashed border-indigo-200 bg-indigo-50/70 text-indigo-700 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-200",
!selectable && !selectable &&
"cursor-default hover:bg-transparent dark:hover:bg-transparent", "cursor-default hover:bg-transparent dark:hover:bg-transparent",
)} )}
style={{ paddingLeft: `${0.5 + level * 1.25}rem` }} style={{ paddingLeft: `${0.5 + level * 1.25}rem` }}
> >
<FolderIcon <Icon
aria-hidden="true" aria-hidden="true"
className={classNames( className={classNames(
active active
@ -213,19 +365,34 @@ export default function TreeList({
onNewFolderValueChange, onNewFolderValueChange,
onCreateFolder, onCreateFolder,
showPathInput = true, showPathInput = true,
pathInputReadOnly = false,
newFolderReadOnly = false,
previewPath,
previewName,
previewLabel,
showCreateButton = true, showCreateButton = true,
readOnly = false, readOnly = false,
disabled = false, disabled = false,
className, className,
}: TreeListProps) { }: TreeListProps) {
const [internalNewFolderValue, setInternalNewFolderValue] = useState(""); const [internalNewFolderValue, setInternalNewFolderValue] = useState("");
const displayNodes = useMemo( const baseDisplayNodes = useMemo(
() => () =>
Array.isArray(nodes) && nodes.length > 0 Array.isArray(nodes) && nodes.length > 0
? nodes ? nodes
: buildFallbackNodes(selectedPath), : buildFallbackNodes(selectedPath),
[nodes, selectedPath], [nodes, selectedPath],
); );
const displayNodes = useMemo(
() =>
addPreviewNodeToTree(
baseDisplayNodes,
previewPath,
previewName,
previewLabel,
),
[baseDisplayNodes, previewPath, previewName, previewLabel],
);
const canEdit = !disabled && !readOnly; const canEdit = !disabled && !readOnly;
const isNewFolderControlled = newFolderValue !== undefined; const isNewFolderControlled = newFolderValue !== undefined;
@ -284,13 +451,13 @@ export default function TreeList({
value={selectedPath} value={selectedPath}
onChange={(event) => onSelectedPathChange?.(event.target.value)} onChange={(event) => onSelectedPathChange?.(event.target.value)}
placeholder={pathPlaceholder} placeholder={pathPlaceholder}
disabled={!canEdit} disabled={disabled}
readOnly={readOnly} readOnly={readOnly || pathInputReadOnly}
className={classNames( className={classNames(
"block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300", "block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300",
"placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600", "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", "dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500",
!canEdit && (!canEdit || pathInputReadOnly) &&
"cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400", "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-white/10 dark:text-gray-400",
)} )}
/> />
@ -325,6 +492,7 @@ export default function TreeList({
type="text" type="text"
value={currentNewFolderValue} value={currentNewFolderValue}
onChange={(event) => updateNewFolderValue(event.target.value)} onChange={(event) => updateNewFolderValue(event.target.value)}
readOnly={newFolderReadOnly}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
@ -333,7 +501,11 @@ export default function TreeList({
}} }}
disabled={!canEdit} disabled={!canEdit}
placeholder={newFolderPlaceholder} placeholder={newFolderPlaceholder}
className="min-w-0 flex-1 bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:text-gray-500 dark:text-white dark:placeholder:text-gray-500" className={classNames(
"min-w-0 flex-1 bg-transparent text-sm text-gray-900 outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:text-gray-500 dark:text-white dark:placeholder:text-gray-500",
newFolderReadOnly &&
"cursor-not-allowed text-gray-500 dark:text-gray-400",
)}
/> />
{showCreateButton && ( {showCreateButton && (

View File

@ -159,6 +159,7 @@ export type Operation = {
hardDrive: string hardDrive: string
laptop: string laptop: string
remark: string remark: string
milestoneStorage: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }

View File

@ -1,4 +1,4 @@
// frontend/src/pages/OperationModal.tsx // frontend/src/pages/operations/OperationModal.tsx
import { import {
useEffect, useEffect,
@ -13,12 +13,15 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
import { import {
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
CheckIcon, CheckIcon,
CircleStackIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
DocumentTextIcon, DocumentTextIcon,
MapPinIcon, MapPinIcon,
PencilSquareIcon, PencilSquareIcon,
PlusCircleIcon, PlusCircleIcon,
PlusIcon,
UserGroupIcon, UserGroupIcon,
VideoCameraIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid' } from '@heroicons/react/20/solid'
import Modal from '../../components/Modal' import Modal from '../../components/Modal'
@ -55,6 +58,7 @@ type OperationTabId =
| 'responsibilities' | 'responsibilities'
| 'notes' | 'notes'
| 'equipment' | 'equipment'
| 'storage'
| 'journal' | 'journal'
type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>> type OperationTabIcon = ComponentType<SVGProps<SVGSVGElement>>
@ -83,6 +87,29 @@ type OperationAssigneesResponse = {
officers?: OperationAssigneeUser[] officers?: OperationAssigneeUser[]
} }
type EquipmentDevice = {
id: string
inventoryNumber: string
manufacturer: string
model: string
milestoneChildDevices: Array<{
id: string
deviceType: string
name: string
displayName: string
enabled: boolean
}>
}
type ModalMilestoneStorage = {
id: string
displayName: string
name: string
diskPath: string
mounted: boolean
isDefault: boolean
}
function getOperationAssigneeName(user: OperationAssigneeUser) { function getOperationAssigneeName(user: OperationAssigneeUser) {
return user.displayName || user.username || user.email return user.displayName || user.username || user.email
} }
@ -146,6 +173,31 @@ async function readApiError(response: Response, fallback: string) {
return data?.error ?? fallback return data?.error ?? fallback
} }
function deviceToComboboxItem(device: EquipmentDevice): ComboboxItem {
return {
id: device.id,
name: device.inventoryNumber || device.model || device.id,
secondaryText:
[device.manufacturer, device.model].filter(Boolean).join(' · ') ||
undefined,
}
}
function buildCameraOptionsForDevice(
device: EquipmentDevice | undefined,
): ComboboxItem[] {
if (!device) return []
return (device.milestoneChildDevices ?? [])
.filter(
(c) => c.deviceType === 'camera' || c.deviceType === 'cameras',
)
.map((c) => ({
id: c.id,
name: c.name,
secondaryText: c.displayName !== c.name ? c.displayName : undefined,
}))
}
const operationTabs: Array<{ const operationTabs: Array<{
id: OperationTabId id: OperationTabId
title: string title: string
@ -179,9 +231,15 @@ const operationTabs: Array<{
{ {
id: 'equipment', id: 'equipment',
title: 'Technik', title: 'Technik',
description: 'Kamera, Router, Switchbox und Laptop.', description: 'Router, Kameras, Switchbox, Laptop und Festplatte.',
icon: WrenchScrewdriverIcon, icon: WrenchScrewdriverIcon,
}, },
{
id: 'storage',
title: 'Speicher',
description: 'Milestone-Speicher anlegen und verwalten.',
icon: CircleStackIcon,
},
{ {
id: 'journal', id: 'journal',
title: 'Journal', title: 'Journal',
@ -270,6 +328,31 @@ export default function OperationModal({
const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([]) const [selectedOperationTeam, setSelectedOperationTeam] = useState<ComboboxItem[]>([])
const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false) const [isLoadingOperationAssignees, setIsLoadingOperationAssignees] = useState(false)
const [equipmentDevices, setEquipmentDevices] = useState<EquipmentDevice[]>([])
const [isLoadingDevices, setIsLoadingDevices] = useState(false)
const [selectedRouter, setSelectedRouter] = useState<ComboboxItem | null>(null)
const [selectedCameras, setSelectedCameras] = useState<ComboboxItem[]>([])
const [milestoneStorages, setMilestoneStorages] = useState<ModalMilestoneStorage[]>([])
const [selectedMilestoneStorage, setSelectedMilestoneStorage] = useState<ModalMilestoneStorage | null>(null)
const [isLoadingStorages, setIsLoadingStorages] = useState(false)
const [showCreateStorageForm, setShowCreateStorageForm] = useState(false)
const [isCreatingStorage, setIsCreatingStorage] = useState(false)
const [createStorageName, setCreateStorageName] = useState('')
const [createStorageDiskPath, setCreateStorageDiskPath] = useState('')
const [createStorageError, setCreateStorageError] = useState<string | null>(null)
const routerOptions = useMemo<ComboboxItem[]>(
() => equipmentDevices.map(deviceToComboboxItem),
[equipmentDevices],
)
const cameraOptions = useMemo<ComboboxItem[]>(() => {
if (!selectedRouter) return []
const device = equipmentDevices.find((d) => d.id === selectedRouter.id)
return buildCameraOptionsForDevice(device)
}, [selectedRouter, equipmentDevices])
const visibleTabs = useMemo( const visibleTabs = useMemo(
() => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'), () => operationTabs.filter((tab) => isEditing || tab.id !== 'journal'),
[isEditing], [isEditing],
@ -283,6 +366,12 @@ export default function OperationModal({
setActiveTab('masterData') setActiveTab('masterData')
setTargetObject(editingOperation?.targetObject ?? '') setTargetObject(editingOperation?.targetObject ?? '')
setKwAddress(editingOperation?.kwAddress ?? '') setKwAddress(editingOperation?.kwAddress ?? '')
setMilestoneStorages([])
setSelectedMilestoneStorage(null)
setShowCreateStorageForm(false)
setCreateStorageName('')
setCreateStorageDiskPath('')
setCreateStorageError(null)
}, [ }, [
open, open,
editingOperation?.id, editingOperation?.id,
@ -298,12 +387,21 @@ export default function OperationModal({
const abortController = new AbortController() const abortController = new AbortController()
void loadOperationAssignees(abortController.signal) void loadOperationAssignees(abortController.signal)
void loadEquipmentDevices(abortController.signal)
return () => { return () => {
abortController.abort() abortController.abort()
} }
}, [open]) }, [open])
useEffect(() => {
if (!open || activeTab !== 'storage') {
return
}
void loadMilestoneStorages()
}, [open, activeTab])
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return return
@ -330,6 +428,52 @@ export default function OperationModal({
operationTeamOptions, operationTeamOptions,
]) ])
useEffect(() => {
if (!open) {
setSelectedRouter(null)
setSelectedCameras([])
return
}
const routerName = editingOperation?.router ?? ''
let routerItem: ComboboxItem | null = null
if (routerName) {
const device = equipmentDevices.find(
(d) =>
normalizePersonName(d.inventoryNumber || d.model || d.id) ===
normalizePersonName(routerName),
)
routerItem = device
? deviceToComboboxItem(device)
: createFallbackComboboxItem(routerName)
}
setSelectedRouter(routerItem)
const cameraNames = parseOperationTeamMembers(editingOperation?.camera ?? '')
if (cameraNames.length > 0 && routerItem) {
const device = equipmentDevices.find((d) => d.id === routerItem!.id)
const options = buildCameraOptionsForDevice(device)
const cameras = cameraNames
.map(
(name) =>
findComboboxItemByName(options, name) ??
createFallbackComboboxItem(name),
)
.filter((item): item is ComboboxItem => item !== null)
setSelectedCameras(cameras)
} else {
setSelectedCameras([])
}
}, [
open,
editingOperation?.id,
editingOperation?.router,
editingOperation?.camera,
equipmentDevices,
])
useEffect(() => { useEffect(() => {
if (!open || activeTab !== 'journal' || !hasUnreadJournal) { if (!open || activeTab !== 'journal' || !hasUnreadJournal) {
return return
@ -442,13 +586,105 @@ export default function OperationModal({
return return
} }
// Modal soll trotzdem nutzbar bleiben.
console.error(error) console.error(error)
} finally { } finally {
setIsLoadingOperationAssignees(false) setIsLoadingOperationAssignees(false)
} }
} }
async function loadEquipmentDevices(signal?: AbortSignal) {
setIsLoadingDevices(true)
try {
const response = await fetch(`${API_URL}/devices`, {
credentials: 'include',
signal,
})
if (!response.ok) return
const data = (await response.json()) as { devices?: EquipmentDevice[] }
setEquipmentDevices(data.devices ?? [])
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
console.error(err)
} finally {
setIsLoadingDevices(false)
}
}
async function loadMilestoneStorages(autoSelectName?: string) {
setIsLoadingStorages(true)
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
credentials: 'include',
})
if (!response.ok) return
const data = (await response.json()) as { storages?: ModalMilestoneStorage[] }
const storages = data.storages ?? []
setMilestoneStorages(storages)
const nameToSelect = autoSelectName?.trim() || editingOperation?.milestoneStorage?.trim() || ''
if (nameToSelect) {
const match = storages.find(
(s) =>
(s.displayName || s.name) === nameToSelect ||
s.name === nameToSelect ||
s.displayName === nameToSelect,
)
if (match) setSelectedMilestoneStorage(match)
}
} catch {
// silent storage list is informational
} finally {
setIsLoadingStorages(false)
}
}
async function handleCreateMilestoneStorage(e: FormEvent) {
e.preventDefault()
const name = createStorageName.trim()
const diskPath = createStorageDiskPath.trim()
if (!name || !diskPath) {
setCreateStorageError('Name und Pfad sind erforderlich.')
return
}
setIsCreatingStorage(true)
setCreateStorageError(null)
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: name,
name,
diskPath,
maxSize: 0,
retainMinutes: 0,
}),
})
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { error?: string } | null
setCreateStorageError(data?.error ?? 'Speicher konnte nicht angelegt werden.')
return
}
const createdName = name
setCreateStorageName('')
setCreateStorageDiskPath('')
setShowCreateStorageForm(false)
await loadMilestoneStorages(createdName)
} catch {
setCreateStorageError('Verbindung fehlgeschlagen.')
} finally {
setIsCreatingStorage(false)
}
}
function handleOperationLeaderChange(item: ComboboxItem | null) { function handleOperationLeaderChange(item: ComboboxItem | null) {
setSelectedOperationLeader(item) setSelectedOperationLeader(item)
} }
@ -457,6 +693,15 @@ export default function OperationModal({
setSelectedOperationTeam(items) setSelectedOperationTeam(items)
} }
function handleRouterChange(item: ComboboxItem | null) {
setSelectedRouter(item)
setSelectedCameras([])
}
function handleCameraChange(items: ComboboxItem[]) {
setSelectedCameras(items)
}
function handleOpenChange(nextOpen: boolean) { function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) { if (!nextOpen && isSaving) {
return return
@ -470,7 +715,7 @@ export default function OperationModal({
open={open} open={open}
setOpen={handleOpenChange} setOpen={handleOpenChange}
zIndexClassName="z-[9999]" zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-3xl bg-white dark:bg-gray-900" panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
> >
<form <form
key={editingOperation?.id ?? 'create'} key={editingOperation?.id ?? 'create'}
@ -482,11 +727,6 @@ export default function OperationModal({
name="technology" name="technology"
value={editingOperation?.technology ?? ''} value={editingOperation?.technology ?? ''}
/> />
<input
type="hidden"
name="hardDrive"
value={editingOperation?.hardDrive ?? ''}
/>
<input <input
type="hidden" type="hidden"
@ -500,6 +740,24 @@ export default function OperationModal({
value={selectedOperationTeam.map((item) => item.name).join(', ')} value={selectedOperationTeam.map((item) => item.name).join(', ')}
/> />
<input
type="hidden"
name="router"
value={selectedRouter?.name ?? ''}
/>
<input
type="hidden"
name="camera"
value={selectedCameras.map((c) => c.name).join(', ')}
/>
<input
type="hidden"
name="milestoneStorage"
value={selectedMilestoneStorage ? (selectedMilestoneStorage.displayName || selectedMilestoneStorage.name) : ''}
/>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800"> <div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div> <div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> <DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
@ -574,6 +832,8 @@ export default function OperationModal({
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-4 py-6 sm:px-6"> <div className="px-4 py-6 sm:px-6">
{/* Stammdaten */}
<div className={activeTab === 'masterData' ? 'block' : 'hidden'}> <div className={activeTab === 'masterData' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
@ -636,6 +896,7 @@ export default function OperationModal({
</section> </section>
</div> </div>
{/* Adressen */}
<div className={activeTab === 'addresses' ? 'block' : 'hidden'}> <div className={activeTab === 'addresses' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
@ -667,6 +928,7 @@ export default function OperationModal({
</section> </section>
</div> </div>
{/* Zuständigkeiten */}
<div className={activeTab === 'responsibilities' ? 'block' : 'hidden'}> <div className={activeTab === 'responsibilities' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
@ -760,6 +1022,7 @@ export default function OperationModal({
</section> </section>
</div> </div>
{/* Zusatzinfos */}
<div className={activeTab === 'notes' ? 'block' : 'hidden'}> <div className={activeTab === 'notes' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
@ -801,77 +1064,307 @@ export default function OperationModal({
</section> </section>
</div> </div>
{/* Technik */}
<div className={activeTab === 'equipment' ? 'block' : 'hidden'}> <div className={activeTab === 'equipment' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>
<SectionHeader <SectionHeader
title="Technik" title="Technik"
description="Eingesetzte oder vorgesehene technische Ausstattung." description="Router, zugeordnete Kameras und sonstige technische Ausstattung."
/> />
<div className="mt-4 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="mt-4 space-y-6">
<div> {/* Kamera-Ausstattung */}
<label htmlFor="camera" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
Kamera <div className="mb-4 flex items-center gap-2">
</label> <VideoCameraIcon className="size-4 shrink-0 text-indigo-600 dark:text-indigo-400" />
<div className="mt-2"> <div>
<input <h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
id="camera" Kamera-Ausstattung
name="camera" </h4>
type="text" <p className="mt-0.5 text-xs text-indigo-700/80 dark:text-indigo-300/80">
defaultValue={editingOperation?.camera ?? ''} Wähle den Router und die zugehörigen Kameras aus.
className={inputClassName} </p>
/> </div>
</div>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-indigo-900 dark:text-indigo-200">
Router
</label>
<Combobox
items={routerOptions}
value={selectedRouter}
onChange={handleRouterChange}
variant="secondary"
allowCustomValue
placeholder={
isLoadingDevices
? 'Geräte werden geladen...'
: 'Router auswählen oder eingeben'
}
emptyText="Kein passendes Gerät gefunden."
disabled={isLoadingDevices}
/>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between">
<label className="block text-sm font-medium text-indigo-900 dark:text-indigo-200">
Kameras
</label>
{selectedCameras.length > 0 && (
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-600/20 ring-inset dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-300/20">
{selectedCameras.length} ausgewählt
</span>
)}
</div>
<MultiCombobox
items={cameraOptions}
value={selectedCameras}
onChange={handleCameraChange}
placeholder={
!selectedRouter
? 'Erst einen Router auswählen'
: cameraOptions.length === 0
? 'Keine Kameras an diesem Gerät verfügbar'
: 'Kameras auswählen'
}
emptyText="Keine Kameras gefunden."
disabled={!selectedRouter || cameraOptions.length === 0}
/>
</div>
</div> </div>
</div> </div>
<div> {/* Sonstige Ausstattung */}
<label htmlFor="router" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-white/10 dark:bg-white/[0.03]">
Router <div className="mb-3">
</label> <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
<div className="mt-2"> Sonstige Ausstattung
<input </h4>
id="router"
name="router"
type="text"
defaultValue={editingOperation?.router ?? ''}
className={inputClassName}
/>
</div> </div>
</div>
<div> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<label htmlFor="switchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <div>
Switchbox <label htmlFor="switchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
</label> Switchbox
<div className="mt-2"> </label>
<input <div className="mt-2">
id="switchbox" <input
name="switchbox" id="switchbox"
type="text" name="switchbox"
defaultValue={editingOperation?.switchbox ?? ''} type="text"
className={inputClassName} defaultValue={editingOperation?.switchbox ?? ''}
/> className={inputClassName}
</div> />
</div> </div>
</div>
<div> <div>
<label htmlFor="laptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white"> <label htmlFor="laptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Laptop Laptop
</label> </label>
<div className="mt-2"> <div className="mt-2">
<input <input
id="laptop" id="laptop"
name="laptop" name="laptop"
type="text" type="text"
defaultValue={editingOperation?.laptop ?? ''} defaultValue={editingOperation?.laptop ?? ''}
className={inputClassName} className={inputClassName}
/> />
</div>
</div>
<div>
<label htmlFor="hardDrive" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Festplatte
</label>
<div className="mt-2">
<input
id="hardDrive"
name="hardDrive"
type="text"
defaultValue={editingOperation?.hardDrive ?? ''}
placeholder="z. B. Samsung T7 500 GB"
className={inputClassName}
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
{/* Speicher (Milestone) */}
<div className={activeTab === 'storage' ? 'block' : 'hidden'}>
<div className="space-y-4">
{/* Loading */}
{isLoadingStorages && (
<div className="flex items-center justify-center py-10">
<LoadingSpinner size="md" label="Speicher werden geladen…" center />
</div>
)}
{/* Empty state */}
{!isLoadingStorages && milestoneStorages.length === 0 && !showCreateStorageForm && (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-white/20">
<CircleStackIcon className="mx-auto size-8 text-gray-300 dark:text-gray-600" />
<h3 className="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
Keine Speicher vorhanden
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Leg einen neuen Milestone-Speicher an oder verwalte Speicher in der Speicher-Verwaltung.
</p>
</div>
)}
{/* Storage cards */}
{!isLoadingStorages && milestoneStorages.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{milestoneStorages.map((storage) => {
const isSelected = selectedMilestoneStorage?.id === storage.id
return (
<button
key={storage.id}
type="button"
onClick={() => setSelectedMilestoneStorage(isSelected ? null : storage)}
className={classNames(
'relative flex flex-col gap-1 rounded-lg border p-4 text-left transition-all',
isSelected
? 'border-indigo-500 bg-indigo-50 ring-2 ring-indigo-500 dark:border-indigo-400 dark:bg-indigo-500/10 dark:ring-indigo-400'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:hover:bg-white/[0.07]',
)}
>
{isSelected && (
<span className="absolute top-3 right-3">
<CheckIcon className="size-4 text-indigo-600 dark:text-indigo-400" />
</span>
)}
<div className="flex flex-wrap items-center gap-2 pr-6">
<span
className={classNames(
'truncate text-sm font-semibold',
isSelected
? 'text-indigo-900 dark:text-indigo-100'
: 'text-gray-900 dark:text-white',
)}
>
{storage.displayName || storage.name}
</span>
{storage.isDefault && (
<span className="shrink-0 rounded-full bg-indigo-50 px-1.5 py-0.5 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
Standard
</span>
)}
</div>
<span className="truncate font-mono text-xs text-gray-500 dark:text-gray-400">
{storage.diskPath || '—'}
</span>
<span
className={classNames(
'mt-0.5 inline-flex w-fit rounded-full px-1.5 py-0.5 text-xs font-medium',
storage.mounted
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-400',
)}
>
{storage.mounted ? 'Eingehängt' : 'Nicht eingehängt'}
</span>
</button>
)
})}
</div>
)}
{/* Create storage */}
{!isLoadingStorages && (
<>
{!showCreateStorageForm ? (
<button
type="button"
onClick={() => setShowCreateStorageForm(true)}
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600 transition hover:border-indigo-400 hover:text-indigo-600 dark:border-white/20 dark:text-gray-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
>
<PlusIcon className="size-4" />
Neuen Speicher anlegen
</button>
) : (
<div className="rounded-lg border border-indigo-200 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/10">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
Neuen Speicher anlegen
</h4>
<button
type="button"
onClick={() => {
setShowCreateStorageForm(false)
setCreateStorageError(null)
}}
className="text-indigo-500 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<XMarkIcon className="size-4" />
</button>
</div>
{createStorageError && (
<div className="mb-3 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
{createStorageError}
</div>
)}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-indigo-900 dark:text-indigo-200">
Name *
</label>
<input
type="text"
value={createStorageName}
onChange={(e) => setCreateStorageName(e.target.value)}
placeholder="z. B. Einsatz-Speicher-2025"
className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
/>
</div>
<div>
<label className="block text-xs font-medium text-indigo-900 dark:text-indigo-200">
Speicherpfad *
</label>
<input
type="text"
value={createStorageDiskPath}
onChange={(e) => setCreateStorageDiskPath(e.target.value)}
placeholder={`z. B. D:\\Milestone\\${createStorageName || 'Einsatz'}`}
className="mt-1 block w-full rounded-md bg-white px-3 py-1.5 text-sm text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-white/5 dark:text-white dark:outline-white/10"
/>
<p className="mt-1 text-xs text-indigo-700/70 dark:text-indigo-300/70">
Ein Archivspeicher wird automatisch unter diesem Pfad angelegt.
</p>
</div>
<div className="flex justify-end">
<Button
type="button"
color="indigo"
size="sm"
isLoading={isCreatingStorage}
onClick={(e) => void handleCreateMilestoneStorage(e as unknown as FormEvent)}
leadingIcon={<PlusIcon className="size-3.5" />}
>
Anlegen
</Button>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Journal */}
{isEditing && ( {isEditing && (
<div className={activeTab === 'journal' ? 'block' : 'hidden'}> <div className={activeTab === 'journal' ? 'block' : 'hidden'}>
<section className={sectionClassName}> <section className={sectionClassName}>

View File

@ -0,0 +1,310 @@
// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx
import { useEffect, useState } from "react";
import {
MicrophoneIcon,
VideoCameraIcon,
} from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "../../../components/LoadingSpinner";
import Tables, { type TableColumn } from "../../../components/Tables";
import { Badge } from "../../../components/Badge";
import EmptyState from "../../../components/EmptyState";
import { useNotifications } from "../../../components/Notifications";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
type MilestoneCameraListItem = {
id: string;
name: string;
displayName: string;
type: "camera" | "microphone";
enabled: boolean;
recordingEnabled: boolean;
recordingStorageId: string;
hardwareId: string;
};
type StorageSimple = {
id: string;
name: string;
displayName: string;
};
type Props = {
canWrite?: boolean;
};
export default function MilestoneCamerasPage({ canWrite }: Props) {
const { notify } = useNotifications();
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
void Promise.all([loadDevices(), loadStorages()]);
}, []);
async function loadDevices() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
credentials: "include",
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setError(data?.error ?? "Geräte konnten nicht geladen werden");
return;
}
const data = (await response.json()) as {
devices: MilestoneCameraListItem[];
};
setDevices(data.devices ?? []);
} catch {
setError("Verbindung zum Server fehlgeschlagen");
} finally {
setIsLoading(false);
}
}
async function loadStorages() {
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
credentials: "include",
});
if (!response.ok) return;
const data = (await response.json()) as {
storages?: { id: string; name: string; displayName: string }[];
};
const map: Record<string, StorageSimple> = {};
for (const s of data.storages ?? []) {
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
}
setStorageMap(map);
} catch {
// silent storage names are cosmetic
}
}
async function handleToggle(
device: MilestoneCameraListItem,
field: "enabled" | "recordingEnabled",
) {
const newValue = !device[field];
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: newValue } : d,
),
);
const collection = device.type === "camera" ? "cameras" : "microphones";
try {
const response = await fetch(
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: newValue }),
},
);
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
}
} catch {
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error("Verbindung fehlgeschlagen");
}
}
const searchLower = search.trim().toLowerCase();
const filtered = searchLower
? devices.filter(
(d) =>
d.name.toLowerCase().includes(searchLower) ||
d.displayName.toLowerCase().includes(searchLower),
)
: devices;
const cameraCount = filtered.filter((d) => d.type === "camera").length;
const micCount = filtered.filter((d) => d.type === "microphone").length;
const columns: TableColumn<MilestoneCameraListItem>[] = [
{
key: "name",
header: "Name",
isPrimary: true,
render: (row) => (
<div className="flex items-center gap-2">
{row.type === "camera" ? (
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
) : (
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
)}
<div>
<div className="font-medium text-gray-900 dark:text-white">
{row.name}
</div>
{row.displayName && row.displayName !== row.name && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{row.displayName}
</div>
)}
</div>
</div>
),
},
{
key: "enabled",
header: "Status",
hideOnMobile: "sm",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "enabled");
}}
className="cursor-pointer"
>
<Badge tone={row.enabled ? "green" : "gray"} hover>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.enabled ? "green" : "gray"}>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingEnabled",
header: "Aufnahme",
hideOnMobile: "md",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "recordingEnabled");
}}
className="cursor-pointer"
>
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingStorageId",
header: "Speicher",
hideOnMobile: "lg",
render: (row) => {
const storage = storageMap[row.recordingStorageId];
if (storage) {
return (
<span className="text-sm text-gray-700 dark:text-gray-300">
{storage.displayName || storage.name}
</span>
);
}
if (row.recordingStorageId) {
return (
<span className="font-mono text-xs text-gray-400">
{row.recordingStorageId}
</span>
);
}
return <span className="text-gray-400"></span>;
},
},
];
return (
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Milestone-Geräte
</h1>
{!isLoading && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
</p>
)}
</div>
</div>
<div className="relative max-w-sm">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
placeholder="Suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
</div>
)}
{!isLoading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{!isLoading && !error && filtered.length === 0 && (
<EmptyState
icon={VideoCameraIcon}
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
description={
search
? "Versuche einen anderen Suchbegriff."
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
}
className="py-16"
/>
)}
{!isLoading && !error && filtered.length > 0 && (
<Tables
rows={filtered}
columns={columns}
getRowId={(row) => row.id}
variant="border"
/>
)}
</div>
);
}

View File

@ -0,0 +1,310 @@
// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx
import { useEffect, useState } from "react";
import {
MicrophoneIcon,
VideoCameraIcon,
} from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "../../../components/LoadingSpinner";
import Tables, { type TableColumn } from "../../../components/Tables";
import { Badge } from "../../../components/Badge";
import EmptyState from "../../../components/EmptyState";
import { useNotifications } from "../../../components/Notifications";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
type MilestoneCameraListItem = {
id: string;
name: string;
displayName: string;
type: "camera" | "microphone";
enabled: boolean;
recordingEnabled: boolean;
recordingStorageId: string;
hardwareId: string;
};
type StorageSimple = {
id: string;
name: string;
displayName: string;
};
type Props = {
canWrite?: boolean;
};
export default function MilestoneCamerasPage({ canWrite }: Props) {
const { notify } = useNotifications();
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
void Promise.all([loadDevices(), loadStorages()]);
}, []);
async function loadDevices() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
credentials: "include",
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setError(data?.error ?? "Geräte konnten nicht geladen werden");
return;
}
const data = (await response.json()) as {
devices: MilestoneCameraListItem[];
};
setDevices(data.devices ?? []);
} catch {
setError("Verbindung zum Server fehlgeschlagen");
} finally {
setIsLoading(false);
}
}
async function loadStorages() {
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
credentials: "include",
});
if (!response.ok) return;
const data = (await response.json()) as {
storages?: { id: string; name: string; displayName: string }[];
};
const map: Record<string, StorageSimple> = {};
for (const s of data.storages ?? []) {
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
}
setStorageMap(map);
} catch {
// silent storage names are cosmetic
}
}
async function handleToggle(
device: MilestoneCameraListItem,
field: "enabled" | "recordingEnabled",
) {
const newValue = !device[field];
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: newValue } : d,
),
);
const collection = device.type === "camera" ? "cameras" : "microphones";
try {
const response = await fetch(
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: newValue }),
},
);
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
}
} catch {
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error("Verbindung fehlgeschlagen");
}
}
const searchLower = search.trim().toLowerCase();
const filtered = searchLower
? devices.filter(
(d) =>
d.name.toLowerCase().includes(searchLower) ||
d.displayName.toLowerCase().includes(searchLower),
)
: devices;
const cameraCount = filtered.filter((d) => d.type === "camera").length;
const micCount = filtered.filter((d) => d.type === "microphone").length;
const columns: TableColumn<MilestoneCameraListItem>[] = [
{
key: "name",
header: "Name",
isPrimary: true,
render: (row) => (
<div className="flex items-center gap-2">
{row.type === "camera" ? (
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
) : (
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
)}
<div>
<div className="font-medium text-gray-900 dark:text-white">
{row.name}
</div>
{row.displayName && row.displayName !== row.name && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{row.displayName}
</div>
)}
</div>
</div>
),
},
{
key: "enabled",
header: "Status",
hideOnMobile: "sm",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "enabled");
}}
className="cursor-pointer"
>
<Badge tone={row.enabled ? "green" : "gray"} hover>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.enabled ? "green" : "gray"}>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingEnabled",
header: "Aufnahme",
hideOnMobile: "md",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "recordingEnabled");
}}
className="cursor-pointer"
>
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingStorageId",
header: "Speicher",
hideOnMobile: "lg",
render: (row) => {
const storage = storageMap[row.recordingStorageId];
if (storage) {
return (
<span className="text-sm text-gray-700 dark:text-gray-300">
{storage.displayName || storage.name}
</span>
);
}
if (row.recordingStorageId) {
return (
<span className="font-mono text-xs text-gray-400">
{row.recordingStorageId}
</span>
);
}
return <span className="text-gray-400"></span>;
},
},
];
return (
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Milestone-Geräte
</h1>
{!isLoading && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
</p>
)}
</div>
</div>
<div className="relative max-w-sm">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
placeholder="Suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
</div>
)}
{!isLoading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{!isLoading && !error && filtered.length === 0 && (
<EmptyState
icon={VideoCameraIcon}
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
description={
search
? "Versuche einen anderen Suchbegriff."
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
}
className="py-16"
/>
)}
{!isLoading && !error && filtered.length > 0 && (
<Tables
rows={filtered}
columns={columns}
getRowId={(row) => row.id}
variant="border"
/>
)}
</div>
);
}

View File

@ -0,0 +1,310 @@
// frontend\src\pages\operations\milestone-devices\MilestoneDevicesPage.tsx
import { useEffect, useState } from "react";
import {
MicrophoneIcon,
VideoCameraIcon,
} from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "../../../components/LoadingSpinner";
import Tables, { type TableColumn } from "../../../components/Tables";
import { Badge } from "../../../components/Badge";
import EmptyState from "../../../components/EmptyState";
import { useNotifications } from "../../../components/Notifications";
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
type MilestoneCameraListItem = {
id: string;
name: string;
displayName: string;
type: "camera" | "microphone";
enabled: boolean;
recordingEnabled: boolean;
recordingStorageId: string;
hardwareId: string;
};
type StorageSimple = {
id: string;
name: string;
displayName: string;
};
type Props = {
canWrite?: boolean;
};
export default function MilestoneDevicesPage({ canWrite }: Props) {
const { notify } = useNotifications();
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
void Promise.all([loadDevices(), loadStorages()]);
}, []);
async function loadDevices() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
credentials: "include",
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setError(data?.error ?? "Geräte konnten nicht geladen werden");
return;
}
const data = (await response.json()) as {
devices: MilestoneCameraListItem[];
};
setDevices(data.devices ?? []);
} catch {
setError("Verbindung zum Server fehlgeschlagen");
} finally {
setIsLoading(false);
}
}
async function loadStorages() {
try {
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
credentials: "include",
});
if (!response.ok) return;
const data = (await response.json()) as {
storages?: { id: string; name: string; displayName: string }[];
};
const map: Record<string, StorageSimple> = {};
for (const s of data.storages ?? []) {
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
}
setStorageMap(map);
} catch {
// silent storage names are cosmetic
}
}
async function handleToggle(
device: MilestoneCameraListItem,
field: "enabled" | "recordingEnabled",
) {
const newValue = !device[field];
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: newValue } : d,
),
);
const collection = device.type === "camera" ? "cameras" : "microphones";
try {
const response = await fetch(
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: newValue }),
},
);
if (!response.ok) {
const data = (await response.json().catch(() => null)) as {
error?: string;
} | null;
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
}
} catch {
setDevices((current) =>
current.map((d) =>
d.id === device.id ? { ...d, [field]: !newValue } : d,
),
);
notify.error("Verbindung fehlgeschlagen");
}
}
const searchLower = search.trim().toLowerCase();
const filtered = searchLower
? devices.filter(
(d) =>
d.name.toLowerCase().includes(searchLower) ||
d.displayName.toLowerCase().includes(searchLower),
)
: devices;
const cameraCount = filtered.filter((d) => d.type === "camera").length;
const micCount = filtered.filter((d) => d.type === "microphone").length;
const columns: TableColumn<MilestoneCameraListItem>[] = [
{
key: "name",
header: "Name",
isPrimary: true,
render: (row) => (
<div className="flex items-center gap-2">
{row.type === "camera" ? (
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
) : (
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
)}
<div>
<div className="font-medium text-gray-900 dark:text-white">
{row.name}
</div>
{row.displayName && row.displayName !== row.name && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{row.displayName}
</div>
)}
</div>
</div>
),
},
{
key: "enabled",
header: "Status",
hideOnMobile: "sm",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "enabled");
}}
className="cursor-pointer"
>
<Badge tone={row.enabled ? "green" : "gray"} hover>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.enabled ? "green" : "gray"}>
{row.enabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingEnabled",
header: "Aufnahme",
hideOnMobile: "md",
render: (row) =>
canWrite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleToggle(row, "recordingEnabled");
}}
className="cursor-pointer"
>
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
</button>
) : (
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
</Badge>
),
},
{
key: "recordingStorageId",
header: "Speicher",
hideOnMobile: "lg",
render: (row) => {
const storage = storageMap[row.recordingStorageId];
if (storage) {
return (
<span className="text-sm text-gray-700 dark:text-gray-300">
{storage.displayName || storage.name}
</span>
);
}
if (row.recordingStorageId) {
return (
<span className="font-mono text-xs text-gray-400">
{row.recordingStorageId}
</span>
);
}
return <span className="text-gray-400"></span>;
},
},
];
return (
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Milestone-Geräte
</h1>
{!isLoading && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
</p>
)}
</div>
</div>
<div className="relative max-w-sm">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
<input
type="search"
placeholder="Suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
</div>
)}
{!isLoading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{!isLoading && !error && filtered.length === 0 && (
<EmptyState
icon={VideoCameraIcon}
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
description={
search
? "Versuche einen anderen Suchbegriff."
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
}
className="py-16"
/>
)}
{!isLoading && !error && filtered.length > 0 && (
<Tables
rows={filtered}
columns={columns}
getRowId={(row) => row.id}
variant="border"
/>
)}
</div>
);
}