updated
This commit is contained in:
parent
dd387a884c
commit
1abfc72e3a
321
backend/external/agent_main.go
vendored
321
backend/external/agent_main.go
vendored
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user